web_frontend_server.py
1 """ 2 Production frontend server. 3 4 Serves the pre-built React bundle from /web_dist with SPA client-side routing. 5 Used by the web container in production mode (AG3NTUM_MODE=prod). 6 In development mode, the Vite dev server is used instead. 7 8 Routing: if the request path matches a static file under the dist directory, 9 serve it. Otherwise, serve index.html for React Router client-side routing. 10 11 Set WEB_DIST_DIR env var to override the dist directory (used in tests). 12 """ 13 import os 14 from pathlib import Path 15 16 from starlette.applications import Starlette 17 from starlette.requests import Request 18 from starlette.responses import FileResponse, Response 19 from starlette.routing import Route 20 21 22 def _get_dist_dir() -> Path: 23 """Return the web distribution directory path.""" 24 return Path(os.environ.get("WEB_DIST_DIR", "/web_dist")) 25 26 27 async def serve(request: Request) -> Response: 28 """Serve static files with SPA fallback to index.html.""" 29 dist = _get_dist_dir() 30 path = request.path_params.get("path", "") 31 32 if path: 33 file_path = (dist / path).resolve() 34 # Serve the file if it exists and is within the dist directory 35 if file_path.is_file() and str(file_path).startswith(str(dist.resolve())): 36 return FileResponse(str(file_path)) 37 38 # SPA fallback: serve index.html for all unmatched paths 39 index = dist / "index.html" 40 if not index.is_file(): 41 return Response( 42 "Frontend not built. Run: ./run.sh build", 43 status_code=503, 44 media_type="text/plain", 45 ) 46 return FileResponse(str(index), media_type="text/html") 47 48 49 app = Starlette(routes=[ 50 Route("/", endpoint=serve), 51 Route("/{path:path}", endpoint=serve), 52 ])