aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
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/src
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/src')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/api/webapp.py49
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/app.py31
2 files changed, 72 insertions, 8 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