clone

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

fix/175-template-quality-pass
lofi / package / runtime / upgrade-coordination.ts
upgrade-coordination.ts 8.8K
1/**2 * Cross-tab coordination for the service-worker swap.3 *4 * Two tabs on the same scope share one store, so an `applyUpdate()` in one5 * tab must not race writes in another. The coordinator announces upgrade6 * intent on a scope-keyed BroadcastChannel, every tab pauses new local writes7 * for the swap window, and a scope-keyed Web Lock — held shared for the8 * duration of each local write — lets the announcing tab wait for in-flight9 * writes to settle (an exclusive acquisition) before the worker swap. Channel10 * and lock names carry the registration scope exactly like the service11 * worker's cache names, so sibling lofi apps on one origin never cross-talk.12 *13 * Every browser surface is feature-detected: without BroadcastChannel or Web14 * Locks the coordinator degrades to local-only pausing, and the swap still15 * proceeds after a bounded quiescence timeout.16 *17 * Internal module: not part of the package export map. Applications reach18 * this behavior through `applyUpdate()` and the `pwa` application config.19 *20 * @module21 */2223/** Minimal BroadcastChannel surface, injectable for tests. */24export type UpgradeChannel = {25 postMessage(message: unknown): void;26 addEventListener(type: "message", listener: (event: MessageEvent) => void): void;27 close(): void;28};2930/** Minimal Web Locks surface, injectable for tests. */31export type UpgradeLockManager = {32 request<T>(33 name: string,34 options: { mode: "shared" | "exclusive" },35 callback: (lock: unknown) => Promise<T> | T,36 ): Promise<T>;37};3839/** Derives the scope key used in channel and lock names from a scope path. */40export function upgradeScopeKey(scopePath: string): string {41 return scopePath.replace(/[^a-zA-Z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "root";42}4344/** The BroadcastChannel name for one scope's upgrade announcements. */45export function upgradeChannelName(scopePath: string): string {46 return `lofi-scope-${upgradeScopeKey(scopePath)}-upgrade`;47}4849/** The Web Lock name held shared by local writes on one scope. */50export function upgradeLockName(scopePath: string): string {51 return `lofi-scope-${upgradeScopeKey(scopePath)}-writes`;52}5354/** Injectable surfaces and bounds for one upgrade coordinator. */55export type UpgradeCoordinatorOptions = {56 /** The service-worker registration scope path (e.g. `/app/`). */57 scopePath: string;58 /** Channel factory; defaults to the browser BroadcastChannel when present. */59 channel?: (name: string) => UpgradeChannel | undefined;60 /** Lock manager; defaults to `navigator.locks` when present. */61 locks?: () => UpgradeLockManager | undefined;62 /** How long tabs pause writes waiting for the swap; defaults to 15 seconds. */63 pauseWindowMs?: number;64 /** How long the announcer waits for in-flight writes; defaults to 5 seconds. */65 quiescenceTimeoutMs?: number;66 readonly setTimeout?: (callback: () => void, milliseconds: number) => unknown;67 readonly clearTimeout?: (handle: unknown) => void;68};6970/** Coordinates one tab's participation in a scope-wide worker swap. */71export type UpgradeCoordinator = {72 /**73 * Announces upgrade intent to every tab on the scope (this one included)74 * and resolves once in-flight local writes have settled or the bounded75 * quiescence window expired — the safe moment to swap the worker.76 */77 announceUpgrade(): Promise<void>;78 /**79 * Acquires the shared write lock for one local write, waiting out any80 * announced swap window first. The returned function releases the hold and81 * is safe to call more than once.82 */83 acquireWriteLock(): Promise<() => void>;84 /** Ends this tab's pause window after the swap completed or was abandoned. */85 notifyActivation(): void;86 /** Whether writes are currently paused for an announced swap. */87 writesPaused(): boolean;88 /** Closes the channel and releases timers. */89 dispose(): void;90};9192const upgradeIntentMessage = { type: "lofi-upgrade-intent" } as const;9394/** Creates an isolated coordinator; production wiring uses the shared one. */95export function createUpgradeCoordinator(options: UpgradeCoordinatorOptions): UpgradeCoordinator {96 const pauseWindowMs = options.pauseWindowMs ?? 15_000;97 const quiescenceTimeoutMs = options.quiescenceTimeoutMs ?? 5_000;98 const scheduleTimeout = (callback: () => void, milliseconds: number) =>99 options.setTimeout ? options.setTimeout(callback, milliseconds) : (100 globalThis.setTimeout(callback, milliseconds)101 );102 const cancelTimeout = (handle: unknown) => {103 if (options.clearTimeout) options.clearTimeout(handle);104 else globalThis.clearTimeout(handle as number);105 };106 const locks = () =>107 options.locks108 ? options.locks()109 : (typeof navigator !== "undefined" && "locks" in navigator110 ? (navigator as { locks: UpgradeLockManager }).locks111 : undefined);112 const openChannel = (name: string): UpgradeChannel | undefined => {113 if (options.channel) return options.channel(name);114 return typeof BroadcastChannel === "undefined" ? undefined : new BroadcastChannel(name);115 };116117 const lockName = upgradeLockName(options.scopePath);118 let paused = false;119 let pauseTimer: unknown;120 const resumeWaiters = new Set<() => void>();121 let disposed = false;122123 const resume = () => {124 if (!paused) return;125 paused = false;126 if (pauseTimer !== undefined) cancelTimeout(pauseTimer);127 pauseTimer = undefined;128 for (const waiter of resumeWaiters) waiter();129 resumeWaiters.clear();130 };131132 // The pause window is bounded: a swap that never completes (the waiting133 // worker failed, the announcing tab closed) must not leave every sibling134 // tab refusing writes forever.135 const pause = () => {136 if (paused || disposed) return;137 paused = true;138 if (pauseTimer !== undefined) cancelTimeout(pauseTimer);139 pauseTimer = scheduleTimeout(resume, pauseWindowMs);140 };141142 const channel = openChannel(upgradeChannelName(options.scopePath));143 channel?.addEventListener("message", (event) => {144 const message = event.data as { type?: string } | undefined;145 if (message?.type === upgradeIntentMessage.type) pause();146 });147148 const waitWhilePaused = (): Promise<void> => {149 if (!paused) return Promise.resolve();150 return new Promise((resolve) => resumeWaiters.add(resolve));151 };152153 const acquireQuiescence = (): Promise<void> => {154 const manager = locks();155 if (!manager) return Promise.resolve();156 return new Promise((resolve) => {157 let settled = false;158 const finish = () => {159 if (settled) return false;160 settled = true;161 return true;162 };163 const timer = scheduleTimeout(() => {164 if (finish()) resolve();165 }, quiescenceTimeoutMs);166 // Acquiring the exclusive lock resolves only once every shared holder167 // (an in-flight local write) released; the lock is dropped immediately168 // — quiescence is a moment, not a held state.169 void Promise.resolve(manager.request(lockName, { mode: "exclusive" }, () => undefined))170 .catch(() => undefined)171 .then(() => {172 cancelTimeout(timer);173 if (finish()) resolve();174 });175 });176 };177178 return {179 async announceUpgrade() {180 channel?.postMessage(upgradeIntentMessage);181 pause();182 await acquireQuiescence();183 },184 async acquireWriteLock() {185 await waitWhilePaused();186 const manager = locks();187 if (!manager) return () => undefined;188 let release: () => void = () => undefined;189 const held = new Promise<void>((resolve) => {190 release = resolve;191 });192 // Wait for the acquisition itself: a write must not proceed while an193 // announced swap holds (or is queued for) the exclusive lock.194 await new Promise<void>((resolve) => {195 void Promise.resolve(manager.request(lockName, { mode: "shared" }, () => {196 resolve();197 return held;198 })).catch(() => resolve());199 });200 return release;201 },202 notifyActivation: resume,203 writesPaused: () => paused,204 dispose() {205 disposed = true;206 resume();207 channel?.close();208 },209 };210}211212let shared: UpgradeCoordinator | null = null;213214/**215 * Configures the shared coordinator for the registration scope in effect.216 * Called by the boot wiring once the deployment scope is known; repeated217 * calls replace the previous coordinator.218 */219export function configureUpgradeCoordinator(scopePath: string): UpgradeCoordinator {220 shared?.dispose();221 shared = createUpgradeCoordinator({ scopePath });222 return shared;223}224225/** The shared coordinator, or `null` before boot wiring configured one. */226export function sharedUpgradeCoordinator(): UpgradeCoordinator | null {227 return shared;228}229230/**231 * Acquires the shared write lock for one local write through the shared232 * coordinator; a no-op release when no coordinator is configured.233 */234export function acquireUpgradeWriteLock(): Promise<() => void> {235 return shared ? shared.acquireWriteLock() : Promise.resolve(() => undefined);236}