diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-16 20:57:52 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-16 20:57:52 +0200 |
| commit | 5dea19d9950518887be7eb14696f600ee023dbbd (patch) | |
| tree | 024b5f946f737713b175f33b27426f9d9a74cb5a /packages | |
| parent | 373f83236816aaff536dfa2f9589801acac44012 (diff) | |
| download | meshbay-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')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 49 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/app.py | 31 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_asset_versioning.py | 103 | ||||
| -rw-r--r-- | packages/meshbay-hub/tests/test_hub_api.py | 19 |
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) ───────────────────────────────────────────────── |