summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_asset_versioning.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-16 20:57:52 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-16 20:57:52 +0200
commit5dea19d9950518887be7eb14696f600ee023dbbd (patch)
tree024b5f946f737713b175f33b27426f9d9a74cb5a /packages/meshbay-hub/tests/test_asset_versioning.py
parent373f83236816aaff536dfa2f9589801acac44012 (diff)
downloadmeshbay-5dea19d9950518887be7eb14696f600ee023dbbd.tar.gz
fix(hub): serve the SPA under a fingerprint of what it is
`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/<content-hash>/`. 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.
Diffstat (limited to 'packages/meshbay-hub/tests/test_asset_versioning.py')
-rw-r--r--packages/meshbay-hub/tests/test_asset_versioning.py103
1 files changed, 103 insertions, 0 deletions
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/<hash>/app.js` importing `./i18n.js` resolves
+to `/a/<hash>/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/<hash>/sw.js it would only control /a/<hash>/, 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"