// Service Worker: the virtual origin for guest ESM (DESIGN §Module system) and for // guest node:http servers (/~/@/http//). // route.ts decides each request: /~/ URLs, plus same-origin requests made by a guest // app's clients (see clientApps). Everything else falls through untouched (respondWith // is never called), so the shell page and runtime workers load from the network exactly // as before. Served requests relay to the owning host page over its bridge // MessageChannel; the host forwards them to its kernel (the sole FS owner) and routes // the replies back here. Each host page registers its own bridge under the token its // /~/ URLs carry, so pages on one origin each reach their own runtime. // A Service Worker cannot intercept WebSockets, so guest documents get a shim // (/~/ws-shim.js, served from here) that asks this SW for a raw connection instead; the // SW passes the connection's port to the owning host and keeps no part in its bytes. // // Composing: a site's own worker at scope "/" may run this one with // importScripts("/dist/runtime/sw.js"). By default the runtime activates a new version at once // (skipWaiting on install) and takes over open pages (clients.claim on activate). A site // that drives its own updates turns either off by setting, before importScripts: // self.nibServiceWorker = { skipWaiting: false, claim: false }; // and then calls self.skipWaiting() itself (e.g. on its page's SKIP_WAITING message). // Read once, at evaluation; a missing or non-boolean field keeps its default. Whatever // `claim` says, boot() on an uncontrolled page still sends { t: "claim" } and is claimed // (clients.claim() takes every uncontrolled page in scope, not only the one that asked). import type { BridgeReply, BridgeRequest, ConnectionMessage, HostToSw, HttpRequestRecord, PageToSw, SwToHost } from "../shared/protocol"; import { rawHttpResponse } from "../shared/protocol"; import { RESOURCE_POLICY_PARAM, type ResourcePolicy } from "../shared/public-types"; import { parseHostToken, type HostToken } from "../shared/vurls"; import { injectShim, wantsShim } from "./inject"; import { connectionApp, route, type AppRef } from "./route"; /** The shim's source, bundled in by build.mjs. */ declare const __WS_SHIM__: string; interface FetchEventLike { request: Request; clientId: string; resultingClientId: string; respondWith(response: Promise | Response): void; } // ServiceWorkerGlobalScope is not in the WebWorker lib; the narrow surface used // here is spelled out so typos fail the typecheck instead of shipping. interface ServiceWorkerScope { addEventListener(type: "install", listener: () => void): void; addEventListener(type: "activate", listener: (event: { waitUntil(p: Promise): void }) => void): void; addEventListener(type: "message", listener: (event: MessageEvent & { waitUntil(p: Promise): void }) => void): void; addEventListener(type: "fetch", listener: (event: FetchEventLike) => void): void; skipWaiting(): Promise; clients: { claim(): Promise; get(id: string): Promise<{ id: string } | undefined>; matchAll(options: { includeUncontrolled: boolean; type: "all" | "window" }): Promise>; }; location: Location; } const sw = self as unknown as ServiceWorkerScope; /** Set by a composing site worker on `self` before importScripts (see the top). */ export interface ServiceWorkerOptions { /** Activate a new version as soon as it installs. Default true. */ skipWaiting?: boolean; /** Take control of open pages on activate. Default true. */ claim?: boolean; } const options = ((): Required => { const given = (self as { nibServiceWorker?: unknown }).nibServiceWorker; const read = (key: keyof ServiceWorkerOptions): boolean => { const value = typeof given === "object" && given !== null ? (given as Record)[key] : undefined; return typeof value === "boolean" ? value : true; }; return { skipWaiting: read("skipWaiting"), claim: read("claim") }; })(); /** A host page's bridge, and the window client that registered it. */ interface Bridge { port: MessagePort; client: string; } const bridges = new Map(); /** Hosts whose page is gone. A token lives exactly as long as its page. */ const retired = new Set(); /** Fetches waiting for their host to register (again), by host. */ const arrivals = new Map void>>(); const pending = new Map void }>(); // Seeded from the clock so ids never repeat across SW restarts: a reply the kernel // sends late for a stopped SW's request lands on the new bridge, and must not // resolve a new request that reused its id. let nextId = Date.now() * 1024; /** Which guest server each client (iframe document, worker) belongs to. In memory * only: after an idle kill it is re-learned from referrers (route.ts). */ const clientApps = new Map(); /** A bridge round trip that never answers must not hang the module graph forever * (kernel terminated, host wedged). Bounded at 10 s → the fetch 404s/504s. */ const BRIDGE_TIMEOUT_MS = 10_000; /** A guest server's answer is its own business: a dev server's first page compiles before it * answers, which here can take longer than the bridge bound. A dead host still fails fast, * since `check` answers for it; this bounds only a live server that never replies. */ const HTTP_TIMEOUT_MS = 300_000; /** How long a fetch waits for its host to answer a rebridge before failing. */ const REVIVE_WAIT_MS = 3_000; // A (re)started SW holds no bridges: ask every open host page to register again. let lastRebridge = -Infinity; function rebridge(): void { if (Date.now() - lastRebridge < REVIVE_WAIT_MS) return; lastRebridge = Date.now(); void sw.clients.matchAll({ includeUncontrolled: false, type: "window" }).then((windows) => { for (const w of windows) w.postMessage({ t: "rebridge" } satisfies SwToHost); }, () => {}); } rebridge(); sw.addEventListener("install", () => { if (options.skipWaiting) void sw.skipWaiting(); }); sw.addEventListener("activate", (event) => { // A new version inherits the old one's pages, and their workers, before activate // fires, but none of their bridges: its top-level rebridge ran while it was still // installing and controlled nobody. Ask them now, past the broadcast limit. lastRebridge = -Infinity; rebridge(); // Claim existing clients so the page — and the workers it creates afterwards — // are controlled even when this SW registered after the page load. A page that // loads uncontrolled later (hard reload) asks for the same with { t: "claim" }. if (options.claim) event.waitUntil(sw.clients.claim()); }); sw.addEventListener("message", (event) => { const msg = event.data as Partial | null; const client = (event.source as { id?: string } | null)?.id; if (msg?.t === "bridge") { const host = parseHostToken(msg.host); if (msg.port && host && client) register(host, msg.port, client); } if (msg?.t === "claim") event.waitUntil(sw.clients.claim()); if (msg?.t === "ping" && msg.port instanceof MessagePort) msg.port.postMessage({ t: "pong" } satisfies SwToHost); const page = event.data as Partial | null; if (page?.t === "connect" && page.socket instanceof MessagePort && client) { const app = connectionApp({ path: String(page.path), page: String(page.page), selfOrigin: sw.location.origin, clientId: client }, clientApps); event.waitUntil(openConnection(page.socket, app, client)); } }); /** Hand the page's connection port to its app's host, which passes it on to the kernel. */ async function openConnection(socket: MessagePort, app: AppRef | null, client: string): Promise { if (app && !clientApps.has(client)) adopt(client, app); const bridge = app && await bridgeFor(app.host); if (!app || !bridge) { const why = app ? noHost(app.host) : "ERR_NIB_NO_APP: this page belongs to no guest server\n"; socket.postMessage(rawHttpResponse(502, "Bad Gateway", why) satisfies ConnectionMessage); socket.postMessage(null satisfies ConnectionMessage); socket.close(); return; } bridge.port.postMessage({ t: "connect", port: app.port, socket } satisfies BridgeRequest, [socket]); void check(app.host, bridge); } function register(host: HostToken, port: MessagePort, client: string): void { const previous = bridges.get(host); bridges.set(host, { port, client }); retired.delete(host); previous?.port.close(); port.onmessage = (reply: MessageEvent) => { const data = reply.data; if (data?.t !== "source" && data?.t !== "httpResult") return; const p = pending.get(data.id); if (!p) return; // late reply after the timeout already answered — drop it pending.delete(data.id); p.resolve(data); }; for (const arrived of arrivals.get(host) ?? []) arrived(); arrivals.delete(host); void prune(); } function retire(host: HostToken): void { bridges.delete(host); retired.add(host); for (const [id, p] of pending) { if (p.host !== host) continue; pending.delete(id); p.resolve(null); } } /** Retire the bridge if its registering window is gone. Messages to a closed page's * port vanish silently (Chromium 156 has no MessagePort close event), so liveness * is asked of the Clients API instead. */ async function check(host: HostToken, bridge: Bridge): Promise { const alive = await sw.clients.get(bridge.client).catch(() => undefined); if (!alive && bridges.get(host) === bridge) retire(host); } async function prune(): Promise { await Promise.all([...bridges].map(([host, bridge]) => check(host, bridge))); } /** The host's bridge; for a host this SW does not know, waits (bounded) for it to * answer a rebridge, which is how a restarted SW finds its hosts again. */ async function bridgeFor(host: HostToken): Promise { const known = bridges.get(host); if (known) return known; if (retired.has(host)) return null; void prune(); rebridge(); return await new Promise((resolve) => { const arrived = (): void => { clearTimeout(timer); resolve(bridges.get(host) ?? null); }; const timer = setTimeout(() => { const waiting = arrivals.get(host) ?? []; waiting.splice(waiting.indexOf(arrived), 1); if (waiting.length === 0) arrivals.delete(host); resolve(null); }, REVIVE_WAIT_MS); const waiting = arrivals.get(host) ?? []; waiting.push(arrived); arrivals.set(host, waiting); }); } sw.addEventListener("fetch", (event) => { const request = event.request; const decided = route({ url: new URL(request.url), selfOrigin: sw.location.origin, mode: request.mode, destination: request.destination, clientId: event.clientId, resultingClientId: event.resultingClientId, referrer: request.referrer, }, clientApps); switch (decided.kind) { case "network": return; case "shim": event.respondWith(new Response(__WS_SHIM__, { headers: { "Content-Type": "text/javascript; charset=utf-8", ...ISOLATION }, })); return; case "missing": event.respondWith(textResponse(404, `ERR_MODULE_NOT_FOUND: not a virtual URL: ${request.url}\n`)); return; case "module": event.respondWith(serveModule(request, decided.host)); return; case "redirect": event.respondWith(Response.redirect(new URL(decided.location, request.url), decided.status)); return; case "http": if (decided.adopt) adopt(decided.adopt, decided.app); event.respondWith(serveHttp(request, decided.app, decided.path)); return; } }); function adopt(clientId: string, app: AppRef): void { // Ids already in the table before this adopt are the only prune candidates: the // client just adopted may still be reserved (not yet listed by matchAll). const candidates = [...clientApps.keys()].filter((id) => id !== clientId); clientApps.set(clientId, app); if (candidates.length === 0) return; void sw.clients.matchAll({ includeUncontrolled: true, type: "all" }).then((live) => { const alive = new Set(live.map((c) => c.id)); for (const id of candidates) if (!alive.has(id)) clientApps.delete(id); }, () => {}); } /** One bridge round trip; null when the host went away or never answered. */ function roundTrip(host: HostToken, bridge: Bridge, build: (id: number) => BridgeRequest, timeoutMs = BRIDGE_TIMEOUT_MS): Promise { const id = nextId++; return new Promise((resolve) => { const timer = setTimeout(() => { pending.delete(id); resolve(null); }, timeoutMs); pending.set(id, { host, resolve: (reply) => { clearTimeout(timer); resolve(reply); }, }); bridge.port.postMessage(build(id)); // In parallel, not first: a live host costs no latency, a closed one answers now. void check(host, bridge); }); } const noHost = (host: HostToken): string => `ERR_NIB_NO_HOST: no runtime host page is open for @${host}\n`; // The page is COEP require-corp: a nested document (the iframe) without COEP is // blocked outright, so every answer carries both headers, errors included. The host // names the CORP in the script URL (BootOptions.resourcePolicy); a site worker that // composes this one has no such parameter and keeps the default. const resourcePolicy: ResourcePolicy = new URL(sw.location.href).searchParams.get(RESOURCE_POLICY_PARAM) === "cross-origin" ? "cross-origin" : "same-origin"; const ISOLATION = { "Cross-Origin-Embedder-Policy": "require-corp", "Cross-Origin-Resource-Policy": resourcePolicy, }; const NULL_BODY_STATUS = new Set([101, 103, 204, 205, 304]); async function serveModule(request: Request, host: HostToken): Promise { const bridge = await bridgeFor(host); if (!bridge) return textResponse(404, noHost(host)); const data = await roundTrip(host, bridge, (id) => ({ t: "get", id, url: request.url })); if (data?.t !== "source" || !data.ok) { return textResponse(404, retired.has(host) ? noHost(host) : `ERR_MODULE_NOT_FOUND: virtual module not served: ${request.url}\n`); } // Structured clone always delivers ArrayBuffer-backed bytes; the lib's BodyInit // only accepts ArrayBuffer views, so this cast is type-level only. return new Response(data.bytes as Uint8Array, { status: 200, headers: { "Content-Type": "text/javascript; charset=utf-8", ...ISOLATION }, }); } async function serveHttp(request: Request, app: AppRef, url: string): Promise { const bridge = await bridgeFor(app.host); if (!bridge) return textResponse(502, noHost(app.host)); const method = request.method; const req: HttpRequestRecord = { method, url, headers: [...request.headers], body: method === "GET" || method === "HEAD" ? null : new Uint8Array(await request.arrayBuffer()), }; const data = await roundTrip(app.host, bridge, (id) => ({ t: "http", id, port: app.port, req }), HTTP_TIMEOUT_MS); if (data?.t !== "httpResult") { return retired.has(app.host) ? textResponse(502, noHost(app.host)) : textResponse(504, `ERR_NIB_HTTP_TIMEOUT: no answer from port ${app.port}\n`); } if (!data.res) { return textResponse(502, `${data.error ?? "ERR_NIB_NO_LISTENER"}: nothing is listening on port ${app.port}\n`); } const { status, headers, body } = data.res; const out = new Headers(headers); for (const [name, value] of Object.entries(ISOLATION)) out.set(name, value); const nullBody = method === "HEAD" || NULL_BODY_STATUS.has(status); let sent = body; if (!nullBody && wantsShim(request.mode, out, body)) { sent = injectShim(body); if (out.has("content-length")) out.set("content-length", String(sent.length)); } try { return new Response(nullBody ? null : sent as Uint8Array, { status, headers: out }); } catch (e) { // e.g. a status outside 200–599: the guest's response is unrepresentable as a Response. return textResponse(502, `ERR_NIB_BAD_RESPONSE: ${String(e)}\n`); } } function textResponse(status: number, text: string): Response { return new Response(text, { status, headers: { "Content-Type": "text/plain; charset=utf-8", ...ISOLATION }, }); }