Files
Signage/src/browser/storage.ts
T
ekklesiaandClaude Opus 5.5 607f375809 fix(display): keep one object URL per cached slide, and give them back
The Cache Storage media cache minted a new object URL on every lookup
and never revoked any. Every state refresh looks up every cached slide,
so a running display pinned one more blob per slide per refresh until
the browser refused its requests with net::ERR_INSUFFICIENT_RESOURCES.
It then showed its offline banner on a working network. Two tabs leaked
twice as fast, and real photographs fill blob storage far sooner than
test slides.

Each cache entry now has exactly one object URL:
- every lookup reuses it;
- pruning or replacing the entry revokes it;
- release(), new on SignageMediaCache, revokes all of them, and the
  controller calls it on stop once the stage is gone.

Two lookups of one slide racing each other keep the first URL and make
no second one. The in-memory cache has nothing to give back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 21:45:19 -05:00

298 lines
11 KiB
TypeScript

/**
* 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<Value>(key: string): Promise<Value | undefined>;
write(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<void>;
}
/**
* 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<string | undefined>;
/** A local URL for previously stored bytes, or `undefined` if they were never stored. */
find(key: string): Promise<string | undefined>;
/** 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<void>;
count(): Promise<number>;
/** 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<string, unknown>();
return {
delete(key: string): Promise<void> {
values.delete(key);
return Promise.resolve();
},
read<Value>(key: string): Promise<Value | undefined> {
return Promise.resolve(values.get(key) as Value | undefined);
},
write(key: string, value: unknown): Promise<void> {
// 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<string, string>();
return {
count: () => Promise.resolve(stored.size),
find: (key: string) => Promise.resolve(stored.get(key)),
prune(keep: readonly string[]): Promise<void> {
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<string | undefined> {
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<IDBDatabase> {
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<Result>(
mode: IDBTransactionMode,
run: (store: IDBObjectStore) => IDBRequest,
): Promise<Result | undefined> {
let database: IDBDatabase;
try {
database = await open();
} catch {
return undefined;
}
return new Promise<Result | undefined>((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<void> {
await transact("readwrite", (store) => store.delete(key));
},
read<Value>(key: string): Promise<Value | undefined> {
return transact<Value>("readonly", (store) => store.get(key));
},
async write(key: string, value: unknown): Promise<void> {
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<Cache | undefined> {
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<string, string>();
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<string | undefined> {
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<number> {
const cache = await open();
if (!cache) {
return 0;
}
try {
return (await cache.keys()).length;
} catch {
return 0;
}
},
async find(key: string): Promise<string | undefined> {
const cache = await open();
return cache ? localUrl(cache, cacheRequest(key)) : undefined;
},
async prune(keep: readonly string[]): Promise<void> {
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<string | undefined> {
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;
}
},
};
}