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)