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
|
"""SSR frontend service: server-rendered content pages (content.py), the
|
||||||
climate data comes from the backend's content API over HTTP (api_client.py) --
|
interactive tool's SPA shells, and every static asset. All climate data comes
|
||||||
this process holds no data of its own and does no polars/DB work.
|
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
|
Repo-split Stage 7a: frontend now owns static-asset + SPA-shell serving too
|
||||||
JS/CSS/images to backend's own StaticFiles mount -- prod/beta's Caddy never
|
(moved from backend/web/app.py, which used to read them from a sibling
|
||||||
sends those paths here, and backend's own dev/bare-metal reverse-proxy fallback
|
frontend/ directory -- a relationship that won't exist once backend and
|
||||||
(THERMOGRAPH_FRONTEND_BASE_INTERNAL) only forwards the specific content paths
|
frontend are separate repos). Backend keeps a single catch-all proxy route as
|
||||||
content.register() claims. A static mount here would just be unreachable dead
|
its Caddy-less fallback, so every real topology is unaffected; only which
|
||||||
code in every deployment.
|
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
|
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")
|
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")
|
@app.get("/healthz")
|
||||||
def healthz():
|
def healthz():
|
||||||
|
|
@ -24,5 +44,51 @@ def healthz():
|
||||||
return {"status": "ok"}
|
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().
|
# NOTE: "/" is not here -- the homepage is server-rendered by content.register().
|
||||||
content.register(app)
|
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