feat: initial Signage 0.1.0
This commit is contained in:
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user