Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a6ef9b7c8f | ||
|
|
44e06df66c |
@@ -3,4 +3,3 @@
|
||||
*.lock
|
||||
__pycache__/
|
||||
dist/
|
||||
/_version.py
|
||||
|
||||
@@ -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)
|
||||
@@ -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,267 @@
|
||||
"""FastAPI static file serving with zstd compression and SPA support."""
|
||||
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
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
|
||||
|
||||
# Dev mode: index files but don't load content, return error responses
|
||||
_DEVMODE = os.getenv("FASTAPI_VUE_FRONTEND_URL")
|
||||
|
||||
logger = logging.getLogger("uvicorn.error") # Use FastAPI logging style
|
||||
|
||||
|
||||
class Frontend:
|
||||
"""Static file server with automatic zstd compression and caching.
|
||||
|
||||
Features:
|
||||
- Automatic zstd compression for compressible files
|
||||
- ETag-based caching with configurable cache headers
|
||||
- SPA (Single Page Application) support
|
||||
- Favicon handling from hashed assets
|
||||
- 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 should have immutable cache headers
|
||||
zstdlevel: Zstd compression level (default: 18)
|
||||
favicon: Path to favicon for automatic /favicon.ico handling
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
directory: Path | str,
|
||||
*,
|
||||
index: str = "index.html",
|
||||
spa: bool = False,
|
||||
catch_all: bool | None = None,
|
||||
cached: str | list[str] | None = None,
|
||||
zstdlevel: int = 18,
|
||||
favicon: str | None = None,
|
||||
) -> 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
|
||||
if cached is None:
|
||||
self.cached_paths = []
|
||||
elif isinstance(cached, str):
|
||||
self.cached_paths = [cached]
|
||||
else:
|
||||
self.cached_paths = cached
|
||||
self.zstdlevel = zstdlevel
|
||||
self.favicon = favicon
|
||||
self.devmode = bool(_DEVMODE)
|
||||
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:
|
||||
p = PurePosixPath(self.favicon)
|
||||
base = str(p.with_suffix(""))
|
||||
ext = p.suffix
|
||||
hashed_path = next(
|
||||
(path for path in www if path.startswith(base) and path.endswith(ext)),
|
||||
None,
|
||||
)
|
||||
if hashed_path:
|
||||
www["/favicon.ico"] = www[hashed_path]
|
||||
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, *, log=True):
|
||||
"""Load or reload static files from disk.
|
||||
|
||||
In dev mode (FASTAPI_VUE_FRONTEND_URL set), only indexes paths without loading content.
|
||||
"""
|
||||
if self.devmode:
|
||||
# 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} %)"
|
||||
)
|
||||
|
||||
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)
|
||||
paths = self._devmode_paths if self.devmode else self.www.keys()
|
||||
handler = _devmode_respond if self.devmode 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 self.devmode 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)
|
||||
files = self._devmode_paths if self.devmode 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 self.devmode 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": f"Frontend assets served by Vite in dev mode. Connect via {_DEVMODE} 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 = ".."
|
||||
+617
-182
File diff suppressed because it is too large
Load Diff
+1
-4
@@ -9,7 +9,7 @@ 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",
|
||||
"tomlkit>=0.12.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -22,9 +22,6 @@ fastapi-vue-setup = "fastapi_vue_setup:main"
|
||||
[tool.hatch.version]
|
||||
source = "vcs"
|
||||
|
||||
[tool.hatch.build.hooks.vcs]
|
||||
version-file = "_version.py"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = ["fastapi_vue_setup.py", "template/**/*", "_version.py"]
|
||||
|
||||
|
||||
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,53 +1,25 @@
|
||||
# auto-upgrade@fastapi-vue-setup - remove this if you modify this file
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import uvicorn
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
from uvicorn import Config, Server
|
||||
from fastapi_vue import server
|
||||
|
||||
from .APP_MODULE import APP_VAR
|
||||
|
||||
DEFAULT_PORT = 5080
|
||||
|
||||
|
||||
def run_server(endpoints: list[dict], *, proxy="", devmode=False):
|
||||
conf: dict[str, object] = {"app": "MODULE_NAME.APP_MODULE:APP_VAR"}
|
||||
if proxy:
|
||||
conf["proxy_headers"] = True
|
||||
conf["forwarded_allow_ips"] = proxy
|
||||
if devmode:
|
||||
conf["reload"] = True
|
||||
conf["reload_dirs"] = ["MODULE_NAME"]
|
||||
APP_VAR.debug = True
|
||||
|
||||
if len(endpoints) > 1:
|
||||
# Run separate servers for multiple endpoints
|
||||
async def serve_all():
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
for ep in endpoints:
|
||||
tg.create_task(Server(Config(**conf, **ep)).serve())
|
||||
|
||||
asyncio.run(serve_all())
|
||||
else:
|
||||
uvicorn.run(**conf, **endpoints[0])
|
||||
DEFAULT_PORT = TEMPLATE_DEFAULT_PORT
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Run the MODULE_NAME server.")
|
||||
parser.add_argument(
|
||||
"endpoint",
|
||||
nargs="?",
|
||||
help=(
|
||||
f"Endpoint (default: localhost:{DEFAULT_PORT}). "
|
||||
"Forms: host:port | :port | [ipv6]:port | ip | host | unix:/path.sock"
|
||||
),
|
||||
"-l",
|
||||
"--listen",
|
||||
action="append",
|
||||
help=(f"Endpoint (default: localhost:{DEFAULT_PORT})."),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
proxy = os.getenv("FORWARDED_ALLOW_IPS", "127.0.0.1,::1")
|
||||
devmode = bool(os.getenv("FASTAPI_VUE_FRONTEND_URL"))
|
||||
endpoints = parse_endpoint(args.endpoint, DEFAULT_PORT)
|
||||
run_server(endpoints, proxy=proxy, devmode=devmode)
|
||||
server.run(
|
||||
"MODULE_NAME.APP_MODULE:APP_VAR",
|
||||
listen=args.listen,
|
||||
default_port=DEFAULT_PORT,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,9 +5,7 @@ from fastapi import FastAPI
|
||||
from fastapi_vue import Frontend
|
||||
|
||||
# Vue Frontend static files
|
||||
frontend = Frontend(
|
||||
Path(__file__).with_name("frontend-build"), spa=True, cached=["/assets/"]
|
||||
)
|
||||
frontend = Frontend(Path(__file__).with_name("frontend-build"), cached=["/assets/"])
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -29,5 +27,5 @@ async def health_check():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# Final catch-all route for frontend files (keep at end of file)
|
||||
# Serve the Vue frontend (needs to be last if SPA catch-all is used)
|
||||
frontend.route(app, "/")
|
||||
|
||||
+50
-265
@@ -1,306 +1,91 @@
|
||||
#!/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] [--backend host:port]
|
||||
|
||||
The optional host:port argument sets where the Vite frontend listens.
|
||||
Supported forms: host[:port], :port (all interfaces), or just port.
|
||||
The --backend option sets where the FastAPI backend listens (default: localhost:5180).
|
||||
|
||||
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 frontend and FastAPI backend with auto-reload."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import ipaddress
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from sys import stderr
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi_vue import server
|
||||
|
||||
exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102
|
||||
# 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_fastapi,
|
||||
setup_vite,
|
||||
)
|
||||
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_VITE_PORT = 5173
|
||||
DEFAULT_BACKEND_PORT = 5180
|
||||
FRONTEND_PATH = Path(__file__).parent.parent / "frontend"
|
||||
|
||||
EPILOG = """
|
||||
scripts/devserver.py # Default ports on localhost
|
||||
scripts/devserver.py 3000 # Vite on localhost:3000
|
||||
scripts/devserver.py :3000 --backend 8000 # *:3000, localhost:8000
|
||||
"""
|
||||
|
||||
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.
|
||||
"""
|
||||
DEFAULT_VITE_PORT = TEMPLATE_VITE_PORT
|
||||
DEFAULT_DEV_PORT = TEMPLATE_DEV_PORT
|
||||
|
||||
|
||||
def parse_endpoint(
|
||||
value: str | None, default_port: int = DEFAULT_VITE_PORT
|
||||
) -> tuple[str | None, int, bool]:
|
||||
"""Parse an endpoint for Vite or backend.
|
||||
|
||||
Returns (host, port, all_ifaces).
|
||||
"""
|
||||
if not value:
|
||||
return DEFAULT_HOST, default_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_port, False
|
||||
|
||||
# Use urllib.parse for everything else
|
||||
parsed = urlparse(f"//{value}")
|
||||
host = parsed.hostname or DEFAULT_HOST
|
||||
port = parsed.port or default_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")
|
||||
async def run_devserver(frontend: str, backend: str) -> None:
|
||||
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)
|
||||
|
||||
result = find_js_runtime() # noqa # type: ignore
|
||||
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],
|
||||
"--clearScreen=false",
|
||||
f"--port={vite_port}",
|
||||
]
|
||||
|
||||
if all_ifaces:
|
||||
dev_cmd.append("--host")
|
||||
elif vite_host and vite_host != "localhost":
|
||||
dev_cmd.append(f"--host={vite_host}")
|
||||
|
||||
if name == "bun":
|
||||
stderr.write(BUN_BUG)
|
||||
|
||||
return install_cmd, dev_cmd, name
|
||||
|
||||
|
||||
async def wait_for_backend(backend_host: str, backend_port: int):
|
||||
"""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://{backend_host}:{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()
|
||||
|
||||
|
||||
async def run_devserver(
|
||||
vite_host: str | None,
|
||||
vite_port: int,
|
||||
all_ifaces: bool,
|
||||
backend_host: str,
|
||||
backend_port: int,
|
||||
) -> 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
|
||||
viteurl, npm_install, vite = setup_vite(frontend, DEFAULT_VITE_PORT)
|
||||
backurl, module, backend_config = setup_fastapi(
|
||||
backend, "MODULE_NAME.APP_MODULE:APP_VAR", DEFAULT_DEV_PORT
|
||||
)
|
||||
|
||||
# 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["FASTAPI_VUE_BACKEND_URL"] = f"http://{backend_host}:{backend_port}"
|
||||
cwd = str(Path(__file__).parent.parent)
|
||||
frontend_cwd = str(FRONTEND_PATH)
|
||||
# Tell the everyone where the frontend and backend are (vite proxy, etc)
|
||||
os.environ["FASTAPI_VUE_FRONTEND_URL"] = viteurl
|
||||
os.environ["FASTAPI_VUE_BACKEND_URL"] = backurl
|
||||
|
||||
backend_proc: asyncio.subprocess.Process | None = None
|
||||
install_proc: asyncio.subprocess.Process | None = None
|
||||
frontend_proc: asyncio.subprocess.Process | None = None
|
||||
async with ProcessGroup() as pg:
|
||||
npm_i = await pg.spawn(*npm_install, cwd=front)
|
||||
await check_ports_free(viteurl, backurl)
|
||||
|
||||
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
|
||||
)
|
||||
# Run backend in a thread (server.run is blocking)
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_in_executor(None, lambda: server.run(module, **backend_config))
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
# Wait for both install and backend to be ready
|
||||
await pg.wait(npm_i, ready(backurl, path="/api/health?from=devserver.py"))
|
||||
|
||||
# Start backend (concurrent with install)
|
||||
backend_cmd = [
|
||||
"uvicorn",
|
||||
"MODULE_NAME.app:app",
|
||||
"--host",
|
||||
backend_host,
|
||||
"--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(backend_host, backend_port), 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")
|
||||
# Start Vite dev server (ProcessGroup waits for any exit, then terminates others)
|
||||
await pg.spawn(*vite, cwd=front)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run Vite and FastAPI development servers",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=EPILOG,
|
||||
epilog=HELP_EPILOG,
|
||||
)
|
||||
parser.add_argument(
|
||||
"frontend",
|
||||
nargs="?",
|
||||
metavar="host:port",
|
||||
help="Vite frontend endpoint (default: localhost:5173)",
|
||||
help=f"Vite frontend endpoint (default: localhost:{DEFAULT_VITE_PORT})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
metavar="host:port",
|
||||
help="FastAPI backend endpoint (default: localhost:5180)",
|
||||
help=f"FastAPI backend endpoint (default: localhost:{DEFAULT_DEV_PORT})",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
vite_host, vite_port, all_ifaces = parse_endpoint(args.frontend, DEFAULT_VITE_PORT)
|
||||
backend_host, backend_port, _ = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT)
|
||||
# Backend host defaults to localhost (never None)
|
||||
backend_host = backend_host or DEFAULT_HOST
|
||||
|
||||
with contextlib.suppress(KeyboardInterrupt):
|
||||
asyncio.run(
|
||||
run_devserver(vite_host, vite_port, all_ifaces, backend_host, backend_port)
|
||||
)
|
||||
asyncio.run(run_devserver(args.frontend, args.backend))
|
||||
|
||||
|
||||
HELP_EPILOG = """
|
||||
scripts/devserver.py # Default ports on localhost
|
||||
scripts/devserver.py 3000 # Vite on localhost:3000
|
||||
scripts/devserver.py :3000 --backend 8000 # *:3000, localhost:8000
|
||||
|
||||
JS_RUNTIME environment variable can be used to select the JS runtime
|
||||
"""
|
||||
|
||||
|
||||
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 # 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() # noqa # type: ignore
|
||||
|
||||
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", "dev", "--"),
|
||||
"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]).name, *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,183 @@
|
||||
"""Utilities meant for devserver script, used only in source repository with dev deps."""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
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]).name
|
||||
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, str, dict]:
|
||||
"""Parse backend endpoint and build server.run() config.
|
||||
|
||||
Returns (url, module, config_dict).
|
||||
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"]
|
||||
|
||||
config = {
|
||||
"listen": f"{host}:{port}",
|
||||
"reload": True,
|
||||
"reload_dirs": [module.split(".")[0]], # Don't reload on frontend changes
|
||||
"forwarded_allow_ips": "*",
|
||||
}
|
||||
return f"http://{host}:{port}", module, config
|
||||
@@ -1,87 +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"),
|
||||
}
|
||||
# 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"),
|
||||
}
|
||||
|
||||
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