"""SSR frontend service: server-rendered content pages (content.py), the interactive tool's SPA shells, and every static asset. All climate data comes from the backend's content API over HTTP (api_client.py) -- this process holds no data of its own and does no polars/DB work. Repo-split Stage 7a: frontend now owns static-asset + SPA-shell serving too (moved from backend/web/app.py, which used to read them from a sibling frontend/ directory -- a relationship that won't exist once backend and frontend are separate repos). Backend keeps a single catch-all proxy route as its Caddy-less fallback, so every real topology is unaffected; only which process actually reads the files off disk changed. """ import hashlib import mimetypes import os from fastapi import FastAPI, Request, Response from fastapi.staticfiles import StaticFiles import content # content's own `paths` (not a fresh `import paths` here) -- in the test # environment, conftest.py's sys.modules swap-then-restore trick means a # top-level `import paths` in THIS module, executed later than content.py's, # would bind to backend's paths module instead of frontend's own (they share # the bare name "paths"). content.paths was bound correctly during the swap # window and stays that way regardless of what sys.modules["paths"] points to # later -- reusing it sidesteps the timing issue entirely, in tests and prod # alike. paths = content.paths app = FastAPI(title="Thermograph SSR", version="0.1.0") # The PWA manifest is served as a static file; register its media type so # StaticFiles labels it correctly (not in Python's default mimetypes map). mimetypes.add_type("application/manifest+json", ".webmanifest") @app.get("/healthz") def healthz(): """Liveness probe: the process is up and serving. Deliberately does no I/O -- no call to the backend -- so it stays cheap and reliable for a tight healthcheck interval. Readiness (backend reachable) is a separate concern.""" return {"status": "ok"} def _not_modified(request: Request, etag: str) -> bool: inm = request.headers.get("if-none-match") return inm is not None and inm == etag def _page(name): """Route handler for one SPA-shell HTML page (the interactive tool's calendar/day/score/compare/legend/alerts views). Serves the file with its __ORIGIN__ placeholder (the link-preview/Open Graph tags) filled in -- preview crawlers need absolute URLs, and the host differs between LAN and prod. Uses content._origin (not a simpler duplicate) since this route is reached both directly (Caddy) and through backend's proxy fallback, and only that version prefers X-Forwarded-Host over Host (repo-split Stage 5) -- required for the proxied case to resolve the real browser-facing host instead of this internal hop's own address.""" path = os.path.join(paths.STATIC_DIR, name) def route(request: Request): with open(path, encoding="utf-8") as f: html = f.read() html = html.replace("__ORIGIN__", f"{content._origin(request)}{content.BASE}") # Search-engine verification tags (same source as the SSR content # pages), so the interactive tool's own pages carry them too. verify = content.head_verify_html() if verify: html = html.replace("", f"\n {verify}", 1) etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"' if _not_modified(request, etag): return Response(status_code=304, headers={"ETag": etag}) return Response(html, media_type="text/html", headers={"ETag": etag}) return route # NOTE: "/" is not here -- the homepage is server-rendered by content.register(). content.register(app) app.add_api_route(f"{content.BASE}/calendar", _page("calendar.html"), methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(f"{content.BASE}/day", _page("day.html"), methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(f"{content.BASE}/score", _page("score.html"), methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(f"{content.BASE}/compare", _page("compare.html"), methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(f"{content.BASE}/legend", _page("legend.html"), methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route(f"{content.BASE}/alerts", _page("subscriptions.html"), methods=["GET", "HEAD"], include_in_schema=False) # Everything else under BASE (app.js, style.css, nav.js, manifest, favicons, …) # is a static asset. Registered last so the explicit routes above win. Mounts # must start with "/", so at the root (BASE == "") mount at "/" -- the # earlier routes still win because Starlette matches in registration order. app.mount(content.BASE or "/", StaticFiles(directory=paths.STATIC_DIR), name="static")