"""FastAPI application for LandscapeForge. Mounts: - OpenEnv endpoints (`/reset`, `/step`, `/schema`, `/ws`, etc.) via create_app - Frontend API helpers at `/api/*` (see `api_routes.py`) - React SPA at `/` (built to `/app/env/frontend/dist` via Vite) """ from pathlib import Path try: from openenv.core.env_server.http_server import create_app except Exception as e: # pragma: no cover raise ImportError( "openenv is required for the web interface. Install dependencies via 'uv sync'." ) from e try: from ..models import LandscapeforgeAction, LandscapeforgeObservation from .landscapeforge_environment import LandscapeforgeEnvironment from .api_routes import router as lf_api_router except (ModuleNotFoundError, ImportError): from models import LandscapeforgeAction, LandscapeforgeObservation # type: ignore from server.landscapeforge_environment import LandscapeforgeEnvironment # type: ignore from server.api_routes import router as lf_api_router # type: ignore app = create_app( LandscapeforgeEnvironment, LandscapeforgeAction, LandscapeforgeObservation, env_name="landscapeforge", # Single shared env for plain HTTP /reset + /step so the API playground # (which doesn't carry session IDs) operates on consistent state. # WebSocket clients still get fresh instances via SUPPORTS_CONCURRENT_SESSIONS. max_concurrent_envs=1, ) # Frontend-facing API (landscape, baseline_race, arena, llm_run) app.include_router(lf_api_router) # ---- React SPA serving ---- # The Dockerfile builds the frontend into `frontend/dist/`. Locate it relative # to this file, and serve it under `/` with a SPA fallback to index.html. try: from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse _here = Path(__file__).resolve().parent.parent _dist = _here / "frontend" / "dist" if _dist.is_dir(): # Assets (hashed css/js) at /assets, vite dist root items at / app.mount("/assets", StaticFiles(directory=_dist / "assets"), name="lf-assets") @app.get("/") def _index(): return FileResponse(_dist / "index.html") # SPA fallback: any route the FastAPI router doesn't own, serve # index.html so client-side routing (if any) works. @app.get("/{path:path}") def _spa_fallback(path: str): # Don't swallow API/OpenEnv routes — FastAPI matches those first # because they're registered before this catch-all. candidate = _dist / path if candidate.is_file(): return FileResponse(candidate) return FileResponse(_dist / "index.html") else: import logging logging.getLogger(__name__).warning( "Frontend dist not found at %s — SPA routes disabled.", _dist ) except Exception as _e: import logging logging.getLogger(__name__).warning("SPA mount failed: %s", _e) def main(): """Entry point for direct execution.""" import argparse import os import uvicorn parser = argparse.ArgumentParser() parser.add_argument("--port", type=int, default=int(os.environ.get("PORT", 8000))) parser.add_argument("--host", type=str, default="0.0.0.0") args = parser.parse_args() uvicorn.run(app, host=args.host, port=args.port) if __name__ == "__main__": main()