@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,175 @@
1
+ /**
2
+ * Answering preview requests addressed to virtual servers (`/__port/<n>/…`).
3
+ * The request reaches the server carrying the cookies the host holds for that
4
+ * port; the response streams back to the service worker as the server writes
5
+ * it (head, then `chunk` messages, then `end`); its `Set-Cookie`s go into the
6
+ * jar (a service worker could not set them); and an HTML page gets the preview
7
+ * bridge — the WebSocket shim and `document.cookie` — injected after `<head>`.
8
+ */
9
+ import {
10
+ bridgeInsertionPoint,
11
+ drainBody,
12
+ errorBridgeScript,
13
+ scriptTag,
14
+ parsePortPath,
15
+ previewCookieScript,
16
+ previewSocketScript,
17
+ readBody,
18
+ textResponse,
19
+ type CookieJar,
20
+ type PortCookieJars,
21
+ } from '@seanhogg/builderforce-webcontainers-core';
22
+ import type { WireReply, WireRequest, WireResponse } from '../protocol.js';
23
+ import type { ProcessHost } from './processHost.js';
24
+
25
+ export interface PortResponderOptions {
26
+ /** The runtime's cookie jars. Without them, the browser's own `Cookie` header passes through and `Set-Cookie` is lost. */
27
+ cookies?: PortCookieJars;
28
+ /** Inject the preview bridge (errors and console to the host, WebSocket shim, `document.cookie`) into HTML pages. Default true. */
29
+ bridge?: boolean;
30
+ /** The host's preview scripts, read per page so `setPreviewScripts` applies to virtual servers too. */
31
+ scripts?: () => readonly string[];
32
+ }
33
+
34
+ export interface OpenedPortResponse {
35
+ status: number;
36
+ /** Flattened for a `Response` (`Set-Cookie` removed — it went to the jar). */
37
+ headers: Record<string, string>;
38
+ body: ReadableStream<Uint8Array>;
39
+ }
40
+
41
+ const NO_STORE = { 'cache-control': 'no-store' };
42
+
43
+ function headerKey(headers: Record<string, unknown>, name: string): string | undefined {
44
+ return Object.keys(headers).find((key) => key.toLowerCase() === name);
45
+ }
46
+
47
+ /** Send a preview request to its virtual server; resolves with the head and a body that streams. */
48
+ export async function openPortResponse(host: ProcessHost, request: WireRequest, options: PortResponderOptions = {}): Promise<OpenedPortResponse> {
49
+ const target = parsePortPath(request.path)!;
50
+ const jar = options.cookies?.jar(target.port);
51
+ const headers: Record<string, string> = {};
52
+ for (const [key, value] of Object.entries(request.headers ?? {})) if (!jar || key.toLowerCase() !== 'cookie') headers[key] = value;
53
+ const cookie = jar?.header(target.path);
54
+ if (cookie) headers.cookie = cookie;
55
+ const response =
56
+ (await host.requestStream(target.port, { method: request.method ?? 'GET', url: target.path + request.search, headers, body: request.body ? new Uint8Array(request.body) : null })) ??
57
+ textResponse(502, `Nothing is listening on port ${target.port}. Start a server (node server.js) first.`);
58
+
59
+ const out: Record<string, string> = {};
60
+ const setCookies: string[] = [];
61
+ for (const [key, value] of Object.entries(response.headers)) {
62
+ if (key.toLowerCase() === 'set-cookie') setCookies.push(...(Array.isArray(value) ? value : [value]));
63
+ else out[key] = Array.isArray(value) ? value.join(', ') : String(value);
64
+ }
65
+ if (response.status === 502 && !headerKey(out, 'cache-control')) Object.assign(out, NO_STORE);
66
+ jar?.store(setCookies, target.path);
67
+
68
+ let body = response.body;
69
+ const type = headerKey(out, 'content-type');
70
+ // A compressed body cannot be edited in flight; such a page goes without the bridge.
71
+ const hasBody = request.method !== 'HEAD' && response.status !== 204 && response.status !== 304;
72
+ if (options.bridge !== false && hasBody && type && /^\s*text\/html/i.test(out[type]!) && !headerKey(out, 'content-encoding')) {
73
+ body = injectBridge(body, bridgeFor(jar, target.path, options.scripts?.() ?? []));
74
+ const length = headerKey(out, 'content-length');
75
+ if (length) delete out[length];
76
+ // The bridge pushes a `<meta charset>` past the 1,024-byte prescan; say it in the header instead.
77
+ if (!/charset=/i.test(out[type]!)) out[type] = `${out[type]}; charset=utf-8`;
78
+ }
79
+ return { status: response.status, headers: out, body };
80
+ }
81
+
82
+ /** The same bridge a dev-server page gets (errors, console, sockets), plus cookies and the host's scripts. */
83
+ function bridgeFor(jar: CookieJar | undefined, path: string, scripts: readonly string[]): string {
84
+ return [errorBridgeScript(), previewSocketScript(), jar ? previewCookieScript(jar.documentCookie(path)) : '', ...scripts.map(scriptTag)].join('');
85
+ }
86
+
87
+ /** Insert `script` into a streamed document, after `<head>` (see `bridgeInsertionPoint`), passing the rest through as it arrives. */
88
+ export function injectBridge(body: ReadableStream<Uint8Array>, script: string): ReadableStream<Uint8Array> {
89
+ // windows-1252 maps every byte to one UTF-16 unit, so string indices are byte offsets.
90
+ const latin1 = new TextDecoder('latin1');
91
+ const bridge = new TextEncoder().encode(script);
92
+ let held = new Uint8Array(0);
93
+ let injected = false;
94
+ const tryInject = (controller: TransformStreamDefaultController<Uint8Array>, complete: boolean) => {
95
+ const at = bridgeInsertionPoint(latin1.decode(held), complete);
96
+ if (at === undefined) return;
97
+ if (at) controller.enqueue(held.subarray(0, at));
98
+ controller.enqueue(bridge);
99
+ if (at < held.byteLength) controller.enqueue(held.subarray(at));
100
+ held = new Uint8Array(0);
101
+ injected = true;
102
+ };
103
+ return body.pipeThrough(
104
+ new TransformStream<Uint8Array, Uint8Array>({
105
+ transform(chunk, controller) {
106
+ if (injected) return controller.enqueue(chunk);
107
+ const joined = new Uint8Array(held.byteLength + chunk.byteLength);
108
+ joined.set(held);
109
+ joined.set(chunk, held.byteLength);
110
+ held = joined;
111
+ tryInject(controller, false);
112
+ },
113
+ flush(controller) {
114
+ if (!injected) tryInject(controller, true);
115
+ },
116
+ }),
117
+ );
118
+ }
119
+
120
+ /** A transferable copy of a chunk. */
121
+ const transferable = (data: Uint8Array) => new Uint8Array(data).buffer as ArrayBuffer;
122
+
123
+ /** Answer a preview request with the whole body (tests, simple hosts). The preview path streams instead: {@link createPortResponder}. */
124
+ export async function servePortRequest(host: ProcessHost, request: WireRequest, options?: PortResponderOptions): Promise<{ message: WireResponse; transfer: Transferable[] }> {
125
+ const opened = await openPortResponse(host, request, options);
126
+ const body = transferable(await readBody(opened.body));
127
+ return { message: { type: 'response', reqId: request.reqId, status: opened.status, headers: opened.headers, body }, transfer: [body] };
128
+ }
129
+
130
+ export interface PortResponder {
131
+ /** Answer one preview request: post the head, then the body chunk by chunk as the server writes it. */
132
+ serve(request: WireRequest, post: (reply: WireReply, transfer: Transferable[]) => void): Promise<void>;
133
+ /** The preview stopped reading `reqId`: hang up on the server. */
134
+ cancel(reqId: number): void;
135
+ /** Hang up on every response still streaming. */
136
+ dispose(): void;
137
+ }
138
+
139
+ /** One per service-worker channel (request ids are unique within a channel). */
140
+ export function createPortResponder(host: ProcessHost, options?: PortResponderOptions): PortResponder {
141
+ const streaming = new Map<number, () => void>();
142
+ return {
143
+ async serve(request, post) {
144
+ const { reqId } = request;
145
+ let opened: OpenedPortResponse;
146
+ try {
147
+ opened = await openPortResponse(host, request, options);
148
+ } catch (error) {
149
+ return post({ type: 'response', reqId, status: 500, headers: { 'content-type': 'text/plain; charset=utf-8', ...NO_STORE }, body: String(error instanceof Error ? error.message : error) }, []);
150
+ }
151
+ post({ type: 'response', reqId, status: opened.status, headers: opened.headers, body: null, streaming: true }, []);
152
+ streaming.set(
153
+ reqId,
154
+ drainBody(opened.body, {
155
+ chunk(data) {
156
+ const buffer = transferable(data);
157
+ post({ type: 'chunk', reqId, data: buffer }, [buffer]);
158
+ },
159
+ end(error) {
160
+ streaming.delete(reqId);
161
+ post(error ? { type: 'end', reqId, error } : { type: 'end', reqId }, []);
162
+ },
163
+ }),
164
+ );
165
+ },
166
+ cancel(reqId) {
167
+ streaming.get(reqId)?.();
168
+ streaming.delete(reqId);
169
+ },
170
+ dispose() {
171
+ for (const stop of streaming.values()) stop();
172
+ streaming.clear();
173
+ },
174
+ };
175
+ }
@@ -5,6 +5,10 @@
5
5
  * write reaches the page, then every other worker). Children a worker spawns
