/** * Uploaded files the view draws with, keyed by file name. Nothing is evicted: file names are * content addressed, so a loaded file can never go stale. A miss is not an error – the subclass * starts the load and reports "not yet", and `onChange` fires when the file lands. */ export abstract class FileLibrary { private base: string; private readonly loading = new Set(); /** Never retried, so one broken reference cannot spam the network. */ private readonly failed = new Set(); private readonly listeners = new Set<() => void>(); constructor( base: string, private readonly tag: string, ) { this.base = base; } /** Where the files are served from. Set once at start-up, before anything draws. */ configure(base: string): void { this.base = base.replace(/\/+$/, ''); } /** Runs `fn` whenever a file has arrived; returns the unsubscribe. */ onChange(fn: () => void): () => void { this.listeners.add(fn); return () => this.listeners.delete(fn); } /** Runs the listeners: something they draw from has changed without a file arriving here. */ notifyChange(): void { for (const listener of this.listeners) listener(); } protected abstract fetchFile(url: string, file: string): Promise; /** Keeps what arrived; the listeners run right after. */ protected abstract keep(file: string, loaded: T): void; /** A file that could not be loaded; it is not asked for again. */ protected hasFailed(file: string): boolean { return this.failed.has(file); } protected startLoading(file: string): void { if (this.loading.has(file) || this.failed.has(file)) return; this.loading.add(file); void this.fetchFile(`${this.base}/${file}`, file).then( (loaded) => { this.loading.delete(file); this.keep(file, loaded); this.notifyChange(); }, (error: unknown) => { this.loading.delete(file); this.failed.add(file); console.error(`[${this.tag}] cannot load ${file}`, error); }, ); } }