Files
readest/apps/readest-app/src/utils/event.ts
T
Huang Xin 981579c255 feat(sync): cross-device custom font sync (#4077)
* refactor(sync): kind-agnostic replica primitives

Extract dict-only sync into shared primitives (registry, pull/apply
orchestrator, persist env, schema allowlist) so other kinds can plug
in. Companion changes: per-replica Storage Manager grouping,
useReplicaPull boot-race recovery, manifest=null reconciliation on
every boot pull, copyFile takes explicit srcBase + dstBase, settled-
event helpers, lenient webDownload Content-Length (R2/S3 signed URLs
commonly omit it), and generic "File" transfer toast copy any replica
kind can share.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(sync): cross-device custom font sync

Plug the font replica adapter into the kind-agnostic primitives:
font store gains replica wiring, custom font import publishes the
replica row + queues a binary upload, and bootstrap registers the
font adapter and download-complete handler.

Includes legacy flat-path migration so pre-existing fonts sync
without re-import, full @font-face activation on auto-download
(load + mount the rule, mirroring manual import), and a fix to
createCustomFont so contentId / bundleDir / byteSize survive the
trip through addFont — otherwise import-time publish silently
no-oped on missing contentId.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 08:28:44 +02:00

118 lines
3.7 KiB
TypeScript

class EventDispatcher {
private syncListeners: Map<string, Array<(event: CustomEvent) => boolean>>;
private asyncListeners: Map<string, Set<(event: CustomEvent) => Promise<void> | void>>;
constructor() {
this.syncListeners = new Map();
this.asyncListeners = new Map();
}
on(event: string, callback: (event: CustomEvent) => Promise<void> | void): void {
if (!this.asyncListeners.has(event)) {
this.asyncListeners.set(event, new Set());
}
this.asyncListeners.get(event)!.add(callback);
}
off(event: string, callback: (event: CustomEvent) => Promise<void> | void): void {
this.asyncListeners.get(event)?.delete(callback);
}
async dispatch(event: string, detail?: unknown): Promise<void> {
const listeners = this.asyncListeners.get(event);
if (listeners) {
const customEvent = new CustomEvent(event, { detail });
for (const listener of listeners) {
await listener(customEvent);
}
}
}
onSync(event: string, callback: (event: CustomEvent) => boolean): void {
if (!this.syncListeners.has(event)) {
this.syncListeners.set(event, []);
}
this.syncListeners.get(event)!.push(callback);
}
offSync(event: string, callback: (event: CustomEvent) => boolean): void {
const listeners = this.syncListeners.get(event);
if (listeners) {
this.syncListeners.set(
event,
listeners.filter((listener) => listener !== callback),
);
}
}
dispatchSync(event: string, detail?: unknown): boolean {
const listeners = this.syncListeners.get(event);
if (listeners) {
const customEvent = new CustomEvent(event, { detail });
for (const listener of [...listeners].reverse()) {
const consumed = listener(customEvent);
if (consumed) {
return true;
}
}
}
return false;
}
}
export const eventDispatcher = new EventDispatcher();
// ─── Settled events (one-shot, replay-on-subscribe) ────────────────────
//
// `markSettled` records that a one-shot event has fired and dispatches
// it to currently-subscribed listeners. Subsequent calls with the same
// name are no-ops — the event has already settled.
//
// `onSettled` subscribes a listener that fires exactly once: synchronously
// if the event has already settled (replay), otherwise on the next
// markSettled. The listener auto-unsubscribes after firing. Returns an
// unsubscribe function that cancels the subscription if the event hasn't
// settled yet.
//
// Use case: boot-readiness signals like "replica-sync-ready" or
// "transferManager-ready" where late subscribers must still observe
// that the milestone happened. The plain eventDispatcher fires-and-
// forgets, so a listener that subscribes after dispatch misses it
// forever.
const settledEvents = new Map<string, unknown>();
export const markSettled = async (name: string, detail?: unknown): Promise<void> => {
if (settledEvents.has(name)) return;
settledEvents.set(name, detail);
await eventDispatcher.dispatch(name, detail);
};
export const onSettled = <T = unknown>(
name: string,
listener: (detail: T) => void,
): (() => void) => {
if (settledEvents.has(name)) {
listener(settledEvents.get(name) as T);
return () => {};
}
let invoked = false;
const wrapped = (event: CustomEvent) => {
if (invoked) return;
invoked = true;
eventDispatcher.off(name, wrapped);
listener(event.detail as T);
};
eventDispatcher.on(name, wrapped);
return () => {
if (!invoked) {
eventDispatcher.off(name, wrapped);
}
};
};
/** Test seam — clear the settled-events registry between specs. */
export const __resetSettledEventsForTests = (): void => {
settledEvents.clear();
};