diff options
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) ───────────────────────────────────────────────── |