Skip to content

Serve the web UI with cache headers so upgrades are picked up - #763

Open
cyberb wants to merge 1 commit into
masterfrom
web-cache-headers
Open

Serve the web UI with cache headers so upgrades are picked up#763
cyberb wants to merge 1 commit into
masterfrom
web-cache-headers

Conversation

@cyberb

@cyberb cyberb commented Aug 16, 2026

Copy link
Copy Markdown
Member

Problem

Reported from a phone: after a platform upgrade the app list renders, but pressing an app icon does nothing, and dark mode shows as white. A manual refresh fixes both.

Two bugs compounding, confirmed against a live device:

1. No Cache-Control header on anything. The device UI and auth UI were served with only Last-Modified/ETag. With no explicit directive browsers fall back to heuristic freshness (~10% of the file's age), so index.html was served from cache without ever asking the server:

$ curl -sk -I https://<device>/
last-modified: Tue, 11 Aug 2026 00:38:00 GMT
etag: "6a7a6ee8-514"
(no cache-control)

2. Missing assets returned 200 text/html. try_files $uri $uri/ /index.html caught asset requests too:

$ curl -sk -o /dev/null -w '%{http_code} %{content_type}' https://<device>/assets/App.deadbeef.js
200 text/html

Every route in router/index.js is a lazy import(). So the cached old bundle painted the main screen fine, but pressing an app icon fetched a route chunk that no longer existed on disk, got the SPA shell back, and the ES module loader rejected it on MIME type — a silent no-op. Dark mode was the same story: the cached bundle predated the theme feature, so the page stayed white.

Fix

config/nginx/public.conf, for both the device UI and the auth/login UI:

  • Cache-Control: no-cache at server level so index.html always revalidates — a cheap 304 via the existing ETag
  • a /assets/ location with public, max-age=31536000, immutable and try_files $uri =404, so content-hashed files cache for a year and a genuinely missing chunk fails loudly instead of masquerading as HTML

add_header in a location replaces inherited headers rather than adding to them, so the /assets/ blocks re-declare HSTS and CORS — without that they'd be silently dropped for every JS/CSS file.

web/platform/src/util/staleAssets.js — client-side self-healing: vite:preloadError and router.onError trigger a single reload, guarded by a sessionStorage flag so a genuinely broken deploy can't become a reload loop. The flag clears after router.isReady() so a later upgrade can self-heal again.

Tests

  • backend/nginx/public_config_test.go — asserts the generated config revalidates index, marks assets immutable, 404s missing assets, and keeps HSTS/CORS inside the /assets/ blocks (the last one guards the add_header inheritance trap)
  • web/platform/tests/unit/staleAssets.spec.js — 5 tests: error detection, reload-once, ignoring unrelated router errors, the Vite preload path, and re-arming after the flag clears
  • test/test.py — 6 integration tests asserting no-cache on index, immutable + javascript content-type on assets, and 404 on a missing asset, for both the device UI and auth.<domain>

Generated config additionally verified with nginx -t (syntax is ok / test is successful).

Caveat

Devices already holding a stale index.html won't pick up the new reload handler, since they won't fetch the new bundle. Those need one final manual refresh — after which they revalidate forever.

The device UI and the auth UI were served with no Cache-Control header at
all, so browsers applied heuristic freshness (roughly 10% of the file's
age) and served a stale index.html without ever revalidating. After a
platform refresh a phone would render the whole previous app from cache.

Compounding it, "try_files $uri $uri/ /index.html" also caught asset
requests, so a chunk that no longer existed on disk came back as 200
text/html instead of 404. Every route is a lazy import(), so the cached
old bundle painted the app list fine but pressing an app icon fetched a
route chunk, got HTML, and the module loader rejected on MIME type - the
click silently did nothing. Dark mode showed the same way: the cached
bundle predated the theme, so the page stayed white until a manual
refresh.

index.html is now no-cache, so it always revalidates and turns into a
cheap 304 via the existing ETag. Hashed assets under /assets/ are
immutable for a year and a missing one returns 404 rather than the SPA
shell. add_header in a location replaces inherited headers rather than
adding to them, so the /assets/ blocks re-declare HSTS and CORS.

The frontend recovers on its own too: vite:preloadError and router
onError trigger a single reload, guarded by a sessionStorage flag so a
genuinely broken deploy cannot loop. The flag clears once the router is
ready so a later upgrade can self-heal again.

Devices already holding a stale index.html need one last manual refresh
before the new headers take effect; after that they revalidate forever.

Generated config verified with nginx -t. Covered by nginx config unit
tests, frontend unit tests for the reload guard, and integration tests
asserting the headers and the 404 on both the device and auth UIs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant