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
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:
co-authored by
Claude Opus 5.5
parent
b479ae9296
commit
5f52334664
+63
-3
@@ -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> {
|
||||
|
||||
@@ -117,3 +117,90 @@ describe("Signage's media cache and object URLs", () => {
|
||||
expect(live.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A display opened over plain `http://` on a LAN address is not a secure context, and a browser
|
||||
* gives such a page no Cache Storage. Stage 0's real kiosk ran exactly that way.
|
||||
*
|
||||
* The fallback used to remember only the signed delivery URL and report it as cached. Online that
|
||||
* looked identical; offline, the next slide's URL could not be fetched, the slide was skipped as
|
||||
* unusable, and the wall sat on one image until the network came back. The bytes are what a display
|
||||
* needs offline, so the bytes are what this keeps — in the page's memory, for as long as it runs.
|
||||
*/
|
||||
describe("Signage's media cache on an origin without Cache Storage", () => {
|
||||
const realCreate = URL.createObjectURL;
|
||||
const realRevoke = URL.revokeObjectURL;
|
||||
let live: Set<string>;
|
||||
let reachable: boolean;
|
||||
let media: SignageMediaCache;
|
||||
|
||||
beforeEach(() => {
|
||||
live = new Set();
|
||||
reachable = true;
|
||||
let next = 0;
|
||||
URL.createObjectURL = () => {
|
||||
next += 1;
|
||||
const url = `blob:test/${next}`;
|
||||
live.add(url);
|
||||
return url;
|
||||
};
|
||||
URL.revokeObjectURL = (url: string) => {
|
||||
live.delete(url);
|
||||
};
|
||||
media = createBrowserMediaCache({
|
||||
// No `caches`: what an insecure origin gets.
|
||||
fetch: async (url: string) => {
|
||||
if (!reachable) {
|
||||
throw new TypeError("Failed to fetch");
|
||||
}
|
||||
return url.includes("missing")
|
||||
? new Response("not found", { status: 404 })
|
||||
: new Response(new Uint8Array([1, 2, 3]), { headers: { "content-type": "image/webp" } });
|
||||
},
|
||||
location: { origin: "http://192.168.1.20:3000" },
|
||||
} as unknown as typeof globalThis);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
URL.createObjectURL = realCreate;
|
||||
URL.revokeObjectURL = realRevoke;
|
||||
});
|
||||
|
||||
it("keeps the bytes, so a slide it cached can still be painted with no network", async () => {
|
||||
const stored = await media.store("slide/a", "http://files.example.test/a?sig=1");
|
||||
|
||||
// A local URL for bytes this page holds — never the delivery URL, which needs the network.
|
||||
expect(stored).toMatch(/^blob:/u);
|
||||
expect(stored).not.toContain("files.example.test");
|
||||
|
||||
reachable = false;
|
||||
expect(await media.find("slide/a")).toBe(stored);
|
||||
expect(await media.count()).toBe(1);
|
||||
});
|
||||
|
||||
it("reports nothing as cached that it does not actually hold", async () => {
|
||||
expect(await media.store("slide/a", "http://files.example.test/missing?sig=1")).toBeUndefined();
|
||||
reachable = false;
|
||||
expect(await media.store("slide/b", "http://files.example.test/b?sig=1")).toBeUndefined();
|
||||
|
||||
expect(await media.find("slide/a")).toBeUndefined();
|
||||
expect(await media.find("slide/b")).toBeUndefined();
|
||||
expect(await media.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("gives back the URLs it replaces, prunes, and holds when the display stops", async () => {
|
||||
for (const key of ["slide/a", "slide/b", "slide/c"]) {
|
||||
await media.store(key, `http://files.example.test/${key}?sig=1`);
|
||||
}
|
||||
await media.store("slide/a", "http://files.example.test/slide/a?sig=2");
|
||||
expect(live.size).toBe(3);
|
||||
|
||||
await media.prune(["slide/a", "slide/b"]);
|
||||
expect(live.size).toBe(2);
|
||||
expect(await media.find("slide/c")).toBeUndefined();
|
||||
expect(await media.count()).toBe(2);
|
||||
|
||||
media.release();
|
||||
expect(live.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type OfflineSnapshot,
|
||||
resolveOffline,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../../src/browser/offline.js";
|
||||
import type { SceneSelection } from "../../src/browser/scene-request.js";
|
||||
import {
|
||||
createBrowserMediaCache,
|
||||
createMemoryMediaCache,
|
||||
createMemoryStore,
|
||||
signageMediaKey,
|
||||
@@ -574,3 +575,259 @@ describe("a display that loses its network", () => {
|
||||
await controller.stop();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Stage 0's real kiosk: two native images cycling, then the laptop's network switched off for a
|
||||
* minute. The offline note appeared, but the wall stayed on one image until the network returned.
|
||||
*
|
||||
* The page was served over plain `http://` on a LAN address, which a browser does not treat as a
|
||||
* secure context, so there was no Cache Storage. The media cache's fallback then remembered only each
|
||||
* slide's signed delivery URL. Offline, the next slide's URL could not be fetched, the slide was
|
||||
* marked unusable, and local continuation kept landing on the one image already on screen.
|
||||
*
|
||||
* This runs the whole controller on a clock that only moves when the test says so, across several
|
||||
* slide intervals, with every timer it arms honoured in order.
|
||||
*/
|
||||
describe("a running display that loses its network for several slides", () => {
|
||||
const SLOT_MS = 5000;
|
||||
const realCreate = URL.createObjectURL;
|
||||
const realRevoke = URL.revokeObjectURL;
|
||||
let root: HTMLElement;
|
||||
|
||||
beforeEach(() => {
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
let next = 0;
|
||||
URL.createObjectURL = () => {
|
||||
next += 1;
|
||||
return `blob:display/${next}`;
|
||||
};
|
||||
URL.revokeObjectURL = () => undefined;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
URL.createObjectURL = realCreate;
|
||||
URL.revokeObjectURL = realRevoke;
|
||||
});
|
||||
|
||||
async function settle(): Promise<void> {
|
||||
for (let index = 0; index < 12; index += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
}
|
||||
|
||||
/** A clock and timer queue the controller and its stage both run on. */
|
||||
function createClock(startMs: number) {
|
||||
let nowMs = startMs;
|
||||
let nextHandle = 0;
|
||||
const timers = new Map<number, { readonly atMs: number; readonly handler: () => void }>();
|
||||
return {
|
||||
clearTimer: (handle: unknown) => {
|
||||
timers.delete(handle as number);
|
||||
},
|
||||
now: () => nowMs,
|
||||
setTimer: (handler: () => void, delayMs: number) => {
|
||||
nextHandle += 1;
|
||||
timers.set(nextHandle, { atMs: nowMs + Math.max(0, delayMs), handler });
|
||||
return nextHandle;
|
||||
},
|
||||
/** Moves time forward, running every timer that falls due on the way, in order. */
|
||||
async advance(ms: number): Promise<void> {
|
||||
const targetMs = nowMs + ms;
|
||||
for (;;) {
|
||||
let due: [number, { readonly atMs: number; readonly handler: () => void }] | undefined;
|
||||
for (const entry of timers) {
|
||||
if (entry[1].atMs <= targetMs && (!due || entry[1].atMs < due[1].atMs)) {
|
||||
due = entry;
|
||||
}
|
||||
}
|
||||
if (!due) {
|
||||
break;
|
||||
}
|
||||
timers.delete(due[0]);
|
||||
nowMs = due[1].atMs;
|
||||
due[1].handler();
|
||||
await settle();
|
||||
}
|
||||
nowMs = targetMs;
|
||||
await settle();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Which of the two images a selection is. */
|
||||
function slideOf(selectionId: string | undefined): string | undefined {
|
||||
return selectionId?.split("-")[1];
|
||||
}
|
||||
|
||||
/** The server's answer at `atMs`: two images alternating, each play its own selection, and a
|
||||
* freshly signed delivery URL on every answer — exactly as Signage's own server sends them. */
|
||||
function serverState(atMs: number): DisplayChannelStateView {
|
||||
const cycle = Math.floor(atMs / SLOT_MS);
|
||||
const slide = (c: number) => (c % 2 === 0 ? "a" : "b");
|
||||
const history: DisplaySelection[] = [cycle, cycle - 1, cycle - 2]
|
||||
.filter((c) => c >= 0)
|
||||
.map((c) => ({
|
||||
...selection({
|
||||
id: `sel-${slide(c)}-${c}`,
|
||||
itemId: `item-${slide(c)}`,
|
||||
slotId: `slot-${slide(c)}`,
|
||||
}),
|
||||
media: {
|
||||
display: {
|
||||
expiresAtMs: atMs + 900_000,
|
||||
height: 1080,
|
||||
url: `http://files.example.test/${slide(c)}?sig=${atMs}`,
|
||||
width: 1920,
|
||||
},
|
||||
thumbnail: {
|
||||
expiresAtMs: atMs + 900_000,
|
||||
height: 270,
|
||||
url: `http://files.example.test/${slide(c)}-t?sig=${atMs}`,
|
||||
width: 480,
|
||||
},
|
||||
},
|
||||
stale: false,
|
||||
}));
|
||||
const current = history[0] as DisplaySelection;
|
||||
const startedAtMs = cycle * SLOT_MS;
|
||||
return {
|
||||
channel: { groupId: "group-1", id: "chan-1", key: "default" },
|
||||
crossfadeMs: 0,
|
||||
current,
|
||||
playback: {
|
||||
activeTimelineId: "timeline-1",
|
||||
blackout: false,
|
||||
currentSelectionId: current.id,
|
||||
currentSlotId: current.slotId,
|
||||
deadlineAtMs: startedAtMs + SLOT_MS,
|
||||
pendingTimelineId: null,
|
||||
remainingMs: startedAtMs + SLOT_MS - atMs,
|
||||
revision: cycle,
|
||||
selectionRevision: cycle,
|
||||
startedAtMs,
|
||||
transport: "playing",
|
||||
},
|
||||
selections: history,
|
||||
serverNowMs: atMs,
|
||||
staticHold: null,
|
||||
timeline: {
|
||||
eligibleFromMs: null,
|
||||
eligibleUntilMs: null,
|
||||
id: "timeline-1",
|
||||
revision: 1,
|
||||
slots: [
|
||||
slot("slot-a", 0, { estimatedDurationMs: SLOT_MS }),
|
||||
slot("slot-b", 1, { estimatedDurationMs: SLOT_MS }),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("keeps advancing through its cached images on an origin with no Cache Storage", async () => {
|
||||
const network = { reachable: true };
|
||||
// Mid-slide, so every sample below lands halfway through one.
|
||||
const clock = createClock(12_500);
|
||||
const unreachable = () => new TypeError("Failed to fetch");
|
||||
const host = createFakeHost({
|
||||
permissions: ["signage.display.view"],
|
||||
routes: [
|
||||
{
|
||||
method: "POST",
|
||||
path: "viewers/session",
|
||||
respond: () =>
|
||||
jsonResponse({
|
||||
channelId: "chan-1",
|
||||
channelKey: "default",
|
||||
groupId: "group-1",
|
||||
heartbeatIntervalMs: 12_000,
|
||||
viewerId: "11111111-1111-4111-8111-111111111111",
|
||||
viewerName: "Foyer",
|
||||
} satisfies ViewerSessionResponse),
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: /^viewers\/[^/]+\/display-state$/u,
|
||||
respond: () => {
|
||||
if (!network.reachable) {
|
||||
throw unreachable();
|
||||
}
|
||||
return jsonResponse(serverState(clock.now()));
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: /^viewers\/.*\/heartbeat$/u,
|
||||
respond: () => {
|
||||
if (!network.reachable) {
|
||||
throw unreachable();
|
||||
}
|
||||
return jsonResponse({});
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
// A plain-http LAN origin: no `caches`, but `fetch` — which needs the network like anything else.
|
||||
const media = createBrowserMediaCache({
|
||||
fetch: async () => {
|
||||
if (!network.reachable) {
|
||||
throw unreachable();
|
||||
}
|
||||
return new Response(new Uint8Array([1, 2, 3]), {
|
||||
headers: { "content-type": "image/webp" },
|
||||
});
|
||||
},
|
||||
location: { origin: "http://192.168.1.20:3000" },
|
||||
} as unknown as typeof globalThis);
|
||||
|
||||
const controller = createDisplayController({
|
||||
clearTimer: clock.clearTimer,
|
||||
context: host.context,
|
||||
// What a browser does: bytes this page holds always decode, and a remote URL needs a network.
|
||||
decodeImage: (image) =>
|
||||
(image.getAttribute("src") ?? "").startsWith("blob:") || network.reachable
|
||||
? Promise.resolve()
|
||||
: Promise.reject(new Error("net::ERR_INTERNET_DISCONNECTED")),
|
||||
media,
|
||||
newViewerKey: () => "viewer-fixed-key-0001",
|
||||
now: clock.now,
|
||||
root,
|
||||
setTimer: clock.setTimer,
|
||||
store: createMemoryStore(),
|
||||
});
|
||||
|
||||
await controller.start();
|
||||
await settle();
|
||||
// Online for a full cycle, so both images have been shown and fetched.
|
||||
const online: (string | undefined)[] = [slideOf(controller.diagnostics().renderedSelectionId)];
|
||||
for (let interval = 0; interval < 2; interval += 1) {
|
||||
await clock.advance(SLOT_MS);
|
||||
online.push(slideOf(controller.diagnostics().renderedSelectionId));
|
||||
}
|
||||
expect(online).toEqual(["a", "b", "a"]);
|
||||
expect(controller.diagnostics().offline).toBe(false);
|
||||
|
||||
// The network goes away for six slides.
|
||||
network.reachable = false;
|
||||
const offline: (string | undefined)[] = [];
|
||||
for (let interval = 0; interval < 6; interval += 1) {
|
||||
await clock.advance(SLOT_MS);
|
||||
offline.push(slideOf(controller.diagnostics().renderedSelectionId));
|
||||
expect(controller.diagnostics().offline).toBe(true);
|
||||
}
|
||||
|
||||
// Still cycling, on the server's own phase, from bytes the page already held.
|
||||
expect(offline).toEqual(["b", "a", "b", "a", "b", "a"]);
|
||||
expect(controller.diagnostics().unusableSelectionIds).toEqual([]);
|
||||
expect(root.dataset.slate).toBe("false");
|
||||
|
||||
// The network returns. The server is authoritative again with no reload.
|
||||
network.reachable = true;
|
||||
await clock.advance(70_000);
|
||||
const diagnostics = controller.diagnostics();
|
||||
expect(diagnostics.offline).toBe(false);
|
||||
expect(diagnostics.renderedSelectionId).toMatch(/^sel-[ab]-\d+$/u);
|
||||
expect(Number(diagnostics.renderedSelectionId?.split("-")[2])).toBeGreaterThan(10);
|
||||
await controller.stop();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user