diff --git a/scripts/build-frontend.py b/scripts/build-frontend.py index 3073c8e..5782c4e 100644 --- a/scripts/build-frontend.py +++ b/scripts/build-frontend.py @@ -1,20 +1,12 @@ """Hatch build hook for building paskia-js and 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 -# Import utilities from fastapi-vue -exec(Path(__file__).parent.joinpath("fastapi-vue", "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 / "fastapi-vue")) +from buildutil import build # noqa: E402 class CustomBuildHook(BuildHookInterface): @@ -22,27 +14,5 @@ class CustomBuildHook(BuildHookInterface): def initialize(self, version, build_data): super().initialize(version, build_data) - stderr.write(">>> Building paskia-js library\n") - - install_cmd, build_cmd = find_build_tool() # noqa: F821 # type: ignore - - try: - # Install dependencies for paskia-js - run(install_cmd, cwd="paskia-js") - stderr.write("\n") - # Build paskia-js - run(build_cmd, cwd="paskia-js") - stderr.write("\n") - except Exception as e: - stderr.write(f"Error occurred while building paskia-js: {e}\n") - raise - - stderr.write(">>> Building the frontend\n") - - 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("paskia-js") + build("frontend") diff --git a/scripts/devserver.new.py b/scripts/devserver.new.py deleted file mode 100755 index f8e9e09..0000000 --- a/scripts/devserver.new.py +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env -S uv run -# auto-upgrade@fastapi-vue-setup - remove this if you modify this file -"""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. -""" - -import argparse -import asyncio -import contextlib -import os -from pathlib import Path -from sys import stderr - -import httpx -from fastapi_vue.hostutil import parse_endpoint - -exec((Path(__file__).parent / "fastapi-vue/util.py").read_text("UTF-8")) # noqa: S102 - -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. -""" - - -def resolve_frontend_tools( - 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() # 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") - - if name == "bun": - stderr.write(BUN_BUG) - - return install_cmd, dev_cmd, name - - -async def wait_for_backend(host: str, port: int): - """Wait for the backend to be ready by polling the health endpoint.""" - max_attempts = 50 - url = f"http://{host}:{port}" - - async with httpx.AsyncClient() as client: - for attempt in range(max_attempts): - try: - await client.get(url, 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_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_port, all_ifaces) - - # Tell the backend where the Vite dev server is - os.environ["FASTAPI_VUE_FRONTEND_URL"] = f"http://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}" - - backend_cmd = [ - "uvicorn", - "paskia.app:app", - "--host", - backend_host, - "--port", - str(backend_port), - "--reload", - ] - - 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) - 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") - - -def main(): - parser = argparse.ArgumentParser( - description="Run Vite and FastAPI development servers", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=EPILOG, - ) - parser.add_argument( - "frontend", - nargs="?", - metavar="host:port", - help="Vite frontend endpoint (default: localhost:5173)", - ) - parser.add_argument( - "--backend", - metavar="host:port", - help="FastAPI backend endpoint (default: localhost:5180)", - ) - args = parser.parse_args() - - # parse_endpoint returns list of dicts with host/port or uds keys - # Multiple entries means bind all interfaces (IPv4 + IPv6) - vite_endpoints = parse_endpoint(args.frontend, DEFAULT_VITE_PORT) - backend_endpoints = parse_endpoint(args.backend, DEFAULT_BACKEND_PORT) - - # Vite doesn't support unix sockets - if "uds" in vite_endpoints[0]: - stderr.write("┃ ⚠️ Unix sockets not supported for frontend\n") - raise SystemExit(1) - if "uds" in backend_endpoints[0]: - stderr.write("┃ ⚠️ Unix sockets not supported for backend\n") - raise SystemExit(1) - - vite_port = vite_endpoints[0]["port"] - all_ifaces = len(vite_endpoints) > 1 - backend_host = backend_endpoints[0]["host"] - backend_port = backend_endpoints[0]["port"] - - with contextlib.suppress(KeyboardInterrupt): - asyncio.run(run_devserver(vite_port, all_ifaces, backend_host, backend_port)) - - -if __name__ == "__main__": - main() diff --git a/scripts/devserver.py b/scripts/devserver.py index 07b5771..d33b7cb 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -1,9 +1,9 @@ #!/usr/bin/env -S uv run -"""Run Vite development server for frontend and FastAPI backend with auto-reload. +"""Run Vite development server for frontend and Paskia backend with auto-reload. This script is only available when running from the git repository source, not from the installed package. It starts both the Vite frontend dev server -and the FastAPI backend with auto-reload enabled. +and the Paskia backend with auto-reload enabled. Usage: uv run scripts/devserver.py [-l host:port] [options...] @@ -25,45 +25,26 @@ Options: """ import argparse -import atexit -import ipaddress +import asyncio import json import os import shutil -import signal -import subprocess +import sys +from contextlib import suppress from pathlib import Path -from sys import stderr -from threading import Thread 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 + DEFAULT_VITE_PORT = 4403 # overrides by CLI option BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts CADDY_PORT = 443 # HTTPS port for Caddy proxy CADDY_HTTP_PORT = 80 # HTTP port for ACME challenges -DEFAULT_HOST = "localhost" - -NO_FRONTEND_TOOL = """\ -┃ ⚠️ deno, npm or bunx needed to run the frontend server. -""" - -BUN_BUG = """\ -┃ ⚠️ Bun cannot correctly proxy API requests to the backend. -┃ Bug report: https://github.com/oven-sh/bun/issues/9882 -┃ -┃ Options: -┃ - sudo caddy run --config caddy/Caddyfile.dev -┃ - Install deno or npm instead -┃ -┃ Caddy will skip the Vite for API calls and serve everything at port 443. -┃ Otherwise Vite serves at port 8077 and proxies to backend (broken with bun). -""" - -NO_FRONTEND = """\ -┃ -┃ The backend will still try reaching Vite at {vite_url} -┃ for various frontend assets, so make sure to start it manually. -""" CADDYFILE_SITE_BLOCK = """\ SITE_ADDR { @@ -79,140 +60,43 @@ SITE_ADDR { """ -def parse_endpoint( - value: str | None, default_port: int -) -> tuple[str | None, int | None, str | None, bool]: - """Parse an endpoint for Vite (simplified version for dev.py). - - Returns (host, port, uds_path, all_ifaces). - """ - if not value: - return DEFAULT_HOST, default_port, None, False - - # Port only (numeric) -> localhost:port - if value.isdigit(): - return DEFAULT_HOST, int(value), None, 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), None, True - - # UNIX domain socket - if value.startswith("unix:"): - uds_path = value[5:] or None - if uds_path is None: - raise SystemExit("unix: path must not be empty") - return None, None, uds_path, False - - # 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}") - return value, default_port, None, 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, None, False - - -def run_vite( - vite_url: str, - vite_host: str | None, - vite_port: int, - env: dict, - auth_host: str | None = None, -): - """Spawn the frontend dev server (deno, npm, or bunx) as a background process.""" +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(): - stderr.write( - f"┃ ⚠️ Frontend source not found at {devpath}\n" - + NO_FRONTEND.format(vite_url=vite_url) - ) - return - - options = [ - ("deno", "run", "dev"), - ("npm", "--silent", "run", "dev", "--"), - ("bunx", "--bun", "vite"), - ] - cmd = None - tool_name = None - for option in options: - if tool := shutil.which(option[0]): - cmd = [tool, *option[1:]] - tool_name = option[0] - break - - # Add Vite CLI args for host/port - vite_args = ["--port", str(vite_port), "--logLevel", "silent"] - if vite_host: - vite_args.extend(["--host", vite_host]) - - vite_process = None - - def start_vite(): - nonlocal vite_process - if cmd is None: - stderr.write(NO_FRONTEND_TOOL + NO_FRONTEND.format(vite_url=vite_url)) - return - assert tool_name is not None - try: - if tool_name == "bunx": - stderr.write(BUN_BUG) - - full_cmd = cmd + vite_args - stderr.write(f">>> {' '.join([tool_name, *full_cmd[1:]])}\n") - vite_env = env.copy() - if auth_host: - vite_env["PASKIA_AUTH_HOST"] = auth_host - vite_process = subprocess.Popen( - full_cmd, cwd=str(devpath), shell=False, env=vite_env - ) - except Exception as e: - stderr.write( - f"┃ ⚠️ Vite couldn't start: {e}\n" - + NO_FRONTEND.format(vite_url=vite_url) - ) - - def cleanup(): - if vite_process: - vite_process.terminate() - vite_process.wait() - - # Start Vite in a separate thread - vite_thread = Thread(target=start_vite, daemon=True) - vite_thread.start() - - atexit.register(cleanup) - signal.signal(signal.SIGTERM, lambda *_: cleanup()) - signal.signal(signal.SIGINT, lambda *_: cleanup()) - - -def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None: - """Spawn Caddy as HTTPS reverse proxy for the given origins.""" - caddy_path = shutil.which("caddy") - if not caddy_path: - stderr.write("┃ ⚠️ Caddy not found. Install it to use --caddy option.\n") + logger.warning("Frontend source not found at %s", devpath) return None - # Build Caddyfile with a site block for each origin + 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 = [] for origin in origins: parsed = urlparse(origin) - # Extract scheme://host:port from origin URL scheme = parsed.scheme or "https" - host = parsed.hostname or parsed.path # handle case without scheme + host = parsed.hostname or parsed.path port = parsed.port or (CADDY_HTTP_PORT if scheme == "http" else CADDY_PORT) - # Use standard ports without explicit port in address (cleaner URLs) if port in (80, 443): site_addr = f"{scheme}://{host}" else: @@ -223,160 +107,173 @@ def run_caddy(origins: list[str], vite_port: int) -> subprocess.Popen | None: .replace("VITE_PORT", str(vite_port)) ) caddyfile_parts.append(block) + return "\n".join(caddyfile_parts) - caddyfile = "\n".join(caddyfile_parts) - caddy_process = None - try: - # Use sudo to bind to privileged ports (80/443) for ACME certificate fetching - cmd = ["sudo", caddy_path, "run", "--config", "-", "--adapter", "caddyfile"] - caddy_process = subprocess.Popen( - cmd, - stdin=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - caddy_process.stdin.write(caddyfile.encode()) - caddy_process.stdin.close() - except Exception as e: - stderr.write(f"┃ ⚠️ Caddy couldn't start: {e}\n") - return None +async def run_caddy(origins: list[str], vite_port: int) -> asyncio.subprocess.Process: + """Start Caddy as HTTPS reverse proxy, wait for ready signal.""" + caddy_path = shutil.which("caddy") + if not caddy_path: + logger.warning("Caddy not found. Install it to use --caddy option.") + raise SystemExit(1) - # Helper to parse Caddy log line (JSON or plain text) into (level, logger, msg) - def parse_caddy_log(line: str) -> tuple[str, str, str] | None: - """Parse a Caddy log line, return (level, logger, msg) or None if unparseable.""" - line = line.rstrip("\n") - if not line: - return None + caddyfile = build_caddyfile(origins, vite_port) + cmd = ["sudo", caddy_path, "run", "--config", "-", "--adapter", "caddyfile"] - # Try JSON format first - try: - log = json.loads(line) - return ( - log.get("level", ""), - log.get("logger", ""), - log.get("msg", ""), - ) - except json.JSONDecodeError: - pass + logger.info(">>> sudo caddy @ %s", " ".join(origins)) + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + proc.stdin.write(caddyfile.encode()) + await proc.stdin.drain() + proc.stdin.close() - # Plain text format: "2025/12/06 22:59:41.390 INFO logger msg..." - # or "2025/12/06 22:59:41.390 INFO msg..." (no logger) - parts = line.split("\t") - if len(parts) >= 2: - # First part is "timestamp LEVEL", rest are logger and/or message - first = parts[0].rsplit(None, 1) # split off the level from timestamp - if len(first) == 2: - level = first[1].lower() - if len(parts) == 2: - return (level, "", parts[1]) - else: - return (level, parts[1], "\t".join(parts[2:])) - - # Unparseable - return as-is with no level/logger - return ("", "", line) - - def strip_caddy_verbose(msg: str) -> str: - """Remove verbose prefixes from Caddy error messages.""" - return msg.replace("loading initial config: loading new config: ", "") - - def format_caddy_log(level: str, logger: str, msg: str) -> str: - """Format a parsed Caddy log for display.""" - msg = strip_caddy_verbose(msg) - if logger: - return f"┃ [{level.upper()}] {logger}: {msg}\n" - else: - return f"┃ [{level.upper()}] {msg}\n" - - # Read stderr line by line until Caddy signals it's ready or exits - # Caddy outputs logs; "serving initial configuration" means it's ready + # Wait for ready signal or failure while True: - exit_code = caddy_process.poll() - if exit_code is not None: - # Process exited - read remaining stderr and report failure - remaining = ( - caddy_process.stderr.read().decode() if caddy_process.stderr else "" - ) - if remaining: - for line in remaining.splitlines(): - if line: - parsed = parse_caddy_log(line) - if parsed: - level, logger, msg = parsed - if level: - stderr.write(format_caddy_log(level, logger, msg)) - else: - stderr.write(f"┃ {strip_caddy_verbose(msg)}\n") - else: - stderr.write(f"┃ {strip_caddy_verbose(line)}\n") - stderr.write(f"┃ ⚠️ Caddy startup failed (exit code {exit_code})\n") - return None + if proc.returncode is not None: + remaining = await proc.stderr.read() + for line in remaining.decode().splitlines(): + if line: + logger.info("caddy: %s", line) + logger.warning("Caddy startup failed (exit code %d)", proc.returncode) + raise SystemExit(1) - # Read one line from stderr (blocks until data available) - line = caddy_process.stderr.readline().decode() + line = await proc.stderr.readline() if not line: continue - # Check for ready signal - if "serving initial configuration" in line: + decoded = line.decode().rstrip() + if "serving initial configuration" in decoded: break - parsed = parse_caddy_log(line) - if not parsed: - continue + # Parse and show errors during startup + if decoded: + try: + log = json.loads(decoded) + level = log.get("level", "") + if level in ("error", "fatal", "warn"): + logger.warning("caddy: %s", log.get("msg", decoded)) + except json.JSONDecodeError: + if "error" in decoded.lower() or "fatal" in decoded.lower(): + logger.warning("caddy: %s", decoded) - level, logger, msg = parsed - - # Filter out info-level and admin messages - if level == "info" or logger == "admin": - continue - - # Show errors/fatal to user - if level in ("error", "fatal"): - stderr.write(format_caddy_log(level, logger, msg)) - elif not level: - # Unparseable non-empty line (e.g., sudo prompt) - pass through with prefix - stderr.write(f"┃ {strip_caddy_verbose(msg)}\n") - stderr.flush() - - # Start a background thread to drain stderr and show errors - def drain_stderr(): + # Start background task to drain stderr + async def drain_caddy_stderr(): while True: - line = caddy_process.stderr.readline().decode() + line = await proc.stderr.readline() if not line: break + decoded = line.decode().rstrip() + if decoded: + try: + log = json.loads(decoded) + level = log.get("level", "") + if level in ("error", "fatal", "warn"): + logger.warning("caddy: %s", log.get("msg", decoded)) + except json.JSONDecodeError: + pass # Ignore non-JSON output after startup - parsed = parse_caddy_log(line) - if not parsed: - continue + asyncio.create_task(drain_caddy_stderr()) + return proc - level, logger, msg = parsed - # Filter out info-level and admin messages - if level == "info" or logger == "admin": - continue +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] - # Show errors/warnings to user - if level in ("error", "fatal", "warn"): - stderr.write(format_caddy_log(level, logger, msg)) - elif not level: - # Unparseable line - pass through with prefix - stderr.write(f"┃ {strip_caddy_verbose(msg)}\n") + if "uds" in ep: + logger.warning("Unix sockets are not supported for Vite frontend") + raise SystemExit(1) - drain_thread = Thread(target=drain_stderr, daemon=True) - drain_thread.start() + 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" - def cleanup(): - if caddy_process: - caddy_process.terminate() - caddy_process.wait() + vite_url = f"http://localhost:{vite_port}" + backend_url = f"http://localhost:{BACKEND_PORT}" - atexit.register(cleanup) + # Compute origins for Caddy + caddy_origins = [] + if args.auth_host: + auth_host = args.auth_host + if "://" not in auth_host: + auth_host = f"https://{auth_host}" + caddy_origins.append(auth_host) + caddy_origins.append(f"https://{args.rp_id}") + if args.origins: + for origin in args.origins: + if "://" not in origin: + origin = f"https://{origin}" + caddy_origins.append(origin) + if not args.auth_host and not args.origins: + caddy_origins.append(f"https://{args.rp_id}") + # Remove duplicates while preserving order + seen = set() + caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))] - return caddy_process + # 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 + 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: + caddy_proc = await run_caddy(caddy_origins, vite_port) + 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 + ) def main(): - # Parse optional listen argument for Vite frontend parser = argparse.ArgumentParser(add_help=False) parser.add_argument( "-l", @@ -393,85 +290,8 @@ def main(): parser.add_argument("--auth-host", help="Dedicated auth host") args, remaining = parser.parse_known_args() - # Parse Vite endpoint - vite_host, vite_port, vite_uds, all_ifaces = parse_endpoint( - args.listen, DEFAULT_VITE_PORT - ) - - if vite_uds: - raise SystemExit("┃ ⚠️ Unix sockets are not supported for Vite frontend") - - # Handle all-interfaces case (:port syntax) - # Vite uses 0.0.0.0 to listen on all interfaces (IPv4 only, sufficient for dev) - if all_ifaces: - vite_host = "0.0.0.0" - - # Build Vite URL for FASTAPI_VUE_FRONTEND_URL (always use localhost for URL) - vite_url = f"http://localhost:{vite_port}" - - # Compute origins for Caddy (user-specified or auto-generated) - caddy_origins = [] - if args.auth_host: - auth_host = args.auth_host - if "://" not in auth_host: - auth_host = f"https://{auth_host}" - caddy_origins.append(auth_host) - # Also run on rp-id when auth-host is specified - caddy_origins.append(f"https://{args.rp_id}") - if args.origins: - for origin in args.origins: - if "://" not in origin: - origin = f"https://{origin}" - caddy_origins.append(origin) - # If neither auth-host nor origins specified, run on rp-id - if not args.auth_host and not args.origins: - caddy_origins.append(f"https://{args.rp_id}") - - # Remove duplicates while preserving order - seen = set() - caddy_origins = [x for x in caddy_origins if not (x in seen or seen.add(x))] - - # Start Caddy if requested (after computing origins) - if args.caddy: - if not caddy_origins: - caddy_origins = [f"https://{args.rp_id}"] - stderr.write(f">>> sudo caddy @ {' '.join(caddy_origins)}\n") - if not run_caddy(caddy_origins, vite_port): - raise SystemExit(1) - - # Set dev mode env vars for subprocesses (fastapi-vue convention) - env = os.environ.copy() - env["FASTAPI_VUE_FRONTEND_URL"] = vite_url - env["FASTAPI_VUE_BACKEND_URL"] = f"http://localhost:{BACKEND_PORT}" - # User-facing URL: Caddy HTTPS when running, else Vite HTTP - if args.caddy: - env["PASKIA_SITE_URL"] = caddy_origins[0] # auth-host or https://{rp-id} - else: - env["PASKIA_SITE_URL"] = vite_url - - # Start Vite dev server - run_vite(vite_url, vite_host, vite_port, env, args.auth_host) - - # Build command with origin args - cmd = ["paskia", "-l", f"localhost:{BACKEND_PORT}"] - - # Pass through rp-id (always pass, has default) - cmd.extend(["--rp-id", args.rp_id]) - - # Pass through auth-host if specified - if args.auth_host: - cmd.extend(["--auth-host", args.auth_host]) - - # Pass through origins as specified - if args.origins: - for origin in args.origins: - cmd.extend(["--origin", origin]) - - # Add remaining args (ones we didn't parse) - cmd.extend(remaining) - - stderr.write(f">>> (devmode) {' '.join(cmd)}\n") - subprocess.run(cmd, env=env) + with suppress(KeyboardInterrupt): + asyncio.run(run_devserver(args, remaining)) if __name__ == "__main__": diff --git a/scripts/fastapi-vue/build-frontend.py b/scripts/fastapi-vue/build-frontend.py index 7fccf65..152c48c 100644 --- a/scripts/fastapi-vue/build-frontend.py +++ b/scripts/fastapi-vue/build-frontend.py @@ -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") diff --git a/scripts/fastapi-vue/buildutil.py b/scripts/fastapi-vue/buildutil.py new file mode 100644 index 0000000..941a7c1 --- /dev/null +++ b/scripts/fastapi-vue/buildutil.py @@ -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]).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) diff --git a/scripts/fastapi-vue/devutil.py b/scripts/fastapi-vue/devutil.py new file mode 100644 index 0000000..8da9d35 --- /dev/null +++ b/scripts/fastapi-vue/devutil.py @@ -0,0 +1,190 @@ +"""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, env: dict | 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) + 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 diff --git a/scripts/fastapi-vue/util.py b/scripts/fastapi-vue/util.py deleted file mode 100644 index 236b2c5..0000000 --- a/scripts/fastapi-vue/util.py +++ /dev/null @@ -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