Move static asset + SPA-shell ownership from backend to frontend (repo-split Stage 7a) (#22)
This commit is contained in:
parent
c51c60bd94
commit
d6056afabe
2 changed files with 109 additions and 10 deletions
86
app.py
86
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 <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
33
tests/test_pages.py
Normal 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
|
||||
Loading…
Reference in a new issue