Make every PWA shell release content-addressed and atomic #512

Merged
timmy merged 1 commits from timmy/511-content-addressed-shell into main 2026-08-10 20:27:07 +00:00
2 changed files with 100 additions and 6 deletions

View File

@ -13,6 +13,9 @@ import rjsmin
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', 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,
)
)

View File

@ -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)