@seanhogg/builderforce-webcontainers 2026.10.7 → 2026.10.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/dist/assets.js +3 -3
  2. package/dist/host.d.ts +7 -1
  3. package/dist/host.d.ts.map +1 -1
  4. package/dist/host.js +27 -8
  5. package/dist/host.js.map +1 -1
  6. package/dist/index.d.ts +6 -2
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +3 -1
  9. package/dist/index.js.map +1 -1
  10. package/dist/node/index.d.ts +4 -8
  11. package/dist/node/index.d.ts.map +1 -1
  12. package/dist/node/index.js +1 -25
  13. package/dist/node/index.js.map +1 -1
  14. package/dist/node/portResponder.d.ts +45 -0
  15. package/dist/node/portResponder.d.ts.map +1 -0
  16. package/dist/node/portResponder.js +136 -0
  17. package/dist/node/portResponder.js.map +1 -0
  18. package/dist/node/processHost.d.ts +13 -2
  19. package/dist/node/processHost.d.ts.map +1 -1
  20. package/dist/node/processHost.js +62 -16
  21. package/dist/node/processHost.js.map +1 -1
  22. package/dist/node/protocol.d.ts +42 -9
  23. package/dist/node/protocol.d.ts.map +1 -1
  24. package/dist/node/protocol.js.map +1 -1
  25. package/dist/node/responseWire.d.ts +27 -0
  26. package/dist/node/responseWire.d.ts.map +1 -0
  27. package/dist/node/responseWire.js +99 -0
  28. package/dist/node/responseWire.js.map +1 -0
  29. package/dist/node/worker.js +141 -125
  30. package/dist/node/workerProcess.d.ts +3 -1
  31. package/dist/node/workerProcess.d.ts.map +1 -1
  32. package/dist/node/workerProcess.js +49 -19
  33. package/dist/node/workerProcess.js.map +1 -1
  34. package/dist/previewBridge.d.ts +37 -0
  35. package/dist/previewBridge.d.ts.map +1 -0
  36. package/dist/previewBridge.js +116 -0
  37. package/dist/previewBridge.js.map +1 -0
  38. package/dist/previewMessages.d.ts +5 -0
  39. package/dist/previewMessages.d.ts.map +1 -1
  40. package/dist/previewMessages.js +18 -11
  41. package/dist/previewMessages.js.map +1 -1
  42. package/dist/process-worker.js +141 -125
  43. package/dist/protocol.d.ts +23 -1
  44. package/dist/protocol.d.ts.map +1 -1
  45. package/dist/protocol.js +8 -0
  46. package/dist/protocol.js.map +1 -1
  47. package/dist/relay.html +1 -1
  48. package/dist/snapshotStore.d.ts +30 -0
  49. package/dist/snapshotStore.d.ts.map +1 -0
  50. package/dist/snapshotStore.js +115 -0
  51. package/dist/snapshotStore.js.map +1 -0
  52. package/dist/sw.js +1 -1
  53. package/dist/swRouter.d.ts +8 -1
  54. package/dist/swRouter.d.ts.map +1 -1
  55. package/dist/swRouter.js +40 -5
  56. package/dist/swRouter.js.map +1 -1
  57. package/package.json +3 -2
  58. package/src/host.ts +31 -8
  59. package/src/index.ts +6 -2
  60. package/src/node/index.ts +4 -23
  61. package/src/node/portResponder.ts +175 -0
  62. package/src/node/processHost.ts +73 -15
  63. package/src/node/protocol.ts +25 -4
  64. package/src/node/responseWire.ts +97 -0
  65. package/src/node/workerProcess.ts +52 -21
  66. package/src/previewBridge.ts +148 -0
  67. package/src/previewMessages.ts +17 -9
  68. package/src/protocol.ts +35 -1
  69. package/src/snapshotStore.ts +135 -0
  70. package/src/sw.ts +2 -0
  71. package/src/swRouter.ts +35 -6
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The host end of the preview bridge (see core's previewBridge.ts for the page
3
+ * end). A service worker cannot carry a WebSocket or set a cookie, so preview
4
+ * pages ask this page instead, each over a MessagePort they transfer:
5
+ *
6
+ * • `SOCKET_MESSAGE_TYPE` — connect a WebSocket to the virtual server on a
7
+ * port. The handshake and frames run over an upgraded connection to the
8
+ * server (`connectWebSocket` over `ProcessHost.upgrade`), carrying the
9
+ * preview's cookies for that port; messages relay both ways on the port.
10
+ * • `COOKIE_MESSAGE_TYPE` — keep a virtual-server page's `document.cookie`
11
+ * in step with the jar, and store what the page assigns.
12
+ *
13
+ * One window listener per runtime, accepting only messages from the preview
14
+ * origin whose page sits under this runtime's base (as `listenToPreview` does).
15
+ */
16
+ import {
17
+ COOKIE_MESSAGE_TYPE,
18
+ SOCKET_MESSAGE_TYPE,
19
+ connectWebSocket,
20
+ parsePortPath,
21
+ type ByteChannel,
22
+ type HttpRequestData,
23
+ type PortCookieJars,
24
+ type WebSocketConnection,
25
+ } from '@seanhogg/builderforce-webcontainers-core';
26
+ import { previewPageOf } from './previewMessages.js';
27
+
28
+ export interface PreviewBridgeOptions {
29
+ /** Open an upgraded connection to the server on `port` (`ProcessHost.upgrade`). */
30
+ upgrade(port: number, request: HttpRequestData): ByteChannel | undefined;
31
+ /** The runtime's cookie jars: sent with WebSocket handshakes and mirrored into `document.cookie`. */
32
+ cookies?: PortCookieJars;
33
+ }
34
+
35
+ /** The slice of MessagePort the bridge uses. */
36
+ export interface BridgePort {
37
+ postMessage(message: unknown, transfer?: Transferable[]): void;
38
+ onmessage: ((event: { data: unknown }) => void) | null;
39
+ close(): void;
40
+ }
41
+
42
+ export interface PreviewBridge {
43
+ /** WebSockets currently open through the bridge. */
44
+ readonly openSockets: number;
45
+ dispose(): void;
46
+ }
47
+
48
+ /** Pages kept in sync per server port; older ones (navigated away, never said so) are dropped past this. */
49
+ const COOKIE_PAGES_PER_PORT = 50;
50
+
51
+ interface BridgeMessage {
52
+ type?: unknown;
53
+ href?: unknown;
54
+ port?: unknown;
55
+ path?: unknown;
56
+ protocols?: unknown;
57
+ }
58
+
59
+ export function listenToPreviewBridge(
60
+ target: Pick<Window, 'addEventListener' | 'removeEventListener'>,
61
+ origin: string,
62
+ base: string,
63
+ options: PreviewBridgeOptions,
64
+ ): PreviewBridge {
65
+ const sockets = new Set<WebSocketConnection>();
66
+ const cookiePages = new Map<number, Array<{ port: BridgePort; path: string }>>();
67
+
68
+ function openSocket(channel: BridgePort, data: BridgeMessage, page: URL): void {
69
+ const port = Number(data.port);
70
+ const path = typeof data.path === 'string' && data.path.startsWith('/') ? data.path : undefined;
71
+ const protocols = Array.isArray(data.protocols) ? data.protocols.filter((p): p is string => typeof p === 'string') : [];
72
+ if (!Number.isInteger(port) || port < 1 || port > 65535 || !path) {
73
+ channel.postMessage({ kind: 'close', code: 1006, reason: '', wasClean: false });
74
+ return channel.close();
75
+ }
76
+ const headers: Record<string, string> = { host: `localhost:${port}`, origin: page.origin };
77
+ const cookie = options.cookies?.jar(port).header(path);
78
+ if (cookie) headers.cookie = cookie;
79
+ if (typeof navigator !== 'undefined' && navigator.userAgent) headers['user-agent'] = navigator.userAgent;
80
+ const connection = connectWebSocket((request) => options.upgrade(port, request), { path, protocols, headers });
81
+ sockets.add(connection);
82
+ connection.onOpen(({ protocol }) => channel.postMessage({ kind: 'open', protocol }));
83
+ connection.onMessage((message) => {
84
+ if (typeof message === 'string') return channel.postMessage({ kind: 'message', data: message });
85
+ const buffer = new Uint8Array(message).buffer as ArrayBuffer;
86
+ channel.postMessage({ kind: 'message', data: buffer }, [buffer]);
87
+ });
88
+ connection.onClose((info) => {
89
+ sockets.delete(connection);
90
+ channel.postMessage({ kind: 'close', ...info });
91
+ channel.close();
92
+ });
93
+ channel.onmessage = (event) => {
94
+ const message = event.data as { kind?: unknown; data?: unknown; code?: unknown; reason?: unknown } | null;
95
+ if (message?.kind === 'send') {
96
+ if (typeof message.data === 'string') connection.send(message.data);
97
+ else if (message.data instanceof ArrayBuffer) connection.send(new Uint8Array(message.data));
98
+ } else if (message?.kind === 'close') {
99
+ connection.close(typeof message.code === 'number' ? message.code : undefined, typeof message.reason === 'string' ? message.reason : undefined);
100
+ }
101
+ };
102
+ }
103
+
104
+ function syncCookies(channel: BridgePort, page: URL): void {
105
+ const cookies = options.cookies;
106
+ const server = parsePortPath(page.pathname.slice(base.length - 1));
107
+ if (!cookies || !server) return channel.close();
108
+ const pages = cookiePages.get(server.port) ?? [];
109
+ pages.push({ port: channel, path: server.path });
110
+ if (pages.length > COOKIE_PAGES_PER_PORT) pages.shift()?.port.close();
111
+ cookiePages.set(server.port, pages);
112
+ channel.onmessage = (event) => {
113
+ const message = event.data as { kind?: unknown; value?: unknown } | null;
114
+ if (message?.kind === 'set-cookie' && typeof message.value === 'string') cookies.jar(server.port).setFromDocument(message.value, server.path);
115
+ };
116
+ // The page started from the jar as it was when served; catch up on anything since.
117
+ channel.postMessage({ kind: 'cookies', value: cookies.jar(server.port).documentCookie(server.path) });
118
+ }
119
+
120
+ const stopCookies = options.cookies?.onChange((port) => {
121
+ const jar = options.cookies!.jar(port);
122
+ for (const page of cookiePages.get(port) ?? []) page.port.postMessage({ kind: 'cookies', value: jar.documentCookie(page.path) });
123
+ });
124
+
125
+ const onMessage = (event: MessageEvent) => {
126
+ const data = event.data as BridgeMessage | null;
127
+ const channel = event.ports?.[0] as BridgePort | undefined;
128
+ const page = channel && data ? previewPageOf(event, origin, base) : undefined;
129
+ if (!page) return;
130
+ if (data!.type === SOCKET_MESSAGE_TYPE) openSocket(channel!, data!, page);
131
+ else if (data!.type === COOKIE_MESSAGE_TYPE) syncCookies(channel!, page);
132
+ };
133
+ target.addEventListener('message', onMessage as EventListener);
134
+
135
+ return {
136
+ get openSockets() {
137
+ return sockets.size;
138
+ },
139
+ dispose() {
140
+ target.removeEventListener('message', onMessage as EventListener);
141
+ stopCookies?.();
142
+ for (const connection of [...sockets]) connection.close(1001, 'The runtime stopped.');
143
+ sockets.clear();
144
+ for (const pages of cookiePages.values()) for (const page of pages) page.port.close();
145
+ cookiePages.clear();
146
+ },
147
+ };
148
+ }
@@ -30,6 +30,21 @@ export interface PreviewMessages {
30
30
 
31
31
  const LEVELS = new Set<string>(['log', 'info', 'warn', 'error', 'debug']);
32
32
 
33
+ /**
34
+ * The preview page a window message came from, when it is one of THIS runtime's:
35
+ * sent from the preview origin, by a page under `base`. Undefined otherwise.
36
+ */
37
+ export function previewPageOf(event: MessageEvent, origin: string, base: string): URL | undefined {
38
+ const href = (event.data as { href?: unknown } | null)?.href;
39
+ if (typeof href !== 'string' || event.origin !== origin) return undefined;
40
+ try {
41
+ const page = new URL(href);
42
+ return page.pathname.startsWith(base) ? page : undefined; // else: another runtime's preview
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ }
47
+
33
48
  function subscribe<T>(set: Set<(value: T) => void>, listener: (value: T) => void): () => void {
34
49
  set.add(listener);
35
50
  return () => set.delete(listener);
@@ -40,15 +55,8 @@ export function listenToPreview(target: Pick<Window, 'addEventListener' | 'remov
40
55
  const consoles = new Set<(message: PreviewConsoleMessage) => void>();
41
56
 
42
57
  const onMessage = (event: MessageEvent) => {
43
- const data = event.data as { type?: unknown; href?: unknown; message?: unknown; stack?: unknown; level?: unknown; text?: unknown } | null;
44
- if (!data || typeof data.href !== 'string' || event.origin !== origin) return;
45
- let path: string;
46
- try {
47
- path = new URL(data.href).pathname;
48
- } catch {
49
- return;
50
- }
51
- if (!path.startsWith(base)) return; // another runtime's preview
58
+ if (!previewPageOf(event, origin, base)) return;
59
+ const data = event.data as { type?: unknown; href: string; message?: unknown; stack?: unknown; level?: unknown; text?: unknown };
52
60
  if (data.type === ERROR_MESSAGE_TYPE) {
53
61
  const error: PreviewError = { message: String(data.message ?? ''), stack: typeof data.stack === 'string' ? data.stack : undefined, href: data.href };
54
62
  for (const listener of errors) listener(error);
package/src/protocol.ts CHANGED
@@ -66,9 +66,33 @@ export interface WireResponse {
66
66
  reqId: number;
67
67
  status: number;
68
68
  headers: Record<string, string>;
69
- body: string | ArrayBuffer;
69
+ /** The whole body — or, with `streaming`, null: the body follows as `chunk` messages until `end`. */
70
+ body: string | ArrayBuffer | null;
71
+ streaming?: boolean;
70
72
  }
71
73
 
74
+ /** page → worker: the next piece of a streaming response's body. */
75
+ export interface WireChunk {
76
+ type: 'chunk';
77
+ reqId: number;
78
+ data: ArrayBuffer;
79
+ }
80
+
81
+ /** page → worker: a streaming response is complete (`error`: it broke off, e.g. the server process exited). */
82
+ export interface WireEnd {
83
+ type: 'end';
84
+ reqId: number;
85
+ error?: string;
86
+ }
87
+
88
+ /** worker → page: the preview stopped reading a streaming response (closed an EventSource, navigated away). */
89
+ export interface WireCancel {
90
+ type: 'cancel';
91
+ reqId: number;
92
+ }
93
+
94
+ export type WireReply = WireResponse | WireChunk | WireEnd;
95
+
72
96
  /**
73
97
  * The URL prefix previews live under, for a worker scope. Serving the worker from
74
98
  * `/__bfwc/sw.js` (scope `/__bfwc/`) keeps it off every other page of the host app;
@@ -92,3 +116,13 @@ export function isWireResponse(value: unknown): value is WireResponse {
92
116
  const v = value as WireResponse | null;
93
117
  return !!v && v.type === 'response' && typeof v.reqId === 'number';
94
118
  }
119
+
120
+ export function isWireReply(value: unknown): value is WireReply {
121
+ const v = value as WireReply | null;
122
+ return !!v && (v.type === 'response' || v.type === 'chunk' || v.type === 'end') && typeof v.reqId === 'number';
123
+ }
124
+
125
+ export function isWireCancel(value: unknown): value is WireCancel {
126
+ const v = value as WireCancel | null;
127
+ return !!v && v.type === 'cancel' && typeof v.reqId === 'number';
128
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Snapshots that outlive the page: `exportZip` bytes kept in IndexedDB under a
3
+ * key the host chooses (say, the project id plus a hash of package.json), so a
4
+ * reload can `importZip` an installed `node_modules` instead of reinstalling.
5
+ *
6
+ * Bounded: snapshots over `maxBytes` are not stored, and only the
7
+ * `maxEntries` most recently used are kept. Every operation fails soft —
8
+ * storage blocked (private windows) or full means a miss, never a throw.
9
+ */
10
+
11
+ export interface SnapshotStoreOptions {
12
+ /** IndexedDB database name. Default `bfwc-snapshots`. */
13
+ name?: string;
14
+ /** Snapshots kept, least recently used dropped first. Default 4. */
15
+ maxEntries?: number;
16
+ /** Largest snapshot stored, in bytes. Default 256 MB. */
17
+ maxBytes?: number;
18
+ /** Injected for tests; defaults to `globalThis.indexedDB`. */
19
+ indexedDB?: IDBFactory;
20
+ }
21
+
22
+ export interface SnapshotStore {
23
+ /** The snapshot saved under `key`, or undefined. */
24
+ load(key: string): Promise<Uint8Array | undefined>;
25
+ /** Save (replacing any snapshot under `key`); false when it was not stored (too large, storage unavailable). */
26
+ save(key: string, zip: Uint8Array): Promise<boolean>;
27
+ remove(key: string): Promise<void>;
28
+ /** Keys held, most recently used first. */
29
+ keys(): Promise<string[]>;
30
+ }
31
+
32
+ interface Row {
33
+ key: string;
34
+ zip: Uint8Array;
35
+ usedAt: number;
36
+ }
37
+
38
+ const STORE = 'snapshots';
39
+
40
+ function request<T>(req: IDBRequest<T>): Promise<T> {
41
+ return new Promise((resolve, reject) => {
42
+ req.onsuccess = () => resolve(req.result);
43
+ req.onerror = () => reject(req.error);
44
+ });
45
+ }
46
+
47
+ function done(tx: IDBTransaction): Promise<void> {
48
+ return new Promise((resolve, reject) => {
49
+ tx.oncomplete = () => resolve();
50
+ tx.onerror = () => reject(tx.error);
51
+ tx.onabort = () => reject(tx.error);
52
+ });
53
+ }
54
+
55
+ export function createSnapshotStore(options: SnapshotStoreOptions = {}): SnapshotStore {
56
+ const maxEntries = options.maxEntries ?? 4;
57
+ const maxBytes = options.maxBytes ?? 256 * 1024 * 1024;
58
+ let opening: Promise<IDBDatabase | undefined> | undefined;
59
+
60
+ const open = () =>
61
+ (opening ??= (async () => {
62
+ const factory = options.indexedDB ?? globalThis.indexedDB;
63
+ if (!factory) return undefined;
64
+ try {
65
+ const req = factory.open(options.name ?? 'bfwc-snapshots', 1);
66
+ req.onupgradeneeded = () => {
67
+ if (!req.result.objectStoreNames.contains(STORE)) req.result.createObjectStore(STORE, { keyPath: 'key' });
68
+ };
69
+ return await request(req);
70
+ } catch {
71
+ return undefined;
72
+ }
73
+ })());
74
+
75
+ const rows = async (db: IDBDatabase): Promise<Row[]> => {
76
+ const all = (await request(db.transaction(STORE, 'readonly').objectStore(STORE).getAll())) as Row[];
77
+ return all.sort((a, b) => b.usedAt - a.usedAt);
78
+ };
79
+
80
+ return {
81
+ async load(key) {
82
+ const db = await open();
83
+ if (!db) return undefined;
84
+ try {
85
+ const tx = db.transaction(STORE, 'readwrite');
86
+ const store = tx.objectStore(STORE);
87
+ const row = (await request(store.get(key))) as Row | undefined;
88
+ if (row) store.put({ ...row, usedAt: Date.now() });
89
+ await done(tx);
90
+ return row?.zip;
91
+ } catch {
92
+ return undefined;
93
+ }
94
+ },
95
+ async save(key, zip) {
96
+ if (zip.byteLength > maxBytes) return false;
97
+ const db = await open();
98
+ if (!db) return false;
99
+ try {
100
+ const tx = db.transaction(STORE, 'readwrite');
101
+ tx.objectStore(STORE).put({ key, zip, usedAt: Date.now() } satisfies Row);
102
+ await done(tx);
103
+ const stale = (await rows(db)).slice(maxEntries);
104
+ if (stale.length) {
105
+ const prune = db.transaction(STORE, 'readwrite');
106
+ for (const row of stale) prune.objectStore(STORE).delete(row.key);
107
+ await done(prune);
108
+ }
109
+ return true;
110
+ } catch {
111
+ return false;
112
+ }
113
+ },
114
+ async remove(key) {
115
+ const db = await open();
116
+ if (!db) return;
117
+ try {
118
+ const tx = db.transaction(STORE, 'readwrite');
119
+ tx.objectStore(STORE).delete(key);
120
+ await done(tx);
121
+ } catch {
122
+ // storage unavailable: nothing to remove
123
+ }
124
+ },
125
+ async keys() {
126
+ const db = await open();
127
+ if (!db) return [];
128
+ try {
129
+ return (await rows(db)).map((row) => row.key);
130
+ } catch {
131
+ return [];
132
+ }
133
+ },
134
+ };
135
+ }
package/src/sw.ts CHANGED
@@ -74,6 +74,8 @@ worker.addEventListener('fetch', (event) => {
74
74
  details = { method: request.method, headers, body };
75
75
  }
76
76
  const r = await router.respond(target, details);
77
+ // A virtual server's body is a stream fed as the server writes (SSE, chunked progress).
78
+ if (NULL_BODY.has(r.status) && r.body instanceof ReadableStream) void r.body.cancel();
77
79
  return new Response(NULL_BODY.has(r.status) ? null : r.body, { status: r.status, headers: r.headers });
78
80
  })(),
79
81
  );
package/src/swRouter.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { PREVIEW_ID, isPortPath, isWireResponse, previewPrefix, type WireResponse } from './protocol.js';
1
+ import { PREVIEW_ID, isPortPath, isWireReply, previewPrefix, type WireCancel, type WireResponse } from './protocol.js';
2
2
 
3
3
  /** The part of MessagePort the router uses — a fake in tests, a real port in the worker. */
4
4
  export interface PortLike {
@@ -22,7 +22,8 @@ export interface RequestDetails {
22
22
  export interface RoutedResponse {
23
23
  status: number;
24
24
  headers: Record<string, string>;
25
- body: string | ArrayBuffer;
25
+ /** A stream when the page streams the body (a virtual server's response): chunks arrive as the server writes them. */
26
+ body: string | ArrayBuffer | ReadableStream<Uint8Array> | null;
26
27
  }
27
28
 
28
29
  export interface SwRouterOptions {
@@ -46,6 +47,12 @@ const TEXT = { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no
46
47
  * router asks open pages to re-attach and waits briefly. A port that stops
47
48
  * answering (its tab closed) is dropped on timeout, so the NEXT request goes
48
49
  * through the re-attach path instead of timing out forever.
50
+ *
51
+ * A virtual server's response streams: the page sends the head (`streaming`),
52
+ * then `chunk` messages and an `end`, and the router answers the fetch with a
53
+ * ReadableStream fed by them. The timeout covers only the wait for the head (an
54
+ * event stream may stay open indefinitely); cancelling the stream (the preview
55
+ * closed its EventSource) tells the page to hang up on the server.
49
56
  */
50
57
  export function createSwRouter(options: SwRouterOptions) {
51
58
  const prefix = previewPrefix(options.scopePath);
@@ -54,14 +61,29 @@ export function createSwRouter(options: SwRouterOptions) {
54
61
  const ports = new Map<string, PortLike>();
55
62
  const waiters = new Map<string, Array<(port: PortLike) => void>>();
56
63
  const pending = new Map<number, (response: WireResponse) => void>();
64
+ const streams = new Map<number, ReadableStreamDefaultController<Uint8Array>>();
57
65
  let nextId = 0;
58
66
 
59
67
  function attach(id: string, port: PortLike): void {
60
68
  ports.set(id, port);
61
69
  port.onmessage = (event) => {
62
- if (!isWireResponse(event.data)) return;
63
- pending.get(event.data.reqId)?.(event.data);
64
- pending.delete(event.data.reqId);
70
+ const reply = event.data;
71
+ if (!isWireReply(reply)) return;
72
+ if (reply.type === 'response') {
73
+ pending.get(reply.reqId)?.(reply);
74
+ pending.delete(reply.reqId);
75
+ return;
76
+ }
77
+ const controller = streams.get(reply.reqId);
78
+ if (!controller) return;
79
+ try {
80
+ if (reply.type === 'chunk') return controller.enqueue(new Uint8Array(reply.data));
81
+ streams.delete(reply.reqId);
82
+ if (reply.error) controller.error(new Error(reply.error));
83
+ else controller.close();
84
+ } catch {
85
+ streams.delete(reply.reqId); // the reader already went away
86
+ }
65
87
  };
66
88
  for (const wake of waiters.get(id) ?? []) wake(port);
67
89
  waiters.delete(id);
@@ -125,7 +147,14 @@ export function createSwRouter(options: SwRouterOptions) {
125
147
  }, timeoutMs);
126
148
  pending.set(reqId, (response) => {
127
149
  clearTimeout(timer);
128
- resolve({ status: response.status, headers: response.headers, body: response.body });
150
+ if (!response.streaming) return resolve({ status: response.status, headers: response.headers, body: response.body });
151
+ const body = new ReadableStream<Uint8Array>({
152
+ start: (controller) => void streams.set(reqId, controller),
153
+ cancel: () => {
154
+ if (streams.delete(reqId)) port.postMessage({ type: 'cancel', reqId } satisfies WireCancel);
155
+ },
156
+ });
157
+ resolve({ status: response.status, headers: response.headers, body });
129
158
  });
130
159
  port.postMessage({ type: 'request', reqId, path: target.path, search: target.search, ...details });
131
160
  });