/// import { get, set } from "idb-keyval"; import { callChannel, handleChannelCalls, withTransfer } from "../core/data-calls.js"; import { handleClaimRequests } from "../core/service-worker-control.js"; import { sendHttpRequest } from "../http/http-send-recieve.js"; import { errorMessage } from "../link/errors.js"; import { connectHttpLink, HTTP_LINK_PROTOCOL, type HttpLink, LinkLostError, serveHttpLink, trackedLink, } from "../link/http-link.js"; import { heldLockNames, holdLock, locksAvailable, newLockName, watchLock } from "../link/locks.js"; import { PortRendezvous } from "../link/rendezvous.js"; import type { WebSocketConnection } from "../ws/client.js"; import { newWebSocketUpgradeRequest } from "../ws/upgrade.js"; import { type HandlerInfo, HandlerTable, leastInflightRoute, type RouteFunction, } from "./handler-table.js"; import { ATTACH_CALL, type AttachParams, type AttachReply, type ControlMessage, DETACH_CALL, DRAIN_CALL, type DrainParams, KEEPALIVE_MESSAGE, NEED_LINKS_CALL, type NeedLinksParams, WAKE_MESSAGE, WS_CONNECT_CALL, } from "./protocol.js"; export interface SwHttpDispatcherOptions { self: ServiceWorkerGlobalScope; log?: (...args: unknown[]) => void; /** * Picks the handler of a request among the live handlers of its key. * Default `leastInflightRoute`: the fewest requests in flight, the oldest on * a tie — with one tab per key, the first live handler, as before. */ route?: RouteFunction; /** * How long a request waits for a handler while its key is handed over (the * handlers it has are all draining, or `route` picked none), in ms. Past * it, 503. Default 15 s. */ attachTimeoutMs?: number; /** * How long a request for a key with no handler waits for the pages to * attach their links again (after a ServiceWorker restart), in ms. Past it, * 404 for a key this worker has served before. Default 3 s. */ relinkTimeoutMs?: number; /** How long a replaced link may finish its requests, in ms. Default 5 s. */ handoverMs?: number; /** How long a socket waits for its handler's answer, in ms. Default 15 s. */ wsConnectTimeoutMs?: number; /** Per-stream inactivity timeout of the links (webrun-rpc's). Default none. */ streamTimeout?: number; /** * Period of the backup liveness check, in ms: handlers whose owner lock is * no longer held are dropped. Default 5 s; 0 turns it off. */ livenessCheckMs?: number; } /** One attached handler link, as the ServiceWorker holds it. */ class HandlerEntry implements HandlerInfo { draining = false; readonly attachedAt = Date.now(); readonly sockets: PortRendezvous; stopWatch: () => void = () => {}; closeUp?: () => Promise; constructor( readonly key: string, readonly id: string, readonly owner: string, readonly meta: unknown, readonly link: HttpLink, readonly ctl: MessagePort | undefined, readonly clientId: string | undefined, readonly ownerLock: string | undefined, ) { this.sockets = new PortRendezvous(`s:${id}`); } get inflight(): number { return this.link.inflight; } } /** The reason requests fail with when their handler dies under them. */ export const HANDLER_LOST_MESSAGE = "The handler was lost while this request was in flight"; const TIMED_OUT = Symbol("timed-out"); /** * The same-origin ServiceWorker: routes `fetch` events under its scope to * handler links that pages attach — handlers in the pages themselves * (`SwHttpAdapter.register`) or in Dedicated Workers * (`SwHttpAdapter.attachWorker` + `serveHttpInWorker`). * * A url `//...` belongs to the handlers of `key`. Of those, the * `route` option picks one per request. Requests and responses stream with * backpressure in both directions, and an aborted fetch reaches the handler * as a cancellation (webrun-rpc + webrun-http-streams). * * Urls of keys this worker never served go to the network untouched: the * worker does not call `respondWith` for them. * * RESTARTS. The browser stops an idle ServiceWorker, and every port it held * dies with it. Nothing is persisted but the set of keys ever served: the * first request after a restart asks every window to attach its links again * (`NEED_LINKS`) and waits for them. * * LIVENESS. Each handler holds an owner lock; when it is granted to this * worker, the handler is gone and its requests in flight fail at once (502). * This worker holds a router lock that handlers watch the same way. */ export class SwHttpDispatcher { readonly self: ServiceWorkerGlobalScope; readonly log: (...args: unknown[]) => void; readonly options: SwHttpDispatcherOptions; readonly table = new HandlerTable(); /** * Keys that were served at some point on this worker (sticky, kept in * IndexedDB). Lets a fetch tell "a site url whose handler is gone → 404" * from "an unknown url under the scope → the network". */ readonly claimedKeys = new Set(); /** The Web Lock this worker holds while it lives; handlers watch it. */ readonly routerLock?: string; private _routerHeld: Promise = Promise.resolve(); private _restored: Promise = Promise.resolve(); private _restoreSettled = false; private _cleanup: Array<() => void> = []; private _livenessTimer?: ReturnType; private readonly _relinkRounds = new Map }>(); /** Keys whose last handler was drained: until the time stored, requests wait for the next. */ private readonly _vacant = new Map(); constructor(options: SwHttpDispatcherOptions) { this.options = options; this.self = options.self; this.log = options.log ?? (() => {}); if (locksAvailable()) this.routerLock = newLockName("webrun-http-router"); } get scope(): string { return `${new URL("./", this.self.location.href)}`; } /** The key `url` belongs to: its first path segment under the scope. */ keyOf(url: string | URL): string | undefined { const href = `${url}`; const scope = this.scope; if (!href.startsWith(scope)) return undefined; const key = href .substring(scope.length) .replace(/[?#].*$/, "") .split("/")[0]; return key ? decodeURIComponent(key) : undefined; } /** Whether `key` has ever been served on this worker. */ isClaimedKey(key: string): boolean { return this.claimedKeys.has(key); } /** Whether requests for `key` are this worker's to answer. */ serves(key: string): boolean { return this.claimedKeys.has(key) || this.table.all(key).length > 0; } start(): void { const self = this.self; if (this.routerLock) this._routerHeld = holdLock(this.routerLock).then(() => {}); this._restored = this._restoreClaimedKeys(); const latch = () => { this._restoreSettled = true; }; this._restored.then(latch, latch); this._cleanup.push( handleClaimRequests(self), handleChannelCalls(self, ATTACH_CALL, (event, params, ...ports) => this._attach(event, params as AttachParams, ports), ), handleChannelCalls(self, DETACH_CALL, (_event, params) => { const entry = this._find(params as DrainParams); if (!entry) return false; this._close(entry, new LinkLostError("The handler was detached")); return true; }), handleChannelCalls(self, DRAIN_CALL, async (_event, params) => { const entry = this._find(params as DrainParams); if (!entry) return false; // Requests for the key wait for its next owner, even once this one // is gone, for up to `attachTimeoutMs`. this._vacant.set(entry.key, Date.now() + (this.options.attachTimeoutMs ?? 15_000)); await this._retire(entry, (params as DrainParams).ms ?? this.options.handoverMs ?? 5_000); return true; }), handleChannelCalls(self, WS_CONNECT_CALL, async (event, params) => { if (!this._isOwnClient(event)) throw new Error("Only this origin's pages may open sockets"); const { url, protocols } = params as { url: string; protocols?: string[] }; const connection = await this.connectWebSocket(url, protocols ?? []); return withTransfer(connection, [connection.port]); }), ); const onMessage = (event: ExtendableMessageEvent) => { const type = (event.data as { type?: unknown } | null)?.type; if (type === WAKE_MESSAGE) { // Started by this very message if it was stopped: hold it up until the // pages had a chance to link again. event.waitUntil(this._askForLinks()); } else if (type === KEEPALIVE_MESSAGE) { // Receiving the message is what resets the idle timer. event.waitUntil(Promise.resolve()); } }; self.addEventListener("message", onMessage); this._cleanup.push(() => self.removeEventListener("message", onMessage)); const onInstall = (event: ExtendableEvent) => { this.log("Skip waiting on install."); event.waitUntil(self.skipWaiting()); }; const onActivate = (event: ExtendableEvent) => { this.log("Claim control over all clients."); event.waitUntil(self.clients.claim()); }; const onFetch = (event: FetchEvent) => this._onFetch(event); self.addEventListener("install", onInstall); self.addEventListener("activate", onActivate); self.addEventListener("fetch", onFetch); this._cleanup.push(() => { self.removeEventListener("install", onInstall); self.removeEventListener("activate", onActivate); self.removeEventListener("fetch", onFetch); }); } async stop(): Promise { for (const cleanup of this._cleanup.splice(0)) cleanup(); if (this._livenessTimer) clearInterval(this._livenessTimer); this._livenessTimer = undefined; for (const entry of this.table.entries()) { this._close(entry, new LinkLostError("The ServiceWorker dispatcher stopped")); } } /** * Answers `request` as a fetch under the scope would be: through the * handler its key routes to. A url outside the scope, or of a key this * worker never served, goes to the network. */ async dispatch(request: Request): Promise { const key = this.keyOf(request.url); if (key === undefined) return await fetch(request); await this._restored.catch(() => {}); const entry = await this._acquire(request, key); if (entry === TIMED_OUT) { return new Response(null, { status: 503, statusText: "Service Unavailable (no handler attached in time)", }); } if (!entry) { if (!this.serves(key)) return await fetch(request); // Key was served at some point but has no live handler now (its tab // closed). 404 rather than the network: a dev server's SPA fallback // would serve the app shell for every site url and stale tabs would // keep resurrecting registrations. return new Response(null, { status: 404, statusText: "Not Found (no active handler)" }); } try { return await entry.link.send(request); } catch (error) { this.log("[dispatch] failed", key, error); return new Response(errorMessage(error), { status: 502, statusText: "Bad Gateway", headers: { "content-type": "text/plain; charset=utf-8" }, }); } } /** * Opens a socket to the handler `url` routes to: the handler accepts or * refuses it like an HTTP upgrade, and the socket's port comes back. After * that the socket runs between the caller and the handler directly; this * worker is not on its data path. */ async connectWebSocket( url: string, protocols: string[] = [], headers?: [string, string][], ): Promise { const request = newWebSocketUpgradeRequest(url, { protocols, headers }); const key = this.keyOf(request.url); if (key === undefined) throw new Error(`${url} is outside this ServiceWorker's scope`); await this._restored.catch(() => {}); const entry = await this._acquire(request, key); if (!entry || entry === TIMED_OUT) throw new Error(`No handler serves ${url}`); if (!entry.ctl) throw new Error(`The handler of ${url} does not accept sockets`); const id = entry.sockets.newId(); const answer = entry.sockets.wait( id, this.options.wsConnectTimeoutMs ?? 15_000, `the socket request for ${url}`, ); entry.ctl.postMessage({ t: "ws-connect", id, url: request.url, protocols, headers, } satisfies ControlMessage); return await answer; } private _onFetch(event: FetchEvent): void { const request = event.request; const key = this.keyOf(request.url); // Outside the scope: not ours. No `respondWith`, so the browser does the // request exactly as it would without a worker. if (key === undefined) return; if (this._restoreSettled) { if (this.serves(key)) { event.respondWith(this.dispatch(request)); } else if (request.mode === "navigate") { // A navigation under the scope is answered even when it is nobody's, // by passing it through: Firefox can leave a page uncontrolled when // its worker did not answer the navigation, and an uncontrolled page's // `fetch()` never reaches this worker. Everything else is left alone. event.respondWith(fetch(request)); } return; } // Before the stored keys are read there is nothing to decide on, and // `respondWith` cannot be called after an await: claim the request, and // re-issue it if it turns out to be nobody's. event.respondWith( (async () => { await this._restored.catch(() => {}); if (!this.serves(key)) return await fetch(request); return await this.dispatch(request); })(), ); } private async _attach( event: MessageEvent, params: AttachParams, ports: MessagePort[], ): Promise { const clientId = (event.source as Client | null)?.id; const [down, ...rest] = ports; if (!down) throw new Error("An attach carries at least one port"); this.log("[attach]", clientId, params); if (params.wire === undefined) { // A page of version 1 (before #32): its own stream transport, no // backpressure. Served as it was, until the page reloads; after a // restart it is asked for a port the way version 1 asked // (`_askLegacyPage`). const link = trackedLink( (request) => sendHttpRequest(down, request), () => down.close(), ); const id = clientId ?? crypto.randomUUID(); this._add( new HandlerEntry(params.key, id, id, undefined, link, undefined, clientId, undefined), ); return { ...params }; } if (params.wire !== HTTP_LINK_PROTOCOL) { for (const port of ports) port.close(); throw new Error( `This ServiceWorker speaks ${HTTP_LINK_PROTOCOL}, the page ${params.wire}. ` + "Reload the page so that both run the same version of @statewalker/webrun-http-browser.", ); } const ctl = params.ctl ? rest.shift() : undefined; const up = params.up ? rest.shift() : undefined; const streamTimeout = this.options.streamTimeout; const link = await connectHttpLink(down, { streamTimeout }); const entry = new HandlerEntry( params.key, params.id ?? clientId ?? crypto.randomUUID(), params.owner ?? crypto.randomUUID(), params.meta, link, ctl, clientId, params.ownerLock, ); if (up) { entry.closeUp = await serveHttpLink(up, (request) => this.dispatch(request), { streamTimeout, }); } if (ctl) ctl.onmessage = (message) => this._onControl(entry, message.data as ControlMessage); if (params.ownerLock) { entry.stopWatch = watchLock(params.ownerLock, () => { this.log("[owner lost]", entry.key, entry.id); this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE)); }); } this._add(entry); await this._routerHeld; return { accepted: HTTP_LINK_PROTOCOL, routerLock: this.routerLock }; } private _add(entry: HandlerEntry): void { this._vacant.delete(entry.key); const replaced = this.table.add(entry); if (replaced && replaced !== entry) { void this._retire(replaced, this.options.handoverMs ?? 5_000, false); } void this._claim(entry.key); if (entry.ownerLock) this._watchLiveness(); } /** * The backup of the owner-lock watches: every `livenessCheckMs`, drops the * handlers whose owner lock is no longer held. Runs only while some handler * has an owner lock, and keeps nothing awake: a timer is not an event. */ private _watchLiveness(): void { const period = this.options.livenessCheckMs ?? 5_000; if (this._livenessTimer || period <= 0) return; this._livenessTimer = setInterval(async () => { const watched = this.table.entries().filter((entry) => entry.ownerLock); if (watched.length === 0) { clearInterval(this._livenessTimer); this._livenessTimer = undefined; return; } const held = await heldLockNames(); if (!held) return; for (const entry of watched) { if (entry.ownerLock && !held.has(entry.ownerLock) && this.table.remove(entry)) { this.log("[owner gone]", entry.key, entry.id); this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE), false); } } }, period); } /** The entry a drain or detach names, if its owner token still matches. */ private _find({ key, id, owner }: DrainParams): HandlerEntry | undefined { const entry = this.table.get(key, id); return entry && entry.owner === owner ? entry : undefined; } /** * Stops routing to `entry`, lets its requests in flight run for up to `ms`, * then closes it. Requests that arrive meanwhile wait for the next owner. */ private async _retire(entry: HandlerEntry, ms: number, inTable = true): Promise { entry.draining = true; let timer: ReturnType | undefined; await Promise.race([ entry.link.whenIdle(), new Promise((resolve) => { timer = setTimeout(resolve, ms); }), ]); clearTimeout(timer); this._close(entry, new LinkLostError("The handler was drained"), inTable); } private _close(entry: HandlerEntry, reason: Error, inTable = true): void { if (inTable) this.table.remove(entry); entry.stopWatch(); entry.sockets.failAll(reason); void entry.link.close(reason); void entry.closeUp?.().catch(() => {}); if (entry.ctl) { entry.ctl.onmessage = null; entry.ctl.close(); } } /** * The handler for `request`: picked by `route` among the live handlers of * its key, waiting for one when there is none. */ private async _acquire( request: Request, key: string, ): Promise { const picked = await this._pick(request, key); if (picked) return picked; if (this.table.all(key).length > 0) { // A hand-over: every handler of the key drains, or `route` declined // them all. Wait for the next owner. const attached = await this.table.waitFor( key, this.options.attachTimeoutMs ?? 15_000, this.table.live(key).length > 0, ); return attached ? ((await this._pick(request, key)) ?? TIMED_OUT) : TIMED_OUT; } const vacantUntil = this._vacant.get(key) ?? 0; if (vacantUntil > Date.now()) { // The last handler was drained: wait for the next owner. const attached = await this.table.waitFor(key, vacantUntil - Date.now()); return attached ? ((await this._pick(request, key)) ?? TIMED_OUT) : TIMED_OUT; } if (!this.serves(key)) return undefined; // Nothing attached for a key this worker served: it may have restarted // and lost its links. Ask the pages for them. await this._askForLinks(key); return await this._pick(request, key); } private async _pick(request: Request, key: string): Promise { let live = this.table.live(key); // Without Web Locks there is no owner lock: check the page is still there. for (const entry of live) { if (entry.ownerLock || !entry.clientId) continue; if (!(await this.self.clients.get(entry.clientId))) { this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE)); } } live = this.table.live(key); if (live.length === 0) return undefined; const route = this.options.route ?? leastInflightRoute; return route(request, key, live); } /** * Asks every window to attach its links again, and waits until a handler * for `key` is back, every window answered, or `relinkTimeoutMs` passed. * At most one round per key every 10 s: a key whose tab is simply gone * should not cost every request the wait. */ private _askForLinks(key = ""): Promise { const round = this._relinkRounds.get(key); // Requests arriving during a round wait for the same round; after it, a // key whose tab is simply gone does not cost every request the wait. if (round && Date.now() - round.at < 10_000) return round.done; const done = this._linkRound(key); this._relinkRounds.set(key, { at: Date.now(), done }); return done; } private async _linkRound(key: string): Promise { const timeout = this.options.relinkTimeoutMs ?? 3_000; const windows = await this.self.clients.matchAll({ type: "window", includeUncontrolled: true }); this.log("[need-links]", key, windows.length); const params: NeedLinksParams = { routerLock: this.routerLock }; const pending = new Set(windows); const answered = Promise.allSettled( windows.map((client) => callChannel(client, NEED_LINKS_CALL, params).then(() => pending.delete(client)), ), ); let timer: ReturnType | undefined; const timedOut = new Promise((resolve) => { timer = setTimeout(resolve, timeout); }); // Every window answering is the end of the round; a window without this // package never answers, which is what the timeout is for. await Promise.race([answered, timedOut]); clearTimeout(timer); // The windows that did not answer may run version 1 of this package, // which re-links when asked the way version 1's worker asked. for (const client of pending) this._askLegacyPage(client); } /** Asks a version-1 page for a fresh port, as version 1's worker did. */ private _askLegacyPage(client: Client): void { const channel = new MessageChannel(); callChannel(client, ATTACH_CALL, {}, channel.port2).then( (info) => { if (!info?.key || info.wire !== undefined) return; const link = trackedLink( (request) => sendHttpRequest(channel.port1, request), () => channel.port1.close(), ); this._add( new HandlerEntry( info.key, client.id, client.id, undefined, link, undefined, client.id, undefined, ), ); }, () => channel.port1.close(), ); } private _onControl(entry: HandlerEntry, message: ControlMessage): void { if (!message || typeof message !== "object") return; switch (message.t) { case "ws-accept": // Too late (the caller gave up): close the port, or the handler's // socket lingers until its heartbeat notices. if (!entry.sockets.settle(message.id, { port: message.port, protocol: message.protocol })) { message.port.close(); } return; case "ws-refuse": entry.sockets.fail(message.id, new Error(message.message)); return; case "ws-open": // A handler opens a socket to another handler, through this router. this.connectWebSocket(message.url, message.protocols).then( ({ port, protocol }) => entry.ctl?.postMessage( { t: "ws-opened", id: message.id, protocol, port } satisfies ControlMessage, [port], ), (error) => entry.ctl?.postMessage({ t: "ws-failed", id: message.id, message: errorMessage(error), } satisfies ControlMessage), ); return; } } private _isOwnClient(event: MessageEvent): boolean { const source = event.source as Client | null; if (!source?.url) return false; try { return new URL(source.url).origin === this.self.location.origin; } catch { return false; } } private async _claim(key: string): Promise { if (!key || this.claimedKeys.has(key)) return; this.claimedKeys.add(key); try { await set("claimedKeys", [...this.claimedKeys].sort()); } catch (error) { this.log("[claimedKeys] not stored", error); } } private async _restoreClaimedKeys(): Promise { const stored = ((await get("claimedKeys")) ?? []) as string[]; for (const key of stored) this.claimedKeys.add(key); } } /** * Starts the same-origin dispatcher in a ServiceWorker. Returns a function * that stops it. */ export function startHttpDispatcher(options: SwHttpDispatcherOptions): () => void { const dispatcher = new SwHttpDispatcher(options); dispatcher.start(); return () => { void dispatcher.stop(); }; }