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 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue