aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src
diff options
context:
space:
mode:
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