More robust dev mode script, with --caddy to run the proxy as well.

This commit is contained in:
Leo Vasanko
2025-12-06 21:19:09 +00:00
parent 4da1127976
commit c7d4907cf1
+241 -12
View File
@@ -11,23 +11,33 @@ Usage:
The optional host:port argument sets where the Vite frontend listens.
All other options are forwarded to `paskia serve`.
Backend always listens on localhost:4402.
Options:
--caddy Run Caddy as HTTPS proxy on port 4443
--rp-id HOST Relying Party ID (used as hostname for Caddy)
--origin URL Allowed origin(s), passed to backend
--auth-host H Dedicated auth host, passed to backend
"""
import argparse
import atexit
import ipaddress
import json
import os
import shutil
import signal
import subprocess
import sys
import time
from pathlib import Path
from sys import stderr
from threading import Thread
from paskia.fastapi.__main__ import parse_endpoint
from urllib.parse import urlparse
DEFAULT_VITE_PORT = 4403 # overrides by CLI option
BACKEND_PORT = 4402 # hardcoded, also in vite.config.ts
CADDY_PORT = 4443 # HTTPS port for Caddy proxy
DEFAULT_HOST = "localhost"
NO_FRONTEND_TOOL = """\
┃ ⚠️ deno, npm or bunx needed to run the frontend server.
@@ -51,6 +61,77 @@ NO_FRONTEND = """\
┃ for various frontend assets, so make sure to start it manually.
"""
# Global options to disable auto HTTPS redirect (requires port 80)
CADDYFILE_GLOBAL = """\
{
admin off
auto_https off
log {
level warn
}
}
"""
CADDYFILE_SITE_BLOCK = """\
SITE_ADDR {
# API requests go directly to backend
handle /auth/api/* {
reverse_proxy localhost:BACKEND_PORT
}
handle /auth/ws/* {
reverse_proxy localhost:BACKEND_PORT
}
# Everything else goes to Vite
handle {
reverse_proxy localhost:VITE_PORT
}
}
"""
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):
"""Spawn the frontend dev server (deno, npm, or bunx) as a background process."""
@@ -64,7 +145,7 @@ def run_vite(vite_url: str, vite_host: str | None, vite_port: int):
options = [
("deno", "run", "dev"),
("npm", "run", "dev", "--"),
("npm", "--silent", "run", "dev", "--"),
("bunx", "--bun", "vite"),
]
cmd = None
@@ -76,7 +157,7 @@ def run_vite(vite_url: str, vite_host: str | None, vite_port: int):
break
# Add Vite CLI args for host/port
vite_args = ["--port", str(vite_port)]
vite_args = ["--port", str(vite_port), "--logLevel", "silent"]
if vite_host:
vite_args.extend(["--host", vite_host])
@@ -115,10 +196,96 @@ def run_vite(vite_url: str, vite_host: str | None, vite_port: int):
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")
return None
# Build Caddyfile with a site block for each origin
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
port = parsed.port or (4480 if scheme == "http" else CADDY_PORT)
site_addr = f"{scheme}://{host}:{port}"
block = (
CADDYFILE_SITE_BLOCK
.replace("SITE_ADDR", site_addr)
.replace("BACKEND_PORT", str(BACKEND_PORT))
.replace("VITE_PORT", str(vite_port))
)
caddyfile_parts.append(block)
caddyfile = CADDYFILE_GLOBAL + "\n".join(caddyfile_parts)
caddy_process = None
try:
cmd = [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
# Wait briefly to check if Caddy exits immediately (e.g., port binding failure)
time.sleep(0.2)
exit_code = caddy_process.poll()
if exit_code is not None:
# Read and display stderr output, filtering and formatting JSON log lines
caddy_stderr = caddy_process.stderr.read().decode() if caddy_process.stderr else ""
stderr.write(f"┃ ⚠️ Caddy startup failed\n")
if caddy_stderr:
for line in caddy_stderr.splitlines():
if not line:
continue
try:
log = json.loads(line)
# Filter out info-level messages
if log.get("level") == "info":
continue
# Filter out expected "admin endpoint disabled" warning
if log.get("logger") == "admin" and "admin endpoint disabled" in log.get("msg", ""):
continue
# Format: [level] logger: msg
level = log.get("level", "?").upper()
logger = log.get("logger", "")
msg = log.get("msg", "")
if logger:
stderr.write(f"┃ [{level}] {logger}: {msg}\n")
else:
stderr.write(f"┃ [{level}] {msg}\n")
except json.JSONDecodeError:
# Not JSON, print as-is (e.g., "Error: ..." lines)
stderr.write(f"{line}\n")
return None
def cleanup():
if caddy_process:
caddy_process.terminate()
caddy_process.wait()
atexit.register(cleanup)
return caddy_process
def main():
# Parse optional hostport argument for Vite frontend
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("hostport", nargs="?", default=None)
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()
# Parse Vite endpoint
@@ -137,19 +304,81 @@ def main():
# Build Vite URL for PASKIA_DEVMODE (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.origins:
# User specified explicit origins - use those
caddy_origins = args.origins
elif args.caddy:
# Caddy mode without explicit origins: add https origin for the hostname
if args.auth_host:
# auth-host is the primary origin
auth_host = args.auth_host
if "://" not in auth_host:
auth_host = f"https://{auth_host}"
# Add port if not standard
parsed = urlparse(auth_host)
if not parsed.port:
caddy_origins.append(f"{auth_host}:{CADDY_PORT}")
else:
caddy_origins.append(auth_host)
else:
# Use rp-id as the hostname
caddy_origins.append(f"https://{args.rp_id}:{CADDY_PORT}")
# Start Caddy if requested (after computing origins)
if args.caddy:
if not caddy_origins:
caddy_origins = [f"https://{args.rp_id}:{CADDY_PORT}"]
stderr.write(f">>> caddy @ {' '.join(caddy_origins)}/\n")
if not run_caddy(caddy_origins, vite_port):
raise SystemExit(1)
# Start Vite dev server
run_vite(vite_url, vite_host, vite_port)
# Set dev mode with Vite URL
os.environ["PASKIA_DEVMODE"] = vite_url
# Set dev mode with Vite URL in environment for subprocess
env = os.environ.copy()
env["PASKIA_DEVMODE"] = vite_url
# Import CLI after environment is set up
from paskia.fastapi.__main__ import main as cli_main
# Build command with origin args
cmd = ["paskia", "serve", f"localhost:{BACKEND_PORT}"]
# Build argv for the main CLI in Dev mode
# Backend always listens on localhost only (Vite proxies API requests)
sys.argv = ["paskia", "serve", f"localhost:{BACKEND_PORT}"] + remaining
cli_main()
# 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])
# Collect all origins: Caddy origins first (auth-host first), then user origins
# Use a set to track and avoid duplicates
all_origins = []
seen_origins = set(args.origins) if args.origins else set()
# Add Caddy origins first (they include auth-host origin if configured)
if args.caddy:
for origin in caddy_origins:
if origin not in seen_origins:
all_origins.append(origin)
seen_origins.add(origin)
# Add user-specified origins
if args.origins:
for origin in args.origins:
if origin not in seen_origins:
all_origins.append(origin)
seen_origins.add(origin)
# Pass all origins to backend
for origin in all_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)
if __name__ == "__main__":