Compare commits

...
49 Commits
Author SHA1 Message Date
LeoVasanko c63a375ab2 Use ## to allow command be pasted as a whole. 2026-02-10 17:14:14 +00:00
LeoVasanko c98994fd1a Dry run cleanup, also allow --dry-run. 2026-02-10 17:06:42 +00:00
LeoVasanko 6beaa88418 Improved setup complete message. 2026-02-10 17:00:24 +00:00
LeoVasanko 26a77dcf84 Cleaner messages while running the setup. 2026-02-10 16:39:45 +00:00
LeoVasanko 38c9ffe00b Add --version, rename --dry-run to just --dry. 2026-02-10 16:29:30 +00:00
LeoVasanko b87984af9a Consistent form of CLI arguments. 2026-02-10 16:16:32 +00:00
LeoVasanko d3b6fccce0 The devserver now takes --listen for vite endpoint rather than a positional argument. Changed FRONTEND_URL to VITE_URL and streamlined terminology elsewhere. 2026-02-10 16:09:07 +00:00
LeoVasanko d2ee7395c5 health?from=frontend for logs 2026-02-10 16:01:06 +00:00
LeoVasanko b94608ffe3 Run ruff within local venv (otherwise required system path which did not include the venv with uv tool install). 2026-02-09 16:03:30 +00:00
LeoVasanko ef6bce8f3f Use the CLI script rather than python -m for running the main app from devserver. 2026-02-09 15:55:08 +00:00
LeoVasanko b8bf46132a Reload arguments passed differently to avoid uvicorn warning in prod mode. 2026-02-06 22:43:00 +00:00
LeoVasanko ca85c2fcfb Ruff 2026-02-06 22:37:03 +00:00
LeoVasanko 440808361a Patch existing projects better. 2026-02-06 20:09:17 +00:00
LeoVasanko 05fd5c562e Missing import 2026-02-06 19:35:04 +00:00
LeoVasanko 5f0e74713f Improved dry mode message. 2026-02-06 19:17:06 +00:00
LeoVasanko 2d117a9d5e Improved messages. 2026-02-06 19:07:50 +00:00
LeoVasanko 4bf102579a Added automatic handling of FastAPI debug mode. Use templating more completely to enable project name as env prefix etc. 2026-02-06 19:05:36 +00:00
LeoVasanko fbed7873e5 [fastapi_vue] Further cleanup, wildcards for favicon. 2026-02-06 18:09:22 +00:00
LeoVasanko 008b3593db [fastapi_vue] StaticFiles cleanup: use app.debug rather than env variables; default assets caching. 2026-02-06 17:21:40 +00:00
LeoVasanko 3820c6f3c1 Fix argument passing to vite under deno. 2026-02-05 20:11:22 +00:00
LeoVasanko c300a1a291 Simplified uvicorn running in multiprocess mode, avoiding a crash on Windows when it couldn't setup threads (running from a worker thread). 2026-02-02 21:02:34 +00:00
LeoVasanko bdfaa22b0c Another file extension showing, swapping to stem for logging. 2026-02-02 20:47:32 +00:00
LeoVasanko 47e5ec279c Hide tool.CMD .EXE etc. from logs, makes Windows cleaner. 2026-02-02 20:42:18 +00:00
LeoVasanko 093b83737e Improved status messages, added a note of not installing main CLI entry point. 2026-02-02 20:35:30 +00:00
LeoVasanko a84041a923 Refactor with new fastapi-vue utility module to run the server, used in CLI main and devserver to run the main app. Default ports made part of templating, so that --ports can be easily specified to fastapi-vue-setup to customize (respects earlier choices when no --ports is specified). New overall default ports 3100, 3200. 2026-02-02 19:56:31 +00:00
LeoVasanko 9a9a00380f Add a release script to simplify building both. 2026-02-02 19:53:06 +00:00
LeoVasanko 0e4f8f65f2 Moved fastapi-vue project to fastapi-vue-setup repository, using common version numbering (starting with v0.7.0). 2026-02-02 19:51:45 +00:00
LeoVasanko 5391c81e8b Cleanup fixes, and-- cleanup. 2026-02-02 16:43:39 +00:00
LeoVasanko c106ce1a44 Add missing files. 2026-02-02 16:27:34 +00:00
LeoVasanko b7b1920846 Make devserver verify that no servers are already running before starting them. 2026-02-02 16:26:19 +00:00
LeoVasanko 8ccfa1f658 Simplified devserver script, fixed termination on failed npm install. 2026-02-02 15:56:19 +00:00
LeoVasanko c23401de88 Avoid external venv or of fastapi-vue-setup itself leaking into target project. Cleaner and faster uv processing. 2026-02-02 13:46:34 +00:00
LeoVasanko c271f66615 Work around some Microsoft idiocy to attain Windows compatibility. 2026-02-02 13:28:36 +00:00
LeoVasanko 458d798022 Refactored scripts to remove any extra code from build-frontend.py and devserver.py (for better user editability). Individual helper functions can still be imported from scripts/fastapi-vue/ for any customization needs. Removed devmode support from CLI entry point simplifying it. Instead running via FastAPI CLI in dev mode (but it is still easy to modify to use a custom CLI entry). 2026-01-30 18:15:52 +00:00
LeoVasanko f5fcd626e8 Update templates for fastapi_vue 0.5.0 catch-all toggle. 2026-01-25 01:07:56 +00:00
LeoVasanko 1d9ddf7dc5 Update target project python version requirement if needed. 2026-01-24 21:31:07 +00:00
LeoVasanko b189ae66e7 Smarter .gitignore patching. 2026-01-24 21:16:05 +00:00
LeoVasanko 4156eb2db3 Better handling of upgrades, an option to avoid overwriting main and devserver scripts, writing .new.py instead. Error out on unix socket in devserver. 2026-01-21 21:13:23 +00:00
LeoVasanko 3f6559b2db Simplify devserver script and remove its duplicated parse_endpoint function. 2026-01-18 01:15:34 +00:00
LeoVasanko 71d4e053a7 Strictly correct indent and formatting on Vue demo patches. 2026-01-17 23:21:01 +00:00
LeoVasanko 2dec331afb Setup script cleanup. Use discovered app module and var names when creating __main__.py from template. README updates. 2026-01-17 23:04:07 +00:00
LeoVasanko 64f43df3e3 Completely rewritten CLI main, support unix sockets and wildcard bindings. 2026-01-17 22:48:13 +00:00
LeoVasanko 6ccba2be9f Adjust vite args (cleanup, don't clear screen). 2026-01-17 22:20:27 +00:00
LeoVasanko 2b735528ab Fix another Deno issue running Vue without typescript. 2026-01-17 22:19:28 +00:00
LeoVasanko e6170549f0 README 2026-01-17 20:21:31 +00:00
LeoVasanko a2983a62a0 Simplified templating, backend port config on devserver.py, simplified Vue app patches. 2026-01-17 19:51:47 +00:00
LeoVasanko d9323f34e1 Silence type warnings for symbols that actually are available at build time. 2026-01-17 17:08:50 +00:00
LeoVasanko a4379094db README 2026-01-17 16:42:02 +00:00
LeoVasanko 52ec232ce4 Fix deno npm run-p problem that was preventing TypeScript builds on Deno, by using build-only instead. 2026-01-17 16:40:43 +00:00
19 changed files with 2085 additions and 867 deletions
+61 -48
View File
@@ -1,15 +1,13 @@
# 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).
Tool to create or patch FastAPI project with a Vue frontend, with integrated build and development systems. The Python package will not need any JS runtime because it includes a prebuilt Vue frontend in it. For development (Vite and FastAPI auto reloads) and building the package one of npm, deno or bun is required (node is recommended due to bugs in deno and bun).
## Features
- **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
- **Optimized static serving**: Caching, zstd compression and SPA support
## Installation
@@ -28,77 +26,98 @@ uvx fastapi-vue-setup my-app
## Usage
The script may be used to create an all new project folder or to patch or update an existing project to use this framework. It autodetects the project folder given and performs the appropriate actions.
### Create a new project
```sh
fastapi-vue-setup new my-app
fastapi-vue-setup my-app
```
This will:
1. Run `uv init my-app`
2. Run `npm create vue@latest frontend` (interactive)
2. Run `create-vue frontend` (interactive - choose your Vue options)
3. Patch the project with FastAPI integration
4. Install dependencies via `uv add`
### Patch an existing project
```bash
fastapi-vue-setup patch /path/to/project
You should have your pyproject.toml at the current working directory, and Vue with its package.json under `frontend/`. If either one is missing, new applications will be initialised. Otherwise we only patch what can be patched without breaking your existing projects.
```sh
fastapi-vue-setup .
```
### CLI Options
```
fastapi-vue-setup [project-dir] [options]
Options:
- `--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
--module-name NAME Python module name (auto-detected from pyproject.toml)
--dry-run Preview changes without modifying files
```
Same as `patch --force` - overwrites template files with latest versions.
## Port Configuration
The tool uses three distinct ports:
In development, you access the Vite dev server at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5180. Ports and hosts of Vite and FastAPI are configurable by `devserver.py` arguments.
| 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` |
In production, FastAPI serves both the API and static files at `http://localhost:5080`. Configurable by `host:port` argument with defaults set in `__main__.py`
In development, you access the app at `http://localhost:5173`. Vite proxies `/api/*` requests to FastAPI at port 5174.
## Main CLI
In production, FastAPI serves both the API and static files from the same port (8000).
If your project didn't already have `__main__.py`, we create one that runs the FastAPI app with richer configuration than what the FastAPI CLI offers. Running your module starts it in production mode, and optionally host:port may be given as argument to specify where it listens.
If you are running behind a reverse proxy like [Caddy](https://caddyserver.com/) on localhost, your app will trust the proxy headers it sends. However, if you need to configure another proxy host or IP, set `FORWARDED_ALLOW_IPS` env variable before running the server.
The devserver script depends on this CLI entry for running the backend. You will have to modify the `devserver.py` script if your app has its own incompatible main module. Note that we set FastAPI debug mode and Uvicorn reload when configured via `FASTAPI_VUE_BACKEND_URL` env variable (set by `devserver.py`), while for normal production use these stay disabled. The same variable also controls static files serving (disabled in dev mode).
## Vite Plugin Configuration
The `fastapiVue()` plugin in `vite.config.ts` accepts options to customize proxy behavior:
```js
import fastapiVue from "./vite-plugin-fastapi.js";
export default defineConfig({
plugins: [
vue(),
// Default: proxies only /api
fastapiVue(),
// Or specify custom paths to proxy to backend
fastapiVue({ paths: ["/api", "/auth", "/ws"] }),
],
});
```
The plugin reads environment `FASTAPI_VUE_BACKEND_URL` (default: `http://localhost:5180`) to determine where to proxy requests. This is set automatically by `devserver.py`.
## Project Structure
After patching, your project will have:
```
my-app/
├── frontend/ # Vue.js application
├── frontend/ # Vue application
│ ├── src/
│ ├── vite.config.ts # Builds to ../my_app/frontend-build
│ ├── vite-plugin-fastapi.js
│ ├── vite.config.js
│ └── package.json
├── my_app/ # Python package
├── my_app/ # Python module (files included in sdist)
│ ├── __init__.py
│ ├── __main__.py # CLI entry point
│ ├── __main__.py # CLI entrypoint
│ ├── app.py # FastAPI application
│ └── frontend-build/ # Built frontend (gitignored)
├── scripts/
│ ├── devserver.py # Development server
│ └── fastapi-vue/ # Build utilities
└── pyproject.toml # Project configuration
│ ├── devserver.py # CLI dev server (only in source tree)
│ └── fastapi-vue/
│ ├── build-frontend.py
│ └── util.py
└── pyproject.toml
```
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 project directory tree looks roughly like this after project creation or patching. The script finds your existing app module and other files and patches them with minimal changes to enable the Vue-FastAPI interconnection. New Python and Vue projects are created automatically if none exist.
## Development Workflow
@@ -113,12 +132,6 @@ uv build
uv run my-app
```
## Static File Serving
## Frontend 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
Your FastAPI app will use [fastapi-vue](https://git.zi.fi/LeoVasanko/fastapi-vue) to serve the frontend files. Refer to that package's documentation for further configuration.
+47
View File
@@ -0,0 +1,47 @@
# fastapi-vue
Implements Single-Page-App serving at site root with FastAPI, that the standard StaticFiles module cannot handle. This also caches and zstd compresses the files for lightning-fast operation. This is primarily meant for use with Vue frontend, but technically can host any static files in a similar manner.
## Installation
Script [fastapi-vue-setup](https://git.zi.fi/LeoVasanko/fastapi-vue-setup) should normally be used to convert or create a project with connection between FastAPI and Vue. The target project will depend on this package to serve its static files.
```sh
uvx fastapi-vue-setup --help
```
Refer to instructions below for further configuration.
## Usage
```python
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi_vue import Frontend
frontend = Frontend(
Path(__file__).with_name("frontend-build"),
spa=True,
cached=["/assets/"],
)
@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, "/")
```
## Configuration
- `directory`: Path to static files directory
- `spa`: Enable SPA mode (serve index.html for unknown routes)
- `cached`: Path prefixes for immutable cache headers (browser won't check for changes)
- `favicon`: Path to serve at `/favicon.ico` (e.g., `"/logo.png"` will be served as `image/png`)
- `zstdlevel`: Compression level (default: 18)
+3
View File
@@ -0,0 +1,3 @@
from .staticfiles import Frontend
__all__ = ["Frontend"]
+67
View File
@@ -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}]
+78
View File
@@ -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])
+281
View File
@@ -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": "Frontend assets served by Vite in debug mode. You are on backend, connect to frontend instead."
},
)
+23
View File
@@ -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 = ".."
+945 -373
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -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 }
+35
View File
@@ -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()
-3
View File
@@ -1,3 +0,0 @@
"""{{PROJECT_TITLE}} package."""
__version__ = "0.1.0"
+21 -23
View File
@@ -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 = bool(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": ["MODULE_NAME"]}
server.run(
"MODULE_NAME.APP_MODULE:APP_VAR",
listen=args.listen,
default_port=DEFAULT_PORT,
**(dev if DEVMODE else {}),
)
+9 -17
View File
@@ -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 MODULE_NAME.__main__ 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, "/")
+18 -21
View File
@@ -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_DEFAULT_PORT"
export default function fastapiVue() {
return {
name: "fastapi-vite",
config: () => ({
server: {
host: "localhost",
port: vitePort,
strictPort: true,
proxy: {
"/api": {
// Build proxy configuration for each path
const proxy = {}
for (const path of paths) {
proxy[path] = {
target: backendUrl,
changeOrigin: false,
ws: true,
},
},
},
}
}
return {
name: "vite-plugin-fastapi-MODULE_NAME",
config: () => ({
server: { proxy },
build: {
outDir: "../{{MODULE_NAME}}/frontend-build",
outDir: "../MODULE_NAME/frontend-build",
emptyOutDir: true,
},
}),
};
}
}
Regular → Executable
+58 -244
View File
@@ -1,267 +1,81 @@
#!/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
)
# 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)
backend_proc: asyncio.subprocess.Process | None = None
install_proc: asyncio.subprocess.Process | None = None
frontend_proc: asyncio.subprocess.Process | None = None
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():
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)
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)
viteurl, npm_install, vite = setup_vite(listen, DEFAULT_VITE_PORT)
backurl, MODULE_NAME = setup_cli("PROJECT_CLI", backend, DEFAULT_DEV_PORT)
install_proc = 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"
# 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="host:port",
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
)
parser.add_argument(
"--backend",
metavar="host:port",
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 = """
scripts/devserver.py [args to PROJECT_CLI]
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__":
+5 -24
View File
@@ -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")
+191
View File
@@ -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)
+211
View File
@@ -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
-86
View File
@@ -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