From a919242b8f5874089a3f64b3102eec2291cdee9d Mon Sep 17 00:00:00 2001 From: timmy Date: Sun, 9 Aug 2026 06:59:05 +0000 Subject: [PATCH] perf: bundle and fingerprint the PWA runtime (#379) --- README.md | 5 ++- src/frontend_bundle.py | 70 +++++++++++++++++++++++++++++++++++ src/main.py | 10 ++++- src/views.py | 29 +++++++++++---- tests/dashboard_bundle.py | 11 +++++- tests/test_dashboard_auth.py | 10 ++++- tests/test_frontend_bundle.py | 62 +++++++++++++++++++++++++++++++ tests/test_views.py | 6 +-- 8 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 src/frontend_bundle.py create mode 100644 tests/test_frontend_bundle.py diff --git a/README.md b/README.md index a2b6a26..78248bf 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,10 @@ Every response also defines the browser execution boundary with a Content Securi Policy that allows scripts only from the dashboard origin, denies framing and plugins, and blocks unused browser capabilities. Keep these response headers when proxying; do not add inline scripts or broaden `script-src`. The dashboard bootstrap -and stylesheet are same-origin static assets included in the offline PWA shell. +is assembled in source order into one content-addressed JavaScript response. Dashboard +HTML and the offline worker reference that exact fingerprint, while the runtime receives +immutable caching and HTML/worker responses remain revalidated. The stylesheet and +fingerprinted runtime are same-origin assets included atomically in the offline PWA shell. Use **Sign out & clear this device** on shared devices; it clears Stackchain's offline snapshots, drafts, outboxes, background IndexedDB, and PWA caches without removing unrelated forge preferences. Rotate either dashboard secret by replacing diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py new file mode 100644 index 0000000..37e9d5c --- /dev/null +++ b/src/frontend_bundle.py @@ -0,0 +1,70 @@ +"""Build the content-addressed browser shell served by the dashboard.""" + +from __future__ import annotations + +import hashlib +import re +from dataclasses import dataclass +from pathlib import Path + + +SCRIPT_TAG = re.compile(r'^$', re.MULTILINE) +WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" + + +@dataclass(frozen=True) +class FrontendBuild: + dashboard_html: str + runtime_bytes: bytes + runtime_digest: str + runtime_name: str + page_sources: tuple[str, ...] + service_worker_source: str + + +def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes: + chunks = [] + for source in sources: + path = frontend_dir / source.removeprefix("static/") + chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n") + return b"".join(chunks) + + +def build_frontend(frontend_dir: Path) -> FrontendBuild: + """Return one internally consistent HTML, runtime, and offline-worker build.""" + source_html = (frontend_dir / "index.html").read_text() + sources = tuple(SCRIPT_TAG.findall(source_html)) + if not sources: + raise ValueError("dashboard entry document has no page scripts") + + runtime = _bundle(frontend_dir, sources) + digest = hashlib.sha256(runtime).hexdigest()[:16] + runtime_name = f"runtime-{digest}.js" + dashboard_html = SCRIPT_TAG.sub("", source_html) + dashboard_html = dashboard_html.replace( + "", f'\n' + ) + + worker = (frontend_dir / "service-worker.js").read_text() + for source in sources: + if source != WORKER_RUNTIME_SOURCE: + worker = worker.replace(f" BASE + '{source}',\n", "") + worker = worker.replace( + " BASE + 'static/dashboard.css',\n", + f" BASE + 'static/dashboard.css',\n BASE + '{runtime_name}',\n", + ) + worker = re.sub( + r"const CACHE = 'stackchain-dashboard-shell-v\d+';", + f"const CACHE = 'stackchain-dashboard-shell-{digest}';", + worker, + count=1, + ) + + return FrontendBuild( + dashboard_html=dashboard_html, + runtime_bytes=runtime, + runtime_digest=digest, + runtime_name=runtime_name, + page_sources=sources, + service_worker_source=worker, + ) \ No newline at end of file diff --git a/src/main.py b/src/main.py index b73ddc8..df5eaf5 100644 --- a/src/main.py +++ b/src/main.py @@ -37,7 +37,7 @@ from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit from src.suggestion_engine import compute from src.later_store import LaterStore from src.today_store import TodayPlanFull, TodayStore -from src.views import router as frontend_router +from src.views import FRONTEND_BUILD, router as frontend_router @asynccontextmanager async def lifespan(_app: FastAPI): @@ -588,7 +588,13 @@ async def require_operator_session(request: Request, call_next): return await call_next(request) public = ( - path in {"/healthz", "/readyz", "/login", "/manifest.webmanifest"} + path in { + "/healthz", + "/readyz", + "/login", + "/manifest.webmanifest", + "/" + FRONTEND_BUILD.runtime_name, + } or path.startswith("/static/") or (path == "/api/v1/session" and request.method == "POST") ) diff --git a/src/views.py b/src/views.py index 687dc64..cf91feb 100644 --- a/src/views.py +++ b/src/views.py @@ -1,12 +1,14 @@ from pathlib import Path -from fastapi import APIRouter -from fastapi.responses import FileResponse, HTMLResponse +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse, HTMLResponse, Response + +from src.frontend_bundle import build_frontend router = APIRouter() DASHBOARD_FILE = Path(__file__).resolve().parent.parent / "frontend" / "index.html" MANIFEST_FILE = DASHBOARD_FILE.parent / "manifest.webmanifest" -SERVICE_WORKER_FILE = DASHBOARD_FILE.parent / "service-worker.js" +FRONTEND_BUILD = build_frontend(DASHBOARD_FILE.parent) LOGIN_HTML = """ @@ -26,7 +28,7 @@ class RevalidatingHTMLResponse(HTMLResponse): @router.get("/", response_class=RevalidatingHTMLResponse) async def dashboard() -> str: - return DASHBOARD_FILE.read_text() + return FRONTEND_BUILD.dashboard_html @router.get("/login", response_class=RevalidatingHTMLResponse) @@ -39,10 +41,21 @@ async def web_app_manifest() -> FileResponse: return FileResponse(MANIFEST_FILE, media_type="application/manifest+json") -@router.get("/service-worker.js", response_class=FileResponse) -async def service_worker() -> FileResponse: - return FileResponse( - SERVICE_WORKER_FILE, +@router.get("/service-worker.js") +async def service_worker() -> Response: + return Response( + FRONTEND_BUILD.service_worker_source, media_type="application/javascript", headers={"Service-Worker-Allowed": "/", "Cache-Control": "no-cache"}, ) + + +@router.get("/runtime-{digest}.js") +async def runtime_bundle(digest: str) -> Response: + if digest != FRONTEND_BUILD.runtime_digest: + raise HTTPException(status_code=404, detail="Runtime revision not found") + return Response( + FRONTEND_BUILD.runtime_bytes, + media_type="application/javascript", + headers={"Cache-Control": "public, max-age=31536000, immutable"}, + ) diff --git a/tests/dashboard_bundle.py b/tests/dashboard_bundle.py index 3b76a49..8a41826 100644 --- a/tests/dashboard_bundle.py +++ b/tests/dashboard_bundle.py @@ -1,9 +1,17 @@ +import re from pathlib import Path from src.views import dashboard as dashboard_html FRONTEND = Path(__file__).resolve().parents[1] / "frontend" +PAGE_SCRIPT_MANIFEST = "\n".join( + re.findall( + r'^$', + (FRONTEND / "index.html").read_text(), + re.MULTILINE, + ) +) def dashboard_bundle_text() -> str: @@ -18,11 +26,12 @@ def dashboard_bundle_text() -> str: async def dashboard() -> str: - # Exercise the real view lookup before adding its separately served assets. + # Exercise the real view lookup before adding its separately served bootstrap assets. html = await dashboard_html() return "\n".join( ( html, + PAGE_SCRIPT_MANIFEST, (FRONTEND / "dashboard.css").read_text(), (FRONTEND / "dashboard.js").read_text(), ) diff --git a/tests/test_dashboard_auth.py b/tests/test_dashboard_auth.py index d59ee23..2b055ec 100644 --- a/tests/test_dashboard_auth.py +++ b/tests/test_dashboard_auth.py @@ -7,6 +7,7 @@ import pytest from src import main from src.session_store import SessionStoreError +from src.views import FRONTEND_BUILD @pytest.fixture @@ -956,9 +957,16 @@ async def test_public_routes_skip_session_registry_validation(access_control, mo login = await client.get("/login") manifest = await client.get("/manifest.webmanifest") static = await client.get("/static/session.js") + runtime = await client.get("/" + FRONTEND_BUILD.runtime_name) assert signed_in.status_code == 200 - assert [login.status_code, manifest.status_code, static.status_code] == [200, 200, 200] + assert [login.status_code, manifest.status_code, static.status_code, runtime.status_code] == [ + 200, + 200, + 200, + 200, + ] + assert runtime.headers["cache-control"] == "public, max-age=31536000, immutable" @pytest.mark.anyio diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py new file mode 100644 index 0000000..8ffb11e --- /dev/null +++ b/tests/test_frontend_bundle.py @@ -0,0 +1,62 @@ +import gzip +import re +import shutil +from pathlib import Path + +import pytest + +from src.frontend_bundle import build_frontend +from src.views import dashboard, runtime_bundle, service_worker + + +FRONTEND = Path(__file__).resolve().parents[1] / "frontend" +PAGE_SCRIPT = re.compile(r'') + + +def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path): + first = build_frontend(FRONTEND) + second = build_frontend(FRONTEND) + + assert first.runtime_name == second.runtime_name + assert first.runtime_bytes == second.runtime_bytes + assert PAGE_SCRIPT.findall(first.dashboard_html) == [] + assert first.dashboard_html.count("' in first.dashboard_html + assert first.runtime_name.startswith("runtime-") + assert first.runtime_name.endswith(".js") + assert len(gzip.compress(first.runtime_bytes, mtime=0)) <= 100 * 1024 + + changed_frontend = tmp_path / "frontend" + shutil.copytree(FRONTEND, changed_frontend) + (changed_frontend / "widgets.js").write_text( + (changed_frontend / "widgets.js").read_text() + "\n// changed\n" + ) + + changed = build_frontend(changed_frontend) + assert changed.runtime_name != first.runtime_name + assert changed.runtime_bytes != first.runtime_bytes + + +def test_offline_shell_precaches_exact_runtime_without_superseded_page_modules(): + build = build_frontend(FRONTEND) + + assert f"BASE + '{build.runtime_name}'" in build.service_worker_source + for source in build.page_sources: + if source == "static/background-issue-sync.js": + continue + assert f"BASE + '{source}'" not in build.service_worker_source + + +@pytest.mark.anyio +async def test_runtime_asset_is_immutable_while_html_and_worker_revalidate(): + build = build_frontend(FRONTEND) + + html = await dashboard() + bundle = await runtime_bundle(build.runtime_digest) + worker = await service_worker() + + assert build.runtime_name in html + assert bundle.body == build.runtime_bytes + assert bundle.headers["cache-control"] == "public, max-age=31536000, immutable" + assert worker.headers["cache-control"] == "no-cache" + assert build.runtime_name.encode() in worker.body diff --git a/tests/test_views.py b/tests/test_views.py index 77cdecc..d6a9277 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -2,7 +2,7 @@ from pathlib import Path import pytest -from src.views import dashboard +from src.views import FRONTEND_BUILD, dashboard @pytest.mark.anyio @@ -28,7 +28,7 @@ async def test_dashboard_bootstrap_uses_csp_compatible_static_assets(): html = await dashboard() assert '' in html - assert '' in html + assert f'' in html assert "