fix: SPA middleware instead of catch-all, preserves WS

This commit is contained in:
Zac Gaetano 2026-04-05 11:10:18 -04:00
parent 54c1e9d92c
commit 44a8ddf458

View file

@ -1003,17 +1003,36 @@ async def serve_index():
return {"message": "Claude Persistent Agent API v3.0", "docs": "/docs"} return {"message": "Claude Persistent Agent API v3.0", "docs": "/docs"}
@app.get("/{full_path:path}") @app.get("/vite.svg")
async def serve_spa(full_path: str): @app.get("/favicon.ico")
"""Serve React SPA for all non-API routes. API and WS are handled above.""" async def serve_static_root_files():
if full_path.startswith("api"): """Serve known static root files."""
raise HTTPException(404) from starlette.requests import Request
# Try serving the file directly (e.g. vite.svg, favicon) return FileResponse(str(STATIC_DIR / "vite.svg"))
file_path = STATIC_DIR / full_path
if file_path.is_file():
return FileResponse(str(file_path)) # SPA fallback via middleware — this avoids the catch-all route problem
# Fall back to index.html for SPA routing # that breaks WebSocket routing in some Starlette versions
index = STATIC_DIR / "index.html" from starlette.middleware.base import BaseHTTPMiddleware
if index.exists(): from starlette.requests import Request as StarletteRequest
return FileResponse(str(index)) from starlette.responses import Response
raise HTTPException(404)
class SPAFallbackMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: StarletteRequest, call_next):
response = await call_next(request)
# If a non-API GET request returned 404, serve index.html for SPA
if (response.status_code == 404
and request.method == "GET"
and not request.url.path.startswith("/api")
and not request.url.path.startswith("/docs")
and not request.url.path.startswith("/openapi")
and not request.url.path.startswith("/assets")
and "websocket" not in request.headers.get("upgrade", "").lower()):
index = STATIC_DIR / "index.html"
if index.exists():
return FileResponse(str(index))
return response
app.add_middleware(SPAFallbackMiddleware)