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. --- packages/meshbay-hub/src/meshbay_hub/api/webapp.py | 49 ++++++++++++++++++---- packages/meshbay-hub/src/meshbay_hub/app.py | 31 +++++++++++++- 2 files changed, 72 insertions(+), 8 deletions(-) (limited to 'packages/meshbay-hub/src') 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 = """\ MeshBay - +
+ + - - - - - + + + + + -""" +""".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// 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 -- cgit v1.2.3