a digital entity named phi that roams bsky phi.zzstoatzz.io
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: SPA routing was returning index.html for js assets too

the @app.get("/{full_path:path}") catch-all was registered before the
StaticFiles mount, so /_app/immutable/entry/start.*.js requests matched
the catch-all and returned index.html — browsers refuse to load JS
modules served as text/html, so the SPA never booted.

correct pattern:
1. StaticFiles mount serves real files (index.html, _app/*, favicon)
2. 404 handler catches client-side routes (/feed, /mind, ...) and
falls back to index.html — except for /api/* and /health which
stay JSON 404s.

also documented the routing layering so this doesn't get re-introduced.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

+22 -12
+22 -12
src/bot/main.py
··· 217 217 # this directory may not exist (just run `bun run dev` separately and let 218 218 # vite proxy /api/* to the python server) — we mount conditionally so dev 219 219 # of the python side doesn't fail. 220 + # 221 + # routing layering: 222 + # 1. all explicit @app.get/@app.post handlers above (api, control, health) 223 + # 2. StaticFiles mount at "/" — serves index.html for "/" and any real 224 + # file under /app/web/* (assets, favicon) 225 + # 3. 404 handler — for client-side routes (/feed, /mind, etc) that have 226 + # no corresponding file, falls back to index.html so the svelte 227 + # router takes over. 228 + # 229 + # the previous version registered an @app.get("/{full_path:path}") catch-all 230 + # BEFORE the mount, which intercepted every request including JS assets and 231 + # returned index.html with text/html content-type — browsers refuse to load 232 + # js modules served as text/html, so the SPA never booted. 220 233 221 234 WEB_DIR = Path(settings.web_build_dir) 222 235 if WEB_DIR.is_dir(): 223 - 224 - @app.get("/{full_path:path}") 225 - async def spa_fallback(full_path: str): 226 - """SPA fallback: any unmatched route returns index.html. 236 + app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web") 237 + logger.info(f"frontend mounted from {WEB_DIR}") 227 238 228 - sveltekit's adapter-static emits a single index.html with client-side 229 - routing — so /, /feed, /mind, /blog, etc all serve the same shell and 230 - the svelte router takes over. assets under /_app/* and the favicon 231 - are served by the StaticFiles mount below before this handler runs. 232 - """ 239 + @app.exception_handler(404) 240 + async def spa_fallback(request: Request, exc): # noqa: ARG001 241 + # Only fall back for browser navigation requests; api/health 404s 242 + # should still return JSON. 243 + path = request.url.path 244 + if path.startswith("/api/") or path == "/health": 245 + return JSONResponse({"error": "not found"}, status_code=404) 233 246 return FileResponse(WEB_DIR / "index.html") 234 - 235 - app.mount("/", StaticFiles(directory=str(WEB_DIR), html=True), name="web") 236 - logger.info(f"frontend mounted from {WEB_DIR}") 237 247 else: 238 248 logger.warning( 239 249 f"frontend build not found at {WEB_DIR} — only API routes will be served"