fix(display): keep cached image bytes when the browser has no Cache Storage
CI / lint (pull_request) Successful in 24s
CI / typecheck (pull_request) Successful in 25s
CI / build (pull_request) Successful in 32s
CI / test (pull_request) Successful in 33s
CI / package (pull_request) Successful in 24s
CI / integration (pull_request) Successful in 8m43s

A display opened over plain http:// on a LAN address is not a secure
context, so the browser gives it no Cache Storage. The media cache then
fell back to the in-memory test double, which remembered only each
slide's signed delivery URL and still reported it as cached. Online that
looked identical. Offline, the next slide's URL could not be loaded, the
slide was marked unusable, and local continuation kept landing on the
image already on screen. The wall froze until the network returned.

Without Cache Storage, the browser media cache now fetches and holds the
bytes in page memory as object URLs (one per key, revoked on prune,
replace and stop), so a running display keeps cycling through what it
already has. Nothing persists across a reload; on such an origin the
platform cannot cache the display page either.

Regression tests: the media cache on an origin without Cache Storage,
and the whole controller on an ordered fake clock, cycling for six slide
intervals with the network gone and then handing back to the server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
ekklesia
2026-10-13 19:42:00 -05:00
co-authored by Claude Opus 5.5
parent b479ae9296
commit 5f52334664
3 changed files with 408 additions and 4 deletions
+63 -3
View File
@@ -159,14 +159,74 @@ export function createBrowserStore(scope: typeof globalThis = globalThis): Signa
};
}
/** The Cache Storage-backed media cache a real display uses. Absent Cache Storage — an insecure
* origin in development, a policy that refuses — a display works exactly as it did before: online. */
/**
* Image bytes held in this page's memory, for a browser that offers no Cache Storage — a display
* opened over plain `http://` on a LAN address is not a secure context, and gets none.
*
* The bytes, not the delivery URL: a URL needs the network, so a cache that remembered only URLs
* looked identical online and could paint nothing new offline. Holding the bytes keeps a running
* display cycling through what it already has. Nothing survives a reload, and nothing needs to: on
* such an origin the platform cannot cache the display page itself either.
*/
function createPageMediaCache(fetchFn: typeof fetch): SignageMediaCache {
/** One object URL per key, revoked when the key is pruned or replaced, and all of them on stop. */
const objectUrls = new Map<string, string>();
function forget(key: string): void {
const url = objectUrls.get(key);
if (url !== undefined) {
objectUrls.delete(key);
URL.revokeObjectURL(url);
}
}
return {
count: () => Promise.resolve(objectUrls.size),
find: (key: string) => Promise.resolve(objectUrls.get(key)),
prune(keep: readonly string[]): Promise<void> {
const retained = new Set(keep);
for (const key of [...objectUrls.keys()]) {
if (!retained.has(key)) {
forget(key);
}
}
return Promise.resolve();
},
release(): void {
for (const key of [...objectUrls.keys()]) {
forget(key);
}
},
async store(key: string, url: string): Promise<string | undefined> {
try {
const response = await fetchFn(url);
// The same rule as Cache Storage: only a complete, successful response is kept.
if (!response.ok || response.status !== 200) {
return undefined;
}
const blob = await response.blob();
forget(key);
const local = URL.createObjectURL(blob);
objectUrls.set(key, local);
return local;
} catch {
return undefined;
}
},
};
}
/** The Cache Storage-backed media cache a real display uses. Absent Cache Storage, the bytes are
* held in the page instead (`createPageMediaCache`), so a running display still survives an outage. */
export function createBrowserMediaCache(scope: typeof globalThis = globalThis): SignageMediaCache {
const maybeCaches = (scope as { caches?: CacheStorage }).caches;
const fetchFn = (scope as { fetch?: typeof fetch }).fetch;
if (!maybeCaches || !fetchFn) {
if (!fetchFn) {
return createMemoryMediaCache();
}
if (!maybeCaches) {
return createPageMediaCache(fetchFn);
}
const caches: CacheStorage = maybeCaches;
async function open(): Promise<Cache | undefined> {