232 lines
7.7 KiB
Python
Executable File
232 lines
7.7 KiB
Python
Executable File
#!/usr/bin/env -S uv run
|
|
"""Run Vite development server for Vue app and FastAPI backend with auto-reload."""
|
|
|
|
import argparse
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sys
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
# 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 devutil import ( # noqa: E402
|
|
ProcessGroup,
|
|
check_ports_free,
|
|
logger,
|
|
ready,
|
|
setup_cli,
|
|
setup_vite,
|
|
)
|
|
|
|
DEFAULT_VITE_PORT = 4403
|
|
DEFAULT_DEV_PORT = 4402
|
|
CADDY_PORT = 443 # HTTPS port for Caddy proxy
|
|
CADDY_HTTP_PORT = 80 # HTTP port for ACME challenges
|
|
|
|
CADDYFILE_SITE_BLOCK = """\
|
|
SITE_ADDR {
|
|
# WebSockets bypass directly to backend (workaround for bun proxy bug)
|
|
handle /auth/ws/* {
|
|
reverse_proxy BACKEND_ADDR
|
|
}
|
|
# Everything else goes to or via Vite
|
|
handle {
|
|
reverse_proxy VITE_ADDR
|
|
}
|
|
}
|
|
"""
|
|
|
|
|
|
def build_caddyfile(origins: list[str], viteurl: str, backurl: str) -> str:
|
|
"""Build a Caddyfile for the given origins."""
|
|
caddyfile_parts = []
|
|
for origin in origins:
|
|
parsed = urlparse(origin)
|
|
scheme = parsed.scheme or "https"
|
|
host = parsed.hostname or parsed.path
|
|
port = parsed.port or (CADDY_HTTP_PORT if scheme == "http" else CADDY_PORT)
|
|
if port in (80, 443):
|
|
site_addr = f"{scheme}://{host}"
|
|
else:
|
|
site_addr = f"{scheme}://{host}:{port}"
|
|
block = (
|
|
CADDYFILE_SITE_BLOCK.replace("SITE_ADDR", site_addr)
|
|
.replace("BACKEND_ADDR", backurl)
|
|
.replace("VITE_ADDR", viteurl)
|
|
)
|
|
caddyfile_parts.append(block)
|
|
return "\n".join(caddyfile_parts)
|
|
|
|
|
|
async def run_caddy(
|
|
origins: list[str], viteurl: str, backurl: str
|
|
) -> 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)
|
|
|
|
caddyfile = build_caddyfile(origins, viteurl, backurl)
|
|
cmd = ["sudo", caddy_path, "run", "--config", "-", "--adapter", "caddyfile"]
|
|
|
|
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()
|
|
|
|
# Wait for ready signal or failure
|
|
while True:
|
|
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)
|
|
|
|
line = await proc.stderr.readline()
|
|
if not line:
|
|
continue
|
|
|
|
decoded = line.decode().rstrip()
|
|
if "serving initial configuration" in decoded:
|
|
break
|
|
|
|
# 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)
|
|
|
|
# Start background task to drain stderr
|
|
async def drain_caddy_stderr():
|
|
while True:
|
|
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
|
|
|
|
asyncio.create_task(drain_caddy_stderr())
|
|
return proc
|
|
|
|
|
|
async def run_devserver(args: argparse.Namespace, remaining: list[str]) -> None:
|
|
"""Run the development server with all components."""
|
|
reporoot = Path(__file__).parent.parent
|
|
frontend_path = reporoot / "frontend"
|
|
if not (frontend_path / "package.json").exists():
|
|
logger.warning("Frontend source not found at %s", frontend_path)
|
|
raise SystemExit(1)
|
|
|
|
viteurl, npm_install, vite = setup_vite(args.listen, DEFAULT_VITE_PORT)
|
|
backurl, paskia = setup_cli("paskia", args.backend, DEFAULT_DEV_PORT)
|
|
|
|
# Build paskia command with options
|
|
paskia.extend(["--rp-id", args.rp_id])
|
|
if args.auth_host:
|
|
paskia.extend(["--auth-host", args.auth_host])
|
|
if args.origins:
|
|
for origin in args.origins:
|
|
paskia.extend(["--origin", origin])
|
|
paskia.extend(remaining)
|
|
|
|
# Compute origins for Caddy
|
|
caddy_origins = []
|
|
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))]
|
|
|
|
# Set environment for subprocesses
|
|
os.environ["PASKIA_VITE_URL"] = viteurl
|
|
os.environ["PASKIA_BACKEND_URL"] = backurl
|
|
os.environ["PASKIA_SITE_URL"] = caddy_origins[0] if args.caddy else viteurl
|
|
os.environ["PASKIA_DEV"] = "1"
|
|
if args.auth_host:
|
|
os.environ["PASKIA_AUTH_HOST"] = args.auth_host
|
|
|
|
async with ProcessGroup() as pg:
|
|
# Start Caddy first if requested (needs to bind ports)
|
|
if args.caddy:
|
|
caddy_proc = await run_caddy(caddy_origins, viteurl, backurl)
|
|
pg._procs.append(caddy_proc)
|
|
pg._cmds[caddy_proc.pid] = "caddy"
|
|
|
|
npm_proc = await pg.spawn(*npm_install, cwd=frontend_path)
|
|
await check_ports_free(viteurl, backurl)
|
|
await pg.spawn(*paskia)
|
|
await pg.wait(npm_proc, ready(backurl, path="/api/health?from=devserver.py"))
|
|
await pg.spawn(*vite, cwd=frontend_path)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(add_help=False)
|
|
parser.add_argument(
|
|
"-l",
|
|
"--listen",
|
|
metavar="addr",
|
|
help=f"Vite (default: localhost:{DEFAULT_VITE_PORT})",
|
|
)
|
|
parser.add_argument(
|
|
"--backend",
|
|
metavar="addr",
|
|
help=f"FastAPI (default: localhost:{DEFAULT_DEV_PORT})",
|
|
)
|
|
parser.add_argument("--caddy", action="store_true", help="Run Caddy as HTTPS proxy")
|
|
parser.add_argument("--rp-id", default="localhost", help="Relying Party ID")
|
|
parser.add_argument(
|
|
"--origin", action="append", dest="origins", help="Allowed origin(s)"
|
|
)
|
|
parser.add_argument("--auth-host", help="Dedicated auth host")
|
|
args, remaining = parser.parse_known_args()
|
|
|
|
with suppress(KeyboardInterrupt):
|
|
asyncio.run(run_devserver(args, remaining))
|
|
|
|
|
|
HELP_EPILOG = """
|
|
Other options are forwarded to paskia [args]
|
|
|
|
JS_RUNTIME environment variable can be used to select the JS runtime:
|
|
npm, deno, bun, or full path to the runtime executable (node maps to npm).
|
|
"""
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|