feat: initial Signage 0.1.0

This commit is contained in:
ekklesia
2026-08-28 16:41:54 -05:00
parent 20756f11af
commit 92bef4c7aa
107 changed files with 24397 additions and 0 deletions
+251
View File
@@ -0,0 +1,251 @@
/**
* 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>;
}
/** 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();
},
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}`;
}
async function localUrl(cache: Cache, url: string): Promise<string | undefined> {
const response = await cache.match(url);
if (!response) {
return undefined;
}
try {
const blob = await response.blob();
return URL.createObjectURL(blob);
} 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);
}
}
} catch {
// A prune that fails leaves a screen with more than it needs, which is the safe direction.
}
},
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());
return localUrl(cache, cacheRequest(key));
} catch {
return undefined;
}
},
};
}