Files
MediaandClaude Opus 5 ba678d08d5 Complete Phase 5B offline display resilience
Make the /display kiosk survive losing its infrastructure, then surrender
authority cleanly when it returns. The Phase 5A renderer is unchanged: this
builds on its two permanent scene layers, explicit Cache Storage, IndexedDB,
Service Worker, timing anchors, and structured errors.

While connected the server is authoritative. While disconnected the display
becomes a deterministic local continuation of the last authoritative state
it persisted, and the moment the server speaks again the local cursor is
abandoned rather than reconciled or reported back.

A small client evaluator resolves offline playback from the persisted
manifest and clock anchor. Catch-up is arithmetic rather than iterative:
whole rotations are skipped between eligibility boundaries, so a month
offline resolves in milliseconds. Mixed durations, wraparound, [start, end)
schedules, and expirations are evaluated locally, and a null expiration
stays permanent. Mode off keeps the hidden cursor advancing, pause holds
its slide and remainder, static holds until the cached metadata says the
asset genuinely expired, and an activation queued for an unknown playlist
holds the last good frame instead of continuing into a playlist that is
being replaced. Contract tests run this evaluator and the real Phase 3B
engine over identical scenarios and assert the same slide, start, and
deadline, so the client tracks the server without refactoring it.

Cold start restores signage from the Service Worker shell, IndexedDB, and
Cache Storage with the entire stack down, keeping the same persistent
display identity.

Validated display error codes now become Home Assistant incidents through
the Phase 4 machinery, keyed per display and condition so a persisting
error reports once and recovers once. Arbitrary browser text stays
telemetry.

Corrects the deployment assumption: the kiosk runs on a separate machine,
so production requires a trusted HTTPS origin and localhost is development
only. Verifying on a real trusted certificate exposed two production bugs
that localhost had hidden — behind a reverse proxy an outage arrives as a
502 rather than a refused connection, and an unbounded shell fetch left a
restarting kiosk blank for the proxy's whole connect timeout. The worker
now treats a gateway error as an outage and bounds that wait.

Verified with 200/200 backend, 20 client unit, and 30 Playwright tests on
a trusted HTTPS origin with no browser security flags weakened.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-17 20:36:13 -05:00

117 lines
4.1 KiB
JavaScript

/* Announcements signage app-shell worker.
*
* Scope is deliberately narrow: this worker makes the /display application
* shell available for a future offline start. Slide bytes are NOT its job —
* those are owned explicitly by the display asset manager in Cache Storage, so
* the two caches never compete for the same responsibility.
*/
const SHELL_CACHE = "announcements-display-shell-v1";
const SHELL_FALLBACK = "/display";
// A kiosk must reach its slides quickly. When a usable shell is already
// cached, a slow or dead origin is never allowed to delay startup: a reverse
// proxy whose backend is down can otherwise hold a navigation open for its
// full connect timeout, leaving the screen blank the whole time.
const SHELL_NETWORK_TIMEOUT_MS = 3_000;
// Paths the worker must never handle. Media is the asset manager's; the API and
// socket are live server state and must never be answered from a cache.
const BYPASS_PREFIXES = ["/api/", "/socket.io/", "/media/", "/health"];
function isBypassed(url) {
return BYPASS_PREFIXES.some(
(prefix) => url.pathname === prefix.replace(/\/$/, "") || url.pathname.startsWith(prefix),
);
}
self.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
const cache = await caches.open(SHELL_CACHE);
try {
await cache.add(new Request(SHELL_FALLBACK, { cache: "reload" }));
} catch {
// A first install while the network is unavailable still succeeds; the
// shell is captured on the next successful navigation instead.
}
// A kiosk must never be trapped on a stale application version.
await self.skipWaiting();
})(),
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
for (const name of await caches.keys()) {
// Only this worker's own shell generations are removed. The display
// asset cache is left strictly alone.
if (name.startsWith("announcements-display-shell-") && name !== SHELL_CACHE) {
await caches.delete(name);
}
}
await self.clients.claim();
})(),
);
});
async function networkFirst(request) {
const cache = await caches.open(SHELL_CACHE);
const cached =
(await cache.match(SHELL_FALLBACK)) || (await cache.match("/index.html"));
// Without a cached shell there is nothing to fall back to, so wait normally.
if (!cached) {
const response = await fetch(request);
if (response && response.ok) await cache.put(SHELL_FALLBACK, response.clone());
return response;
}
const abort = new AbortController();
const timer = setTimeout(() => abort.abort(), SHELL_NETWORK_TIMEOUT_MS);
try {
const response = await fetch(request, { signal: abort.signal });
clearTimeout(timer);
if (response && response.ok) {
// Keep the shell fresh so an online kiosk always updates.
await cache.put(SHELL_FALLBACK, response.clone());
return response;
}
// A production kiosk reaches this origin through a reverse proxy, so an
// outage behind it arrives as 502/503/504 rather than a refused
// connection. That is still an outage: serve the cached shell.
return cached;
} catch {
clearTimeout(timer);
return cached;
}
}
async function cacheFirst(request) {
const cache = await caches.open(SHELL_CACHE);
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response && response.ok) await cache.put(request, response.clone());
return response;
}
self.addEventListener("fetch", (event) => {
const { request } = event;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
if (isBypassed(url)) return;
if (request.mode === "navigate") {
// Online kiosks always get the newest application; offline ones get the
// last known good shell.
event.respondWith(networkFirst(request));
return;
}
// Vite emits content-hashed asset filenames, so these are safely immutable.
if (url.pathname.startsWith("/assets/")) {
event.respondWith(cacheFirst(request));
}
});