diff --git a/frontend/vite-plugin-fastapi.js b/frontend/vite-plugin-fastapi.js index e59f121..ffef157 100644 --- a/frontend/vite-plugin-fastapi.js +++ b/frontend/vite-plugin-fastapi.js @@ -1,17 +1,18 @@ /** * 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): - * FASTAPI_VUE_BACKEND_URL=http://localhost:5180 - Backend API URL for proxying + * Options: + * paths - Array of paths to proxy (default: ["/api"]) */ -const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180" - export default function fastapiVue({ paths = ["/api"] } = {}) { + const backendUrl = process.env.PASKIA_BACKEND_URL || "http://localhost:4402" + // Build proxy configuration for each path const proxy = {} for (const path of paths) { @@ -23,7 +24,7 @@ export default function fastapiVue({ paths = ["/api"] } = {}) { } return { - name: "fastapi-vite", + name: "vite-plugin-fastapi-paskia", config: () => ({ server: { proxy }, build: { diff --git a/paskia/fastapi/__main__.py b/paskia/fastapi/__main__.py index ab4b9ef..f950428 100644 --- a/paskia/fastapi/__main__.py +++ b/paskia/fastapi/__main__.py @@ -17,6 +17,7 @@ from paskia.util import startupbox from paskia.util.hostutil import normalize_origin DEFAULT_PORT = 4401 +DEVMODE = bool(os.getenv("PASKIA_FRONTEND_URL")) EPILOG = """\ Example: @@ -167,14 +168,12 @@ def main(): startupbox.print_startup_config(config) - devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL")) - run_kwargs: dict = { "log_level": "warning", # Suppress startup messages; we use custom logging "access_log": False, # We use custom AccessLogMiddleware instead } - if devmode: + if DEVMODE: # Security: dev mode must run on localhost:4402 to prevent # accidental public exposure of the Vite dev server if host != "localhost" or port != 4402: @@ -200,7 +199,7 @@ def main(): Config(app="paskia.fastapi:app", **run_kwargs, **ep) ).serve() ) - elif devmode: + elif DEVMODE: # Use uvicorn.run for proper reload support (it handles subprocess spawning) ep = endpoints[0] uvicorn_run("paskia.fastapi:app", **run_kwargs, **ep) diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 1837959..22eaa76 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -12,6 +12,7 @@ from paskia import globals from paskia.db import start_background, stop_background from paskia.db.logging import configure_db_logging from paskia.fastapi import admin, api, auth_host, ws +from paskia.fastapi.__main__ import DEVMODE from paskia.fastapi.logging import AccessLogMiddleware, configure_access_logging from paskia.fastapi.session import AUTH_COOKIE from paskia.util import hostutil, passphrase, vitedev @@ -59,7 +60,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path # Restore uvicorn info logging (suppressed during startup in dev mode) # Keep uvicorn.error at WARNING to suppress WebSocket "connection open/closed" messages - if frontend.devmode: + if app.debug: logging.getLogger("uvicorn").setLevel(logging.INFO) logging.getLogger("uvicorn.error").setLevel(logging.WARNING) await frontend.load() @@ -74,6 +75,7 @@ app = FastAPI( docs_url=None, redoc_url=None, openapi_url=None, + debug=DEVMODE, ) # Custom access logging (uvicorn's access_log is disabled) diff --git a/scripts/devserver.py b/scripts/devserver.py index d33b7cb..75582cb 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -13,9 +13,9 @@ All other options are forwarded to `paskia`. Backend always listens on localhost:4402. Environment: - FASTAPI_VUE_FRONTEND_URL Set by this script for the backend to know where Vite is. - FASTAPI_VUE_BACKEND_URL Set by this script for Vite to know where to proxy API calls. - PASKIA_SITE_URL User-facing URL for reset links (Caddy HTTPS or Vite HTTP). + PASKIA_FRONTEND_URL Set by this script for the backend to know where Vite is. + PASKIA_BACKEND_URL Set by this script for Vite to know where to proxy API calls. + PASKIA_SITE_URL User-facing URL for reset links (Caddy HTTPS or Vite HTTP). Options: --caddy Run Caddy as HTTPS proxy on port 443 (requires sudo) @@ -34,12 +34,16 @@ from contextlib import suppress from pathlib import Path from urllib.parse import urlparse -from fastapi_vue.hostutil import parse_endpoint - # Import utilities from scripts/fastapi-vue (not a package, so we adjust sys.path) sys.path.insert(0, str(Path(__file__).with_name("fastapi-vue"))) -from buildutil import find_dev_tool, find_install_tool, logger # noqa: E402 -from devutil import ProcessGroup, check_ports_free # noqa: E402 +from devutil import ( # noqa: E402 + ProcessGroup, + check_ports_free, + logger, + ready, + setup_cli, + setup_vite, +) DEFAULT_VITE_PORT = 4403 # overrides by CLI option BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts @@ -60,35 +64,6 @@ SITE_ADDR { """ -def build_vite_cmd(vite_host: str, vite_port: int) -> list[str] | None: - """Build the Vite dev command, or None if not available.""" - devpath = Path(__file__).parent.parent / "frontend" - if not (devpath / "package.json").exists(): - logger.warning("Frontend source not found at %s", devpath) - return None - - try: - cmd = find_dev_tool() - except RuntimeError as e: - logger.warning(str(e)) - return None - - # Add Vite CLI args for host/port - cmd.extend([f"--port={vite_port}", "--logLevel=silent"]) - if vite_host and vite_host != "localhost": - cmd.append("--host" if vite_host == "0.0.0.0" else f"--host={vite_host}") - - return cmd - - -def build_npm_install_cmd() -> list[str] | None: - """Build the npm install command, or None if not available.""" - try: - return find_install_tool() - except RuntimeError: - return None - - def build_caddyfile(origins: list[str], vite_port: int) -> str: """Build a Caddyfile for the given origins.""" caddyfile_parts = [] @@ -181,22 +156,26 @@ async def run_caddy(origins: list[str], vite_port: int) -> asyncio.subprocess.Pr async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None: """Run the development server with all components.""" - # Parse Vite endpoint - endpoints = parse_endpoint(args.listen, DEFAULT_VITE_PORT) - ep = endpoints[0] - - if "uds" in ep: - logger.warning("Unix sockets are not supported for Vite frontend") + reporoot = Path(__file__).parent.parent + frontend_path = reporoot / "frontend" + if not (frontend_path / "package.json").exists(): + logger.warning("Frontend source not found at %s", frontend_path) raise SystemExit(1) - vite_host = ep["host"] - vite_port = ep["port"] - # Multiple endpoints means all-interfaces (:port syntax) - if len(endpoints) > 1: - vite_host = "0.0.0.0" + viteurl, npm_install, vite = setup_vite(args.listen, DEFAULT_VITE_PORT) + backurl, paskia = setup_cli("paskia", f"localhost:{BACKEND_PORT}", BACKEND_PORT) - vite_url = f"http://localhost:{vite_port}" - backend_url = f"http://localhost:{BACKEND_PORT}" + # Extract vite port for Caddy config + vite_port = int(viteurl.rsplit(":", 1)[1]) + + # Build paskia command with options + paskia.extend(["--rp-id", args.rp_id]) + if args.auth_host: + paskia.extend(["--auth-host", args.auth_host]) + if args.origins: + for origin in args.origins: + paskia.extend(["--origin", origin]) + paskia.extend(remaining) # Compute origins for Caddy caddy_origins = [] @@ -217,30 +196,13 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None: seen = set() caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))] - # Check ports are free before starting - await check_ports_free(vite_url, backend_url) - # Set environment for subprocesses - os.environ["FASTAPI_VUE_FRONTEND_URL"] = vite_url - os.environ["FASTAPI_VUE_BACKEND_URL"] = backend_url - os.environ["PASKIA_SITE_URL"] = caddy_origins[0] if args.caddy else vite_url + os.environ["PASKIA_FRONTEND_URL"] = viteurl + os.environ["PASKIA_BACKEND_URL"] = backurl + os.environ["PASKIA_SITE_URL"] = caddy_origins[0] if args.caddy else viteurl if args.auth_host: os.environ["PASKIA_AUTH_HOST"] = args.auth_host - # Build commands - frontend_path = Path(__file__).parent.parent / "frontend" - vite_cmd = build_vite_cmd(vite_host, vite_port) - install_cmd = build_npm_install_cmd() - - paskia_cmd = ["paskia", "-l", f"localhost:{BACKEND_PORT}"] - paskia_cmd.extend(["--rp-id", args.rp_id]) - if args.auth_host: - paskia_cmd.extend(["--auth-host", args.auth_host]) - if args.origins: - for origin in args.origins: - paskia_cmd.extend(["--origin", origin]) - paskia_cmd.extend(remaining) - async with ProcessGroup() as pg: # Start Caddy first if requested (needs to bind ports) if args.caddy: @@ -248,29 +210,11 @@ async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None: pg._procs.append(caddy_proc) pg._cmds[caddy_proc.pid] = "caddy" - # Run npm install concurrently with backend startup - if install_cmd and (frontend_path / "package.json").exists(): - npm_proc = await pg.spawn(*install_cmd, cwd=str(frontend_path)) - else: - npm_proc = None - - # Start paskia backend - logger.info(">>> (devmode) %s", " ".join(paskia_cmd)) - paskia_proc = await asyncio.create_subprocess_exec(*paskia_cmd) - pg._procs.append(paskia_proc) - pg._cmds[paskia_proc.pid] = "paskia" - - # Wait for npm install to complete before starting Vite - if npm_proc: - await pg.wait(npm_proc) - - # Start Vite dev server - if vite_cmd: - await pg.spawn(*vite_cmd, cwd=str(frontend_path)) - else: - logger.info( - "Backend expects Vite at %s - start it manually if needed", vite_url - ) + npm_proc = await pg.spawn(*npm_install, cwd=frontend_path) + await check_ports_free(viteurl, backurl) + await pg.spawn(*paskia) + await pg.wait(npm_proc, ready(backurl, path="/api/health?from=devserver.py")) + await pg.spawn(*vite, cwd=frontend_path) def main(): diff --git a/scripts/fastapi-vue/buildutil.py b/scripts/fastapi-vue/buildutil.py index 941a7c1..533c46e 100644 --- a/scripts/fastapi-vue/buildutil.py +++ b/scripts/fastapi-vue/buildutil.py @@ -134,7 +134,7 @@ def find_dev_tool() -> list[str]: Raises RuntimeError if no runtime is found. """ dev_args = { - "deno": ("run", "dev", "--"), + "deno": ("run", "-A", "npm:vite"), "npm": ("--silent", "run", "dev", "--"), "bun": ("run", "dev", "--"), } diff --git a/scripts/fastapi-vue/devutil.py b/scripts/fastapi-vue/devutil.py index 8da9d35..66c4744 100644 --- a/scripts/fastapi-vue/devutil.py +++ b/scripts/fastapi-vue/devutil.py @@ -21,12 +21,12 @@ class ProcessGroup: self._cmds: dict[int, str] = {} # pid -> command name async def spawn( - self, *cmd: str, cwd: str | None = None, env: dict | None = None + 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, env=env) + proc = await asyncio.create_subprocess_exec(*cmd, cwd=cwd) self._procs.append(proc) self._cmds[proc.pid] = cmd_name return proc @@ -188,3 +188,24 @@ def setup_fastapi( "--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