Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b8a24a6b2 | ||
|
|
cb6017cedf | ||
|
|
fab4108e92 | ||
|
|
637f737d4c | ||
|
|
0b986717eb | ||
|
|
9469d57f90 | ||
|
|
1ce0fffe3e | ||
|
|
f483cf978c | ||
|
|
0e12e3a531 | ||
|
|
67bea9a2a7 | ||
|
|
a05c8236f6 | ||
|
|
fc3d6dc912 | ||
|
|
c63a375ab2 | ||
|
|
c98994fd1a | ||
|
|
6beaa88418 | ||
|
|
26a77dcf84 | ||
|
|
38c9ffe00b | ||
|
|
b87984af9a | ||
|
|
d3b6fccce0 | ||
|
|
d2ee7395c5 | ||
|
|
b94608ffe3 | ||
|
|
ef6bce8f3f | ||
|
|
b8bf46132a | ||
|
|
ca85c2fcfb | ||
|
|
440808361a | ||
|
|
05fd5c562e | ||
|
|
5f0e74713f | ||
|
|
2d117a9d5e | ||
|
|
4bf102579a | ||
|
|
fbed7873e5 | ||
|
|
008b3593db | ||
|
|
3820c6f3c1 | ||
|
|
c300a1a291 | ||
|
|
bdfaa22b0c | ||
|
|
47e5ec279c | ||
|
|
093b83737e | ||
|
|
a84041a923 | ||
|
|
9a9a00380f | ||
|
|
0e4f8f65f2 | ||
|
|
5391c81e8b | ||
|
|
c106ce1a44 | ||
|
|
b7b1920846 | ||
|
|
8ccfa1f658 | ||
|
|
c23401de88 | ||
|
|
c271f66615 | ||
|
|
458d798022 | ||
|
|
f5fcd626e8 | ||
|
|
1d9ddf7dc5 | ||
|
|
b189ae66e7 | ||
|
|
4156eb2db3 | ||
|
|
3f6559b2db | ||
|
|
71d4e053a7 | ||
|
|
2dec331afb | ||
|
|
64f43df3e3 | ||
|
|
6ccba2be9f | ||
|
|
2b735528ab | ||
|
|
e6170549f0 | ||
|
|
a2983a62a0 | ||
|
|
d9323f34e1 | ||
|
|
a4379094db | ||
|
|
52ec232ce4 |
@@ -1,124 +1,96 @@
|
||||
# fastapi-vue-setup
|
||||
|
||||
Tool to create or patch FastAPI project with a Vue frontend, with integrated build and development systems. Only building the package needs JS runtime and Vue. Additionally, a devmode setup using Vite dev server with hot auto reloads is available via the scripts/devserver.py script (only intended to be used on the source repo, not included in installed package).
|
||||
Create or patch a FastAPI + Vue project with an integrated dev/build workflow.
|
||||
|
||||
## Features
|
||||
- Development: one command runs Vite + FastAPI (reloads)
|
||||
- Production: `uv build` bakes the built Vue assets into the Python package (no Node/JS runtime needed to *run* the installed package)
|
||||
|
||||
- **No JavaScript**: Your Python package can be installed and used without any JS runtime
|
||||
- **Create new projects** with `uv init` + `create-vue` (interactive)
|
||||
- **Patch existing projects** with build hooks and dev server scripts
|
||||
- **Integrated build system**: Vue frontend builds into Python package during `uv build`
|
||||
- **Development server**: Single command runs Vite + FastAPI with hot-reload
|
||||
- **Optimized static serving**: zstd compression, ETag caching, SPA support
|
||||
## Quick start
|
||||
|
||||
## Installation
|
||||
Install [UV](https://docs.astral.sh/uv/) and any JS runtime (node, deno, or bun).
|
||||
|
||||
Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then:
|
||||
This README uses `my-app` as the example project name:
|
||||
|
||||
```sh
|
||||
uv tool install fastapi-vue-setup
|
||||
fastapi-vue-setup --help
|
||||
```
|
||||
- project directory: `my-app/`
|
||||
- Python module: `my_app`
|
||||
- env prefix: `MY_APP`
|
||||
- CLI command: `my-app`
|
||||
|
||||
Or run directly:
|
||||
Create a new project in `./my-app`:
|
||||
|
||||
```sh
|
||||
uvx fastapi-vue-setup my-app
|
||||
```
|
||||
|
||||
## Usage
|
||||
Once in your source tree, you will typically use `.` for the path. If there is an existing project, `fastapi-vue-setup` will do its best to find and patch a backend module and create or patch a Vue project in `frontend/`. The integration can be upgraded by running a new version of `fastapi-vue-setup` on it, preserving earlier default ports and user customizations.
|
||||
|
||||
### Create a new project
|
||||
## In your project
|
||||
|
||||
ℹ️ Everything below is meant to be run within your project source tree.
|
||||
|
||||
The setup creates a CLI entry for your package, so that it becomes a command to run, not a Python module nor `fastapi myapp...`. The CLI main can be customized, although --listen should be kept for devserver compatibility.
|
||||
|
||||
You can choose the JS runtime with environment `JS_RUNTIME` (e.g. `node`, `deno`, `bun`, or path to one). This is used by the build and the devserver scripts. By default any available runtime on the system is chosen.
|
||||
|
||||
### Development server (Vite + FastAPI)
|
||||
|
||||
```sh
|
||||
fastapi-vue-setup new my-app
|
||||
uv run scripts/devserver.py [args]
|
||||
```
|
||||
|
||||
This will:
|
||||
ℹ️ Arguments are forwarded to the main CLI, except that `--listen` controls where Vite listens, and `--backend` is passed to main CLI as `--listen`.
|
||||
|
||||
1. Run `uv init my-app`
|
||||
2. Run `npm create vue@latest frontend` (interactive)
|
||||
3. Patch the project with FastAPI integration
|
||||
### Production
|
||||
|
||||
### Patch an existing project
|
||||
Build the Python package (this compiles the Vue frontend) and run the production server:
|
||||
|
||||
```bash
|
||||
fastapi-vue-setup patch /path/to/project
|
||||
```sh
|
||||
uv build && uv run my-app [args]
|
||||
```
|
||||
|
||||
Options:
|
||||
Once happy with it, publish the package
|
||||
|
||||
- `--module-name NAME`: Python module name (auto-detected from pyproject.toml)
|
||||
- `--vite-port PORT`: Vite dev server port (default: 5173)
|
||||
- `--backend-port PORT`: Backend API port in dev mode (default: 5174)
|
||||
- `--prod-port PORT`: Production server port (default: 8000)
|
||||
- `--force`: Overwrite existing files
|
||||
- `--dry-run`: Preview changes without modifying files
|
||||
|
||||
### Update an existing project
|
||||
|
||||
```bash
|
||||
fastapi-vue-setup update /path/to/project
|
||||
```sh
|
||||
uv build && uv publish
|
||||
```
|
||||
|
||||
Same as `patch --force` - overwrites template files with latest versions.
|
||||
Afterwards, you can easily run it anywhere, no JS runtimes required:
|
||||
|
||||
## Port Configuration
|
||||
```sh
|
||||
uvx my-app [args]
|
||||
```
|
||||
|
||||
The tool uses three distinct ports:
|
||||
ℹ️ Instead of `uvx` you may consider `uv tool install`, oldskool `pip install` or whatever best suits you.
|
||||
|
||||
| Port | Purpose | Used by |
|
||||
| ---- | --------------------- | ------------------------------ |
|
||||
| 5173 | Vite dev server (HMR) | `npm run dev` via devserver.py |
|
||||
| 5174 | FastAPI in dev mode | uvicorn via devserver.py |
|
||||
| 8000 | Production server | `uv run my-app` |
|
||||
### Vite plugin
|
||||
|
||||
In development, you access the app at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5174.
|
||||
The generated Vite plugin lives in `frontend/vite-plugin-fastapi.js` and defaults to proxying `/api`.
|
||||
|
||||
In production, FastAPI serves both the API and static files from the same port (8000).
|
||||
It reads `MY_APP_BACKEND_URL` to know where to proxy; if unset it falls back to your configured default backend port.
|
||||
|
||||
## Project Structure
|
||||
|
||||
After patching, your project will have:
|
||||
## Project layout (typical)
|
||||
|
||||
```
|
||||
my-app/
|
||||
├── frontend/ # Vue.js application
|
||||
├── frontend/ # Vue app (Vite)
|
||||
│ ├── src/
|
||||
│ ├── vite.config.ts # Builds to ../my_app/frontend-build
|
||||
│ ├── vite-plugin-fastapi.js
|
||||
│ └── package.json
|
||||
├── my_app/ # Python package
|
||||
│ ├── __init__.py
|
||||
│ ├── __main__.py # CLI entry point
|
||||
│ ├── app.py # FastAPI application
|
||||
│ └── frontend-build/ # Built frontend (gitignored)
|
||||
├── scripts/
|
||||
│ ├── devserver.py # Development server
|
||||
│ └── fastapi-vue/ # Build utilities
|
||||
└── pyproject.toml # Project configuration
|
||||
├── my_app/ # Python package
|
||||
│ ├── __main__.py # CLI entrypoint
|
||||
│ ├── app.py # FastAPI app
|
||||
│ └── frontend-build/ # built assets (included in distributions)
|
||||
├── pyproject.toml
|
||||
└── scripts/
|
||||
├── devserver.py # Run Vite and FastAPI together in dev mode
|
||||
└── fastapi-vue/ # Dev utilities (only on the source tree)
|
||||
├── build-frontend.py
|
||||
├── buildutil.py
|
||||
└── devutil.py
|
||||
```
|
||||
|
||||
The script finds your existing fastpi app module (even if not named app.py) and other files and patches them with minimal changes to enable the Vue-FastAPI interconnection.
|
||||
## The fastapi-vue runtime module
|
||||
|
||||
## Development Workflow
|
||||
The backend runs the FastAPI app and serves the frontend build using the companion package in [fastapi-vue/README.md](fastapi-vue/README.md). Your project will depend on Fastapi and this lightweight module.
|
||||
|
||||
```bash
|
||||
# Start dev server (runs both Vite and FastAPI)
|
||||
uv run scripts/devserver.py
|
||||
|
||||
# Build for production
|
||||
uv build
|
||||
|
||||
# Run production server
|
||||
uv run my-app
|
||||
```
|
||||
|
||||
## Static File Serving
|
||||
|
||||
The `Frontend` class provides:
|
||||
|
||||
- **Automatic zstd compression** at level 18
|
||||
- **Smart caching**: Content-hashed assets get `immutable` cache headers
|
||||
- **ETag support**: 304 Not Modified responses for cached content
|
||||
- **SPA routing**: Falls back to index.html for client-side routes
|
||||
- **Favicon handling**: Automatic /favicon.ico from hashed assets
|
||||
ℹ️ Development functionality is in `scripts/fastapi-vue/` directly in your source tree, and is not to be confused with this runtime module. Only the runtime is installed with your package.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# fastapi-vue
|
||||
|
||||
Runtime helpers for FastAPI + Vite/Vue projects.
|
||||
|
||||
## Overview
|
||||
|
||||
This package provides:
|
||||
|
||||
- `fastapi_vue.Frontend`: serves built SPA assets (with SPA support, caching, and optional zstd)
|
||||
- `fastapi_vue.server.run`: a small Uvicorn runner with convenient `listen` endpoint parsing
|
||||
|
||||
## Quickstart
|
||||
|
||||
Serve built frontend assets from `frontend-build/`:
|
||||
|
||||
```python
|
||||
from pathlib import Path
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi_vue import Frontend
|
||||
|
||||
frontend = Frontend(Path(__file__).with_name("frontend-build"), spa=True)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
await frontend.load()
|
||||
yield
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
# Add API routes here...
|
||||
|
||||
# Final catch-all route for frontend files (keep at end of file)
|
||||
frontend.route(app, "/")
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
`Frontend` serves a directory with:
|
||||
|
||||
- RAM caching, with zstd compression when smaller than original
|
||||
- Browser caching: ETag + Last-Modified, Immutable assets
|
||||
- Favicon mapping (serve PNG or other images there instead)
|
||||
- SPA routing (serve browsers index.html at all paths not otherwise handled)
|
||||
|
||||
Dev-mode behavior with `FastAPI(debug=True)`: requests error HTTP 409 with a message telling you to use the Vite dev server instead. Avoids accidentally using outdated `frontend-build` during development.
|
||||
|
||||
- `directory`: Path on local filesystem
|
||||
- `index`: Index file name (default: `index.html`)
|
||||
- `spa`: Serve index at any path (default: `False`)
|
||||
- `catch_all`: Register a single catch-all handler instead of a route to each file; default for SPA
|
||||
- `cached`: Path prefixes treated as immutable (default: `/assets/`)
|
||||
- `favicon`: Optional path or glob (e.g. `/assets/logo*.png`)
|
||||
- `zstdlevel`: Compression level (default: 18)
|
||||
|
||||
ℹ️ Even when your page has a meta tag giving favicon location, browsers still try loading `/favicon.ico` whenever looking at something else. We find it more convenient to simply serve the image where the browser expects it, with correct MIME type. This also allows having a default favicon for your application that can be easily overriden at the reverse proxy (Caddy, Nginx) to serve the company branding if needed in deployment.
|
||||
|
||||
## Server runner
|
||||
|
||||
When you need more flexibility than `fastapi` CLI can provide (e.g. CLI arguments to your own program), you may use this convenience to run FastAPI app with Uvicorn startup on given `listen` endpoints. Runs in the same process if possible but delegates to `uvicorn.run()` for auto-reloads and multiple workers. This would typically be called from your CLI main, which can set its own env variables to pass information to the FastAPI instances that run (Python imports only work in same-process mode).
|
||||
|
||||
```python
|
||||
from fastapi_vue import server
|
||||
|
||||
server.run("my_app.app:app", listen=["localhost:8000"])
|
||||
```
|
||||
|
||||
- As a deployment option, environment `FORWARDED_ALLOW_IPS` controls `X-Forwarded` trusted IPs (default: `127.0.0.1,::1`).
|
||||
@@ -0,0 +1,3 @@
|
||||
from .staticfiles import Frontend
|
||||
|
||||
__all__ = ["Frontend"]
|
||||
@@ -0,0 +1,67 @@
|
||||
import contextlib
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
def parse_endpoint(value: str | None, default_port: int = 0) -> list[dict]:
|
||||
"""Parse an endpoint string into uvicorn bind configurations.
|
||||
|
||||
Args:
|
||||
value: Endpoint string to parse
|
||||
default_port: Port to use when not specified
|
||||
|
||||
Returns:
|
||||
List of dicts with uvicorn bind kwargs (host/port or uds).
|
||||
|
||||
Supported forms:
|
||||
- None or empty -> [{host: "localhost", port: default_port}]
|
||||
- port (numeric) -> [{host: "localhost", port: port}]
|
||||
- :port -> [{host: "0.0.0.0", port}, {host: "::", port}] (all interfaces)
|
||||
- host:port -> [{host, port}]
|
||||
- host -> [{host, port: default_port}]
|
||||
- [ipv6]:port -> [{host: ipv6, port}]
|
||||
- ipv6 (unbracketed) -> [{host: ipv6, port: default_port}]
|
||||
- /path or unix:/path -> [{uds: path}]
|
||||
"""
|
||||
if not value:
|
||||
return [{"host": "localhost", "port": default_port}]
|
||||
|
||||
# Port only (numeric) -> localhost:port
|
||||
if value.isdigit():
|
||||
return [{"host": "localhost", "port": int(value)}]
|
||||
|
||||
# Leading colon :port -> bind all interfaces (0.0.0.0 + ::)
|
||||
if value.startswith(":") and value != ":":
|
||||
port_part = value[1:]
|
||||
if not port_part.isdigit():
|
||||
raise SystemExit(f"Invalid port in '{value}'")
|
||||
port = int(port_part)
|
||||
return [{"host": "0.0.0.0", "port": port}, {"host": "::", "port": port}] # noqa: S104
|
||||
|
||||
# UNIX domain socket (unix:/path or just /path)
|
||||
if value.startswith("/"):
|
||||
return [{"uds": value}]
|
||||
if value.startswith("unix:"):
|
||||
uds_path = value[5:] or None
|
||||
if uds_path is None:
|
||||
raise SystemExit("unix: path must not be empty")
|
||||
return [{"uds": uds_path}]
|
||||
|
||||
# Unbracketed IPv6 (cannot safely contain a port) -> detect by multiple colons
|
||||
if value.count(":") > 1 and not value.startswith("["):
|
||||
try:
|
||||
ipaddress.IPv6Address(value)
|
||||
except ValueError as e:
|
||||
raise SystemExit(f"Invalid IPv6 address '{value}': {e}") from e
|
||||
return [{"host": value, "port": default_port}]
|
||||
|
||||
# Use urllib.parse for everything else (host[:port], [ipv6][:port])
|
||||
parsed = urlparse(f"//{value}") # // prefix lets urlparse treat it as netloc
|
||||
host = parsed.hostname or "localhost"
|
||||
port = parsed.port or default_port
|
||||
|
||||
# Validate IP literals (optional; hostname passes through)
|
||||
with contextlib.suppress(ValueError):
|
||||
ipaddress.ip_address(host)
|
||||
|
||||
return [{"host": host, "port": port}]
|
||||
@@ -0,0 +1,78 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from contextlib import suppress
|
||||
|
||||
import uvicorn
|
||||
from uvicorn import Config, Server
|
||||
|
||||
from .hostutil import parse_endpoint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def run(
|
||||
app: str,
|
||||
*,
|
||||
listen: str | list[str] | None = None,
|
||||
default_port: int = 8000,
|
||||
reload: bool = False,
|
||||
workers: int | None = None,
|
||||
**uvicorn_config,
|
||||
):
|
||||
"""Run uvicorn server(s) for the given app.
|
||||
|
||||
Args:
|
||||
app: The ASGI application path (e.g., "myapp.main:app")
|
||||
listen: Endpoint string(s) (see parse_endpoint for formats).
|
||||
default_port: Port to use when not specified in listen args.
|
||||
reload: Enable auto-reload (requires uvicorn.run, single endpoint only).
|
||||
workers: Number of worker processes (requires uvicorn.run, single endpoint only).
|
||||
**uvicorn_config: Additional uvicorn config options (overrides all other settings).
|
||||
"""
|
||||
if listen is None:
|
||||
listen = [f"localhost:{default_port}"]
|
||||
elif isinstance(listen, str):
|
||||
listen = [listen]
|
||||
endpoints: list[dict] = []
|
||||
for ep in listen:
|
||||
endpoints.extend(parse_endpoint(ep, default_port))
|
||||
|
||||
conf: dict[str, object] = {"app": app, "reload": reload, "workers": workers}
|
||||
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
|
||||
if proxy:
|
||||
conf["proxy_headers"] = True
|
||||
conf["forwarded_allow_ips"] = proxy
|
||||
conf.update(uvicorn_config)
|
||||
|
||||
with suppress(KeyboardInterrupt):
|
||||
if reload or workers:
|
||||
serve_multiprocess(endpoints, **conf)
|
||||
else:
|
||||
asyncio.run(serve(endpoints, **conf))
|
||||
|
||||
|
||||
async def serve(endpoints: list[dict], **kwargs) -> None:
|
||||
"""Serve the given endpoints in current process/loop. Does not spawn extra processes."""
|
||||
forbidden = {"reload", "workers"} & {k for k, v in kwargs.items() if v}
|
||||
if forbidden:
|
||||
logger.warning(
|
||||
"Options %s have no effect in simple mode (multiple endpoints)",
|
||||
", ".join(sorted(forbidden)),
|
||||
)
|
||||
await asyncio.gather(*(Server(Config(**kwargs, **ep)).serve() for ep in endpoints))
|
||||
|
||||
|
||||
def serve_multiprocess(endpoints: list[dict], **kwargs) -> None:
|
||||
"""Serve using uvicorn.run() for reload/workers support. Only first endpoint is used."""
|
||||
if len(endpoints) > 1:
|
||||
eps = [
|
||||
ep["uds"] if "uds" in ep else f"{ep['host']}:{ep['port']}"
|
||||
for ep in endpoints
|
||||
]
|
||||
logger.warning(
|
||||
"Current mode supports only one endpoint. Listening: %s, skipped: %s",
|
||||
eps[0],
|
||||
" ".join(eps[1:]),
|
||||
)
|
||||
uvicorn.run(**kwargs, **endpoints[0])
|
||||
@@ -0,0 +1,281 @@
|
||||
"""FastAPI static file serving with zstd compression and SPA support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import logging
|
||||
import mimetypes
|
||||
import time
|
||||
from base64 import urlsafe_b64encode
|
||||
from functools import partial
|
||||
from pathlib import Path, PurePath, PurePosixPath
|
||||
from wsgiref.handlers import format_date_time
|
||||
|
||||
from blake3 import blake3
|
||||
from fastapi import FastAPI, Request, Response
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from starlette.exceptions import HTTPException
|
||||
from starlette.routing import Route
|
||||
from zstandard import ZstdCompressor
|
||||
|
||||
logger = logging.getLogger("uvicorn.error") # Use FastAPI logging style
|
||||
|
||||
__all__ = ["Frontend"]
|
||||
|
||||
|
||||
class Assets:
|
||||
"""Default cached value to /assets/"""
|
||||
|
||||
@staticmethod
|
||||
def parse(cached: str | list[str] | Assets) -> list[str]:
|
||||
match cached:
|
||||
case Assets():
|
||||
return ["/assets/"]
|
||||
case str():
|
||||
return [cached]
|
||||
case list():
|
||||
return cached
|
||||
case _:
|
||||
raise ValueError(f"Invalid cached value: {cached!r}")
|
||||
|
||||
|
||||
class Frontend:
|
||||
"""Static file server with automatic zstd compression and caching.
|
||||
|
||||
Features:
|
||||
- Automatic zstd compression for compressible files
|
||||
- ETag-based caching of immutable assets
|
||||
- SPA (Single Page Application) support
|
||||
- /favicon.ico with correct MIME type (image/png etc)
|
||||
- Dev mode: indexes files but returns error directing to Vite server
|
||||
|
||||
Args:
|
||||
directory: Path to the directory containing static files
|
||||
index: Name of the index file (default: "index.html")
|
||||
spa: Enable SPA mode - serve index.html for unknown routes (default: False)
|
||||
cached: Path prefixes that are immutable (default: "/assets/")
|
||||
favicon: May use wildcards of full path. E.g. /assets/logo*.png matches logo.hash.png created by Vite
|
||||
zstdlevel: Zstd compression level (default: 18)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
directory: Path | str,
|
||||
*,
|
||||
index: str = "index.html",
|
||||
spa: bool = False,
|
||||
catch_all: bool | None = None,
|
||||
cached: str | list[str] | Assets = Assets(),
|
||||
favicon: str | None = None,
|
||||
zstdlevel: int = 18,
|
||||
) -> None:
|
||||
self.www: dict[str, tuple[bytes, bytes | None, dict]] = {}
|
||||
self.base: Path = Path(directory)
|
||||
self.index = index
|
||||
self.spa = spa
|
||||
self._catch_all = spa if catch_all is None else catch_all
|
||||
self.cached_paths = Assets.parse(cached)
|
||||
self.zstdlevel = zstdlevel
|
||||
self.favicon = favicon
|
||||
self._app: FastAPI | None = None
|
||||
self._mount_path: str = ""
|
||||
self._ridx: int = 0
|
||||
self._routes: list[Route] = []
|
||||
|
||||
def _index_only(self) -> set[str]:
|
||||
"""Index file paths without loading content (for dev mode)."""
|
||||
paths: set[str] = set()
|
||||
if not self.base.exists():
|
||||
return paths
|
||||
queue = [PurePath()]
|
||||
while queue:
|
||||
current = self.base / queue.pop(0)
|
||||
for p in current.iterdir():
|
||||
rel = p.relative_to(self.base)
|
||||
if p.is_dir():
|
||||
queue.append(rel)
|
||||
continue
|
||||
name = "/" + rel.as_posix()
|
||||
name = name.removesuffix(self.index)
|
||||
paths.add(name)
|
||||
if self.favicon:
|
||||
p = PurePosixPath(self.favicon)
|
||||
base = str(p.with_suffix(""))
|
||||
ext = p.suffix
|
||||
if any(path.startswith(base) and path.endswith(ext) for path in paths):
|
||||
paths.add("/favicon.ico")
|
||||
return paths
|
||||
|
||||
def _load(self):
|
||||
"""Load static files from disk with compression."""
|
||||
www: dict[str, tuple[bytes, bytes | None, dict]] = {}
|
||||
if not self.base.exists():
|
||||
raise ValueError(f"Frontend folder {self.base} not found (try uv build)")
|
||||
paths = [PurePath()]
|
||||
while paths:
|
||||
current = self.base / paths.pop(0)
|
||||
for p in current.iterdir():
|
||||
rel = p.relative_to(self.base)
|
||||
if p.is_dir():
|
||||
paths.append(rel)
|
||||
continue
|
||||
# Read file
|
||||
name = "/" + rel.as_posix()
|
||||
mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
|
||||
name = name.removesuffix(self.index)
|
||||
data = p.read_bytes()
|
||||
etag = urlsafe_b64encode(blake3(data).digest(9)).decode()
|
||||
if mime.startswith("text/"):
|
||||
mime += "; charset=UTF-8"
|
||||
mtime = p.stat().st_mtime
|
||||
cached = any(name.startswith(prefix) for prefix in self.cached_paths)
|
||||
headers = {
|
||||
"etag": f'"{etag}"',
|
||||
"last-modified": format_date_time(mtime),
|
||||
"cache-control": (
|
||||
"max-age=31536000, immutable" if cached else "no-cache"
|
||||
),
|
||||
"content-type": mime,
|
||||
}
|
||||
zstd = ZstdCompressor(self.zstdlevel).compress(data)
|
||||
if len(zstd) >= len(data):
|
||||
zstd = None
|
||||
www[name] = data, zstd, headers
|
||||
if self.favicon:
|
||||
if m := fnmatch.filter(www, self.favicon):
|
||||
data, zstd, headers = www[m[0]]
|
||||
if "immutable" in headers.get("cache-control", ""):
|
||||
headers = {**headers, "cache-control": "max-age=86400"}
|
||||
www["/favicon.ico"] = data, zstd, headers
|
||||
if not www:
|
||||
msg = "Frontend files missing, check your installation.\n"
|
||||
www["/"] = (
|
||||
msg.encode(),
|
||||
None,
|
||||
{
|
||||
"etag": "error",
|
||||
"content-type": "text/plain",
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
)
|
||||
return www
|
||||
|
||||
async def load(self, *, debug: bool | None = None, log: bool = True):
|
||||
"""Load or reload static files from disk.
|
||||
|
||||
In debug mode, returns 409 instead of files (avoid accidental use of stale builds)
|
||||
If debug is None, uses app.debug (app passed to frontend.route)
|
||||
"""
|
||||
if debug is None:
|
||||
debug = getattr(self._app, "debug", False)
|
||||
if debug:
|
||||
# Dev mode: just index paths, no content loading
|
||||
self._devmode_paths = await run_in_threadpool(self._index_only)
|
||||
self._register_routes()
|
||||
return
|
||||
|
||||
start = time.perf_counter()
|
||||
self.www = await run_in_threadpool(self._load)
|
||||
self._register_routes()
|
||||
duration = time.perf_counter() - start
|
||||
if not log:
|
||||
return
|
||||
compfiles = [(len(d), len(z)) for d, z, _ in self.www.values() if z]
|
||||
raw = sum(v[0] for v in compfiles)
|
||||
comp = sum(v[1] for v in compfiles)
|
||||
ratio = comp / raw * 100 if raw else 100.0
|
||||
if log and self.www:
|
||||
logger.info(
|
||||
f"{self.base.name}: {len(self.www)} files in {1000 * duration:.1f} ms | "
|
||||
f"zstd {len(compfiles)} files {1e-6 * raw:.2f}->{1e-6 * comp:.2f} MB ({ratio:.0f} %)"
|
||||
)
|
||||
if self.favicon and "/favicon.ico" not in self.www:
|
||||
logger.warning("Favicon not found: %s", self.favicon)
|
||||
|
||||
def route(self, app: FastAPI, mount_path="/"):
|
||||
"""Register frontend routes with a FastAPI app.
|
||||
|
||||
In SPA/catch-all mode, this must only be called only after all other routes.
|
||||
|
||||
The calling position determines routing priority, although in regular mode the
|
||||
routes are actually added only after load() is called.
|
||||
|
||||
Args:
|
||||
app: FastAPI application instance
|
||||
mount_path: Path where the frontend should be mounted (default: "/")
|
||||
"""
|
||||
self._app = app
|
||||
self._mount_path = mount_path.rstrip("/")
|
||||
self._ridx = len(app.routes)
|
||||
|
||||
if self._catch_all:
|
||||
# Register catch-all immediately (works without load)
|
||||
path = self._mount_path + "{path:path}"
|
||||
app.api_route(path, methods=["GET", "HEAD"], name="frontend")(self.handle)
|
||||
|
||||
def _register_routes(self):
|
||||
"""Register individual routes for each loaded file (non-catch_all mode)."""
|
||||
if self._app is None or self._catch_all:
|
||||
return
|
||||
|
||||
# Remove previously registered routes (for reload support)
|
||||
for route in list(self._routes):
|
||||
if route in self._app.routes:
|
||||
self._app.routes.remove(route)
|
||||
self._routes.clear()
|
||||
|
||||
# Get paths and select handler based on mode (checked once, not per request)
|
||||
debug = getattr(self._app, "debug", False)
|
||||
paths = self._devmode_paths if debug else self.www.keys()
|
||||
handler = _devmode_respond if debug else self._respond
|
||||
# Insert at the position where route() was called
|
||||
self._app.routes[self._ridx : self._ridx] = self._routes = [
|
||||
Route(
|
||||
self._mount_path + p,
|
||||
endpoint=handler if debug else partial(handler, name=p),
|
||||
methods=["GET", "HEAD"],
|
||||
name=f"frontend{p.replace('/', '_')}",
|
||||
)
|
||||
for p in paths
|
||||
]
|
||||
|
||||
def _respond(self, request: Request, name: str):
|
||||
"""Serve a static file with ETag and compression support."""
|
||||
data, zstd, headers = self.www[name]
|
||||
if request.headers.get("if-none-match") == headers["etag"]:
|
||||
return Response(status_code=304, headers=headers)
|
||||
if zstd and "zstd" in request.headers.get("accept-encoding", ""):
|
||||
return Response(
|
||||
content=zstd, headers={**headers, "content-encoding": "zstd"}
|
||||
)
|
||||
return Response(content=data, headers=headers)
|
||||
|
||||
def handle(self, request: Request, path: str):
|
||||
"""SPA catch-all handler with directory redirects and fallback to index."""
|
||||
name = path.removesuffix(self.index)
|
||||
debug = getattr(self._app, "debug", False)
|
||||
files = self._devmode_paths if debug else self.www
|
||||
|
||||
if name not in files:
|
||||
# Friendly redirect for directories missing trailing slash
|
||||
if name and f"{name}/" in files:
|
||||
return RedirectResponse(request.url.path + "/")
|
||||
# SPA support: serve / for unknown paths if the browser wants HTML
|
||||
if self.spa and "text/html" in request.headers.get("accept", ""):
|
||||
name = "/"
|
||||
# 404 for everything else
|
||||
if name not in files:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
return (_devmode_respond if debug else self._respond)(request, name)
|
||||
|
||||
|
||||
def _devmode_respond(request: Request, name=""):
|
||||
"""Return error response directing to Vite server."""
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"detail": "[devmode] Not serving frontend files here. Should you connect to Vite instead?"
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
[project]
|
||||
name = "fastapi-vue"
|
||||
dynamic = ["version"]
|
||||
description = "Serves Vue assets on a FastAPI app. Use fastapi-vue-setup tool to add Vue build to your package."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"zstandard>=0.23.0",
|
||||
"blake3>=1.0.8",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://git.zi.fi/LeoVasanko/fastapi-vue"
|
||||
Repository = "https://github.com/LeoVasanko/fastapi-vue"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling", "hatch-vcs"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "vcs"
|
||||
raw-options.root = ".."
|
||||
+1092
-406
File diff suppressed because it is too large
Load Diff
+6
-2
@@ -9,7 +9,8 @@ description = "Tool to create or patch FastAPI+Vue projects with integrated buil
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"tomli-w>=1.0.0",
|
||||
"ruff>=0.14.13",
|
||||
"tomlkit>=0.12.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -26,4 +27,7 @@ source = "vcs"
|
||||
include = ["fastapi_vue_setup.py", "template/**/*", "_version.py"]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["ruff"]
|
||||
dev = ["ruff", "fastapi-vue"]
|
||||
|
||||
[tool.uv.sources]
|
||||
fastapi-vue = { path = "fastapi-vue", editable = true }
|
||||
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Build and release both fastapi-vue and fastapi-vue-setup packages."""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
DIST = ROOT / "dist"
|
||||
FASTAPI_VUE = ROOT / "fastapi-vue"
|
||||
|
||||
|
||||
def main():
|
||||
# Clear the dist directory
|
||||
if DIST.exists():
|
||||
shutil.rmtree(DIST)
|
||||
DIST.mkdir()
|
||||
|
||||
# Build fastapi-vue (subdirectory) to root dist
|
||||
subprocess.run(
|
||||
["uv", "build", "--out-dir", str(DIST)],
|
||||
cwd=FASTAPI_VUE,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Build fastapi-vue-setup (root)
|
||||
subprocess.run(
|
||||
["uv", "build", "--out-dir", str(DIST)],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,3 +0,0 @@
|
||||
"""{{PROJECT_TITLE}} package."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
@@ -1,30 +1,28 @@
|
||||
"""Entry point for the application."""
|
||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||
import argparse
|
||||
import os
|
||||
|
||||
import sys
|
||||
from fastapi_vue import server
|
||||
|
||||
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
|
||||
DEVMODE = os.getenv("ENVPREFIX_DEV") == "1"
|
||||
|
||||
|
||||
def main():
|
||||
"""Run the FastAPI application using uvicorn."""
|
||||
import uvicorn
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
endpoint = sys.argv[1]
|
||||
if ":" in endpoint:
|
||||
host, port = endpoint.rsplit(":", 1)
|
||||
host = host or "localhost"
|
||||
port = int(port)
|
||||
else:
|
||||
host = "localhost"
|
||||
port = int(endpoint)
|
||||
else:
|
||||
host = "localhost"
|
||||
port = {{PROD_PORT}}
|
||||
|
||||
uvicorn.run(
|
||||
"{{MODULE_NAME}}.app:app",
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="info",
|
||||
parser = argparse.ArgumentParser(description="Run the MODULE_NAME server.")
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--listen",
|
||||
action="append",
|
||||
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
dev = {"reload": True, "reload_dirs": ["paskia"]} if DEVMODE else {}
|
||||
server.run(
|
||||
"APP_MODULE:APP_VAR",
|
||||
listen=args.listen,
|
||||
default_port=DEFAULT_PORT,
|
||||
**dev,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+9
-17
@@ -1,18 +1,12 @@
|
||||
"""Main FastAPI application."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi_vue import Frontend
|
||||
from MAIN_MODULE import DEVMODE
|
||||
|
||||
# Frontend static file server - configure options here
|
||||
frontend = Frontend(
|
||||
Path(__file__).parent / "frontend-build",
|
||||
spa=True,
|
||||
favicon="/assets/favicon",
|
||||
cached=["/assets/"],
|
||||
)
|
||||
# Vue Frontend static files
|
||||
frontend = Frontend(Path(__file__).with_name("frontend-build"))
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -22,19 +16,17 @@ async def lifespan(app: FastAPI):
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="{{PROJECT_TITLE}}", lifespan=lifespan)
|
||||
app = FastAPI(title="PROJECT_TITLE", debug=DEVMODE, lifespan=lifespan)
|
||||
|
||||
|
||||
# Add API routes here...
|
||||
|
||||
|
||||
# Health check endpoint for the Vue demo app to verify the backend is running
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint for the dev server."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# Add your API routes here
|
||||
# @app.get("/api/example")
|
||||
# async def example():
|
||||
# return {"message": "Hello World"}
|
||||
|
||||
|
||||
# Serve the Vue frontend (needs to be last if SPA catch-all is used)
|
||||
frontend.route(app, "/")
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
/**
|
||||
* FastAPI-Vue Vite Plugin
|
||||
* auto-upgrade@fastapi-vue-setup -- remove this if you edit the plugin
|
||||
*
|
||||
* Configures Vite for FastAPI backend integration:
|
||||
* - Proxies /api/* requests to the FastAPI backend
|
||||
* - Builds to the Python module's frontend-build directory
|
||||
*
|
||||
* Environment variables (with defaults):
|
||||
* VITE_PORT=5173 - Vite dev server port
|
||||
* VITE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying
|
||||
* Options:
|
||||
* paths - Array of paths to proxy (default: ["/api"])
|
||||
*/
|
||||
|
||||
const backendUrl =
|
||||
process.env.VITE_BACKEND_URL || "http://localhost:{{BACKEND_PORT}}";
|
||||
const vitePort = parseInt(process.env.VITE_PORT || "{{VITE_PORT}}");
|
||||
export default function fastapiVue({ paths = ["/api"] } = {}) {
|
||||
const backendUrl = process.env.ENVPREFIX_BACKEND_URL || "http://localhost:TEMPLATE_DEV_PORT"
|
||||
|
||||
// Build proxy configuration for each path
|
||||
const proxy = {}
|
||||
for (const path of paths) {
|
||||
proxy[path] = {
|
||||
target: backendUrl,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
}
|
||||
}
|
||||
|
||||
export default function fastapiVue() {
|
||||
return {
|
||||
name: "fastapi-vite",
|
||||
name: "vite-plugin-fastapi-MODULE_NAME",
|
||||
config: () => ({
|
||||
server: {
|
||||
host: "localhost",
|
||||
port: vitePort,
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: backendUrl,
|
||||
changeOrigin: false,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
server: { proxy },
|
||||
build: {
|
||||
outDir: "../{{MODULE_NAME}}/frontend-build",
|
||||
outDir: "../MODULE_NAME/frontend-build",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Regular → Executable
+60
-245
@@ -1,267 +1,82 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
"""Run Vite development server for frontend and FastAPI backend with auto-reload.
|
||||
|
||||
Usage:
|
||||
uv run scripts/devserver.py [host:port]
|
||||
|
||||
The optional host:port argument sets where the Vite frontend listens.
|
||||
Supported forms: host[:port], :port (all interfaces), or just port.
|
||||
Backend always listens on localhost:{{BACKEND_PORT}}.
|
||||
|
||||
Environment:
|
||||
JS_RUNTIME Path or name of JS runtime to use (deno, npm/node or bun).
|
||||
FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is.
|
||||
"""
|
||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||
"""Run Vite development server for Vue app and FastAPI backend with auto-reload."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import ipaddress
|
||||
import os
|
||||
import sys
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
# Import util.py from scripts/fastapi-vue (not a package, so we adjust sys.path)
|
||||
sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue")))
|
||||
from devutil import ( # type: ignore
|
||||
ProcessGroup,
|
||||
check_ports_free,
|
||||
logger,
|
||||
ready,
|
||||
setup_cli,
|
||||
setup_vite,
|
||||
)
|
||||
|
||||
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
|
||||
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_VITE_PORT = {{VITE_PORT}}
|
||||
BACKEND_PORT = {{BACKEND_PORT}}
|
||||
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
|
||||
|
||||
BUN_BUG = """\
|
||||
┃ ⚠️ Bun cannot correctly proxy API requests to the backend.
|
||||
┃ Bug report: https://github.com/oven-sh/bun/issues/9882
|
||||
┃
|
||||
┃ Consider using deno or npm instead for development.
|
||||
"""
|
||||
|
||||
|
||||
def parse_endpoint(value: str | None) -> tuple[str | None, int, bool]:
|
||||
"""Parse an endpoint for Vite (no unix socket support).
|
||||
|
||||
Returns (host, port, all_ifaces).
|
||||
"""
|
||||
if not value:
|
||||
return DEFAULT_HOST, DEFAULT_VITE_PORT, False
|
||||
|
||||
# Port only (numeric) -> localhost:port
|
||||
if value.isdigit():
|
||||
return DEFAULT_HOST, int(value), False
|
||||
|
||||
# Leading colon :port -> bind all interfaces
|
||||
if value.startswith(":") and value != ":":
|
||||
port_part = value[1:]
|
||||
if not port_part.isdigit():
|
||||
raise SystemExit(f"Invalid port in '{value}'")
|
||||
return None, int(port_part), True
|
||||
|
||||
# Unbracketed IPv6 (cannot safely contain a port)
|
||||
if value.count(":") > 1 and not value.startswith("["):
|
||||
try:
|
||||
ipaddress.IPv6Address(value)
|
||||
except ValueError as e:
|
||||
raise SystemExit(f"Invalid IPv6 address '{value}': {e}") from e
|
||||
return value, DEFAULT_VITE_PORT, False
|
||||
|
||||
# Use urllib.parse for everything else
|
||||
parsed = urlparse(f"//{value}")
|
||||
host = parsed.hostname or DEFAULT_HOST
|
||||
port = parsed.port or DEFAULT_VITE_PORT
|
||||
|
||||
return host, port, False
|
||||
|
||||
|
||||
def resolve_frontend_tools(
|
||||
vite_host: str | None, vite_port: int, all_ifaces: bool
|
||||
) -> tuple[list[str], list[str], str]:
|
||||
"""Resolve frontend install and dev commands.
|
||||
|
||||
Returns (install_cmd, dev_cmd, tool_name).
|
||||
Raises SystemExit if tools are not available.
|
||||
"""
|
||||
if not (FRONTEND_PATH / "package.json").exists():
|
||||
stderr.write(f"┃ ⚠️ Frontend source not found at {FRONTEND_PATH}\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
result = find_js_runtime()
|
||||
if result is None:
|
||||
if not os.environ.get("JS_RUNTIME"):
|
||||
stderr.write("┃ ⚠️ deno, npm or bun needed to run the frontend server.\n")
|
||||
raise SystemExit(1)
|
||||
|
||||
tool, name = result
|
||||
|
||||
install_args = {
|
||||
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
|
||||
"npm": ("install", "--silent"),
|
||||
"bun": ("install", "--silent"),
|
||||
}
|
||||
dev_args = {
|
||||
"deno": ("run", "dev", "--"),
|
||||
"npm": ("--silent", "run", "dev", "--"),
|
||||
"bun": ("run", "dev", "--"),
|
||||
}
|
||||
|
||||
install_cmd = [tool, *install_args[name]]
|
||||
dev_cmd = [tool, *dev_args[name], "--port", str(vite_port)]
|
||||
|
||||
if all_ifaces:
|
||||
dev_cmd.append("--host")
|
||||
elif vite_host:
|
||||
dev_cmd.extend(["--host", vite_host])
|
||||
|
||||
if name == "bun":
|
||||
stderr.write(BUN_BUG)
|
||||
|
||||
return install_cmd, dev_cmd, name
|
||||
|
||||
|
||||
async def wait_for_backend():
|
||||
"""Wait for the backend to be ready by polling the health endpoint."""
|
||||
max_attempts = 50
|
||||
async with httpx.AsyncClient() as client:
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
await client.get(f"http://localhost:{BACKEND_PORT}", timeout=1.0)
|
||||
stderr.write("✓ Backend ready!\n")
|
||||
return True
|
||||
except httpx.RequestError:
|
||||
if attempt == max_attempts - 1:
|
||||
stderr.write("┃ ⚠️ Backend didn't start in time\n")
|
||||
return False
|
||||
await asyncio.sleep(0.1)
|
||||
return False
|
||||
|
||||
|
||||
async def _terminate_process(proc: asyncio.subprocess.Process, name: str) -> None:
|
||||
"""Gracefully terminate a subprocess."""
|
||||
if proc.returncode is not None:
|
||||
return
|
||||
try:
|
||||
proc.terminate()
|
||||
except ProcessLookupError:
|
||||
return
|
||||
try:
|
||||
await asyncio.wait_for(proc.wait(), timeout=2)
|
||||
except TimeoutError:
|
||||
try:
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
return
|
||||
await proc.wait()
|
||||
DEFAULT_VITE_PORT = TEMPLATE_VITE_PORT
|
||||
DEFAULT_DEV_PORT = TEMPLATE_DEV_PORT
|
||||
|
||||
|
||||
async def run_devserver(
|
||||
vite_host: str | None, vite_port: int, all_ifaces: bool
|
||||
listen: str, backend: str, extra_args: list[str] | None = None
|
||||
) -> None:
|
||||
"""Run the development server with install, backend, and frontend."""
|
||||
install_cmd, dev_cmd, tool_name = resolve_frontend_tools(
|
||||
vite_host, vite_port, all_ifaces
|
||||
)
|
||||
reporoot = Path(__file__).parent.parent
|
||||
front = reporoot / "frontend"
|
||||
if not (front / "package.json").exists():
|
||||
logger.warning("Frontend source not found at %s", front)
|
||||
raise SystemExit(1)
|
||||
|
||||
# Tell the backend where the Vite dev server is
|
||||
os.environ["FASTAPI_VUE_FRONTEND_URL"] = (
|
||||
f"http://{vite_host or 'localhost'}:{vite_port}"
|
||||
)
|
||||
# Tell Vite where the backend is (for proxying /api requests)
|
||||
os.environ["VITE_BACKEND_URL"] = f"http://localhost:{BACKEND_PORT}"
|
||||
cwd = str(Path(__file__).parent.parent)
|
||||
frontend_cwd = str(FRONTEND_PATH)
|
||||
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
|
||||
backurl, MODULE_NAME = setup_cli("PROJECT_CLI", backend, DEFAULT_DEV_PORT)
|
||||
|
||||
backend_proc: asyncio.subprocess.Process | None = None
|
||||
install_proc: asyncio.subprocess.Process | None = None
|
||||
frontend_proc: asyncio.subprocess.Process | None = None
|
||||
# Tell the everyone by environment (vite proxy and backend devmode use these)
|
||||
os.environ["ENVPREFIX_VITE_URL"] = viteurl
|
||||
os.environ["ENVPREFIX_BACKEND_URL"] = backurl
|
||||
os.environ["ENVPREFIX_DEV"] = "1"
|
||||
|
||||
try:
|
||||
# Start install (concurrent with backend)
|
||||
stderr.write(f">>> {tool_name} {' '.join(install_cmd[1:])}\n")
|
||||
install_proc = await asyncio.create_subprocess_exec(
|
||||
*install_cmd, cwd=frontend_cwd
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Start backend (concurrent with install)
|
||||
backend_cmd = [
|
||||
"uvicorn",
|
||||
"{{MODULE_NAME}}.app:app",
|
||||
"--host",
|
||||
"localhost",
|
||||
"--port",
|
||||
str(BACKEND_PORT),
|
||||
"--reload",
|
||||
]
|
||||
stderr.write(f">>> {' '.join(backend_cmd)}\n")
|
||||
backend_proc = await asyncio.create_subprocess_exec(*backend_cmd, cwd=cwd)
|
||||
|
||||
# Wait for install to complete and backend to be ready
|
||||
install_task = asyncio.create_task(install_proc.wait(), name="install")
|
||||
backend_ready_task = asyncio.create_task(
|
||||
wait_for_backend(), name="backend_ready"
|
||||
)
|
||||
|
||||
done, pending = await asyncio.wait(
|
||||
{install_task, backend_ready_task},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
for task in done:
|
||||
if task.get_name() == "install":
|
||||
if task.result() != 0:
|
||||
stderr.write("┃ ⚠️ Install failed\n")
|
||||
raise SystemExit(1)
|
||||
elif task.get_name() == "backend_ready" and not task.result():
|
||||
raise SystemExit(1)
|
||||
|
||||
if pending:
|
||||
done2, _ = await asyncio.wait(pending)
|
||||
for task in done2:
|
||||
if task.get_name() == "install":
|
||||
if task.result() != 0:
|
||||
stderr.write("┃ ⚠️ Install failed\n")
|
||||
raise SystemExit(1)
|
||||
elif task.get_name() == "backend_ready" and not task.result():
|
||||
raise SystemExit(1)
|
||||
|
||||
install_proc = None
|
||||
|
||||
# Start Vite dev server
|
||||
stderr.write(f">>> {tool_name} {' '.join(dev_cmd[1:])}\n")
|
||||
frontend_proc = await asyncio.create_subprocess_exec(*dev_cmd, cwd=frontend_cwd)
|
||||
|
||||
# Wait for either process to exit
|
||||
done, pending = await asyncio.wait(
|
||||
{
|
||||
asyncio.create_task(backend_proc.wait(), name="backend"),
|
||||
asyncio.create_task(frontend_proc.wait(), name="frontend"),
|
||||
},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for t in done:
|
||||
t.result()
|
||||
for t in pending:
|
||||
t.cancel()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
stderr.write("\n✓ Shutting down...\n")
|
||||
finally:
|
||||
if frontend_proc is not None:
|
||||
await _terminate_process(frontend_proc, "frontend")
|
||||
if install_proc is not None:
|
||||
await _terminate_process(install_proc, "install")
|
||||
if backend_proc is not None:
|
||||
await _terminate_process(backend_proc, "backend")
|
||||
async with ProcessGroup() as pg:
|
||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||
await check_ports_free(viteurl, backurl)
|
||||
await pg.spawn(*MODULE_NAME, *(extra_args or []))
|
||||
await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
|
||||
await pg.spawn(*vite, cwd=front)
|
||||
|
||||
|
||||
def main():
|
||||
hostport = sys.argv[1] if len(sys.argv) > 1 else None
|
||||
vite_host, vite_port, all_ifaces = parse_endpoint(hostport)
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run Vite and FastAPI development servers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=HELP_EPILOG,
|
||||
)
|
||||
parser.add_argument(
|
||||
"-l",
|
||||
"--listen",
|
||||
metavar="addr",
|
||||
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
metavar="addr",
|
||||
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
||||
)
|
||||
args, extra_args = parser.parse_known_args()
|
||||
with suppress(KeyboardInterrupt):
|
||||
asyncio.run(run_devserver(args.listen, args.backend, extra_args))
|
||||
|
||||
with contextlib.suppress(KeyboardInterrupt):
|
||||
asyncio.run(run_devserver(vite_host, vite_port, all_ifaces))
|
||||
|
||||
HELP_EPILOG = """
|
||||
Other options are forwarded to PROJECT_CLI [args]
|
||||
|
||||
JS_RUNTIME environment variable can be used to select the JS runtime:
|
||||
npm, deno, bun, or full path to the runtime executable (node maps to npm).
|
||||
"""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,34 +1,15 @@
|
||||
"""Hatch build hook for building Vue frontend during package build."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
|
||||
from hatchling.builders.hooks.plugin.interface import BuildHookInterface # type: ignore
|
||||
|
||||
exec(Path(__file__).with_name("util.py").read_text("UTF-8")) # noqa: S102
|
||||
|
||||
|
||||
def run(cmd, **kwargs):
|
||||
"""Run a command and display it."""
|
||||
display_cmd = [Path(cmd[0]).name, *cmd[1:]]
|
||||
stderr.write(f"### {' '.join(display_cmd)}\n")
|
||||
subprocess.run(cmd, check=True, **kwargs)
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
from buildutil import build
|
||||
|
||||
|
||||
class CustomBuildHook(BuildHookInterface):
|
||||
"""Build hook that compiles Vue frontend before packaging."""
|
||||
|
||||
def initialize(self, version, build_data):
|
||||
super().initialize(version, build_data)
|
||||
stderr.write(">>> Building the frontend\n")
|
||||
|
||||
install_cmd, build_cmd = find_build_tool()
|
||||
|
||||
try:
|
||||
run(install_cmd, cwd="frontend")
|
||||
stderr.write("\n")
|
||||
run(build_cmd, cwd="frontend")
|
||||
except Exception as e:
|
||||
stderr.write(f"Error occurred while building frontend: {e}\n")
|
||||
raise
|
||||
build("frontend")
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Utilities used at build time and in devserver script. No dependencies."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class _PrefixFormatter(logging.Formatter):
|
||||
"""Formatter that adds prefix based on log level."""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
if record.levelno >= logging.WARNING:
|
||||
return f"⚠️ {record.getMessage()}"
|
||||
return record.getMessage()
|
||||
|
||||
|
||||
_handler = logging.StreamHandler()
|
||||
_handler.setFormatter(_PrefixFormatter())
|
||||
logger = logging.getLogger("fastapi-vue")
|
||||
logger.addHandler(_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def _check_node_version(node_path: str) -> None:
|
||||
"""Check if Node.js version is >= 20.
|
||||
|
||||
Raises RuntimeError if version is too old or cannot be determined.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[node_path, "--version"], capture_output=True, text=True, check=True
|
||||
)
|
||||
version_str = result.stdout.strip()
|
||||
# Parse version like "v20.10.0" or "v18.17.1"
|
||||
match = re.match(r"v(\d+)", version_str)
|
||||
if match:
|
||||
major_version = int(match.group(1))
|
||||
if major_version >= 20:
|
||||
return
|
||||
raise RuntimeError(
|
||||
f"Node.js {version_str} found, but v20+ required (install with nvm)"
|
||||
)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, ValueError):
|
||||
pass
|
||||
raise RuntimeError("Could not determine Node.js version")
|
||||
|
||||
|
||||
def find_js_runtime() -> tuple[str, str]:
|
||||
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||
|
||||
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||
Raises JSRuntimeError if no suitable runtime is found.
|
||||
"""
|
||||
options = ["npm", "deno", "bun"]
|
||||
node_version_error: RuntimeError | None = None
|
||||
|
||||
# Check for JS_RUNTIME environment variable
|
||||
if js_runtime_env := os.environ.get("JS_RUNTIME"):
|
||||
js_runtime = js_runtime_env
|
||||
js_path = Path(js_runtime)
|
||||
runtime_name = js_path.name
|
||||
# Map node to npm
|
||||
if runtime_name == "node":
|
||||
runtime_name = "npm"
|
||||
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
||||
for option in options:
|
||||
if option == runtime_name or runtime_name.startswith(option):
|
||||
tool = shutil.which(js_runtime)
|
||||
if tool is None:
|
||||
raise RuntimeError(
|
||||
f"JS_RUNTIME={js_runtime_env}: {option} not found"
|
||||
)
|
||||
# Check Node.js version if using npm
|
||||
if option == "npm":
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path is None:
|
||||
raise RuntimeError(
|
||||
f"JS_RUNTIME={js_runtime_env}: node not found"
|
||||
)
|
||||
_check_node_version(node_path) # Raises on failure
|
||||
return tool, option
|
||||
raise RuntimeError(f"JS_RUNTIME={js_runtime_env} not recognized")
|
||||
|
||||
# Auto-detect
|
||||
for option in options:
|
||||
if tool := shutil.which(option):
|
||||
# Check Node.js version if using npm
|
||||
if option == "npm":
|
||||
node_path = shutil.which("node", path=str(Path(tool).parent))
|
||||
if node_path is None:
|
||||
continue
|
||||
try:
|
||||
_check_node_version(node_path)
|
||||
except RuntimeError as e:
|
||||
node_version_error = e
|
||||
continue # Try next runtime
|
||||
return tool, option
|
||||
|
||||
# No runtime found - provide helpful error
|
||||
if node_version_error:
|
||||
raise node_version_error
|
||||
raise RuntimeError("Node.js (v20+), Deno or Bun is required but none was found")
|
||||
|
||||
|
||||
def find_build_tool():
|
||||
"""Find JavaScript runtime and construct install/build commands.
|
||||
|
||||
Returns (install_cmd, build_cmd) tuples of command lists.
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
install = {
|
||||
"deno": ("install", "--allow-scripts=npm:vue-demi"),
|
||||
"npm": ("install",),
|
||||
"bun": ("--bun", "install"),
|
||||
}
|
||||
# Run vite directly for deno to avoid npm-run-all2/run-p issues
|
||||
build = {
|
||||
"deno": ("run", "-A", "npm:vite", "build"),
|
||||
"npm": ("run", "build"),
|
||||
"bun": ("--bun", "run", "build"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
return [tool, *install[name]], [tool, *build[name]]
|
||||
|
||||
|
||||
def find_dev_tool() -> list[str]:
|
||||
"""Find JavaScript runtime and construct dev command.
|
||||
|
||||
Returns dev_cmd (without vite-specific args).
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
dev_args = {
|
||||
"deno": ("run", "-A", "npm:vite"),
|
||||
"npm": ("--silent", "run", "dev", "--"),
|
||||
"bun": ("run", "dev", "--"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
|
||||
if name == "bun":
|
||||
logger.warning(
|
||||
"Bun has a bug in WS proxying (https://github.com/oven-sh/bun/issues/9882). Consider using npm instead."
|
||||
)
|
||||
|
||||
return [tool, *dev_args[name]]
|
||||
|
||||
|
||||
def find_install_tool() -> list[str]:
|
||||
"""Find JavaScript runtime and construct install command.
|
||||
|
||||
Returns install_cmd.
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
install_args = {
|
||||
"deno": ("install", "--quiet", "--allow-scripts=npm:vue-demi"),
|
||||
"npm": ("install", "--silent"),
|
||||
"bun": ("install", "--silent"),
|
||||
}
|
||||
|
||||
tool, name = find_js_runtime()
|
||||
return [tool, *install_args[name]]
|
||||
|
||||
|
||||
def build(folder: str = "frontend") -> None:
|
||||
"""Build the frontend in the specified folder.
|
||||
|
||||
Raises SystemExit(1) on failure.
|
||||
"""
|
||||
logger.info(">>> Building %s", folder)
|
||||
|
||||
try:
|
||||
install_cmd, build_cmd = find_build_tool()
|
||||
except RuntimeError as e:
|
||||
logger.warning(e)
|
||||
raise SystemExit(1)
|
||||
|
||||
def run(cmd):
|
||||
display_cmd = [Path(cmd[0]).stem, *cmd[1:]]
|
||||
logger.info("### %s", " ".join(display_cmd))
|
||||
subprocess.run(cmd, check=True, cwd=folder)
|
||||
|
||||
try:
|
||||
run(install_cmd)
|
||||
logger.info("")
|
||||
run(build_cmd)
|
||||
except subprocess.CalledProcessError:
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Coroutine
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from buildutil import find_dev_tool, find_install_tool, logger
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
|
||||
|
||||
class ProcessGroup:
|
||||
"""Manage async subprocesses with automatic cleanup, like TaskGroup for processes."""
|
||||
|
||||
def __init__(self):
|
||||
self._procs: list[asyncio.subprocess.Process] = []
|
||||
self._cmds: dict[int, str] = {} # pid -> command name
|
||||
|
||||
async def spawn(
|
||||
self, *cmd: str, cwd: str | None = None
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Spawn a subprocess and track it."""
|
||||
cmd_name = Path(cmd[0]).stem
|
||||
logger.info(">>> %s", " ".join([cmd_name, *cmd[1:]]))
|
||||
proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd)
|
||||
self._procs.append(proc)
|
||||
self._cmds[proc.pid] = cmd_name
|
||||
return proc
|
||||
|
||||
async def wait(
|
||||
self, *waitables: "asyncio.subprocess.Process | Coroutine[Any, Any, Any]"
|
||||
) -> None:
|
||||
"""Wait for processes/coroutines to complete, raise SystemExit on failure."""
|
||||
|
||||
async def wait_proc(proc: asyncio.subprocess.Process) -> None:
|
||||
returncode = await proc.wait()
|
||||
if returncode != 0:
|
||||
cmd_name = self._cmds.get(proc.pid, "unknown")
|
||||
raise subprocess.CalledProcessError(returncode, cmd_name)
|
||||
|
||||
tasks = [
|
||||
wait_proc(w) if isinstance(w, asyncio.subprocess.Process) else w
|
||||
for w in waitables
|
||||
]
|
||||
try:
|
||||
await asyncio.gather(*tasks)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.warning("%s failed with exit status %d", e.cmd, e.returncode)
|
||||
raise SystemExit(1) from None
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, *_):
|
||||
"""Wait for one process to exit, terminate others, then wait for all."""
|
||||
await self._cleanup(immediate=exc_type is not None)
|
||||
|
||||
async def _cleanup(self, immediate: bool = False):
|
||||
running = [p for p in self._procs if p.returncode is None]
|
||||
if not running:
|
||||
return
|
||||
|
||||
if not immediate:
|
||||
# Wait for any one process to exit
|
||||
with suppress(asyncio.CancelledError):
|
||||
await asyncio.wait(
|
||||
[asyncio.create_task(p.wait()) for p in running],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# Terminate remaining processes
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
p.terminate()
|
||||
|
||||
# Wait for all to finish (with overall timeout), shielded from cancellation
|
||||
still_running = [p for p in self._procs if p.returncode is None]
|
||||
if still_running:
|
||||
with suppress(asyncio.CancelledError):
|
||||
try:
|
||||
await asyncio.shield(
|
||||
asyncio.wait_for(
|
||||
asyncio.gather(*[p.wait() for p in still_running]),
|
||||
timeout=10,
|
||||
)
|
||||
)
|
||||
except TimeoutError:
|
||||
for p in self._procs:
|
||||
if p.returncode is None:
|
||||
with suppress(ProcessLookupError):
|
||||
p.kill()
|
||||
await p.wait()
|
||||
|
||||
|
||||
async def check_ports_free(*urls: str) -> None:
|
||||
"""Verify URLs are not responding (ports are free). Raise SystemExit if any respond."""
|
||||
|
||||
async def check(client: httpx.AsyncClient, url: str) -> None:
|
||||
with suppress(httpx.RequestError):
|
||||
res = await client.get(url, timeout=0.1)
|
||||
server = res.headers.get("server", "server")
|
||||
logger.warning("Conflicting %s already running at %s", server, url)
|
||||
raise SystemExit(1)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
await asyncio.gather(*[check(client, url) for url in urls])
|
||||
|
||||
|
||||
async def ready(url: str, path: str = "") -> None:
|
||||
"""Wait for the server to be ready by polling an endpoint.
|
||||
|
||||
Raises SystemExit(1) if server doesn't start in time.
|
||||
"""
|
||||
max_attempts = 50
|
||||
full_url = f"{url}{path}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
await client.get(full_url, timeout=1.0)
|
||||
logger.info("✓ Backend ready!")
|
||||
return
|
||||
except httpx.RequestError:
|
||||
if attempt == max_attempts - 1:
|
||||
logger.warning("Backend didn't start in time")
|
||||
raise SystemExit(1)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
|
||||
def setup_vite(
|
||||
endpoint: str, default_port: int = 5173
|
||||
) -> tuple[str, list[str], list[str]]:
|
||||
"""Parse frontend endpoint and build commands.
|
||||
|
||||
Returns (url, install_cmd, dev_cmd).
|
||||
Raises SystemExit(1) on invalid config.
|
||||
"""
|
||||
endpoints = parse_endpoint(endpoint, default_port)
|
||||
|
||||
if "uds" in endpoints[0]:
|
||||
logger.warning("Unix sockets not supported with vite devserver")
|
||||
raise SystemExit(1)
|
||||
|
||||
port = endpoints[0]["port"]
|
||||
host = endpoints[0]["host"]
|
||||
|
||||
install_cmd = find_install_tool()
|
||||
dev_cmd = find_dev_tool()
|
||||
if host != "localhost":
|
||||
dev_cmd.append("--host" if len(endpoints) > 1 else f"--host={host}")
|
||||
dev_cmd.append(f"--port={port}")
|
||||
|
||||
return f"http://{host}:{port}", install_cmd, dev_cmd
|
||||
|
||||
|
||||
def setup_fastapi(
|
||||
endpoint: str, module: str, default_port: int = 8000
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Parse backend endpoint and build uvicorn command.
|
||||
|
||||
Returns (url, uvicorn_cmd).
|
||||
Raises SystemExit(1) on invalid config.
|
||||
"""
|
||||
endpoints = parse_endpoint(endpoint, default_port)
|
||||
|
||||
if "uds" in endpoints[0]:
|
||||
logger.warning("Unix sockets not supported with vite devserver")
|
||||
raise SystemExit(1)
|
||||
|
||||
host = endpoints[0]["host"]
|
||||
port = endpoints[0]["port"]
|
||||
reload_dir = module.split(".")[0] # Don't reload on frontend changes
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
module,
|
||||
f"--host={host}",
|
||||
f"--port={port}",
|
||||
"--reload",
|
||||
f"--reload-dir={reload_dir}",
|
||||
"--forwarded-allow-ips=*",
|
||||
]
|
||||
return f"http://{host}:{port}", cmd
|
||||
|
||||
|
||||
def setup_cli(
|
||||
cli: str, endpoint: str, default_port: int = 8000
|
||||
) -> tuple[str, list[str]]:
|
||||
"""Parse backend endpoint and build CLI command.
|
||||
|
||||
Returns (url, cli_cmd).
|
||||
Raises SystemExit(1) on invalid config.
|
||||
"""
|
||||
endpoints = parse_endpoint(endpoint, default_port)
|
||||
|
||||
if "uds" in endpoints[0]:
|
||||
logger.warning("Unix sockets not supported with vite devserver")
|
||||
raise SystemExit(1)
|
||||
|
||||
host = endpoints[0]["host"]
|
||||
port = endpoints[0]["port"]
|
||||
|
||||
cmd = [cli, f"--listen={host}:{port}"]
|
||||
return f"http://{host}:{port}", cmd
|
||||
@@ -1,86 +0,0 @@
|
||||
"""Shared utilities for build and dev scripts."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
|
||||
|
||||
def find_js_runtime() -> tuple[str, str] | None:
|
||||
"""Find a JavaScript runtime from JS_RUNTIME env or auto-detect.
|
||||
|
||||
Returns (tool_path, tool_name) where tool_name is "deno", "npm", or "bun".
|
||||
Returns None if no runtime is found.
|
||||
"""
|
||||
options = ["deno", "npm", "bun"]
|
||||
|
||||
# Check for JS_RUNTIME environment variable
|
||||
if js_runtime_env := os.environ.get("JS_RUNTIME"):
|
||||
js_runtime = js_runtime_env
|
||||
js_path = Path(js_runtime)
|
||||
runtime_name = js_path.name
|
||||
# Map node to npm
|
||||
if runtime_name == "node":
|
||||
runtime_name = "npm"
|
||||
js_runtime = str(js_path.parent / "npm") if js_path.parent.name else "npm"
|
||||
for option in options:
|
||||
if option == runtime_name or runtime_name.startswith(option):
|
||||
tool = shutil.which(js_runtime)
|
||||
if tool is None:
|
||||
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not found\n")
|
||||
return None
|
||||
return tool, option
|
||||
stderr.write(f"┃ ⚠️ JS_RUNTIME={js_runtime_env} not recognized\n")
|
||||
return None
|
||||
|
||||
# Auto-detect
|
||||
for option in options:
|
||||
if tool := shutil.which(option):
|
||||
return tool, option
|
||||
return None
|
||||
|
||||
|
||||
def find_build_tool():
|
||||
"""Find JavaScript runtime and construct install/build commands.
|
||||
|
||||
Returns (install_cmd, build_cmd) tuples of command lists.
|
||||
Raises RuntimeError if no runtime is found.
|
||||
"""
|
||||
install = {
|
||||
"deno": ("install", "--allow-scripts=npm:vue-demi"),
|
||||
"npm": ("install",),
|
||||
"bun": ("--bun", "install"),
|
||||
}
|
||||
build = {
|
||||
"deno": ("task", "build"),
|
||||
"npm": ("run", "build"),
|
||||
"bun": ("--bun", "run", "build"),
|
||||
}
|
||||
|
||||
result = find_js_runtime()
|
||||
if result is None:
|
||||
raise RuntimeError(
|
||||
"Deno, npm or Bun is required for building but none was found"
|
||||
)
|
||||
|
||||
tool, name = result
|
||||
return [tool, *install[name]], [tool, *build[name]]
|
||||
|
||||
|
||||
def find_dev_tool():
|
||||
"""Find JavaScript runtime and construct dev command.
|
||||
|
||||
Returns (dev_cmd, tool_name) or (None, None) if not found.
|
||||
"""
|
||||
dev_args = {
|
||||
"deno": ("run", "dev", "--"),
|
||||
"npm": ("--silent", "run", "dev", "--"),
|
||||
"bun": ("run", "dev", "--"),
|
||||
}
|
||||
|
||||
result = find_js_runtime()
|
||||
if result is None:
|
||||
return None, None
|
||||
|
||||
tool, name = result
|
||||
return [tool, *dev_args[name]], name
|
||||
Reference in New Issue
Block a user