Refactor dev mode into a source repo script (remove dev subcommand from package).
This commit is contained in:
+19
-36
@@ -7,11 +7,8 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from passkey.util import frontend
|
|
||||||
|
|
||||||
DEFAULT_HOST = "localhost"
|
DEFAULT_HOST = "localhost"
|
||||||
DEFAULT_SERVE_PORT = 4401
|
DEFAULT_SERVE_PORT = 4401
|
||||||
DEFAULT_DEV_PORT = 4402
|
|
||||||
|
|
||||||
|
|
||||||
def is_subdomain(sub: str, domain: str) -> bool:
|
def is_subdomain(sub: str, domain: str) -> bool:
|
||||||
@@ -147,18 +144,6 @@ def main():
|
|||||||
)
|
)
|
||||||
add_common_options(serve)
|
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 subcommand
|
||||||
reset = sub.add_parser(
|
reset = sub.add_parser(
|
||||||
"reset",
|
"reset",
|
||||||
@@ -176,26 +161,17 @@ def main():
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command in {"serve", "dev"}:
|
if args.command == "serve":
|
||||||
default_port = DEFAULT_DEV_PORT if args.command == "dev" else DEFAULT_SERVE_PORT
|
host, port, uds, all_ifaces = parse_endpoint(args.hostport, DEFAULT_SERVE_PORT)
|
||||||
host, port, uds, all_ifaces = parse_endpoint(args.hostport, default_port)
|
|
||||||
devmode = args.command == "dev"
|
|
||||||
else:
|
else:
|
||||||
host = port = uds = all_ifaces = None # type: ignore
|
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
|
# Export configuration via environment for lifespan initialization in each process
|
||||||
os.environ.setdefault("PASSKEY_RP_ID", args.rp_id)
|
os.environ.setdefault("PASSKEY_RP_ID", args.rp_id)
|
||||||
if args.rp_name:
|
if args.rp_name:
|
||||||
os.environ["PASSKEY_RP_NAME"] = args.rp_name
|
os.environ["PASSKEY_RP_NAME"] = args.rp_name
|
||||||
if origin:
|
if args.origin:
|
||||||
os.environ["PASSKEY_ORIGIN"] = origin
|
os.environ["PASSKEY_ORIGIN"] = args.origin
|
||||||
if getattr(args, "auth_host", None):
|
if getattr(args, "auth_host", None):
|
||||||
os.environ["PASSKEY_AUTH_HOST"] = args.auth_host
|
os.environ["PASSKEY_AUTH_HOST"] = args.auth_host
|
||||||
else:
|
else:
|
||||||
@@ -216,7 +192,7 @@ def main():
|
|||||||
_globals.init(
|
_globals.init(
|
||||||
rp_id=args.rp_id,
|
rp_id=args.rp_id,
|
||||||
rp_name=args.rp_name,
|
rp_name=args.rp_name,
|
||||||
origin=origin,
|
origin=args.origin,
|
||||||
default_admin=os.getenv("PASSKEY_DEFAULT_ADMIN") or None,
|
default_admin=os.getenv("PASSKEY_DEFAULT_ADMIN") or None,
|
||||||
default_org=os.getenv("PASSKEY_DEFAULT_ORG") or None,
|
default_org=os.getenv("PASSKEY_DEFAULT_ORG") or None,
|
||||||
bootstrap=True,
|
bootstrap=True,
|
||||||
@@ -230,12 +206,21 @@ def main():
|
|||||||
exit_code = reset_cmd.run(getattr(args, "query", None))
|
exit_code = reset_cmd.run(getattr(args, "query", None))
|
||||||
raise SystemExit(exit_code)
|
raise SystemExit(exit_code)
|
||||||
|
|
||||||
if args.command in {"serve", "dev"}:
|
if args.command == "serve":
|
||||||
run_kwargs: dict = {
|
run_kwargs: dict = {
|
||||||
"reload": devmode,
|
|
||||||
"reload_dirs": ["passkey"] if devmode else None,
|
|
||||||
"log_level": "info",
|
"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:
|
if uds:
|
||||||
run_kwargs["uds"] = uds
|
run_kwargs["uds"] = uds
|
||||||
else:
|
else:
|
||||||
@@ -243,16 +228,14 @@ def main():
|
|||||||
run_kwargs["host"] = host
|
run_kwargs["host"] = host
|
||||||
run_kwargs["port"] = port
|
run_kwargs["port"] = port
|
||||||
|
|
||||||
if devmode:
|
|
||||||
os.environ["PASSKEY_DEVMODE"] = "1"
|
|
||||||
frontend.run_dev()
|
|
||||||
|
|
||||||
if all_ifaces and not uds:
|
if all_ifaces and not uds:
|
||||||
|
# Dev mode with all interfaces: use simple single-server approach
|
||||||
if devmode:
|
if devmode:
|
||||||
run_kwargs["host"] = "::"
|
run_kwargs["host"] = "::"
|
||||||
run_kwargs["port"] = port
|
run_kwargs["port"] = port
|
||||||
uvicorn.run("passkey.fastapi:app", **run_kwargs)
|
uvicorn.run("passkey.fastapi:app", **run_kwargs)
|
||||||
else:
|
else:
|
||||||
|
# Production: run separate servers for IPv4 and IPv6
|
||||||
from uvicorn import Config, Server # noqa: E402 local import
|
from uvicorn import Config, Server # noqa: E402 local import
|
||||||
|
|
||||||
from passkey.fastapi import (
|
from passkey.fastapi import (
|
||||||
|
|||||||
@@ -43,15 +43,6 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
|
|||||||
# Re-raise to fail fast
|
# Re-raise to fail fast
|
||||||
raise
|
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
|
yield
|
||||||
# (Optional) add shutdown cleanup here later
|
# (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/api/", api.app)
|
||||||
app.mount("/auth/ws/", ws.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/")
|
@app.get("/auth/restricted/")
|
||||||
async def restricted_view():
|
async def restricted_view():
|
||||||
|
|||||||
@@ -1,43 +1,15 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import atexit
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import shutil
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
from importlib import resources
|
from importlib import resources
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from sys import stderr
|
|
||||||
from threading import Thread
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
__all__ = ["path", "file", "read", "run_dev", "is_dev_mode"]
|
__all__ = ["path", "file", "read", "is_dev_mode"]
|
||||||
|
|
||||||
DEV_SERVER = "http://localhost:4403"
|
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:
|
def _resolve_static_dir() -> Path:
|
||||||
# Try packaged path via importlib.resources (works for wheel/installed).
|
# 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:
|
async def _read_file_async(file_path: Path) -> bytes:
|
||||||
"""Read file asynchronously using asyncio.to_thread."""
|
"""Read file asynchronously using asyncio.to_thread."""
|
||||||
return await asyncio.to_thread(file_path.read_bytes)
|
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())
|
|
||||||
|
|||||||
+129
@@ -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()
|
||||||
Reference in New Issue
Block a user