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]>
358 lines
13 KiB
TypeScript
358 lines
13 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));
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 (!fetchFn) {
|
|
return createMemoryMediaCache();
|
|
}
|
|
if (!maybeCaches) {
|
|
return createPageMediaCache(fetchFn);
|
|
}
|
|
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;
|
|
}
|
|
},
|
|
};
|
|
}
|