Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367// Service Worker: the virtual origin for guest ESM (DESIGN §Module system) and for// guest node:http servers (/~/@<host>/http/<port>/).// 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> | 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): void; addEventListener(type: "message", listener: (event: MessageEvent & { waitUntil(p: Promise<unknown>): void }) => void): void; addEventListener(type: "fetch", listener: (event: FetchEventLike) => void): void; skipWaiting(): Promise<void>; clients: { claim(): Promise<void>; get(id: string): Promise<{ id: string } | undefined>; matchAll(options: { includeUncontrolled: boolean; type: "all" | "window" }): Promise<ReadonlyArray<{ id: string; postMessage(message: unknown): void }>>; }; 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<ServiceWorkerOptions> => { const given = (self as { nibServiceWorker?: unknown }).nibServiceWorker; const read = (key: keyof ServiceWorkerOptions): boolean => { const value = typeof given === "object" && given !== null ? (given as Record<string, unknown>)[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<HostToken, Bridge>();/** Hosts whose page is gone. A token lives exactly as long as its page. */const retired = new Set<HostToken>();
/** Fetches waiting for their host to register (again), by host. */const arrivals = new Map<HostToken, Array<() => void>>();
const pending = new Map<number, { host: HostToken; resolve: (reply: BridgeReply | null) => 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<string, AppRef>();
/** 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<HostToSw> | 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<PageToSw> | 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<void> { 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<BridgeReply>) => { 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<void> { const alive = await sw.clients.get(bridge.client).catch(() => undefined); if (!alive && bridges.get(host) === bridge) retire(host);}
async function prune(): Promise<void> { 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<Bridge | null> { 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<BridgeReply | null> { 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<Response> { 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<ArrayBuffer>, { status: 200, headers: { "Content-Type": "text/javascript; charset=utf-8", ...ISOLATION }, });}
async function serveHttp(request: Request, app: AppRef, url: string): Promise<Response> { 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<ArrayBuffer>, { 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 }, });}