From aa63cae5f004fc4d01471561404d59360784b784 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Sun, 16 Aug 2026 00:21:16 +0200 Subject: fix(hub): revalidate static assets so a split SPA version cannot load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SPA became a module graph that has to agree with itself: app.js imports i18n.js, which imports a catalogue from locales/. Serving one of them from cache while fetching another is not a stale page, it is a broken one, and both directions were reproduced: - a browser holding the previous i18n.js fails to *link* the new app.js ("does not provide an export named 'initLocale'"). That happens before any code runs, so the fallback in the boot cannot catch it — blank page. - the reverse pairing links and renders, but nothing calls initLocale(), so every string comes out as its own key: nav.logout, group.tab_files. Starlette sends only etag and last-modified, and with no explicit freshness a browser is entitled to guess one — roughly 10% of the age since last-modified, which is comfortably long enough to catch a deploy. Caddy is a plain reverse proxy and adds nothing. `no-cache` does not disable caching; it requires a conditional request, which the existing ETag answers with a 304 and no body. Measured: 304, 0 bytes, on every static path. Co-Authored-By: Claude Opus 5 --- packages/meshbay-hub/src/meshbay_hub/app.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/meshbay-hub/src/meshbay_hub/app.py b/packages/meshbay-hub/src/meshbay_hub/app.py index 668ae24..6240785 100644 --- a/packages/meshbay-hub/src/meshbay_hub/app.py +++ b/packages/meshbay-hub/src/meshbay_hub/app.py @@ -123,6 +123,28 @@ def create_app(cfg: HubConfig | None = None) -> FastAPI: app.include_router(webapp_router) from starlette.staticfiles import StaticFiles - app.mount("/", StaticFiles(directory=STATIC_DIR), name="static") + + class RevalidatingStatics(StaticFiles): + """Static assets that must be revalidated, never served blind from cache. + + The SPA is a module graph that has to agree with itself: `app.js` imports + `i18n.js`, which imports a catalogue from `locales/`. Serving one of them + from cache while fetching another is not a stale page, it is a broken + one — a browser holding the previous `i18n.js` fails to link the new + `app.js` ("does not provide an export named 'initLocale'") before any + code runs, and the reverse pairing renders every string as its own key. + + Starlette sends only `etag` and `last-modified`, and with no explicit + freshness a browser is entitled to guess one. `no-cache` does not + disable caching: it requires a conditional request, which the existing + ETag answers with a 304 and no body. + """ + + async def get_response(self, path, scope): + response = await super().get_response(path, scope) + response.headers.setdefault("Cache-Control", "no-cache") + return response + + app.mount("/", RevalidatingStatics(directory=STATIC_DIR), name="static") return app -- cgit v1.2.3