From d91473a018eb0f013fff175b8ae96e4c41b2493d Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 9 May 2026 22:56:37 +0000 Subject: [PATCH] app: SPA fallback for arbitrary client routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StaticFiles(html=True) only serves index.html for directory paths, which 404s on /scenarios, /what-if, /scenarios/123 — anything React Router owns. Subclass StaticFiles to catch the 404 from get_response and return index.html so the SPA can take over routing client-side. API routes still match first (under /api/* in prod), so no risk of shadowing. Found via headless verification through chrome-service: dashboard loaded 200 + nav rendered, but /scenarios + /what-if returned 404. Co-Authored-By: Claude Opus 4.7 --- fire_planner/app.py | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/fire_planner/app.py b/fire_planner/app.py index 7932842..3253f2a 100644 --- a/fire_planner/app.py +++ b/fire_planner/app.py @@ -33,9 +33,12 @@ from pathlib import Path from typing import Any from fastapi import Depends, FastAPI, status +from fastapi.exceptions import HTTPException from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from prometheus_fastapi_instrumentator import Instrumentator +from starlette.types import Scope from fire_planner.api.auth import require_bearer from fire_planner.api.goals import router as goals_router @@ -144,11 +147,29 @@ async def healthz() -> dict[str, Any]: return {"status": "ok", "queue_depth": depth} -# Mount the SPA last so it catches any path the API didn't claim. With -# `html=True`, requests for paths like `/scenarios` (no file at that -# path) return `index.html`, letting React Router own client routing. +class _SPAStaticFiles(StaticFiles): + """StaticFiles with a SPA fallback: any 404 returns index.html so + React Router can own client-side routing for paths like /scenarios, + /what-if, /scenarios/123, etc. + + `StaticFiles(html=True)` only serves index.html for *directories*, + not arbitrary not-found paths — that's not enough for a SPA. + """ + + async def get_response(self, path: str, scope: Scope): # type: ignore[no-untyped-def] + try: + return await super().get_response(path, scope) + except HTTPException as exc: + if exc.status_code == 404: + index = Path(self.directory) / "index.html" # type: ignore[arg-type] + if index.is_file(): + return FileResponse(index) + raise + + +# Mount the SPA last so it catches any path the API didn't claim. if _FRONTEND_DIST and Path(_FRONTEND_DIST).is_dir(): - app.mount("/", StaticFiles(directory=_FRONTEND_DIST, html=True), name="spa") + app.mount("/", _SPAStaticFiles(directory=_FRONTEND_DIST, html=True), name="spa") log.info("Mounted SPA from %s; API at %s/*", _FRONTEND_DIST, _API_PREFIX) elif _FRONTEND_DIST: log.warning("FRONTEND_DIST=%s does not exist; SPA not mounted", _FRONTEND_DIST)