"""Build the single ``gradio.Server`` that powers Case Zero. ``gradio.Server`` is a FastAPI subclass, so all standard FastAPI methods work on it (`.include_router`, `.get`, `.mount`, ...) plus a `.api()` decorator that adds Gradio's queue + SSE streaming + concurrency control. We: * register the game's JSON/SSE routers under ``/api``; * serve the built Preact pixel-art SPA (``web/dist``). The whole app runs 100% inside one Gradio process - no separate frontend host. Static serving uses explicit routes (assets under ``/assets``, index at ``/``) rather than a catch-all mount at ``/`` on purpose: a ``Mount("/")`` shadows Gradio's own internal ``/gradio_api/*`` routes (including its launch health check) and breaks startup. """ from __future__ import annotations from pathlib import Path from fastapi import HTTPException from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from gradio import Server from ..constants import ASSETS_DIR from .routes_case import router as case_router from .routes_run import router as run_router # src/case_zero/api/server.py -> repo root is three parents up from this file's dir. _REPO_ROOT = Path(__file__).resolve().parents[3] WEB_DIST = (_REPO_ROOT / "web" / "dist").resolve() _INDEX = WEB_DIST / "index.html" _AUDIO_DIR = ASSETS_DIR / "ui" class _ImmutableStaticFiles(StaticFiles): """Static files with a far-future cache policy - only safe because every file under /assets carries a content hash in its name, so an update is always a new URL.""" def file_response(self, *args, **kwargs): # type: ignore[no-untyped-def] response = super().file_response(*args, **kwargs) response.headers["Cache-Control"] = "public, max-age=31536000, immutable" return response def build_server() -> Server: """Return a configured (not yet launched) ``gradio.Server``.""" server = Server(title="Case Zero", docs_url=None, redoc_url=None, openapi_url=None) server.include_router(case_router) server.include_router(run_router) # Gradio API endpoints (Server mode): the core game actions registered through Gradio's # own API engine - queue + concurrency + gradio_client-callable + visible in the API view. # This makes the app unambiguously a Gradio application (the custom SPA is the Off-Brand # frontend on top). The SPA itself uses the REST routes above; these mirror them. from .dto import NewCaseRequest from .routes_case import new_case from .routes_run import InterrogateBody, interrogate @server.api(name="new_case") def gr_new_case(case_id: str = "") -> dict: """Generate a fresh case (or load one by id); returns the public case JSON.""" return new_case(NewCaseRequest(case_id=case_id or None)).model_dump(by_alias=True) @server.api(name="interrogate") def gr_interrogate(run_id: str, suspect_id: str, question: str) -> dict: """Ask a suspect a free-text question; returns their reply + server suspicion.""" return interrogate(run_id, suspect_id, InterrogateBody(free_text=question)).model_dump( by_alias=True ) @server.get("/healthz") def healthz() -> dict[str, bool]: return {"ok": True} # UI audio (sfx + ambient music) - a specific prefix, served locally (Off-the-Grid). if _AUDIO_DIR.is_dir(): server.mount("/audio", StaticFiles(directory=str(_AUDIO_DIR)), name="audio") if WEB_DIST.is_dir(): # Hashed JS/CSS live under /assets - a specific prefix that never collides with /api. # Content-hashed filenames are safe to cache forever; new deploys ship new names. server.mount( "/assets", _ImmutableStaticFiles(directory=str(WEB_DIST / "assets")), name="assets", ) # index.html must NEVER be cached blindly: it is the pointer to the current bundle. # Without this header, mobile browsers heuristically cache it and keep loading a # stale bundle after a deploy (players and judges would see the previous UI). _NO_CACHE = {"Cache-Control": "no-cache, must-revalidate"} @server.get("/") def _index() -> FileResponse: return FileResponse(_INDEX, headers=_NO_CACHE) @server.get("/{filename}") def _root_or_spa(filename: str) -> FileResponse: # Serve real root files (favicon.svg, icons.svg); otherwise fall back to the SPA # index so single-segment client routes resolve. Single-segment matching means # this never shadows /gradio_api/* or /api/* (those have deeper paths). candidate = (WEB_DIST / filename).resolve() if candidate.is_file() and candidate.is_relative_to(WEB_DIST): return FileResponse(candidate, headers=_NO_CACHE) if _INDEX.is_file(): return FileResponse(_INDEX, headers=_NO_CACHE) raise HTTPException(status_code=404) return server