Move static asset + SPA-shell ownership from backend to frontend (repo-split Stage 7a) (#22)

This commit is contained in:
emi 2026-07-21 22:48:59 +00:00
parent c51c60bd94
commit d6056afabe
2 changed files with 109 additions and 10 deletions

86
app.py
View file

@ -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 <meta> 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("<head>", f"<head>\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")

33
tests/test_pages.py Normal file
View file

@ -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