6
6
  * (the shell running `node`, `child_process`) are started here too, so every
7
7
  * process — however deep — can be killed by terminating its worker.
8
+ *
9
+ * Responses from a worker's servers stream through here chunk by chunk (see
10
+ * responseWire.ts), and upgraded connections (WebSockets) are relayed as raw
11
+ * bytes between the client end the caller holds and the server's worker.
8
12
  */
9
13
  import {
10
14
  DEFAULT_ENV,
@@ -12,17 +16,21 @@ import {
12
16
  ServerEvents,
13
17
  allocatePid,
14
18
  runArguments,
19
+ textResponse,
15
20
  toRuntimeProcess,
21
+ type ByteChannel,
16
22
  type ChildHandle,
17
23
  type FsChange,
18
24
  type HttpRequestData,
19
25
  type HttpResponseData,
26
+ type HttpStreamResponse,
20
27
  type ProcessOptions,
21
28
  type RuntimeProcess,
22
29
  type VirtualFileSystem,
23
30
  } from '@seanhogg/builderforce-webcontainers-core';
24
31
  import type { FsOp, HostToWorker, WorkerToHost } from './protocol.js';
25
32
  import { portPath } from './protocol.js';
33
+ import { ResponseReceiver, sendResponse } from './responseWire.js';
26
34
 
27
35
  /** The part of `Worker` the host uses — a real Worker, or an in-thread fake in tests. */
28
36
  export interface WorkerLike {
@@ -72,10 +80,17 @@ interface Proc {
72
80
  resolveExit(code: number): void;
73
81
  registrations: Map<number, { close(): void }>;
74
82
  children: Map<number, ChildHandle>;
75
- pendingHttp: Map<number, (response: HttpResponseData) => void>;
83
+ /** Responses its servers are streaming back to requests forwarded here. */
84
+ responses: ResponseReceiver;
85
+ /** Its loopback requests being answered, by the worker's request id: cancel functions. */
86
+ loopbacks: Map<number, () => void>;
87
+ /** Upgraded connections to its servers: the page-side end of each, by connection id. */
88
+ sockets: Map<number, ByteChannel>;
76
89
  outbox: FsOp[];
77
90
  }
78
91
 
92
+ const EXITED = 'The server process exited.';
93
+
79
94
  export class ProcessHost {
80
95
  readonly ports = new PortRegistry();
81
96
  /** Servers listening, ports opening and closing. */
@@ -84,6 +99,7 @@ export class ProcessHost {
84
99
  private applyingFrom?: Proc;
85
100
  private flushScheduled = false;
86
101
  private nextReq = 1;
102
+ private nextConn = 1;
87
103
  private readonly unwatch: () => void;
88
104
  private readonly stopPorts: () => void;
89
105
 
@@ -128,7 +144,9 @@ export class ProcessHost {
128
144
  },
129
145
  registrations: new Map(),
130
146
  children: new Map(),
131
- pendingHttp: new Map(),
147
+ responses: new ResponseReceiver(),
148
+ loopbacks: new Map(),
149
+ sockets: new Map(),
132
150
  outbox: [],
133
151
  };
134
152
  this.procs.add(proc);
@@ -173,11 +191,21 @@ export class ProcessHost {
173
191
  return [...this.procs].map((proc) => ({ ...proc.info, args: [...proc.info.args], ports: [...proc.registrations.keys()] }));
174
192
  }
175
193
 
176
- /** Dispatch an HTTP request to the server on `port` (the preview service worker's `/__port/<port>/` path). */
194
+ /** Dispatch an HTTP request to the server on `port` and wait for the whole response. */
177
195
  request(port: number, request: HttpRequestData): Promise<HttpResponseData | undefined> {
196
+ return this.ports.requestBuffered(port, request);
197
+ }
198
+
199
+ /** Dispatch an HTTP request to the server on `port` (the preview's `/__port/<port>/` path); resolves at the head, the body streaming. */
200
+ requestStream(port: number, request: HttpRequestData): Promise<HttpStreamResponse | undefined> {
178
201
  return this.ports.request(port, request);
179
202
  }
180
203
 
204
+ /** Open an upgraded connection (a WebSocket) to the server on `port`; returns the client end. */
205
+ upgrade(port: number, request: HttpRequestData): ByteChannel | undefined {
206
+ return this.ports.upgrade(port, request);
207
+ }
208
+
181
209
  dispose(): void {
182
210
  for (const proc of [...this.procs]) {
183
211
  proc.worker.terminate();
@@ -206,8 +234,11 @@ export class ProcessHost {
206
234
  private cleanup(proc: Proc): void {
207
235
  for (const registration of proc.registrations.values()) registration.close();
208
236
  proc.registrations.clear();
209
- for (const respond of proc.pendingHttp.values()) respond({ status: 502, headers: { 'content-type': 'text/plain' }, body: new TextEncoder().encode('The server process exited.') });
210
- proc.pendingHttp.clear();
237
+ proc.responses.abort(() => textResponse(502, EXITED), EXITED);
238
+ for (const cancel of [...proc.loopbacks.values()]) cancel();
239
+ proc.loopbacks.clear();
240
+ for (const socket of proc.sockets.values()) socket.close();
241
+ proc.sockets.clear();
211
242
  this.procs.delete(proc);
212
243
  queueMicrotask(() => proc.worker.terminate());
213
244
  }
@@ -287,7 +318,11 @@ export class ProcessHost {
287
318
  return proc.children.get(message.childId)?.resize?.(message.cols, message.rows);
288
319
  case 'listen': {
289
320
  try {
290
- const registration = this.ports.listen(message.port, (request) => this.forward(proc, message.port, request));
321
+ const registration = this.ports.listen(
322
+ message.port,
323
+ (request) => this.forward(proc, message.port, request),
324
+ (request, socket) => this.forwardUpgrade(proc, message.port, request, socket),
325
+ );
291
326
  proc.registrations.set(message.port, registration);
292
327
  } catch (error) {
293
328
  for (const l of proc.stderr) l(`${(error as Error).message}\n`);
@@ -298,12 +333,23 @@ export class ProcessHost {
298
333
  proc.registrations.get(message.port)?.close();
299
334
  proc.registrations.delete(message.port);
300
335
  return;
301
- case 'http-response':
302
- proc.pendingHttp.get(message.reqId)?.(message.response);
303
- proc.pendingHttp.delete(message.reqId);
336
+ case 'response-head':
337
+ case 'response-chunk':
338
+ case 'response-end':
339
+ return proc.responses.receive(message);
340
+ case 'loopback': {
341
+ const { reqId } = message;
342
+ const cancel = sendResponse((reply) => this.post(proc, reply), reqId, this.ports.request(message.port, message.request), () => proc.loopbacks.delete(reqId));
343
+ proc.loopbacks.set(reqId, cancel);
304
344
  return;
305
- case 'loopback':
306
- void this.ports.request(message.port, message.request).then((response) => this.post(proc, { type: 'loopback-response', reqId: message.reqId, response: response ?? null }));
345
+ }
346
+ case 'request-cancel':
347
+ return proc.loopbacks.get(message.reqId)?.();
348
+ case 'socket-data':
349
+ return proc.sockets.get(message.connId)?.send(message.data);
350
+ case 'socket-close':
351
+ proc.sockets.get(message.connId)?.close();
352
+ proc.sockets.delete(message.connId);
307
353
  return;
308
354
  case 'listening':
309
355
  this.servers.announce({ port: message.port, url: message.url });
@@ -311,11 +357,23 @@ export class ProcessHost {
311
357
  }
312
358
  }
313
359
 
314
- private forward(proc: Proc, port: number, request: HttpRequestData): Promise<HttpResponseData> {
360
+ private async forward(proc: Proc, port: number, request: HttpRequestData): Promise<HttpStreamResponse> {
361
+ if (proc.exited) return textResponse(502, EXITED);
315
362
  const reqId = this.nextReq++;
316
- return new Promise((resolve) => {
317
- proc.pendingHttp.set(reqId, resolve);
318
- this.post(proc, { type: 'http-request', reqId, port, request });
363
+ const response = proc.responses.expect(reqId, () => this.post(proc, { type: 'request-cancel', reqId }));
364
+ this.post(proc, { type: 'http-request', reqId, port, request });
365
+ return (await response) ?? textResponse(502, `Nothing is listening on port ${port}.`);
366
+ }
367
+
368
+ /** Relay an upgraded connection: `socket` is the server end; its bytes go to the worker, and the worker's come back. */
369
+ private forwardUpgrade(proc: Proc, port: number, request: HttpRequestData, socket: ByteChannel): void {
370
+ if (proc.exited) return socket.close();
371
+ const connId = this.nextConn++;
372
+ proc.sockets.set(connId, socket);
373
+ socket.onData((data) => this.post(proc, { type: 'socket-data', connId, data }));
374
+ socket.onClose(() => {
375
+ if (proc.sockets.delete(connId)) this.post(proc, { type: 'socket-close', connId });
319
376
  });
377
+ this.post(proc, { type: 'socket-open', connId, port, request });
320
378
  }
321
379
  }
@@ -2,8 +2,15 @@
2
2
  * Messages between the page (the process host, which owns the master file
3
3
  * system and the port table) and one process Worker. Everything is plain
4
4
  * structured-clone data; file contents travel as strings or bytes.
5
+ *
6
+ * HTTP responses stream in both directions with the same three messages: a
7
+ * `response-head`, `response-chunk`s as the server writes, a `response-end`.
8
+ * Worker → page they answer an `http-request` (ids the page allocated); page →
9
+ * worker they answer a `loopback` (ids the worker allocated). `request-cancel`
10
+ * goes the other way: the reader hung up. Upgraded connections (WebSockets)
11
+ * are opened by the page (`socket-open`) and carry raw bytes both ways.
5
12
  */
6
- import type { FileContent, HttpRequestData, HttpResponseData } from '@seanhogg/builderforce-webcontainers-core';
13
+ import type { FileContent, HttpRequestData, HttpResponseHead } from '@seanhogg/builderforce-webcontainers-core';
7
14
 
8
15
  export type FsOp = { op: 'write'; path: string; content: FileContent } | { op: 'remove'; path: string } | { op: 'mkdir'; path: string };
9
16
 
@@ -28,6 +35,15 @@ export interface StartMessage {
28
35
  packageCache: boolean;
29
36
  }
30
37
 
38
+ /** A streamed response, chunk by chunk; `head: null` means nothing listens on that port (loopback only). */
39
+ export type ResponseMessage =
40
+ | { type: 'response-head'; reqId: number; head: HttpResponseHead | null }
41
+ | { type: 'response-chunk'; reqId: number; data: Uint8Array }
42
+ | { type: 'response-end'; reqId: number; error?: string };
43
+
44
+ /** Raw bytes of an upgraded connection, either way; ids allocated by the page. */
45
+ export type SocketMessage = { type: 'socket-data'; connId: number; data: Uint8Array } | { type: 'socket-close'; connId: number };
46
+
31
47
  /** page → worker */
32
48
  export type HostToWorker =
33
49
  | StartMessage
@@ -40,7 +56,10 @@ export type HostToWorker =
40
56
  | { type: 'child-exit'; childId: number; code: number }
41
57
  | { type: 'child-error'; childId: number; code: string; message: string }
42
58
  | { type: 'http-request'; reqId: number; port: number; request: HttpRequestData }
43
- | { type: 'loopback-response'; reqId: number; response: HttpResponseData | null };
59
+ | { type: 'request-cancel'; reqId: number }
60
+ | { type: 'socket-open'; connId: number; port: number; request: HttpRequestData }
61
+ | ResponseMessage
62
+ | SocketMessage;
44
63
 
45
64
  /** worker → page */
46
65
  export type WorkerToHost =
@@ -53,9 +72,11 @@ export type WorkerToHost =
53
72
  | { type: 'child-resize'; childId: number; cols: number; rows: number }
54
73
  | { type: 'listen'; port: number }
55
74
  | { type: 'unlisten'; port: number }
56
- | { type: 'http-response'; reqId: number; response: HttpResponseData }
57
75
  | { type: 'loopback'; reqId: number; port: number; request: HttpRequestData }
58
- | { type: 'listening'; port: number; url: string };
76
+ | { type: 'request-cancel'; reqId: number }
77
+ | { type: 'listening'; port: number; url: string }
78
+ | ResponseMessage
79
+ | SocketMessage;
59
80
 
60
81
  /** The preview-relative URL a virtual server on `port` is reachable at. */
61
82
  export function portPath(previewBase: string, port: number): string {
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Streamed HTTP responses over postMessage, used on both sides of the
3
+ * page ↔ process-worker boundary: the side that holds a response sends it as
4
+ * head / chunk / end messages as the server writes it, and the side that asked
5
+ * rebuilds an `HttpStreamResponse` whose body stream is fed by them. A reader
6
+ * that cancels the body sends `request-cancel` back, so the server hears the
7
+ * client hang up.
8
+ */
9
+ import { drainBody, headOf, pushStream, type HttpStreamResponse, type PushStream } from '@seanhogg/builderforce-webcontainers-core';
10
+ import type { ResponseMessage } from './protocol.js';
11
+
12
+ const encoder = new TextEncoder();
13
+
14
+ /**
15
+ * Send `response` as messages once it resolves (`undefined`: nothing listens).
16
+ * A rejected handler becomes a 500 carrying the error. Returns a cancel for
17
+ * when the requester hangs up; `onDone` runs once, when the exchange is over.
18
+ */
19
+ export function sendResponse(post: (message: ResponseMessage) => void, reqId: number, response: Promise<HttpStreamResponse | undefined>, onDone?: () => void): () => void {
20
+ let finished = false;
21
+ let stop: (() => void) | undefined;
22
+ const done = () => {
23
+ if (finished) return;
24
+ finished = true;
25
+ onDone?.();
26
+ };
27
+ response.then(
28
+ (res) => {
29
+ if (finished) return void res?.body.cancel().catch(() => undefined);
30
+ if (!res) {
31
+ post({ type: 'response-head', reqId, head: null });
32
+ return done();
33
+ }
34
+ post({ type: 'response-head', reqId, head: headOf(res) });
35
+ stop = drainBody(res.body, {
36
+ chunk: (data) => post({ type: 'response-chunk', reqId, data }),
37
+ end: (error) => {
38
+ post(error ? { type: 'response-end', reqId, error } : { type: 'response-end', reqId });
39
+ done();
40
+ },
41
+ });
42
+ },
43
+ (error: unknown) => {
44
+ if (finished) return;
45
+ post({ type: 'response-head', reqId, head: { status: 500, headers: { 'content-type': 'text/plain' } } });
46
+ post({ type: 'response-chunk', reqId, data: encoder.encode(String(error instanceof Error ? error.stack : error)) });
47
+ post({ type: 'response-end', reqId });
48
+ done();
49
+ },
50
+ );
51
+ return () => {
52
+ stop?.();
53
+ done();
54
+ };
55
+ }
56
+
57
+ /** Rebuilds streamed responses from the messages answering requests this side sent. */
58
+ export class ResponseReceiver {
59
+ private readonly heads = new Map<number, { resolve(response: HttpStreamResponse | undefined): void; cancel(): void }>();
60
+ private readonly bodies = new Map<number, PushStream>();
61
+
62
+ /** The response to request `reqId`; `cancel` runs if its reader hangs up before the end. */
63
+ expect(reqId: number, cancel: () => void): Promise<HttpStreamResponse | undefined> {
64
+ return new Promise((resolve) => this.heads.set(reqId, { resolve, cancel }));
65
+ }
66
+
67
+ receive(message: ResponseMessage): void {
68
+ const { reqId } = message;
69
+ if (message.type === 'response-head') {
70
+ const waiting = this.heads.get(reqId);
71
+ this.heads.delete(reqId);
72
+ if (!waiting) return;
73
+ if (!message.head) return waiting.resolve(undefined);
74
+ const body = pushStream(() => {
75
+ this.bodies.delete(reqId);
76
+ waiting.cancel();
77
+ });
78
+ this.bodies.set(reqId, body);
79
+ waiting.resolve({ ...message.head, body: body.stream });
80
+ } else if (message.type === 'response-chunk') {
81
+ this.bodies.get(reqId)?.push(message.data);
82
+ } else {
83
+ const body = this.bodies.get(reqId);
84
+ this.bodies.delete(reqId);
85
+ if (message.error) body?.fail(new Error(message.error));
86
+ else body?.end();
87
+ }
88
+ }
89
+
90
+ /** The other side is gone: waiting requests get `fallback`, bodies in flight break off with `reason`. */
91
+ abort(fallback: () => HttpStreamResponse | undefined, reason: string): void {
92
+ for (const { resolve } of this.heads.values()) resolve(fallback());
93
+ this.heads.clear();
94
+ for (const body of this.bodies.values()) body.fail(new Error(reason));
95
+ this.bodies.clear();
96
+ }
97
+ }
@@ -1,25 +1,31 @@
1
1
  /**
2
2
  * The worker side of one process: mirror the file system, build a `System`
3
3
  * whose spawn/listen/loopback are relayed to the page, run the program, and
4
- * stream its output back. No worker globals in here — `post` and the message
4
+ * stream its output back. Its servers' responses stream to the page as they
5
+ * are written, and connections the page upgrades reach the server's `upgrade`
6
+ * handler as a byte pipe. No worker globals in here — `post` and the message
5
7
  * feed are injected — so the same code runs in a real Worker (worker.ts) and
6
8
  * in-thread in tests.
7
9
  */
8
10
  import {
9
11
  VirtualFileSystem,
10
12
  commandNotFound,
13
+ createChannelPair,
11
14
  defaultPrograms,
12
15
  resolveCommand,
13
16
  runProgram,
17
+ textResponse,
18
+ type ByteChannel,
14
19
  type ChildHandle,
15
20
  type FsChange,
16
21
  type HttpHandler,
17
- type HttpResponseData,
18
22
  type PackageCache,
19
23
  type System,
24
+ type UpgradeHandler,
20
25
  } from '@seanhogg/builderforce-webcontainers-core';
21
26
  import type { FsOp, HostToWorker, StartMessage, WorkerToHost } from './protocol.js';
22
27
  import { portPath } from './protocol.js';
28
+ import { ResponseReceiver, sendResponse } from './responseWire.js';
23
29
 
24
30
  export interface WorkerProcessDeps {
25
31
  post(message: WorkerToHost): void;
@@ -35,10 +41,15 @@ const EPHEMERAL = 49152;
35
41
 
36
42
  export function createWorkerProcess(deps: WorkerProcessDeps): { receive(message: HostToWorker): void } {
37
43
  const fs = new VirtualFileSystem();
38
- const handlers = new Map<number, HttpHandler>();
44
+ const handlers = new Map<number, { handler: HttpHandler; upgrade?: UpgradeHandler }>();
39
45
  const knownPorts = new Set<number>();
40
46
  const children = new Map<number, { stdout: Set<(s: string) => void>; stderr: Set<(s: string) => void>; exit: (code: number) => void }>();
41
- const loopbacks = new Map<number, (response: HttpResponseData | undefined) => void>();
47
+ /** Responses to this process's loopback requests, streaming in from the page. */
48
+ const loopbacks = new ResponseReceiver();
49
+ /** Responses this process's servers are streaming to the page, by the page's request id. */
50
+ const serving = new Map<number, () => void>();
51
+ /** Upgraded connections the page opened, by its connection id (our end of the pipe to the server). */
52
+ const sockets = new Map<number, ByteChannel>();
42
53
  let nextChild = 1;
43
54
  let nextReq = 1;
44
55
  let applying = false;
@@ -117,17 +128,18 @@ export function createWorkerProcess(deps: WorkerProcessDeps): { receive(message:
117
128
  if (!resolveCommand(fs, programs, command, args, cwd, env)) throw commandNotFound(command);
118
129
  return remoteChild(command, args, { ...options, cwd, env });
119
130
  },
120
- listen(port, handler) {
131
+ listen(port, handler, upgrade) {
121
132
  let actual = port;
122
133
  if (!actual) for (actual = EPHEMERAL + (message.pid % 1000) * 8; knownPorts.has(actual); actual++);
123
134
  if (knownPorts.has(actual)) throw Object.assign(new Error(`listen EADDRINUSE: address already in use :::${actual}`), { code: 'EADDRINUSE', errno: -98, syscall: 'listen', port: actual });
124
135
  knownPorts.add(actual);
125
- handlers.set(actual, handler);
136
+ const entry = { handler, upgrade };
137
+ handlers.set(actual, entry);
126
138
  deps.post({ type: 'listen', port: actual });
127
139
  return {
128
140
  port: actual,
129
141
  close() {
130
- if (handlers.get(actual) !== handler) return;
142
+ if (handlers.get(actual) !== entry) return;
131
143
  handlers.delete(actual);
132
144
  knownPorts.delete(actual);
133
145
  deps.post({ type: 'unlisten', port: actual });
@@ -136,12 +148,11 @@ export function createWorkerProcess(deps: WorkerProcessDeps): { receive(message:
136
148
  },
137
149
  loopback(port, request) {
138
150
  const local = handlers.get(port);
139
- if (local) return local(request);
151
+ if (local) return local.handler(request);
140
152
  const reqId = nextReq++;
141
- return new Promise((resolve) => {
142
- loopbacks.set(reqId, resolve);
143
- deps.post({ type: 'loopback', reqId, port, request });
144
- });
153
+ const response = loopbacks.expect(reqId, () => deps.post({ type: 'request-cancel', reqId }));
154
+ deps.post({ type: 'loopback', reqId, port, request });
155
+ return response;
145
156
  },
146
157
  serverUrl: (port) => new URL(portPath(message.previewBase, port), message.previewOrigin).href,
147
158
  announceServer: (port, url) => deps.post({ type: 'listening', port, url }),
@@ -201,17 +212,37 @@ export function createWorkerProcess(deps: WorkerProcessDeps): { receive(message:
201
212
  return;
202
213
  }
203
214
  case 'http-request': {
204
- const handler = handlers.get(message.port);
205
- const respond = (response: HttpResponseData) => deps.post({ type: 'http-response', reqId: message.reqId, response });
206
- if (!handler) return respond({ status: 502, headers: { 'content-type': 'text/plain' }, body: new TextEncoder().encode(`Nothing is listening on port ${message.port}.`) });
207
- void handler(message.request).then(respond, (error: unknown) =>
208
- respond({ status: 500, headers: { 'content-type': 'text/plain' }, body: new TextEncoder().encode(String(error instanceof Error ? error.stack : error)) }),
209
- );
215
+ const { reqId } = message;
216
+ const entry = handlers.get(message.port);
217
+ const response = entry ? entry.handler(message.request) : Promise.resolve(textResponse(502, `Nothing is listening on port ${message.port}.`));
218
+ serving.set(reqId, sendResponse(deps.post, reqId, response, () => serving.delete(reqId)));
210
219
  return;
211
220
  }
212
- case 'loopback-response':
213
- loopbacks.get(message.reqId)?.(message.response ?? undefined);
214
- loopbacks.delete(message.reqId);
221
+ case 'request-cancel':
222
+ return serving.get(message.reqId)?.();
223
+ case 'response-head':
224
+ case 'response-chunk':
225
+ case 'response-end':
226
+ return loopbacks.receive(message);
227
+ case 'socket-open': {
228
+ const { connId } = message;
229
+ const upgrade = handlers.get(message.port)?.upgrade;
230
+ if (!upgrade) return deps.post({ type: 'socket-close', connId });
231
+ const [ours, server] = createChannelPair();
232
+ sockets.set(connId, ours);
233
+ ours.onData((data) => deps.post({ type: 'socket-data', connId, data }));
234
+ ours.onClose(() => {
235
+ sockets.delete(connId);
236
+ deps.post({ type: 'socket-close', connId });
237
+ });
238
+ upgrade(message.request, server);
239
+ return;
240
+ }
241
+ case 'socket-data':
242
+ return sockets.get(message.connId)?.send(message.data);
243
+ case 'socket-close':
244
+ sockets.get(message.connId)?.close();
245
+ sockets.delete(message.connId);
215
246
  return;
216
247
  }
217
248
  },