More robust dev mode script, with --caddy to run the proxy as well.
This commit is contained in:
+241
-12
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user