diff --git a/app.py b/app.py index 366a6bd..a713334 100644 --- a/app.py +++ b/app.py @@ -1,20 +1,40 @@ -"""SSR frontend service: server-rendered content pages only (content.py). 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. +"""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. -No static-asset mount here (repo-split Stage 4): every real topology routes -JS/CSS/images to backend's own StaticFiles mount -- prod/beta's Caddy never -sends those paths here, and backend's own dev/bare-metal reverse-proxy fallback -(THERMOGRAPH_FRONTEND_BASE_INTERNAL) only forwards the specific content paths -content.register() claims. A static mount here would just be unreachable dead -code in every deployment. +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. """ -from fastapi import FastAPI +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(): @@ -24,5 +44,51 @@ def healthz(): 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") diff --git a/tests/test_pages.py b/tests/test_pages.py new file mode 100644 index 0000000..f80496e --- /dev/null +++ b/tests/test_pages.py @@ -0,0 +1,33 @@ +"""SPA-shell pages (/calendar, /day, /score, /compare, /legend, /alerts) and +static-asset serving -- moved here from backend/tests/web/test_api.py +(repo-split Stage 7a: frontend now owns these, not backend).""" +from conftest import B + + +def test_calendar_serves_with_origin_filled_in(client): + r = client.get(f"{B}/calendar") + assert r.status_code == 200 + assert "text/html" in r.headers["content-type"] + assert "__ORIGIN__" not in r.text + assert client.head(f"{B}/calendar").status_code == 200 + + +def test_other_spa_shells_serve(client): + for path in ("/day", "/score", "/compare", "/legend", "/alerts"): + r = client.get(f"{B}{path}") + assert r.status_code == 200, path + assert "__ORIGIN__" not in r.text, path + + +def test_static_asset_serves(client): + r = client.get(f"{B}/app.js") + assert r.status_code == 200 + assert "javascript" in r.headers["content-type"] + + +def test_old_static_index_is_gone(client): + """index.html must not linger behind the static mount as indexable + duplicate content now that / is server-rendered (moved here from + backend/tests/web/test_homepage.py, repo-split Stage 7a -- frontend's own + StaticFiles mount is what actually answers this now).""" + assert client.get(f"{B}/index.html").status_code == 404