clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

feat/prf-provision-custody
lofi / package / runtime / foreground-recovery.ts
foreground-recovery.ts 3.8K
1export type ForegroundRecoveryReason = "pageshow" | "visibilitychange" | "online";2// Package-owned foreground recovery.34export type ForegroundRecoveryState = {5 mode: "local-only" | "managed";6 status: "idle" | "offline-deferred" | "recovering" | "completed" | "failed";7 attempts: number;8 lastReason: ForegroundRecoveryReason | "none";9};1011export type ForegroundRecovery = {12 getState(): ForegroundRecoveryState;13 subscribe(listener: () => void): () => void;14 request(reason: ForegroundRecoveryReason): Promise<void>;15 dispose(): void;16};1718export function createForegroundRecovery(options: {19 enabled: boolean;20 pageTarget: EventTarget;21 visibilityTarget: EventTarget;22 isVisible(): boolean;23 isOnline(): boolean;24 /**25 * Live gate consulted on every recovery request. Configuration (`enabled`)26 * says reconnection is possible; this says it is currently wanted — false27 * while the user has not elected sync, stopped it, or paused the transport.28 */29 shouldReconnect?(): boolean;30 reconnect(): Promise<void>;31}): ForegroundRecovery {32 let active = true;33 let recovery: Promise<void> | null = null;34 let state: ForegroundRecoveryState = {35 mode: options.enabled ? "managed" : "local-only",36 status: "idle",37 attempts: 0,38 lastReason: "none",39 };40 const listeners = new Set<() => void>();41 const publish = (patch: Partial<ForegroundRecoveryState>) => {42 state = { ...state, ...patch };43 for (const listener of listeners) listener();44 };45 const request = (reason: ForegroundRecoveryReason): Promise<void> => {46 if (!active || !options.enabled) return Promise.resolve();47 // A configured server is not consent to replicate: recovery must not48 // silently reconnect an account whose user stopped sync or a transport49 // the inspector paused. Idle is the honest state — nothing to recover.50 if (options.shouldReconnect && !options.shouldReconnect()) return Promise.resolve();51 if (reason !== "online" && !options.isOnline()) {52 publish({ status: "offline-deferred", lastReason: reason });53 return Promise.resolve();54 }55 if (recovery) return recovery;56 publish({ status: "recovering", attempts: state.attempts + 1, lastReason: reason });57 const operation = options.reconnect().then(58 () => publish({ status: "completed" }),59 (error) => {60 publish({ status: "failed" });61 throw error;62 },63 );64 const tracked = operation.finally(() => {65 if (recovery === tracked) recovery = null;66 });67 recovery = tracked;68 return tracked;69 };70 const requestFromEvent = (reason: ForegroundRecoveryReason) => {71 void request(reason).catch(() => {72 // The state already records the failed recovery. Browser event callbacks73 // have no promise consumer, so the failure must not become unhandled.74 });75 };76 const onPageShow = (event: Event) => {77 if ((event as PageTransitionEvent).persisted === true) requestFromEvent("pageshow");78 };79 const onVisibilityChange = () => {80 if (options.isVisible()) requestFromEvent("visibilitychange");81 };82 const onOnline = () => requestFromEvent("online");83 if (options.enabled) {84 options.pageTarget.addEventListener("pageshow", onPageShow);85 options.pageTarget.addEventListener("online", onOnline);86 options.visibilityTarget.addEventListener("visibilitychange", onVisibilityChange);87 }88 return {89 getState: () => ({ ...state }),90 subscribe(listener) {91 listeners.add(listener);92 return () => {93 listeners.delete(listener);94 };95 },96 request,97 dispose() {98 if (!active) return;99 active = false;100 listeners.clear();101 options.pageTarget.removeEventListener("pageshow", onPageShow);102 options.pageTarget.removeEventListener("online", onOnline);103 options.visibilityTarget.removeEventListener("visibilitychange", onVisibilityChange);104 },105 };106}