From a919242b8f5874089a3f64b3102eec2291cdee9d Mon Sep 17 00:00:00 2001
From: timmy
", 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 "