diff --git a/frontend/vite-plugin-fastapi.js b/frontend/vite-plugin-fastapi.js new file mode 100644 index 0000000..e59f121 --- /dev/null +++ b/frontend/vite-plugin-fastapi.js @@ -0,0 +1,35 @@ +/** + * FastAPI-Vue Vite 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 + */ + +const backendUrl = process.env.FASTAPI_VUE_BACKEND_URL || "http://localhost:5180" + +export default function fastapiVue({ paths = ["/api"] } = {}) { + // Build proxy configuration for each path + const proxy = {} + for (const path of paths) { + proxy[path] = { + target: backendUrl, + changeOrigin: false, + ws: true, + } + } + + return { + name: "fastapi-vite", + config: () => ({ + server: { proxy }, + build: { + outDir: "../paskia/frontend-build", + emptyOutDir: true, + }, + }), + } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index e8e5fe6..354bddb 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -4,6 +4,7 @@ import { resolve } from 'node:path' import vue from '@vitejs/plugin-vue' import { existsSync, renameSync, mkdirSync } from 'node:fs' import sirv from 'sirv' +import fastapiVue from './vite-plugin-fastapi.js' // Auth host mode: when set, clients accessing the auth host get /auth/ at / and /auth/admin/ at /admin/ const authHost = process.env.PASKIA_AUTH_HOST @@ -12,6 +13,14 @@ export default defineConfig(({ command }) => ({ appType: 'mpa', publicDir: 'public', plugins: [ + fastapiVue({ paths: [ + "/auth/api", + "/auth/ws", + // Passphrase links: /auth/word1.word2.word3.word4.word5 + "^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$", + // Passphrase links: /word1.word2.word3.word4.word5 + "^/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$", + ] }), vue(), // Auth host routing: rewrite paths when accessing dedicated auth host // Must run before serve-examples to handle / correctly @@ -89,24 +98,6 @@ export default defineConfig(({ command }) => ({ allowedHosts: true, fs: { allow: ['..'] - }, - proxy: { - // Only proxy these two specific backend API paths - '/auth/api': { - target: 'http://localhost:4402' - }, - '/auth/ws': { - target: 'http://localhost:4402', - ws: true - }, - // Passphrase links: /auth/word1.word2.word3.word4.word5 - '^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$': { - target: 'http://localhost:4402' - }, - // Passphrase links: /word1.word2.word3.word4.word5 - '^/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$': { - target: 'http://localhost:4402' - } } }, build: { diff --git a/mainsample.py b/mainsample.py new file mode 100644 index 0000000..f5493ad --- /dev/null +++ b/mainsample.py @@ -0,0 +1,55 @@ +# 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 .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]) + + +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" + ), + ) + 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) + + +if __name__ == "__main__": + main() diff --git a/paskia/config.py b/paskia/config.py index 50c6345..0991cbe 100644 --- a/paskia/config.py +++ b/paskia/config.py @@ -22,4 +22,3 @@ class PaskiaConfig: host: str | None = None port: int | None = None uds: str | None = None - devmode: bool = False diff --git a/paskia/fastapi/__main__.py b/paskia/fastapi/__main__.py index 9c25129..d816944 100644 --- a/paskia/fastapi/__main__.py +++ b/paskia/fastapi/__main__.py @@ -1,16 +1,31 @@ import argparse import asyncio -import ipaddress +import json import logging import os from urllib.parse import urlparse import uvicorn +from fastapi_vue.hostutil import parse_endpoint +from uvicorn import Config, Server +from paskia import globals as _globals +from paskia.bootstrap import bootstrap_if_needed +from paskia.config import PaskiaConfig +from paskia.fastapi import app as fastapi_app +from paskia.fastapi import reset as reset_cmd +from paskia.util import startupbox from paskia.util.hostutil import normalize_origin -DEFAULT_HOST = "localhost" -DEFAULT_SERVE_PORT = 4401 +DEFAULT_PORT = 4401 + +EPILOG = """\ +Examples: + paskia # localhost:4401 + paskia :8080 # All interfaces, port 8080 + paskia unix:/tmp/paskia.sock + paskia reset [user] # Generate passkey reset link +""" def is_subdomain(sub: str, domain: str) -> bool: @@ -34,80 +49,6 @@ def validate_auth_host(auth_host: str, rp_id: str) -> None: ) -def parse_endpoint( - value: str | None, default_port: int -) -> tuple[str | None, int | None, str | None, bool]: - """Parse an endpoint using stdlib (urllib.parse, ipaddress). - - Returns (host, port, uds_path). If uds_path is not None, host/port are None. - - Supported forms: - - host[:port] - - :port (uses default host) - - [ipv6][:port] (bracketed for port usage) - - ipv6 (unbracketed, no port allowed -> default port) - - unix:/path/to/socket.sock - - None -> defaults (localhost:4401) - - Notes: - - For IPv6 with an explicit port you MUST use brackets (e.g. [::1]:8080) - - Unbracketed IPv6 like ::1 implies the default port. - """ - if not value: - return DEFAULT_HOST, default_port, None, False - - # Port only (numeric) -> localhost:port - if value.isdigit(): - try: - port_only = int(value) - except ValueError: # pragma: no cover (isdigit guards) - raise SystemExit(f"Invalid port '{value}'") - return DEFAULT_HOST, port_only, None, False - - # 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}'") - 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) -> detect by multiple colons - if value.count(":") > 1 and not value.startswith("["): - try: - ipaddress.IPv6Address(value) - except ValueError as e: # pragma: no cover - raise SystemExit(f"Invalid IPv6 address '{value}': {e}") - return value, default_port, None, False - - # Use urllib.parse for everything else (host[:port], :port, [ipv6][:port]) - parsed = urlparse(f"//{value}") # // prefix lets urlparse treat it as netloc - host = parsed.hostname - port = parsed.port - - # Host may be None if empty (e.g. ':5500') - if not host: - host = DEFAULT_HOST - if port is None: - port = default_port - - # Validate IP literals (optional; hostname passes through) - try: - # Strip brackets if somehow present (urlparse removes them already) - ipaddress.ip_address(host) - except ValueError: - # Not an IP address -> treat as hostname; no action - pass - - return host, port, None, False - - def add_common_options(p: argparse.ArgumentParser) -> None: p.add_argument( "--rp-id", default="localhost", help="Relying Party ID (default: localhost)" @@ -134,45 +75,44 @@ def main(): logging.basicConfig(level=logging.INFO, format="%(message)s", force=True) parser = argparse.ArgumentParser( - prog="paskia", description="Paskia authentication server" + prog="paskia", + description="Paskia authentication server", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=EPILOG, ) - sub = parser.add_subparsers(dest="command", required=True) - # serve subcommand - serve = sub.add_parser( - "serve", help="Run the server (production style, no auto-reload)" - ) - serve.add_argument( + # Primary argument: either host:port or "reset" subcommand + parser.add_argument( "hostport", nargs="?", help=( "Endpoint (default: localhost:4401). Forms: host[:port] | :port | " - "[ipv6][:port] | ipv6 | unix:/path.sock" + "[ipv6][:port] | ipv6 | unix:/path.sock | 'reset' for credential reset" ), ) - add_common_options(serve) - - # reset subcommand - reset = sub.add_parser( - "reset", - help=( - "Create a credential reset link for a user. Provide part of the display name or UUID. " - "If omitted, targets the master admin (first Administration role user in an auth:admin org)." - ), - ) - reset.add_argument( - "query", + parser.add_argument( + "reset_query", nargs="?", - help="User UUID (full) or case-insensitive substring of display name. If omitted, master admin is used.", + help="For 'reset' command: user UUID or substring of display name", ) - add_common_options(reset) + add_common_options(parser) args = parser.parse_args() - if args.command == "serve": - host, port, uds, all_ifaces = parse_endpoint(args.hostport, DEFAULT_SERVE_PORT) + # Detect "reset" subcommand (first positional is "reset") + is_reset = args.hostport == "reset" + + if is_reset: + endpoints = [] else: - host = port = uds = all_ifaces = None # type: ignore + # Parse endpoint using fastapi_vue.hostutil + endpoints = parse_endpoint(args.hostport, DEFAULT_PORT) + + # Extract host/port/uds from first endpoint for config display and site_url + ep = endpoints[0] if endpoints else {} + host = ep.get("host") + port = ep.get("port") + uds = ep.get("uds") # Collect and normalize origins, handle auth_host origins = [normalize_origin(o) for o in (getattr(args, "origins", None) or [])] @@ -193,8 +133,13 @@ def main(): origins = [x for x in origins if not (x in seen or seen.add(x))] # Compute site_url and site_path for reset links - # Priority: auth_host > first origin with localhost > http://localhost:port - if args.auth_host: + # Priority: PASKIA_SITE_URL (explicit) > auth_host > first origin with localhost > http://localhost:port + explicit_site_url = os.environ.get("PASKIA_SITE_URL") + if explicit_site_url: + # Explicit site URL from devserver or deployment config + site_url = explicit_site_url.rstrip("/") + site_path = "/" if args.auth_host else "/auth/" + elif args.auth_host: site_url = args.auth_host.rstrip("/") site_path = "/" elif origins: @@ -215,8 +160,6 @@ def main(): site_path = "/auth/" # Build runtime configuration - from paskia.config import PaskiaConfig - config = PaskiaConfig( rp_id=args.rp_id, rp_name=args.rp_name or None, @@ -230,8 +173,6 @@ def main(): ) # Export configuration via single JSON env variable for worker processes - import json - config_json = { "rp_id": config.rp_id, "rp_name": config.rp_name, @@ -243,8 +184,6 @@ def main(): os.environ["PASKIA_CONFIG"] = json.dumps(config_json) # Initialize globals (without bootstrap yet) - from paskia import globals as _globals # local import - asyncio.run( _globals.init( rp_id=config.rp_id, @@ -255,80 +194,45 @@ def main(): ) # Print startup configuration - from paskia.util import startupbox - startupbox.print_startup_config(config) # Bootstrap after startup box is printed - from paskia.bootstrap import bootstrap_if_needed - asyncio.run(bootstrap_if_needed()) - # Handle recover-admin command (no server start) - if args.command == "reset": - from paskia.fastapi import reset as reset_cmd # local import - - exit_code = reset_cmd.run(getattr(args, "query", None)) + # Handle reset command (no server start) + if is_reset: + exit_code = reset_cmd.run(args.reset_query) raise SystemExit(exit_code) - if args.command == "serve": - run_kwargs: dict = { - "log_level": "info", - } + # Dev mode: enable reload when FASTAPI_VUE_FRONTEND_URL is set + devmode = bool(os.environ.get("FASTAPI_VUE_FRONTEND_URL")) - # Dev mode: enable reload when PASKIA_DEVMODE is set - devmode = bool(os.environ.get("PASKIA_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: - raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}") - run_kwargs["reload"] = True - run_kwargs["reload_dirs"] = ["paskia"] - # Suppress uvicorn startup messages in dev mode - run_kwargs["log_level"] = "warning" + run_kwargs: dict = { + "log_level": "info", + } - if uds: - run_kwargs["uds"] = uds - else: - if not all_ifaces: - run_kwargs["host"] = host - run_kwargs["port"] = port + 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: + raise SystemExit(f"Dev mode requires localhost:4402, got {host}:{port}") + run_kwargs["reload"] = True + run_kwargs["reload_dirs"] = ["paskia"] + # Suppress uvicorn startup messages in dev mode + run_kwargs["log_level"] = "warning" - if all_ifaces and not uds: - # Dev mode with all interfaces: use simple single-server approach - if devmode: - run_kwargs["host"] = "::" - run_kwargs["port"] = port - uvicorn.run("paskia.fastapi:app", **run_kwargs) - else: - # Production: run separate servers for IPv4 and IPv6 - from uvicorn import Config, Server # noqa: E402 local import + if len(endpoints) > 1: + # Run separate servers for multiple endpoints (e.g. IPv4 + IPv6) + async def serve_all(): + async with asyncio.TaskGroup() as tg: + for ep in endpoints: + tg.create_task( + Server(Config(app=fastapi_app, **run_kwargs, **ep)).serve() + ) - from paskia.fastapi import ( - app as fastapi_app, # noqa: E402 local import - ) - - async def serve_both(): - servers = [] - assert port is not None - for h in ("0.0.0.0", "::"): - try: - cfg = Config( - app=fastapi_app, - host=h, - port=port, - log_level="info", - ) - servers.append(Server(cfg)) - except Exception as e: # pragma: no cover - logging.warning(f"Failed to configure server for {h}: {e}") - tasks = [asyncio.create_task(s.serve()) for s in servers] - await asyncio.gather(*tasks) - - asyncio.run(serve_both()) - else: - uvicorn.run("paskia.fastapi:app", **run_kwargs) + asyncio.run(serve_all()) + else: + uvicorn.run("paskia.fastapi:app", **run_kwargs, **endpoints[0]) if __name__ == "__main__": diff --git a/paskia/fastapi/admin.py b/paskia/fastapi/admin.py index de8dc05..b8fcd03 100644 --- a/paskia/fastapi/admin.py +++ b/paskia/fastapi/admin.py @@ -10,12 +10,12 @@ from paskia.authsession import EXPIRES, reset_expires from paskia.fastapi import authz from paskia.fastapi.session import AUTH_COOKIE from paskia.util import ( - frontend, hostutil, passphrase, permutil, querysafe, useragent, + vitedev, ) app = FastAPI() @@ -77,7 +77,7 @@ async def general_exception_handler(_request, exc: Exception): # pragma: no cov @app.get("/") async def adminapp(request: Request, auth=AUTH_COOKIE): - return Response(*await frontend.read("/auth/admin/index.html")) + return Response(*await vitedev.read("/auth/admin/index.html")) # -------------------- Organizations -------------------- diff --git a/paskia/fastapi/api.py b/paskia/fastapi/api.py index 93a3d6c..263f2e3 100644 --- a/paskia/fastapi/api.py +++ b/paskia/fastapi/api.py @@ -23,7 +23,7 @@ from paskia.authsession import ( from paskia.fastapi import authz, session, user from paskia.fastapi.session import AUTH_COOKIE, AUTH_COOKIE_NAME from paskia.globals import passkey as global_passkey -from paskia.util import frontend, hostutil, htmlutil, passphrase, userinfo +from paskia.util import hostutil, htmlutil, passphrase, userinfo, vitedev bearer_auth = HTTPBearer(auto_error=True) @@ -180,7 +180,7 @@ async def forward_authentication( if wants_html: # Browser request - return full-page HTML with metadata data_attrs = {"mode": e.mode, **e.metadata} - html = (await frontend.read("/int/forward/index.html"))[0] + html = (await vitedev.read("/int/forward/index.html"))[0] html = htmlutil.patch_html_data_attrs(html, **data_attrs) return Response( html, status_code=e.status_code, media_type="text/html; charset=UTF-8" diff --git a/paskia/fastapi/mainapp.py b/paskia/fastapi/mainapp.py index 2b9295b..e7e2938 100644 --- a/paskia/fastapi/mainapp.py +++ b/paskia/fastapi/mainapp.py @@ -5,11 +5,18 @@ from pathlib import Path from fastapi import FastAPI, HTTPException, Request, Response from fastapi.responses import FileResponse, RedirectResponse -from fastapi.staticfiles import StaticFiles +from fastapi_vue import Frontend from paskia.fastapi import admin, api, auth_host, ws from paskia.fastapi.session import AUTH_COOKIE -from paskia.util import frontend, hostutil, passphrase +from paskia.util import hostutil, passphrase, vitedev + +# Vue Frontend static files +frontend = Frontend( + Path(__file__).with_name("frontend-build"), + cached=["/auth/assets/"], +) + # Path to examples/index.html when running from source tree _EXAMPLES_DIR = Path(__file__).parent.parent.parent / "examples" @@ -43,10 +50,11 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path raise # Restore info level logging after startup (suppressed during uvicorn init in dev mode) - if frontend.is_dev_mode(): + if frontend.devmode: logging.getLogger("uvicorn").setLevel(logging.INFO) logging.getLogger("uvicorn.access").setLevel(logging.INFO) + await frontend.load() yield @@ -59,19 +67,11 @@ app.mount("/auth/api/admin/", admin.app) app.mount("/auth/api/", api.app) app.mount("/auth/ws/", ws.app) -# In dev mode (PASKIA_DEVMODE=1), Vite serves assets directly; skip static files mount -if not frontend.is_dev_mode(): - app.mount( - "/auth/assets/", - StaticFiles(directory=frontend.file("auth", "assets")), - name="assets", - ) - @app.get("/auth/restricted/") async def restricted_view(): """Serve the restricted/authentication UI for iframe embedding.""" - return Response(*await frontend.read("/auth/restricted/index.html")) + return Response(*await vitedev.read("/auth/restricted/index.html")) # Navigable URLs are defined here. We support both / and /auth/ as the base path @@ -86,7 +86,7 @@ async def frontapp(request: Request, response: Response, auth=AUTH_COOKIE): The frontend handles mode detection (host mode vs full profile) based on settings. Access control is handled via APIs. """ - return Response(*await frontend.read("/auth/index.html")) + return Response(*await vitedev.read("/auth/index.html")) @app.get("/admin", include_in_schema=False) @@ -128,4 +128,8 @@ async def token_link(token: str): if not passphrase.is_well_formed(token): raise HTTPException(status_code=404) - return Response(*await frontend.read("/int/reset/index.html")) + return Response(*await vitedev.read("/int/reset/index.html")) + + +# Final catch-all route for frontend files (keep at end of file) +frontend.route(app, "/") diff --git a/paskia/util/frontend.py b/paskia/util/frontend.py index a32f1b0..505f280 100644 --- a/paskia/util/frontend.py +++ b/paskia/util/frontend.py @@ -11,7 +11,7 @@ __all__ = ["path", "file", "read", "is_dev_mode"] def _get_dev_server() -> str | None: """Get the dev server URL from environment, or None if not in dev mode.""" - return os.environ.get("PASKIA_DEVMODE") or None + return os.environ.get("FASTAPI_VUE_FRONTEND_URL") or None def _resolve_static_dir() -> Path: diff --git a/paskia/util/startupbox.py b/paskia/util/startupbox.py index 62931b8..fb782ce 100644 --- a/paskia/util/startupbox.py +++ b/paskia/util/startupbox.py @@ -41,7 +41,7 @@ def print_startup_config(config: "PaskiaConfig") -> None: lines.append(line(f"Auth Host: {config.auth_host}")) # Show frontend URL if in dev mode - devmode = os.environ.get("PASKIA_DEVMODE") + devmode = os.environ.get("FASTAPI_VUE_FRONTEND_URL") if devmode: lines.append(line(f"Dev Frontend: {devmode}")) diff --git a/paskia/util/vitedev.py b/paskia/util/vitedev.py new file mode 100644 index 0000000..cd890b0 --- /dev/null +++ b/paskia/util/vitedev.py @@ -0,0 +1,71 @@ +"""Vite dev server proxy for fetching frontend files during development. + +In dev mode (FASTAPI_VUE_FRONTEND_URL set), fetches files from Vite. +In production, reads from the static build directory. + +This complements fastapi_vue.Frontend which handles static file serving +but doesn't provide server-side fetching of HTML content. +""" + +import asyncio +import mimetypes +import os +from pathlib import Path + +import httpx + +__all__ = ["read"] + + +def _get_dev_server() -> str | None: + """Get the dev server URL from environment, or None if not in dev mode.""" + return os.environ.get("FASTAPI_VUE_FRONTEND_URL") or None + + +def _resolve_static_dir() -> Path: + """Resolve the static files directory.""" + from importlib import resources + + # Try packaged path via importlib.resources (works for wheel/installed). + try: # pragma: no cover - trivial path resolution + pkg_dir = resources.files("paskia") / "frontend-build" + fs_path = Path(str(pkg_dir)) + if fs_path.is_dir(): + return fs_path + except Exception: # pragma: no cover - defensive + pass + # Fallback for editable/development before build. + return Path(__file__).parent.parent / "frontend-build" + + +_static_dir: Path = _resolve_static_dir() + + +async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]: + """Read file content and return response tuple. + + In dev mode, fetches from the Vite dev server. + In production, reads from the static build directory. + + Args: + filepath: Path relative to frontend root, e.g. "/auth/index.html" + + Returns: + Tuple of (content, status_code, headers) suitable for + FastAPI Response(*args). + """ + dev_server = _get_dev_server() + if dev_server: + async with httpx.AsyncClient() as client: + resp = await client.get(f"{dev_server}{filepath}") + resp.raise_for_status() + mime = resp.headers.get("content-type", "application/octet-stream") + # Strip charset suffix if present + mime = mime.split(";")[0].strip() + return resp.content, resp.status_code, {"content-type": mime} + else: + # Production: read from static build + file_path = _static_dir / filepath.lstrip("/") + content = await asyncio.to_thread(file_path.read_bytes) + mime, _ = mimetypes.guess_type(str(file_path)) + return content, 200, {"content-type": mime or "application/octet-stream"} diff --git a/pyproject.toml b/pyproject.toml index 8ebaf7e..8bb9e73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,8 +22,9 @@ dependencies = [ "jsondiff>=2.2.1", "msgspec>=0.20.0", "aiofiles>=25.1.0", + "fastapi-vue>=0.3.0", ] -requires-python = ">=3.10" +requires-python = ">=3.11" [project.urls] Homepage = "https://git.zi.fi/LeoVasanko/paskia" @@ -99,3 +100,5 @@ paskia-migrate = "paskia.migrate:main" [tool.hatch.build] artifacts = ["paskia/frontend-build"] targets.sdist.hooks.custom.path = "scripts/build-frontend.py" +packages = ["paskia"] +only-packages = true diff --git a/scripts/devserver.new.py b/scripts/devserver.new.py new file mode 100755 index 0000000..f8e9e09 --- /dev/null +++ b/scripts/devserver.new.py @@ -0,0 +1,277 @@ +#!/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 5d16f73..755c91a 100755 --- a/scripts/devserver.py +++ b/scripts/devserver.py @@ -6,12 +6,17 @@ not from the installed package. It starts both the Vite frontend dev server and the FastAPI backend with auto-reload enabled. Usage: - uv run scripts/dev.py [host:port] [options...] + uv run scripts/devserver.py [host:port] [options...] The optional host:port argument sets where the Vite frontend listens. -All other options are forwarded to `paskia serve`. +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). + Options: --caddy Run Caddy as HTTPS proxy on port 443 (requires sudo) --rp-id HOST Relying Party ID (used as hostname for Caddy) @@ -119,7 +124,11 @@ def parse_endpoint( def run_vite( - vite_url: str, vite_host: str | None, vite_port: int, auth_host: str | None = None + 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.""" devpath = Path(__file__).parent.parent / "frontend" @@ -162,7 +171,7 @@ def run_vite( full_cmd = cmd + vite_args stderr.write(f">>> {' '.join([tool_name, *full_cmd[1:]])}\n") - vite_env = os.environ.copy() + vite_env = env.copy() if auth_host: vite_env["PASKIA_AUTH_HOST"] = auth_host vite_process = subprocess.Popen( @@ -391,7 +400,7 @@ def main(): if all_ifaces: vite_host = "0.0.0.0" - # Build Vite URL for PASKIA_DEVMODE (always use localhost for URL) + # 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) @@ -424,15 +433,21 @@ def main(): if not run_caddy(caddy_origins, vite_port): raise SystemExit(1) - # Start Vite dev server - run_vite(vite_url, vite_host, vite_port, args.auth_host) - - # Set dev mode with Vite URL in environment for subprocess + # Set dev mode env vars for subprocesses (fastapi-vue convention) env = os.environ.copy() - env["PASKIA_DEVMODE"] = vite_url + 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 - # Build command with origin args - cmd = ["paskia", "serve", f"localhost:{BACKEND_PORT}"] + # Start Vite dev server + run_vite(vite_url, vite_host, vite_port, env, args.auth_host) + + # Build command with origin args (no serve subcommand, host:port is first arg) + cmd = ["paskia", f"localhost:{BACKEND_PORT}"] # Pass through rp-id (always pass, has default) cmd.extend(["--rp-id", args.rp_id]) diff --git a/scripts/fastapi-vue/build-frontend.py b/scripts/fastapi-vue/build-frontend.py new file mode 100644 index 0000000..7fccf65 --- /dev/null +++ b/scripts/fastapi-vue/build-frontend.py @@ -0,0 +1,34 @@ +"""Hatch build hook for building Vue frontend during package build.""" + +import subprocess +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) + + +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 diff --git a/scripts/fastapi-vue/util.py b/scripts/fastapi-vue/util.py new file mode 100644 index 0000000..236b2c5 --- /dev/null +++ b/scripts/fastapi-vue/util.py @@ -0,0 +1,87 @@ +"""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