67 lines
3.8 KiB
TypeScript
67 lines
3.8 KiB
TypeScript
import { fault } from '../canvas-core/values.ts';
|
|
|
|
export type DisplaySource = { readonly url: string; release?(): void };
|
|
export type DisplayLease = { readonly ready: Promise<string>; release(): void };
|
|
type Entry = { readonly key: string; readonly owner: object; readonly controller: AbortController; readonly leases: Set<() => void>; promise: Promise<DisplaySource>; source?: DisplaySource; retired: boolean };
|
|
|
|
/** Ephemeral display ownership only. The caller checks authorization and pins
|
|
* durable bytes before acquiring. Scope objects are never serialized or reused
|
|
* across project engines/auth epochs. There is no idle URL retention.
|
|
*/
|
|
export function createDisplayCache(options: { onError?: (error: unknown) => void } = {}) {
|
|
const owners = new Map<object, Map<string, Entry>>(), retired = new WeakSet<object>();
|
|
let closed = false;
|
|
function dispose(source: DisplaySource) { try { source.release?.(); } catch (error) { try { options.onError?.(error); } catch { /* Reporting cannot interrupt cleanup. */ } } }
|
|
function retire(entry: Entry) {
|
|
if (entry.retired) return;
|
|
entry.retired = true;
|
|
const entries = owners.get(entry.owner);
|
|
if (entries?.get(entry.key) === entry) { entries.delete(entry.key); if (!entries.size) owners.delete(entry.owner); }
|
|
entry.controller.abort();
|
|
if (entry.source) { dispose(entry.source); entry.source = undefined; }
|
|
}
|
|
function releaseOwner(owner: object) {
|
|
retired.add(owner);
|
|
for (const entry of [...(owners.get(owner)?.values() ?? [])]) {
|
|
for (const release of [...entry.leases]) release();
|
|
retire(entry);
|
|
}
|
|
}
|
|
return Object.freeze({
|
|
acquire(owner: object, key: string, load: (signal: AbortSignal) => Promise<DisplaySource>): DisplayLease {
|
|
if (closed || retired.has(owner)) throw fault('display_scope_closed');
|
|
if (!owner || typeof owner !== 'object' || typeof key !== 'string' || !key) throw fault('invalid_display_key');
|
|
let entries = owners.get(owner); if (!entries) { entries = new Map(); owners.set(owner, entries); }
|
|
let entry = entries.get(key);
|
|
if (!entry) {
|
|
const created: Entry = { owner, key, controller: new AbortController(), leases: new Set(), retired: false, promise: undefined as unknown as Promise<DisplaySource> };
|
|
entry = created; entries.set(key, created);
|
|
created.promise = Promise.resolve().then(() => {
|
|
if (created.retired) throw fault('display_released');
|
|
return load(created.controller.signal);
|
|
}).then(source => {
|
|
if (!source || typeof source.url !== 'string' || !source.url || source.release !== undefined && typeof source.release !== 'function') { if (typeof source?.release === 'function') dispose(source); throw fault('invalid_display_source'); }
|
|
if (created.retired || !created.leases.size) { dispose(source); throw fault('display_released'); }
|
|
created.source = source; return source;
|
|
}, error => { retire(created); throw error; });
|
|
}
|
|
const shared = entry;
|
|
let released = false, rejectReady: (error: unknown) => void = () => {};
|
|
const release = () => {
|
|
if (released) return;
|
|
released = true; shared.leases.delete(release); rejectReady(fault('display_released'));
|
|
if (!shared.leases.size) retire(shared);
|
|
};
|
|
shared.leases.add(release);
|
|
const ready = new Promise<string>((resolve, reject) => {
|
|
rejectReady = reject;
|
|
shared.promise.then(source => { if (!released) resolve(source.url); }, error => { if (!released) { reject(error); release(); } });
|
|
});
|
|
return Object.freeze({ ready, release });
|
|
},
|
|
releaseOwner,
|
|
close() { if (closed) return; closed = true; for (const owner of [...owners.keys()]) releaseOwner(owner); },
|
|
});
|
|
}
|
|
export type DisplayCache = ReturnType<typeof createDisplayCache>;
|