/** * A display's own local storage: **IndexedDB for structured state, Cache Storage for image bytes** * (ADR 0006). * * Ordinary HTTP cache is deliberately not used as the asset manager. It offers no eviction control, * no way to prune a superseded rendition on purpose, and no way to answer the one question a display * has to answer honestly — *what can I actually render if the network is gone?* * * Everything here is behind a small interface with an in-memory implementation, because a display's * offline behaviour is the part that must be tested and IndexedDB is the part a test runner cannot * reliably host. Both implementations satisfy the same contract; the browser one is thin. */ /** One key-value store of structured, JSON-serializable state. */ export interface SignageLocalStore { read(key: string): Promise; write(key: string, value: unknown): Promise; delete(key: string): Promise; } /** * Image bytes a display can still paint with no network. * * Everything is addressed by a **stable content key**, never by the delivery URL. A delivery URL is * signed and short-lived: it changes on every refresh of the same bytes, so a cache keyed on one * would store the same image over and over and find none of it after a reconnect. The key is derived * from identity that does not change — the provider, the item, and the item's revision — so the * bytes a display cached an hour ago are the bytes it can still paint now. */ export interface SignageMediaCache { /** Fetches from `url` and stores under `key`, returning a local URL to render from. */ store(key: string, url: string): Promise; /** A local URL for previously stored bytes, or `undefined` if they were never stored. */ find(key: string): Promise; /** Removes every key not in the keep set. Called only after a new snapshot is usable, so a prune * can never leave a screen with less than it had. */ prune(keep: readonly string[]): Promise; count(): Promise; /** Gives back every local URL this cache handed out. Called when a display stops; a URL handed * out before this is no longer valid afterwards. */ release(): void; } /** The stable key a native image's bytes are cached under. Deliberately built from identity, never * from a URL. */ export function signageMediaKey(input: { readonly providerId: string; readonly itemId: string; readonly itemRevision: string; readonly rendition: string; }): string { return `signage-media/${encodeURIComponent(input.providerId)}/${encodeURIComponent(input.itemId)}/${encodeURIComponent(input.itemRevision)}/${input.rendition}`; } export const SIGNAGE_LOCAL_DATABASE = "ekklesia-signage"; export const SIGNAGE_LOCAL_STORE = "state"; export const SIGNAGE_MEDIA_CACHE = "ekklesia-signage-media-v1"; export function createMemoryStore(): SignageLocalStore { const values = new Map(); return { delete(key: string): Promise { values.delete(key); return Promise.resolve(); }, read(key: string): Promise { return Promise.resolve(values.get(key) as Value | undefined); }, write(key: string, value: unknown): Promise { // Structured state only. Round-tripping through JSON here is what keeps a `Date`, a function, // or a cycle from being written now and failing to come back later. values.set(key, JSON.parse(JSON.stringify(value)) as unknown); return Promise.resolve(); }, }; } export function createMemoryMediaCache(): SignageMediaCache { const stored = new Map(); return { count: () => Promise.resolve(stored.size), find: (key: string) => Promise.resolve(stored.get(key)), prune(keep: readonly string[]): Promise { const retained = new Set(keep); for (const key of [...stored.keys()]) { if (!retained.has(key)) { stored.delete(key); } } return Promise.resolve(); }, release(): void { // Holds plain URLs, nothing the browser has to be given back. }, store(key: string, url: string): Promise { stored.set(key, url); return Promise.resolve(url); }, }; } /** The IndexedDB-backed store a real display uses. Falls back to memory where IndexedDB is absent — * a private window, a policy that blocks storage — because a display that cannot persist must still * render, online. */ export function createBrowserStore(scope: typeof globalThis = globalThis): SignageLocalStore { const maybeIndexedDb = (scope as { indexedDB?: IDBFactory }).indexedDB; if (!maybeIndexedDb) { return createMemoryStore(); } const indexedDb: IDBFactory = maybeIndexedDb; function open(): Promise { return new Promise((resolve, reject) => { const request = indexedDb.open(SIGNAGE_LOCAL_DATABASE, 1); request.onupgradeneeded = () => { const database = request.result; if (!database.objectStoreNames.contains(SIGNAGE_LOCAL_STORE)) { database.createObjectStore(SIGNAGE_LOCAL_STORE); } }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(new Error("Signage could not open its local store.")); }); } async function transact( mode: IDBTransactionMode, run: (store: IDBObjectStore) => IDBRequest, ): Promise { let database: IDBDatabase; try { database = await open(); } catch { return undefined; } return new Promise((resolve) => { try { const transaction = database.transaction(SIGNAGE_LOCAL_STORE, mode); const request = run(transaction.objectStore(SIGNAGE_LOCAL_STORE)); request.onsuccess = () => resolve(request.result as Result | undefined); request.onerror = () => resolve(undefined); transaction.oncomplete = () => database.close(); } catch { resolve(undefined); } }); } return { async delete(key: string): Promise { await transact("readwrite", (store) => store.delete(key)); }, read(key: string): Promise { return transact("readonly", (store) => store.get(key)); }, async write(key: string, value: unknown): Promise { await transact("readwrite", (store) => store.put(value, key)); }, }; } /** 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. */ 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) { return createMemoryMediaCache(); } const caches: CacheStorage = maybeCaches; async function open(): Promise { try { return await caches.open(SIGNAGE_MEDIA_CACHE); } catch { return undefined; } } /** Cache Storage keys on a request URL, so a stable key becomes a stable absolute URL on this * origin. It is never fetched; it exists only to be a key. */ function cacheRequest(key: string): string { const origin = (scope as { location?: { origin?: string } }).location?.origin ?? "https://signage.invalid"; return `${origin}/${key}`; } /** * The one object URL each cached slide is rendered from, by cache entry. * * An object URL pins its blob in the browser until it is revoked. Every state refresh looks every * cached slide up again, so minting a URL per lookup leaked a blob per slide per refresh, and * within minutes the browser refused the display's own requests for want of resources. So each * entry gets exactly one URL, revoked when the entry is pruned or replaced, and all of them when * the display stops. */ const objectUrls = new Map(); function forget(request: string): void { const url = objectUrls.get(request); if (url !== undefined) { objectUrls.delete(request); URL.revokeObjectURL(url); } } async function localUrl(cache: Cache, request: string): Promise { const existing = objectUrls.get(request); if (existing !== undefined) { return existing; } const response = await cache.match(request); if (!response) { return undefined; } try { const blob = await response.blob(); // Two lookups of one slide can be in flight at once; the first to finish wins, and the other // gives its URL straight back rather than leaving it pinned. const raced = objectUrls.get(request); if (raced !== undefined) { return raced; } const url = URL.createObjectURL(blob); objectUrls.set(request, url); return url; } catch { return undefined; } } return { async count(): Promise { const cache = await open(); if (!cache) { return 0; } try { return (await cache.keys()).length; } catch { return 0; } }, async find(key: string): Promise { const cache = await open(); return cache ? localUrl(cache, cacheRequest(key)) : undefined; }, async prune(keep: readonly string[]): Promise { const cache = await open(); if (!cache) { return; } const retained = new Set(keep.map((key) => cacheRequest(key))); try { for (const request of await cache.keys()) { if (!retained.has(request.url)) { await cache.delete(request); forget(request.url); } } } catch { // A prune that fails leaves a screen with more than it needs, which is the safe direction. } }, release(): void { for (const request of [...objectUrls.keys()]) { forget(request); } }, async store(key: string, url: string): Promise { const cache = await open(); if (!cache) { return undefined; } try { const response = await fetchFn(url); // Only a complete, successful response is stored. A `404` or a redirect replayed later as a // slide would be worse than having nothing cached. if (!response.ok || response.status !== 200) { return undefined; } // Stored under the *stable* key, so the next refresh's freshly signed URL finds these // bytes instead of downloading them again. await cache.put(cacheRequest(key), response.clone()); // New bytes under this key: the URL for the old ones is given back before one is made. forget(cacheRequest(key)); return localUrl(cache, cacheRequest(key)); } catch { return undefined; } }, }; }