app: SPA fallback for arbitrary client routes
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
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:
parent
95b4b4ddd7
commit
d91473a018
1 changed files with 25 additions and 4 deletions
|
|
@ -33,9 +33,12 @@ from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, status
|
from fastapi import Depends, FastAPI, status
|
||||||
|
from fastapi.exceptions import HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from prometheus_fastapi_instrumentator import Instrumentator
|
from prometheus_fastapi_instrumentator import Instrumentator
|
||||||
|
from starlette.types import Scope
|
||||||
|
|
||||||
from fire_planner.api.auth import require_bearer
|
from fire_planner.api.auth import require_bearer
|
||||||
from fire_planner.api.goals import router as goals_router
|
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}
|
return {"status": "ok", "queue_depth": depth}
|
||||||
|
|
||||||
|
|
||||||
# Mount the SPA last so it catches any path the API didn't claim. With
|
class _SPAStaticFiles(StaticFiles):
|
||||||
# `html=True`, requests for paths like `/scenarios` (no file at that
|
"""StaticFiles with a SPA fallback: any 404 returns index.html so
|
||||||
# path) return `index.html`, letting React Router own client routing.
|
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():
|
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)
|
log.info("Mounted SPA from %s; API at %s/*", _FRONTEND_DIST, _API_PREFIX)
|
||||||
elif _FRONTEND_DIST:
|
elif _FRONTEND_DIST:
|
||||||
log.warning("FRONTEND_DIST=%s does not exist; SPA not mounted", _FRONTEND_DIST)
|
log.warning("FRONTEND_DIST=%s does not exist; SPA not mounted", _FRONTEND_DIST)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue