From f18c59c7b3e0eeca2eeb43470e43d94c03fa453b Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 10 Aug 2026 20:25:05 +0000 Subject: [PATCH] feat: content-address complete PWA shell (Closes #511) --- src/frontend_bundle.py | 48 +++++++++++++++++++++++++---- tests/test_frontend_bundle.py | 58 +++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 5e3bd29..143cd17 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -13,6 +13,9 @@ import rjsmin SCRIPT_TAG = re.compile(r'^$', re.MULTILINE) WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" +CACHE_DECLARATION = re.compile( + r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';" +) @dataclass(frozen=True) @@ -22,6 +25,7 @@ class FrontendBuild: runtime_gzip_bytes: bytes runtime_digest: str runtime_name: str + shell_digest: str page_sources: tuple[str, ...] service_worker_source: str @@ -61,11 +65,42 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: " 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, + worker = CACHE_DECLARATION.sub( + "const CACHE = 'stackchain-dashboard-shell-BUILD';", worker, count=1 + ) + + shell_hasher = hashlib.sha256() + shell_inputs = ( + ("dashboard.html", dashboard_html.encode()), + (runtime_name, runtime), + ("static/dashboard.css", (frontend_dir / "dashboard.css").read_bytes()), + ("manifest.webmanifest", (frontend_dir / "manifest.webmanifest").read_bytes()), + ( + "static/icons/stackchain-192.png", + (frontend_dir / "icons/stackchain-192.png").read_bytes(), + ), + ( + "static/icons/stackchain-512.png", + (frontend_dir / "icons/stackchain-512.png").read_bytes(), + ), + ( + WORKER_RUNTIME_SOURCE, + ( + frontend_dir / WORKER_RUNTIME_SOURCE.removeprefix("static/") + ).read_bytes(), + ), + ("service-worker.js", worker.encode()), + ) + for name, content in shell_inputs: + shell_hasher.update(len(name).to_bytes(4, "big")) + shell_hasher.update(name.encode()) + shell_hasher.update(len(content).to_bytes(8, "big")) + shell_hasher.update(content) + shell_digest = shell_hasher.hexdigest()[:16] + worker = worker.replace( + "const CACHE = 'stackchain-dashboard-shell-BUILD';", + f"const CACHE = 'stackchain-dashboard-shell-{shell_digest}';", + 1, ) return FrontendBuild( @@ -74,6 +109,7 @@ def build_frontend(frontend_dir: Path) -> FrontendBuild: runtime_gzip_bytes=runtime_gzip, runtime_digest=digest, runtime_name=runtime_name, + shell_digest=shell_digest, page_sources=sources, service_worker_source=worker, - ) \ No newline at end of file + ) diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 58d5d52..a0d8bcb 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -42,6 +42,64 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path): assert changed.runtime_bytes != first.runtime_bytes +def test_css_only_release_gets_a_new_shell_generation(tmp_path): + first = build_frontend(FRONTEND) + changed_frontend = tmp_path / "frontend" + shutil.copytree(FRONTEND, changed_frontend) + (changed_frontend / "dashboard.css").write_text( + (changed_frontend / "dashboard.css").read_text() + "\n/* release change */\n" + ) + + changed = build_frontend(changed_frontend) + + assert changed.runtime_name == first.runtime_name + assert changed.runtime_bytes == first.runtime_bytes + assert changed.shell_digest != first.shell_digest + assert f"const CACHE = 'stackchain-dashboard-shell-{changed.shell_digest}';" in ( + changed.service_worker_source + ) + + +@pytest.mark.parametrize( + "asset", + [ + "manifest.webmanifest", + "icons/stackchain-192.png", + "icons/stackchain-512.png", + "background-issue-sync.js", + "service-worker.js", + ], +) +def test_every_offline_shell_asset_versions_the_shell_generation(tmp_path, asset): + first = build_frontend(FRONTEND) + changed_frontend = tmp_path / "frontend" + shutil.copytree(FRONTEND, changed_frontend) + path = changed_frontend / asset + path.write_bytes(path.read_bytes() + b"\nrelease-change") + + changed = build_frontend(changed_frontend) + + assert changed.shell_digest != first.shell_digest + + +def test_legacy_cache_marker_is_normalized_out_of_build_identity(tmp_path): + first = build_frontend(FRONTEND) + changed_frontend = tmp_path / "frontend" + shutil.copytree(FRONTEND, changed_frontend) + worker = changed_frontend / "service-worker.js" + worker.write_text( + worker.read_text().replace( + "const CACHE = 'stackchain-dashboard-shell-v85';", + "const CACHE = 'stackchain-dashboard-shell-v999';", + ) + ) + + changed = build_frontend(changed_frontend) + + assert changed.shell_digest == first.shell_digest + assert changed.service_worker_source == first.service_worker_source + + @pytest.mark.anyio async def test_runtime_is_transferred_as_gzip_when_the_client_accepts_it(): transport = httpx.ASGITransport(app=main.app)