summaryrefslogtreecommitdiffstats
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
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.
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py49
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py31
-rw-r--r--packages/meshbay-hub/tests/test_asset_versioning.py103
-rw-r--r--packages/meshbay-hub/tests/test_hub_api.py19
4 files changed, 188 insertions, 14 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
index f804ec5..392e8d1 100644
--- a/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
+++ b/packages/meshbay-hub/src/meshbay_hub/api/webapp.py
@@ -11,6 +11,7 @@ Static files are served from meshbay_hub/static/ via Starlette StaticFiles.
The root route (/) returns the SPA HTML shell.
"""
+import hashlib
from pathlib import Path
from fastapi import APIRouter
@@ -20,6 +21,33 @@ STATIC_DIR = Path(__file__).parent.parent / "static"
router = APIRouter(tags=["webapp"])
+# Assets the shell pulls in, in load order. Everything else is imported by
+# app.js and rides on the same query string via window.__MB_ASSET_V.
+_ASSETS = ("style.css", "keyderive.js", "crypto.js", "transport.js", "app.js",
+ "i18n.js", "downloads.js", "transfers.js", "zipstream.js")
+
+
+def _asset_version() -> str:
+ """A fingerprint of what we are actually serving.
+
+ `Cache-Control: no-cache` only binds a browser that asks. One that cached
+ app.js *before* that header existed applies heuristic freshness — a
+ fraction of the file's age, which for a file dated weeks ago is days — and
+ never asks at all. It then runs last week's player against this week's node
+ for as long as that lasts, which is indistinguishable from the fix not
+ working. Changing the URL is the only thing that reaches such a browser,
+ and a content hash changes it exactly when the content changes.
+ """
+ h = hashlib.sha256()
+ for name in _ASSETS:
+ path = STATIC_DIR / name
+ if path.exists():
+ h.update(path.read_bytes())
+ return h.hexdigest()[:12]
+
+
+ASSET_V = _asset_version()
+
@router.get("/app", response_class=HTMLResponse)
async def app_root():
@@ -43,18 +71,25 @@ _HTML = """\
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MeshBay</title>
- <link rel="stylesheet" href="/style.css">
+ <link rel="stylesheet" href="/a/{v}/style.css">
</head>
<body>
<div id="app"></div>
+ <!-- Everything below is loaded from a path that carries the fingerprint of
+ what we are serving, so a browser holding a heuristically-cached copy of
+ an older build fetches this one instead of deciding it need not ask —
+ and app.js's own relative imports inherit the prefix, which is the only
+ way the module graph is guaranteed not to be a mixture of two builds.
+ See _asset_version() and VersionedStatics. -->
+ <script>window.__MB_ASSET_V = "{v}";</script>
<!-- Argon2id (WebAssembly, inlined) — WebCrypto has no memory-hard KDF, and the
keypair bundle needs one: it is protected by the passphrase alone and sits
on every node its owner joins (C4). Vendored, see static/vendor/PROVENANCE.md -->
- <script src="/vendor/argon2.min.js"></script>
- <script src="/keyderive.js"></script>
- <script src="/crypto.js"></script>
- <script src="/transport.js"></script>
- <script type="module" src="/app.js"></script>
+ <script src="/a/{v}/vendor/argon2.min.js"></script>
+ <script src="/a/{v}/keyderive.js"></script>
+ <script src="/a/{v}/crypto.js"></script>
+ <script src="/a/{v}/transport.js"></script>
+ <script type="module" src="/a/{v}/app.js"></script>
</body>
</html>
-"""
+""".replace("{v}", ASSET_V)
diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py
index 6240785..423ad03 100644
--- a/packages/meshbay-hub/src/meshbay_hub/app.py
+++ b/packages/meshbay-hub/src/meshbay_hub/app.py
@@ -35,7 +35,7 @@ from meshbay_hub.api.relay import router as relay_router
from meshbay_hub.api.signaling import router as signaling_router
from meshbay_hub.api.admin import router as admin_router
from meshbay_hub.api.notifications import router as notifications_router
-from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR
+from meshbay_hub.api.webapp import router as webapp_router, STATIC_DIR, ASSET_V
from meshbay_hub.api.middleware import limiter
@@ -145,6 +145,35 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI:
response.headers.setdefault("Cache-Control", "no-cache")
return response
+ class VersionedStatics(StaticFiles):
+ """The same files under a URL that changes when they do.
+
+ `no-cache` above only binds a browser that asks. One that cached the SPA
+ before that header existed applies heuristic freshness and does not ask
+ at all, so it runs an old player against a new node — a fix that is
+ deployed, served, and not running, which looks exactly like a fix that
+ does not work. Measured: a phone kept a player without the read-ahead
+ bound and filled the browser's buffer ceiling at 106 MB, while the
+ server had been serving the bounded one for an hour.
+
+ Serving the graph under /a/<fingerprint>/ solves it for every file at
+ once, because relative imports inherit the prefix: `app.js` reaching for
+ `./i18n.js` gets the version it was built against, and never a mixture.
+ The URL changes with the content, so these may be cached hard.
+ """
+
+ async def get_response(self, path, scope):
+ response = await super().get_response(path, scope)
+ response.headers["Cache-Control"] = (
+ "public, max-age=31536000, immutable")
+ return response
+
+ # Before "/", which would otherwise swallow it.
+ app.mount(f"/a/{ASSET_V}", VersionedStatics(directory=STATIC_DIR),
+ name="static-versioned")
+ # Still served unversioned: sw.js must stay at the root or its scope stops
+ # covering the pages it intercepts downloads for, and old bookmarks of
+ # /style.css and the like should not 404.
app.mount("/", RevalidatingStatics(directory=STATIC_DIR), name="static")
return app
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"
diff --git a/packages/meshbay-hub/tests/test_hub_api.py b/packages/meshbay-hub/tests/test_hub_api.py
index 5314b93..724c8ec 100644
--- a/packages/meshbay-hub/tests/test_hub_api.py
+++ b/packages/meshbay-hub/tests/test_hub_api.py
@@ -722,19 +722,26 @@ async def test_ip_log_cleanup(app):
@pytest.mark.asyncio
async def test_webapp_html_includes_scripts(client):
- """SPA HTML shell includes all required script tags."""
+ """SPA HTML shell includes all required script tags.
+
+ The paths carry a build fingerprint (`/a/<hash>/app.js`) so that a browser
+ cannot serve an older build out of its own cache — see
+ test_asset_versioning.py. What this test still owns is that every piece is
+ referenced at all, and in an order where each one's dependencies are
+ already loaded.
+ """
+ from meshbay_hub.api.webapp import ASSET_V
+
r = await client.get("/")
assert r.status_code == 200
html = r.text
assert "<!DOCTYPE html>" in html
assert '<div id="app">' in html
- assert 'src="/keyderive.js"' in html
- assert 'src="/crypto.js"' in html
- assert 'src="/transport.js"' in html
- assert 'src="/app.js"' in html
+ for asset in ("keyderive.js", "crypto.js", "transport.js", "app.js"):
+ assert f'src="/a/{ASSET_V}/{asset}"' in html, f"{asset} is not loaded"
assert 'type="module"' in html
assert 'rel="stylesheet"' in html
- assert 'href="/style.css"' in html
+ assert f'href="/a/{ASSET_V}/style.css"' in html
# ── Password split (T1 fix) ─────────────────────────────────────────────────