"""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 # Static assets aren't content-hashed in their filenames, so this has to stay # short -- it's a revalidation window, not a long-lived immutable cache. Still # saves a full body re-transfer (the mount's weak ETag otherwise forces a # conditional GET on every navigation) for anything fetched again within it. _STATIC_CACHE_CONTROL = "public, max-age=300" class _CachedStaticFiles(StaticFiles): """StaticFiles with a Cache-Control header stamped onto every response, including 304s -- see _STATIC_CACHE_CONTROL above.""" def file_response(self, *args, **kwargs) -> Response: response = super().file_response(*args, **kwargs) response.headers["Cache-Control"] = _STATIC_CACHE_CONTROL return response # 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. The shell file itself (and the verification tags, also constant for the process's lifetime) is read and prepped once, not on every request -- only the __ORIGIN__ substitution actually varies per request, and even that repeats across requests (one canonical origin in the common topology), so the substituted HTML + its ETag are memoized per origin instead of re-read-and-re-sha1'd every time.""" path = os.path.join(paths.STATIC_DIR, name) _template: list[str] = [] # lazy singleton cell, populated on first request _by_origin: dict[str, tuple[str, str]] = {} # origin_prefix -> (html, etag) def _load_template() -> str: if not _template: with open(path, encoding="utf-8") as f: html = f.read() # Search-engine verification tags (same source as the SSR # content pages), so the interactive tool's own pages carry them # too. Also constant for the process's life -- folded in here. verify = content.head_verify_html() if verify: html = html.replace("", f"\n {verify}", 1) _template.append(html) return _template[0] def route(request: Request): origin_prefix = f"{content._origin(request)}{content.BASE}" cached = _by_origin.get(origin_prefix) if cached is None: html = _load_template().replace("__ORIGIN__", origin_prefix) etag = f'W/"{hashlib.sha1(html.encode()).hexdigest()[:20]}"' cached = (html, etag) _by_origin[origin_prefix] = cached html, etag = cached 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 "/", _CachedStaticFiles(directory=paths.STATIC_DIR), name="static")