magpie/scripts/spa_server.py
Alan Weinstock 8ea4baa915 fix(ui): sub-path routing and API proxy for /magpie/ base URL
- spa_server.py: strip /magpie prefix before API check and file lookup;
  all HTTP methods call _normalise_path() first so /magpie/api/v1/* proxies
  correctly and /magpie/assets/* resolve against the dist root
- manage.sh: pass --base /magpie to spa_server.py so the handler knows
  the deployment prefix
- main.ts: pass import.meta.env.BASE_URL to createWebHistory so Vue Router
  strips the /magpie prefix before matching routes

Without these fixes, assets returned index.html (MIME type error), API
calls returned HTML instead of JSON (stats.posts undefined), and router
routes never matched (matched: []).

Closes: #12
2026-06-15 11:50:12 -07:00

137 lines
4.8 KiB
Python

#!/usr/bin/env python3
"""
Static SPA server with index.html fallback and API reverse proxy.
Python's built-in http.server returns 404 for any path that isn't a real
file, which breaks Vue Router's history-mode navigation. This server falls
back to index.html for any path that doesn't match a real file, letting the
client-side router handle the route.
Requests to /api/* are forwarded to the FastAPI backend (--api-port).
Usage:
python scripts/spa_server.py --port 8531 --directory frontend/dist --api-port 8532
"""
import argparse
import http.client
import os
from http.server import HTTPServer, SimpleHTTPRequestHandler
class SPAHandler(SimpleHTTPRequestHandler):
api_port: int = 8532
base_prefix: str = "" # e.g. "/magpie" — stripped before file lookup
# ------------------------------------------------------------------ #
# Path helpers
# ------------------------------------------------------------------ #
def _strip_base(self, path: str) -> str:
"""Remove the base prefix so file lookup works against the dist root."""
p = path.split("?", 1)[0].split("#", 1)[0]
if self.base_prefix and p.startswith(self.base_prefix):
path = path[len(self.base_prefix):]
return path or "/"
# ------------------------------------------------------------------ #
# API proxy
# ------------------------------------------------------------------ #
def _proxy_api(self) -> None:
"""Forward this request to the FastAPI backend and relay the response."""
conn = http.client.HTTPConnection("127.0.0.1", self.api_port, timeout=120)
body_len = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(body_len) if body_len else None
# Strip hop-by-hop headers before forwarding.
skip = {"host", "connection", "transfer-encoding"}
fwd_headers = {k: v for k, v in self.headers.items() if k.lower() not in skip}
try:
conn.request(self.command, self.path, body=body, headers=fwd_headers)
resp = conn.getresponse()
self.send_response(resp.status)
for header, value in resp.getheaders():
if header.lower() not in ("transfer-encoding", "connection"):
self.send_header(header, value)
self.end_headers()
self.wfile.write(resp.read())
except OSError as exc:
self.send_error(502, f"API proxy error: {exc}")
finally:
conn.close()
# ------------------------------------------------------------------ #
# Request dispatch
# ------------------------------------------------------------------ #
def _normalise_path(self) -> None:
"""Strip base prefix so all subsequent checks work on bare /api/ or /asset paths."""
self.path = self._strip_base(self.path)
def _is_api(self) -> bool:
path = self.path.split("?", 1)[0]
return path.startswith("/api/")
def do_GET(self) -> None:
self._normalise_path()
if self._is_api():
self._proxy_api()
return
full = self.translate_path(self.path)
if not os.path.isfile(full):
self.path = "/index.html"
super().do_GET()
def do_POST(self) -> None:
self._normalise_path()
if self._is_api():
self._proxy_api()
return
self.send_error(405, "Method Not Allowed")
def do_PUT(self) -> None:
self._normalise_path()
if self._is_api():
self._proxy_api()
return
self.send_error(405, "Method Not Allowed")
def do_PATCH(self) -> None:
self._normalise_path()
if self._is_api():
self._proxy_api()
return
self.send_error(405, "Method Not Allowed")
def do_DELETE(self) -> None:
self._normalise_path()
if self._is_api():
self._proxy_api()
return
self.send_error(405, "Method Not Allowed")
def log_message(self, fmt: str, *args: object) -> None:
if int(args[1]) >= 400:
super().log_message(fmt, *args)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8531)
parser.add_argument("--directory", default="frontend/dist")
parser.add_argument("--api-port", type=int, default=8532)
parser.add_argument("--base", default="", help="URL base prefix to strip (e.g. /magpie)")
args = parser.parse_args()
SPAHandler.api_port = args.api_port
SPAHandler.base_prefix = args.base.rstrip("/")
os.chdir(args.directory)
server = HTTPServer(("", args.port), SPAHandler)
print(
f"Serving {args.directory} on :{args.port} "
f"(SPA fallback + API proxy → :{args.api_port})"
)
server.serve_forever()
if __name__ == "__main__":
main()