/** * The SharedWorker broker (docs/multi-tab.md): one instance per origin, * every tab connects over its own port. Chromium and WebKit expose no * `Worker` constructor in SharedWorkerGlobalScope, so the broker cannot * spawn SQLite itself — it asks a connected tab to spawn worker.ts and * hand back a MessagePort (the tab bridges one MessageChannel between * us and the spawned worker). The broker then talks to SQLite directly. * * The broker owns coordination state, nothing else: * - id namespacing: every tab numbers its requests from 1, so ids are * rewritten to broker-unique ids inbound and mapped back outbound. * - refcounted opens: the first open of a name really opens; later * opens join as holders and get the CURRENT user_version via a * synthesized pragma query. close reaches SQLite only when the last * holder leaves; destroy always forwards. * - compute liveness: the host tab dying kills the compute worker but * never the broker. Each new tab connection probes the compute * channel with a ping; no answer within the deadline resets the * epoch. Pending requests are requeued and holders are kept, since * they are the state a fresh compute must restore, and a * replacement host is recruited one candidate at a time, the tab * that just messaged us first, then the most recently connected. * A candidate silent past its own deadline is passed over, since * postMessage to a dead port neither throws nor arrives and * silence is the only liveness signal there is. Requests fail only * once every candidate is quiet. * * Typed structurally instead of via lib "WebWorker" so the workspace * can typecheck without conflicting global libs (same as worker.ts). */ import type { BrokerControlMessage, ClientControlMessage, WorkerRequest, WorkerResponse, } from './protocol'; const PING_DEADLINE_MS = 1000; /** How long a compute that has not answered anything yet gets: it may * still be fetching and compiling sqlite-wasm. On a slow machine that * takes seconds, and the 1 s liveness deadline retired every fresh * worker before it could answer, again and again, until the tabs' own * open deadline (remelonDB#6 on Firefox, where the broker respawns the * compute after every last close). */ const STARTUP_DEADLINE_MS = 10_000; const RELEASE_DEADLINE_MS = 4000; // the ceiling for a tab's sync lease; renewal is cheap, immortality is not const MAX_SYNC_LEASE_MS = 300_000; interface PortLike { postMessage(message: unknown, transfer?: readonly unknown[]): void; addEventListener( type: 'message', listener: (event: { data: unknown; ports?: readonly PortLike[] }) => void, ): void; start?(): void; } interface Route { readonly port: PortLike; readonly originalId: number; /** The as-sent request, so an epoch reset can replay instead of fail. */ readonly request: WorkerRequest; /** Reshape the worker's result before answering (synthesized requests). */ readonly transform?: (result: unknown) => unknown; /** Undo broker state recorded before the worker accepted the request. */ readonly onFailure?: () => void; } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- SharedWorkerGlobalScope is not in this project's lib, so the connect event is declared here. const scope = globalThis as unknown as { addEventListener( type: 'connect', listener: (event: { ports: readonly PortLike[] }) => void, ): void; }; let computePort: PortLike | null = null; /** False while a fresh compute is re-opening held databases. */ let computeReady = false; /** The compute has answered at least once since adoption: alive and * loaded, so silence now means something. */ let computeAnswered = false; /** Held databases to restore on the next adopt (set by resetEpoch). */ let namesToRestore: string[] = []; let lastResponseAt = 0; let watchdogTimer: ReturnType | null = null; /** * New connections probe the compute channel, but a lone surviving tab * gets no new connection: without this, its requests to a dead compute * would hang forever. Any send with no response for a while triggers * the same probe (which epoch-resets, respawns and replays on silence). */ const scheduleWatchdog = (): void => { if (watchdogTimer !== null) { return; } watchdogTimer = setTimeout(() => { watchdogTimer = null; if (!computePort || routes.size === 0) { return; } if (Date.now() - lastResponseAt >= 2000) { probeCompute(); } scheduleWatchdog(); }, 2500); }; let hostedWorker: { terminate(): void } | null = null; let brokerHostingFailed = false; let retiringCompute: PortLike | null = null; let releaseTimer: ReturnType | null = null; /** * Firefox exposes the Worker constructor in SharedWorkerGlobalScope * (Chromium and WebKit do not — the reason for the tab-hosted design). * Where it exists, host the compute worker HERE: it then survives every * tab navigation, so its OPFS handles never orphan (remelonDB#4 — on * Firefox a dead page-worker's handles are never released within the * session, so the tab-hosted design breaks on any full page load). */ declare const Worker: | (new ( url: URL, options?: { type: string }, ) => PortLike & { terminate(): void; addEventListener(type: 'error', listener: (error: unknown) => void): void; }) | undefined; const reportBrokerHostingFailure = (error: unknown): void => { const message = typeof error === 'object' && error !== null && 'message' in error ? String(error.message) : String(error); console.warn( `[remelonDB] shared worker could not host compute (${message}); ` + 'falling back to compute host: browser tab', ); }; const spawnComputeHere = (): boolean => { if (typeof Worker !== 'function' || brokerHostingFailed) { return false; } try { // the literal `new Worker(new URL(...))` shape is load-bearing: // bundlers statically rewrite exactly this pattern to the built // chunk URL — an aliased constructor ships the raw specifier and // 404s in production const worker = new Worker(new URL('./worker.ts', import.meta.url), { type: 'module', }); worker.addEventListener('error', (error) => { // the hosted worker failed to load or crashed on startup: fall // back to tab-hosted compute instead of hanging every request if (hostedWorker === worker) { brokerHostingFailed = true; reportBrokerHostingFailure(error); hostedWorker = null; computePort = null; worker.terminate(); // every spawn goes through one recruiter, so this path gets the // same candidate ordering and retry-on-silence as the others restartRecruitment(); } }); hostedWorker = worker; adoptComputePort(worker); console.debug('[remelonDB] compute host: shared worker broker'); return true; } catch (error) { brokerHostingFailed = true; reportBrokerHostingFailure(error); hostedWorker = null; return false; } }; let nextRouteId = 1; const routes = new Map(); const holders = new Map>(); // the storage each held name was opened with: restores must not turn a // memory database into an opfs one, and a joiner asking for the other // kind must be refused, not silently attached const holderStorage = new Map(); const syncLeases = new Map(); const backlog: Array<{ port: PortLike; request: WorkerRequest; transform?: (result: unknown) => unknown; onFailure?: () => void; }> = []; /** * Ports in the order they connected, newest last. A port cannot be * tested for liveness, since posting to a page that has navigated * away neither throws nor arrives (remelonDB#38), so recency is the * best evidence available, and the port that just sent us a message * is better evidence still. */ const connectedPorts: PortLike[] = []; const SPAWN_CANDIDATES = 8; /** How long a tab gets to answer a spawnWorker control before the next * candidate is asked. Spawning is a `new Worker` call; a live page does * it in milliseconds. */ const SPAWN_DEADLINE_MS = 1200; interface Recruitment { readonly asked: Set; timer: ReturnType | null; } let recruitment: Recruitment | null = null; const rememberPort = (port: PortLike): void => { const index = connectedPorts.indexOf(port); if (index !== -1) { connectedPorts.splice(index, 1); } connectedPorts.push(port); if (connectedPorts.length > SPAWN_CANDIDATES) { connectedPorts.shift(); } }; const stopRecruitment = (): void => { if (recruitment?.timer) { clearTimeout(recruitment.timer); } recruitment = null; }; const failBacklog = (reason: string): void => { for (const item of backlog.splice(0)) { item.onFailure?.(); item.port.postMessage({ id: item.request.id, ok: false, error: `WebSqliteDriver: ${reason}`, } satisfies WorkerResponse); } }; /** * Recruit a tab to host the compute worker. The broker cannot spawn one * itself on Chromium or WebKit, and it cannot tell whether the tab it * asks still exists, so it asks one candidate at a time and moves on * when nobody adopts within the deadline. `preferred` is the port whose * request triggered this: it just sent a message, so it is alive. */ const requestSpawn = (preferred?: PortLike): void => { let active = recruitment; if (active) { if (active.timer) { clearTimeout(active.timer); active.timer = null; } } else { active = { asked: new Set(), timer: null }; recruitment = active; } if (computePort) { stopRecruitment(); return; } if (spawnComputeHere()) { return; } const candidates = [ ...(preferred ? [preferred] : []), ...[...connectedPorts].reverse(), ]; const asker = candidates.find((port) => !active.asked.has(port)); if (!asker) { stopRecruitment(); failBacklog( 'no tab is available to host the database worker — every candidate ' + 'went away without answering; reload the page', ); return; } active.asked.add(asker); asker.postMessage({ control: 'spawnWorker', } satisfies BrokerControlMessage); // A dead page swallows that control silently. Nothing else would ever // notice, so this timer is what keeps the broker from waiting on a // worker nobody is building. active.timer = setTimeout(() => { if (recruitment === active && !computePort) { active.timer = null; requestSpawn(); } }, SPAWN_DEADLINE_MS); }; const restartRecruitment = (preferred?: PortLike): void => { stopRecruitment(); requestSpawn(preferred); }; /** * The compute channel is gone. Instead of failing everything pending * (remelonDB#3: a fresh tab used to see "worker went away" because an * unrelated tab closed), requeue the pending requests and respawn — * self-hosted where possible, else via the first pending tab. Only when * no respawn path exists does the failure surface. */ const finishEpochReset = (target: PortLike): void => { if (computePort !== target) { return; } if (releaseTimer) { clearTimeout(releaseTimer); releaseTimer = null; } retiringCompute = null; hostedWorker?.terminate(); hostedWorker = null; computePort = null; computeReady = false; stopRecruitment(); // ping probes are self-addressed bookkeeping, not user work: they // must never be replayed, and above all never chosen as the spawn // asker — their fake port swallows the spawnWorker control and the // whole respawn dies silently const pending = [...routes.values()].filter( (route) => route.request.op !== 'ping' && route.request.op !== 'releasePool', ); routes.clear(); // holders are NOT cleared: they are exactly the state a fresh compute // must restore, so surviving tabs keep working (their queries would // otherwise hit a blank worker as "database is not open"). Names // whose own open is among the replayed requests are excluded — the // replay opens those itself. const replayedOpens = new Set( [...pending, ...backlog].flatMap(({ request }) => request.op === 'open' ? [request.name] : [], ), ); namesToRestore = [...holders.keys()].filter( (name) => !replayedOpens.has(name), ); if (pending.length > 0) { // stranded routes were accepted BEFORE anything now sitting in the // backlog, so they replay from the front, keeping their own order — // pushing them to the back replayed a query before the open it // depended on const stranded = pending.map((route) => { const entry = route.transform ? { port: route.port, request: route.request, transform: route.transform, } : { port: route.port, request: route.request }; return route.onFailure ? { ...entry, onFailure: route.onFailure } : entry; }); backlog.unshift(...stranded); } if (pending.length > 0 || backlog.length > 0) { // Candidates are tried newest-connected first and retried on // silence: after a page load the oldest pending route belongs to // the page that just died, and asking it wedges the broker // (remelonDB#38). restartRecruitment(); } }; /** * Retire a broker-hosted compute gracefully. Firefox can leave a worker's * sync access handles locked for the rest of the browser session when it is * terminated after resume, so let sqlite-wasm pause its VFS first. A truly * dead worker still falls through to the bounded hard reset. */ const resetEpoch = (): void => { const target = computePort; if (!target || retiringCompute === target) { return; } if (!hostedWorker) { finishEpochReset(target); return; } computeReady = false; retiringCompute = target; const routeId = nextRouteId++; const request = { id: routeId, op: 'releasePool' } satisfies WorkerRequest; routes.set(routeId, { request, originalId: -1, port: { postMessage: () => { finishEpochReset(target); }, addEventListener: () => {}, }, }); target.postMessage(request); releaseTimer = setTimeout(() => { finishEpochReset(target); }, RELEASE_DEADLINE_MS); }; const adoptComputePort = (port: PortLike): void => { computePort = port; computeAnswered = false; stopRecruitment(); port.addEventListener('message', (event) => { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- MessageEvent.data is `any` across the port boundary; the protocol module is the contract, and an unrouted id falls out below. const response = event.data as WorkerResponse; if (computePort === port) { computeAnswered = true; } const route = routes.get(response.id); if (!route) { return; } lastResponseAt = Date.now(); routes.delete(response.id); if (!response.ok) { route.onFailure?.(); } if (response.ok && route.transform) { route.port.postMessage({ id: route.originalId, ok: true, result: route.transform(response.result), } satisfies WorkerResponse); } else { route.port.postMessage({ ...response, id: route.originalId }); } // A self-hosted compute worker holds the SAH pool for the broker's // lifetime, which outlives every tab. Release it when nothing is // open and nothing is pending; the next open spawns a fresh one. if ( hostedWorker && holders.size === 0 && routes.size === 0 && backlog.length === 0 ) { resetEpoch(); } }); port.start?.(); const heldNames = namesToRestore; namesToRestore = []; if (heldNames.length === 0) { computeReady = true; flushBacklog(); return; } // restore held databases before anything else runs against the fresh // compute; the backlog flushes when the last re-open answers let reopensPending = heldNames.length; for (const name of heldNames) { const routeId = nextRouteId++; const request = { id: -1, op: 'open', name, // restore what was held, not what is convenient: a memory database // restored as opfs persists data the caller asked not to persist storage: holderStorage.get(name) ?? 'opfs', } as const; routes.set(routeId, { request, originalId: -1, port: { postMessage: () => { reopensPending -= 1; if (reopensPending === 0) { computeReady = true; flushBacklog(); } }, addEventListener: () => {}, }, }); port.postMessage({ ...request, id: routeId } satisfies WorkerRequest); } }; const flushBacklog = (): void => { const queued = backlog.splice(0); for (const item of queued) { // replay the SEND, not the routing decision — holder bookkeeping // already happened when the request was first handled. onFailure // travels too: dropping it here let a failed cold open keep its // holder entry and poison the name for the broker's life. send(item.port, item.request, item.transform, item.onFailure); } }; /** Post to the compute channel (requires computePort to be live). */ const send = ( port: PortLike, request: WorkerRequest, transform?: (result: unknown) => unknown, onFailure?: () => void, ): void => { const routeId = nextRouteId++; const base = { port, originalId: request.id, request }; const route = transform ? { ...base, transform } : base; routes.set(routeId, onFailure ? { ...route, onFailure } : route); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- `send` states a live compute channel as its precondition; callers backlog the request instead when there is none. computePort!.postMessage({ ...request, id: routeId }); scheduleWatchdog(); }; const forward = ( port: PortLike, request: WorkerRequest, transform?: (result: unknown) => unknown, onFailure?: () => void, ): void => { if (!computePort || !computeReady) { const entry = { port, request }; const queued = transform ? { ...entry, transform } : entry; backlog.push(onFailure ? { ...queued, onFailure } : queued); // spawn only when there is no compute at all — during the re-open // gate a live compute exists and must not get a twin if (!computePort && recruitment === null) { requestSpawn(port); } return; } send(port, request, transform, onFailure); }; /** * A peer's change set is untrusted input: it is relayed straight into * every other tab's record cache, so a wrong shape corrupts rows there. * Structural only — the receiver sanitizes the record contents. */ const isChangeSet = (value: unknown): boolean => { if (typeof value !== 'object' || value === null || Array.isArray(value)) { return false; } return Object.values(value).every( (changes) => Array.isArray(changes) && changes.every((change: unknown) => { if (typeof change !== 'object' || change === null) { return false; } const { type, record } = change as { type?: unknown; record?: unknown; }; return ( (type === 'created' || type === 'updated' || type === 'destroyed') && typeof record === 'object' && record !== null && typeof (record as { id?: unknown }).id === 'string' ); }), ); }; const answer = (port: PortLike, id: number, result: unknown): void => { port.postMessage({ id, ok: true, result } satisfies WorkerResponse); }; /** * Write-block arbitration (docs/multi-tab.md): a plain token queue. An * exclusive slot (a db.write block) is granted only when nothing is * held; shared slots (db.read windows) coexist with each other. Strict * FIFO — a waiting writer blocks later readers, so writers can't starve. */ interface SlotWaiter { readonly port: PortLike; readonly requestId: number; readonly exclusive: boolean; } let nextSlot = 1; const heldSlots = new Map(); const slotQueue: SlotWaiter[] = []; const heldExclusive = (): boolean => [...heldSlots.values()].some((slot) => slot.exclusive); const grantSlots = (): void => { while (slotQueue.length > 0) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- the loop condition is `slotQueue.length > 0`, so index 0 exists. const head = slotQueue[0]!; const canGrant = head.exclusive ? heldSlots.size === 0 : !heldExclusive(); if (!canGrant) { return; } slotQueue.shift(); const slot = nextSlot++; heldSlots.set(slot, { exclusive: head.exclusive, owner: head.port }); answer(head.port, head.requestId, { slot }); if (head.exclusive) { return; // exclusive holder: nothing else runs until release } } }; const handle = (port: PortLike, request: WorkerRequest): void => { switch (request.op) { case 'acquireSlot': { slotQueue.push({ port, requestId: request.id, exclusive: request.exclusive, }); grantSlots(); return; } case 'releaseSlot': { // only the owner releases: slot ids are guessable, and a foreign // release puts two tabs inside exclusive write blocks at once — // the mutualExclusion violation docs/multi-tab.qnt rules out if (heldSlots.get(request.slot)?.owner === port) { heldSlots.delete(request.slot); } answer(port, request.id, null); grantSlots(); return; } case 'syncTurn': { const now = Date.now(); // an unbounded (or Infinity) lease from one tab would wedge sync // ownership for the broker's life; clamp to a sane ceiling const leaseMs = Number.isFinite(request.leaseMs) ? Math.min(Math.max(request.leaseMs, 1), MAX_SYNC_LEASE_MS) : MAX_SYNC_LEASE_MS; const lease = syncLeases.get(request.name); const granted = !lease || lease.port === port || lease.expiresAt <= now; if (granted) { syncLeases.set(request.name, { port, expiresAt: now + leaseMs, }); } answer(port, request.id, { granted }); return; } case 'publishChanges': { if (!isChangeSet(request.changes)) { port.postMessage({ id: request.id, ok: false, error: 'SharedWorker broker: publishChanges was given a change set ' + 'that is not tables of { type, record: { id } } changes', } satisfies WorkerResponse); return; } // fan out to every OTHER tab holding this database; the sender's // own cache is already up to date (its commit did that) for (const holder of holders.get(request.name) ?? []) { if (holder !== port) { holder.postMessage({ control: 'externalChanges', name: request.name, changes: request.changes, } satisfies BrokerControlMessage); } } answer(port, request.id, null); return; } case 'open': { const existing = holders.get(request.name); const alreadyHeld = existing?.has(port) ?? false; const heldStorage = holderStorage.get(request.name); if (existing && existing.size > 0 && heldStorage !== request.storage) { // a silent join across storage kinds either persists what the // caller declined or loses what it expected to keep port.postMessage({ id: request.id, ok: false, error: `database '${request.name}' is open with storage ` + `'${heldStorage ?? 'opfs'}', not '${request.storage}'`, } satisfies WorkerResponse); return; } const undoHolder = () => { const holding = holders.get(request.name); holding?.delete(port); if (holding?.size === 0) { holders.delete(request.name); holderStorage.delete(request.name); } }; if (existing && existing.size > 0) { existing.add(port); // joiner — even when the compute is not up yet: the first // open is already queued ahead of this pragma, so replay order // serves it correctly. Gating on a live compute made two // near-simultaneous cold opens both real, and the second died // on the server's "already open" guard (remelonDB#3). // joiner: report the connection's current version forward( port, { id: request.id, op: 'query', name: request.name, sql: 'pragma user_version', args: [], }, (rows) => ({ userVersion: Number( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- a transform receives the worker's result as `unknown`; this one reads the single column its own `pragma user_version` query selects. (rows as readonly { user_version?: unknown }[])[0] ?.user_version ?? 0, ), }), alreadyHeld ? undefined : undoHolder, ); return; } const set = holders.get(request.name) ?? new Set(); set.add(port); holders.set(request.name, set); holderStorage.set(request.name, request.storage); forward(port, request, undefined, undoHolder); return; } case 'close': { const holding = holders.get(request.name); holding?.delete(port); if (holding && holding.size > 0) { answer(port, request.id, null); // others still hold it open return; } holders.delete(request.name); holderStorage.delete(request.name); forward(port, request); return; } case 'destroy': { // the other holders' connections die with the file; without this // they would learn it from the next request failing for (const holder of holders.get(request.name) ?? []) { if (holder !== port) { holder.postMessage({ control: 'databaseDestroyed', name: request.name, } satisfies BrokerControlMessage); } } holders.delete(request.name); holderStorage.delete(request.name); forward(port, request); return; } default: forward(port, request); } }; /** Probe the compute channel; reset the epoch when it stopped answering. */ const probeCompute = (): void => { const target = computePort; if (!target || retiringCompute === target) { return; } const routeId = nextRouteId++; const ping = { id: routeId, op: 'ping' } satisfies WorkerRequest; let answered = false; routes.set(routeId, { // a self-addressed route: mark answered, deliver to nobody (and if // an epoch reset replays it, a ping is a harmless no-op) request: ping, port: { postMessage: () => { answered = true; }, addEventListener: () => {}, }, originalId: -1, }); target.postMessage(ping); // ponytail: a worker that never loads is noticed after 10 s instead of // 1 s; the hosted worker's error event still catches a failed load at // once. Make it a ready signal from the worker if that ever matters. const deadline = computeAnswered ? PING_DEADLINE_MS : STARTUP_DEADLINE_MS; setTimeout(() => { routes.delete(routeId); if (!answered && computePort === target) { resetEpoch(); } }, deadline); }; scope.addEventListener('connect', (event) => { const port = event.ports[0]; if (!port) { return; } port.addEventListener('message', (messageEvent) => { const raw: unknown = messageEvent.data; if (typeof raw !== 'object' || raw === null) { return; } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- MessageEvent.data is `any` across the port boundary; the protocol module is the contract and the control check below discriminates. const data = raw as WorkerRequest | ClientControlMessage; // A control is never a request: anything carrying the field leaves // here, and the handoff branch is entered by name rather than by // the field's presence, so a control added later is ignored instead // of being read as an adoption or forwarded to the worker. if ('control' in data) { // Checked by name on purpose, as the comment above says: the union // has one member today, so the type says this can never be true. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (data.control !== 'adoptWorkerPort') { return; } const transferred = messageEvent.ports?.[0]; if (transferred) { // A tab asked earlier can answer after another tab already // won the race (a suspended page waking up, say). Taking the // late worker would strand the live one and leave two computes // holding the SAH pool, so the loser is told to bin its worker. if (computePort) { port.postMessage({ control: 'discardWorker', } satisfies BrokerControlMessage); return; } adoptComputePort(transferred); } return; } rememberPort(port); handle(port, data); }); port.start?.(); rememberPort(port); probeCompute(); // A page that connects while an earlier spawn request is outstanding // is the best host available: it is provably alive, and the tab that // was asked may have gone away without answering (remelonDB#38). if (!computePort && backlog.length > 0) { requestSpawn(port); } });