app: SPA fallback for arbitrary client routes
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

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 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-05-09 22:56:37 +00:00
parent 95b4b4ddd7
commit d91473a018

View file

@ -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)