From 5dea19d9950518887be7eb14696f600ee023dbbd Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 16 Aug 2026 20:57:52 +0200 Subject: fix(hub): serve the SPA under a fingerprint of what it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Cache-Control: no-cache` requires a browser to revalidate, but it only binds one that asks. A browser that cached app.js before that header existed applies heuristic freshness instead — a fraction of the file's age, which for a file dated weeks ago is days — and never asks. It then runs an old player against a new node. That cost most of a session. A phone kept a player without the read-ahead bound and filled the browser's buffer ceiling at 106 MB, the exact symptom the bound had been written to remove, for an hour after the bounded player went live. A fix that is written, tested, deployed and served, and still not what runs, is indistinguishable from a fix that does not work. The whole module graph now lives under `/a//`. A path prefix rather than a query string, because relative imports inherit it: `app.js` reaching for `./i18n.js` gets the build it was written against, and never a mixture of two — which does not render a stale page, it fails to link. The URL changes with the content, so those may be cached hard. `sw.js` stays at the root. Its scope is its own path, and under the prefix it would no longer control the pages whose downloads it exists to intercept. --- .../meshbay-hub/tests/test_asset_versioning.py | 103 +++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 packages/meshbay-hub/tests/test_asset_versioning.py (limited to 'packages/meshbay-hub/tests/test_asset_versioning.py') diff --git a/packages/meshbay-hub/tests/test_asset_versioning.py b/packages/meshbay-hub/tests/test_asset_versioning.py new file mode 100644 index 0000000..e5889f0 --- /dev/null +++ b/packages/meshbay-hub/tests/test_asset_versioning.py @@ -0,0 +1,103 @@ +""" +Making sure the browser runs the build we deployed. + +A fix can be written, tested, deployed, and served, and still not be what runs. +`Cache-Control: no-cache` requires a browser to revalidate — but it only binds +one that asks, and a browser that cached the SPA *before* that header existed +applies heuristic freshness instead: a fraction of the file's age, which for a +file dated weeks ago is days. It does not ask, so it never learns. + +That happened here. A phone ran a player without the read-ahead bound and filled +the browser's buffer ceiling at 106 MB — the exact symptom the bound was written +to remove — for an hour after the bounded player went live. Two sessions were +spent looking at the node. + +So the URL now carries a fingerprint of what is being served, and the whole +module graph lives under it: `/a//app.js` importing `./i18n.js` resolves +to `/a//i18n.js`. A URL that changes with the content cannot serve +yesterday's build, and cannot serve half of each. +""" + +import re + +import pytest +from fastapi.testclient import TestClient + +from meshbay_hub.api.webapp import ASSET_V, STATIC_DIR, _asset_version +from meshbay_hub.app import create_app + + +@pytest.fixture(scope="module") +def client(): + return TestClient(create_app()) + + +def _shell_refs(html: str) -> list[str]: + return re.findall(r'(?:src|href)="([^"]+)"', html) + + +def test_every_asset_the_shell_loads_is_versioned(client): + refs = _shell_refs(client.get("/").text) + assert refs, "the shell references nothing at all" + stale = [u for u in refs if not u.startswith(f"/a/{ASSET_V}/")] + assert not stale, ( + f"served from an unversioned URL, so a stale cache can win: {stale}") + + +def test_the_module_graph_resolves_under_the_same_prefix(client): + """The point of a path prefix rather than a query string. + + `import './i18n.js'` from a versioned app.js resolves against the versioned + directory. A query string on app.js alone would not: its imports would fall + back to the bare paths, and the graph could be assembled from two builds — + which does not render a stale page, it fails to link at all. + """ + for path in ("app.js", "i18n.js", "locales/fr.js", "transport.js", + "vendor/argon2.min.js"): + r = client.get(f"/a/{ASSET_V}/{path}") + assert r.status_code == 200, f"{path} is not served under the version" + + +def test_versioned_assets_may_be_cached_hard(client): + """Which is the trade the fingerprint buys.""" + cc = client.get(f"/a/{ASSET_V}/app.js").headers.get("cache-control", "") + assert "immutable" in cc and "max-age=31536000" in cc, ( + f"versioned assets are served as {cc!r} — the whole point of a URL that " + "changes with the content is that it need never be revalidated") + + +def test_the_unversioned_path_still_revalidates(client): + """It still answers — old bookmarks, and sw.js has to live there.""" + r = client.get("/app.js") + assert r.status_code == 200 + assert r.headers.get("cache-control") == "no-cache" + + +def test_the_service_worker_is_not_versioned(client): + """Its scope is its own path. + + Served from /a//sw.js it would only control /a//, and the + pages whose downloads it exists to intercept are not under there. + """ + assert client.get("/sw.js").status_code == 200 + assert "/sw.js" not in _shell_refs(client.get("/").text) + + +def test_the_fingerprint_follows_the_content(tmp_path, monkeypatch): + """Otherwise it is decoration. + + Pinning this to the file contents rather than to the package version is + deliberate: a redeploy without a version bump is the common case during a + debugging session, and that is exactly when a stale player costs the most. + """ + before = _asset_version() + target = STATIC_DIR / "app.js" + original = target.read_bytes() + try: + target.write_bytes(original + b"\n// touched\n") + assert _asset_version() != before, ( + "the fingerprint did not move when app.js did, so a redeploy " + "serves the new file at the old URL") + finally: + target.write_bytes(original) + assert _asset_version() == before, "the fingerprint is not reproducible" -- cgit v1.2.3