From 8609f2fe69869fa42dd10e11cb67a31479fbe9d2 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Fri, 5 Dec 2025 18:36:13 +0000 Subject: [PATCH] Refactor dev mode into a source repo script (remove dev subcommand from package). --- passkey/fastapi/__main__.py | 55 ++++++--------- passkey/fastapi/mainapp.py | 17 +++-- passkey/util/frontend.py | 85 +----------------------- scripts/dev.py | 129 ++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 129 deletions(-) create mode 100644 scripts/dev.py diff --git a/passkey/fastapi/__main__.py b/passkey/fastapi/__main__.py index ade6ca7..1e51516 100644 --- a/passkey/fastapi/__main__.py +++ b/passkey/fastapi/__main__.py @@ -7,11 +7,8 @@ from urllib.parse import urlparse import uvicorn -from passkey.util import frontend - DEFAULT_HOST = "localhost" DEFAULT_SERVE_PORT = 4401 -DEFAULT_DEV_PORT = 4402 def is_subdomain(sub: str, domain: str) -> bool: @@ -147,18 +144,6 @@ def main(): ) add_common_options(serve) - # dev subcommand - dev = sub.add_parser("dev", help="Run the server in development (auto-reload)") - dev.add_argument( - "hostport", - nargs="?", - help=( - "Endpoint (default: localhost:4402). Forms: host[:port] | :port | " - "[ipv6][:port] | ipv6 | unix:/path.sock" - ), - ) - add_common_options(dev) - # reset subcommand reset = sub.add_parser( "reset", @@ -176,26 +161,17 @@ def main(): args = parser.parse_args() - if args.command in {"serve", "dev"}: - default_port = DEFAULT_DEV_PORT if args.command == "dev" else DEFAULT_SERVE_PORT - host, port, uds, all_ifaces = parse_endpoint(args.hostport, default_port) - devmode = args.command == "dev" + if args.command == "serve": + host, port, uds, all_ifaces = parse_endpoint(args.hostport, DEFAULT_SERVE_PORT) else: host = port = uds = all_ifaces = None # type: ignore - devmode = False - - # Determine origin (dev mode default override) - origin = args.origin - if devmode and not args.origin and not args.rp_id: - # Dev mode: Vite runs on another port, override: - origin = "http://localhost:4403" # Export configuration via environment for lifespan initialization in each process os.environ.setdefault("PASSKEY_RP_ID", args.rp_id) if args.rp_name: os.environ["PASSKEY_RP_NAME"] = args.rp_name - if origin: - os.environ["PASSKEY_ORIGIN"] = origin + if args.origin: + os.environ["PASSKEY_ORIGIN"] = args.origin if getattr(args, "auth_host", None): os.environ["PASSKEY_AUTH_HOST"] = args.auth_host else: @@ -216,7 +192,7 @@ def main(): _globals.init( rp_id=args.rp_id, rp_name=args.rp_name, - origin=origin, + origin=args.origin, default_admin=os.getenv("PASSKEY_DEFAULT_ADMIN") or None, default_org=os.getenv("PASSKEY_DEFAULT_ORG") or None, bootstrap=True, @@ -230,12 +206,21 @@ def main(): exit_code = reset_cmd.run(getattr(args, "query", None)) raise SystemExit(exit_code) - if args.command in {"serve", "dev"}: + if args.command == "serve": run_kwargs: dict = { - "reload": devmode, - "reload_dirs": ["passkey"] if devmode else None, "log_level": "info", } + + # Dev mode: enable reload when PASSKEY_DEVMODE is set + devmode = os.environ.get("PASSKEY_DEVMODE") == "1" + 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"] = ["passkey"] + if uds: run_kwargs["uds"] = uds else: @@ -243,16 +228,14 @@ def main(): run_kwargs["host"] = host run_kwargs["port"] = port - if devmode: - os.environ["PASSKEY_DEVMODE"] = "1" - frontend.run_dev() - 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("passkey.fastapi:app", **run_kwargs) else: + # Production: run separate servers for IPv4 and IPv6 from uvicorn import Config, Server # noqa: E402 local import from passkey.fastapi import ( diff --git a/passkey/fastapi/mainapp.py b/passkey/fastapi/mainapp.py index fc5d23e..43bd38f 100644 --- a/passkey/fastapi/mainapp.py +++ b/passkey/fastapi/mainapp.py @@ -43,15 +43,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path # Re-raise to fail fast raise - # In dev mode, Vite serves assets directly; in production, mount static files - # This is deferred to lifespan because PASSKEY_DEVMODE is set after module import - if not frontend.is_dev_mode(): - app.mount( - "/auth/assets/", - StaticFiles(directory=frontend.file("auth", "assets")), - name="assets", - ) - yield # (Optional) add shutdown cleanup here later @@ -65,6 +56,14 @@ app.mount("/auth/api/admin/", admin.app) app.mount("/auth/api/", api.app) app.mount("/auth/ws/", ws.app) +# In dev mode (PASSKEY_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(): diff --git a/passkey/util/frontend.py b/passkey/util/frontend.py index 2dbe129..bc37a00 100644 --- a/passkey/util/frontend.py +++ b/passkey/util/frontend.py @@ -1,43 +1,15 @@ import asyncio -import atexit import mimetypes import os -import shutil -import signal -import subprocess from importlib import resources from pathlib import Path -from sys import stderr -from threading import Thread import httpx -__all__ = ["path", "file", "read", "run_dev", "is_dev_mode"] +__all__ = ["path", "file", "read", "is_dev_mode"] DEV_SERVER = "http://localhost:4403" -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 = """\ -┃ -┃ Note: only static build of the frontend is served at localhost:4402. -┃ The page will not update with frontend code changes. -""" - def _resolve_static_dir() -> Path: # Try packaged path via importlib.resources (works for wheel/installed). @@ -97,58 +69,3 @@ async def read(filepath: str) -> tuple[bytes, int, dict[str, str]]: async def _read_file_async(file_path: Path) -> bytes: """Read file asynchronously using asyncio.to_thread.""" return await asyncio.to_thread(file_path.read_bytes) - - -def run_dev(): - """Spawn the frontend dev server (deno, npm, or bunx) as a background process.""" - devpath = Path(__file__).parent.parent.parent / "frontend" - if not (devpath / "package.json").exists(): - raise RuntimeError( - "Dev frontend is only available when running from git." - if "site-packages" in devpath.parts - else f"Frontend source code not found at {devpath}" - ) - - options = [ - ("deno", "run", "dev"), - ("npm", "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 - - vite_process = None - - def start_vite(): - nonlocal vite_process - if cmd is None: - stderr.write(NO_FRONTEND_TOOL) - stderr.write(NO_FRONTEND) - return - assert tool_name is not None - try: - if tool_name == "bunx": - stderr.write(BUN_BUG) - - stderr.write(f">>> {' '.join([tool_name, *cmd[1:]])}\n") - vite_process = subprocess.Popen(cmd, cwd=str(devpath), shell=False) - except Exception as e: - stderr.write(f"┃ ⚠️ Vite couldn't start: {e}\n") - stderr.write(NO_FRONTEND) - - def cleanup(): - 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()) diff --git a/scripts/dev.py b/scripts/dev.py new file mode 100644 index 0000000..6599134 --- /dev/null +++ b/scripts/dev.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Development server script for Paskia. + +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. + +Usage: + python scripts/dev.py [options...] + +All options are forwarded to `paskia serve`. +""" + +import atexit +import os +import shutil +import signal +import subprocess +import sys +from pathlib import Path +from sys import stderr +from threading import Thread + +# Set dev mode environment variable BEFORE importing anything from paskia +os.environ["PASKIA_DEVMODE"] = "1" + +# Ensure the package is importable when running from repo root +sys.path.insert(0, str(Path(__file__).parent.parent)) + +DEFAULT_DEV_PORT = 4402 +DEV_SERVER = "http://localhost:4403" + +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 = """\ +┃ +┃ Note: only static build of the frontend is served at localhost:4402. +┃ The page will not update with frontend code changes. +""" + + +def run_vite(): + """Spawn the frontend dev server (deno, npm, or bunx) as a background process.""" + devpath = Path(__file__).parent.parent / "frontend" + if not (devpath / "package.json").exists(): + stderr.write(f"┃ ⚠️ Frontend source not found at {devpath}\n") + stderr.write(NO_FRONTEND) + return + + options = [ + ("deno", "run", "dev"), + ("npm", "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 + + vite_process = None + + def start_vite(): + nonlocal vite_process + if cmd is None: + stderr.write(NO_FRONTEND_TOOL) + stderr.write(NO_FRONTEND) + return + assert tool_name is not None + try: + if tool_name == "bunx": + stderr.write(BUN_BUG) + + stderr.write(f">>> {' '.join([tool_name, *cmd[1:]])}\n") + vite_process = subprocess.Popen(cmd, cwd=str(devpath), shell=False) + except Exception as e: + stderr.write(f"┃ ⚠️ Vite couldn't start: {e}\n") + stderr.write(NO_FRONTEND) + + 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 main(): + # Start Vite dev server first + run_vite() + + # Set default origin for Vite if not specified + if "--origin" not in sys.argv: + os.environ.setdefault("PASKIA_ORIGIN", DEV_SERVER) + + # Build argv for the main CLI + # Dev mode always listens on localhost:4402 (security: prevents public exposure) + # User args come after, allowing overrides of other options + sys.argv = ["paskia", "serve", f"localhost:{DEFAULT_DEV_PORT}"] + sys.argv[1:] + + from paskia.fastapi.__main__ import main as cli_main + + cli_main() + + +if __name__ == "__main__": + main()