@seanhogg/builderforce-webcontainers-core 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 (150) hide show
  1. package/dist/archive.d.ts +2 -0
  2. package/dist/archive.d.ts.map +1 -1
  3. package/dist/archive.js +6 -4
  4. package/dist/archive.js.map +1 -1
  5. package/dist/html.d.ts +2 -0
  6. package/dist/html.d.ts.map +1 -1
  7. package/dist/html.js +5 -3
  8. package/dist/html.js.map +1 -1
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +2 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/node/http/client.d.ts +3 -1
  14. package/dist/node/http/client.d.ts.map +1 -1
  15. package/dist/node/http/client.js +74 -5
  16. package/dist/node/http/client.js.map +1 -1
  17. package/dist/node/http/index.d.ts.map +1 -1
  18. package/dist/node/http/index.js +2 -1
  19. package/dist/node/http/index.js.map +1 -1
  20. package/dist/node/http/messages.d.ts +87 -0
  21. package/dist/node/http/messages.d.ts.map +1 -0
  22. package/dist/node/http/messages.js +146 -0
  23. package/dist/node/http/messages.js.map +1 -0
  24. package/dist/node/http/server.d.ts +13 -98
  25. package/dist/node/http/server.d.ts.map +1 -1
  26. package/dist/node/http/server.js +34 -202
  27. package/dist/node/http/server.js.map +1 -1
  28. package/dist/node/http/serverResponse.d.ts +42 -0
  29. package/dist/node/http/serverResponse.d.ts.map +1 -0
  30. package/dist/node/http/serverResponse.js +89 -0
  31. package/dist/node/http/serverResponse.js.map +1 -0
  32. package/dist/node/http/upgradeSocket.d.ts +34 -0
  33. package/dist/node/http/upgradeSocket.d.ts.map +1 -0
  34. package/dist/node/http/upgradeSocket.js +73 -0
  35. package/dist/node/http/upgradeSocket.js.map +1 -0
  36. package/dist/node/runtime.js +2 -2
  37. package/dist/node/runtime.js.map +1 -1
  38. package/dist/previewBridge.d.ts +42 -0
  39. package/dist/previewBridge.d.ts.map +1 -0
  40. package/dist/previewBridge.js +192 -0
  41. package/dist/previewBridge.js.map +1 -0
  42. package/dist/shell/ast.d.ts +101 -0
  43. package/dist/shell/ast.d.ts.map +1 -0
  44. package/dist/shell/ast.js +7 -0
  45. package/dist/shell/ast.js.map +1 -0
  46. package/dist/shell/builtins.d.ts +14 -0
  47. package/dist/shell/builtins.d.ts.map +1 -1
  48. package/dist/shell/builtins.js +87 -7
  49. package/dist/shell/builtins.js.map +1 -1
  50. package/dist/shell/compound.d.ts +30 -0
  51. package/dist/shell/compound.d.ts.map +1 -0
  52. package/dist/shell/compound.js +73 -0
  53. package/dist/shell/compound.js.map +1 -0
  54. package/dist/shell/exec.d.ts +27 -6
  55. package/dist/shell/exec.d.ts.map +1 -1
  56. package/dist/shell/exec.js +127 -41
  57. package/dist/shell/exec.js.map +1 -1
  58. package/dist/shell/expand.d.ts +5 -1
  59. package/dist/shell/expand.d.ts.map +1 -1
  60. package/dist/shell/expand.js +7 -5
  61. package/dist/shell/expand.js.map +1 -1
  62. package/dist/shell/lineReader.d.ts +9 -0
  63. package/dist/shell/lineReader.d.ts.map +1 -0
  64. package/dist/shell/lineReader.js +42 -0
  65. package/dist/shell/lineReader.js.map +1 -0
  66. package/dist/shell/packageManagers.d.ts +20 -0
  67. package/dist/shell/packageManagers.d.ts.map +1 -0
  68. package/dist/shell/packageManagers.js +74 -0
  69. package/dist/shell/packageManagers.js.map +1 -0
  70. package/dist/shell/parse.d.ts +16 -66
  71. package/dist/shell/parse.d.ts.map +1 -1
  72. package/dist/shell/parse.js +244 -243
  73. package/dist/shell/parse.js.map +1 -1
  74. package/dist/shell/program.d.ts.map +1 -1
  75. package/dist/shell/program.js +2 -1
  76. package/dist/shell/program.js.map +1 -1
  77. package/dist/shell/testCommand.d.ts +9 -0
  78. package/dist/shell/testCommand.d.ts.map +1 -0
  79. package/dist/shell/testCommand.js +105 -0
  80. package/dist/shell/testCommand.js.map +1 -0
  81. package/dist/shell/tokenize.d.ts +25 -0
  82. package/dist/shell/tokenize.d.ts.map +1 -0
  83. package/dist/shell/tokenize.js +279 -0
  84. package/dist/shell/tokenize.js.map +1 -0
  85. package/dist/system/cookieJar.d.ts +59 -0
  86. package/dist/system/cookieJar.d.ts.map +1 -0
  87. package/dist/system/cookieJar.js +187 -0
  88. package/dist/system/cookieJar.js.map +1 -0
  89. package/dist/system/httpStream.d.ts +40 -0
  90. package/dist/system/httpStream.d.ts.map +1 -0
  91. package/dist/system/httpStream.js +156 -0
  92. package/dist/system/httpStream.js.map +1 -0
  93. package/dist/system/index.d.ts +9 -1
  94. package/dist/system/index.d.ts.map +1 -1
  95. package/dist/system/index.js +4 -0
  96. package/dist/system/index.js.map +1 -1
  97. package/dist/system/kernel.d.ts +10 -2
  98. package/dist/system/kernel.d.ts.map +1 -1
  99. package/dist/system/kernel.js +12 -3
  100. package/dist/system/kernel.js.map +1 -1
  101. package/dist/system/ports.d.ts +9 -4
  102. package/dist/system/ports.d.ts.map +1 -1
  103. package/dist/system/ports.js +21 -2
  104. package/dist/system/ports.js.map +1 -1
  105. package/dist/system/programs.d.ts +1 -1
  106. package/dist/system/programs.d.ts.map +1 -1
  107. package/dist/system/programs.js +2 -0
  108. package/dist/system/programs.js.map +1 -1
  109. package/dist/system/types.d.ts +36 -6
  110. package/dist/system/types.d.ts.map +1 -1
  111. package/dist/system/webSocketClient.d.ts +37 -0
  112. package/dist/system/webSocketClient.d.ts.map +1 -0
  113. package/dist/system/webSocketClient.js +152 -0
  114. package/dist/system/webSocketClient.js.map +1 -0
  115. package/dist/system/webSocketFrames.d.ts +39 -0
  116. package/dist/system/webSocketFrames.d.ts.map +1 -0
  117. package/dist/system/webSocketFrames.js +117 -0
  118. package/dist/system/webSocketFrames.js.map +1 -0
  119. package/package.json +1 -1
  120. package/src/archive.ts +8 -4
  121. package/src/html.ts +5 -3
  122. package/src/index.ts +2 -1
  123. package/src/node/http/client.ts +69 -6
  124. package/src/node/http/index.ts +2 -1
  125. package/src/node/http/messages.ts +150 -0
  126. package/src/node/http/server.ts +37 -202
  127. package/src/node/http/serverResponse.ts +98 -0
  128. package/src/node/http/upgradeSocket.ts +79 -0
  129. package/src/node/runtime.ts +2 -2
  130. package/src/previewBridge.ts +194 -0
  131. package/src/shell/ast.ts +85 -0
  132. package/src/shell/builtins.ts +97 -7
  133. package/src/shell/compound.ts +89 -0
  134. package/src/shell/exec.ts +128 -40
  135. package/src/shell/expand.ts +8 -6
  136. package/src/shell/lineReader.ts +46 -0
  137. package/src/shell/packageManagers.ts +75 -0
  138. package/src/shell/parse.ts +233 -236
  139. package/src/shell/program.ts +2 -1
  140. package/src/shell/testCommand.ts +105 -0
  141. package/src/shell/tokenize.ts +257 -0
  142. package/src/system/cookieJar.ts +187 -0
  143. package/src/system/httpStream.ts +168 -0
  144. package/src/system/index.ts +24 -1
  145. package/src/system/kernel.ts +16 -4
  146. package/src/system/ports.ts +23 -4
  147. package/src/system/programs.ts +3 -1
  148. package/src/system/types.ts +40 -6
  149. package/src/system/webSocketClient.ts +177 -0
  150. package/src/system/webSocketFrames.ts +118 -0
package/src/html.ts CHANGED
@@ -5,11 +5,13 @@
5
5
  * • CRA's `%PUBLIC_URL%` placeholder is filled;
6
6
  * • a page with no module script (CRA) gets its entry injected;
7
7
  * • the error bridge is prepended, so runtime errors and console output reach the host;
8
+ * • the WebSocket bridge follows, so `new WebSocket('ws://localhost:3000')` reaches a virtual server;
8
9
  * • the host's preview scripts run next, before the app;
9
10
  * • the live-reload listener is prepended, so an edit re-renders the preview;
10
11
  * • the attribution badge is appended unless the host turned it off.
11
12
  */
12
13
  import { injectAttribution } from './attribution.js';
14
+ import { previewSocketScript } from './previewBridge.js';
13
15
 
14
16
  export const ERROR_MESSAGE_TYPE = 'bfwc:error';
15
17
  export const CONSOLE_MESSAGE_TYPE = 'bfwc:console';
@@ -81,8 +83,8 @@ export interface HtmlOptions {
81
83
  scripts?: readonly string[];
82
84
  }
83
85
 
84
- /** `</script>` inside a script body would end the tag early. */
85
- const scriptTag = (source: string) => `<script>${source.replace(/<\/script/gi, '<\\/script')}</script>`;
86
+ /** A host script as a tag. `</script>` inside the body would end the tag early, so it is escaped. */
87
+ export const scriptTag = (source: string): string => `<script>${source.replace(/<\/script/gi, '<\\/script')}</script>`;
86
88
 
87
89
  export function transformHtml(html: string, options: HtmlOptions): string {
88
90
  const { base } = options;
@@ -97,7 +99,7 @@ export function transformHtml(html: string, options: HtmlOptions): string {
97
99
  }
98
100
  if (options.attribution !== false) out = injectAttribution(out);
99
101
 
100
- const bridge = [errorBridgeScript(), liveReloadScript(base), ...(options.scripts ?? []).map(scriptTag)].join('\n');
102
+ const bridge = [errorBridgeScript(), previewSocketScript(), liveReloadScript(base), ...(options.scripts ?? []).map(scriptTag)].join('\n');
101
103
  if (/<head[^>]*>/i.test(out)) return out.replace(/<head[^>]*>/i, (head) => `${head}\n${bridge}`);
102
104
  return bridge + out;
103
105
  }
package/src/index.ts CHANGED
@@ -44,7 +44,8 @@ export { rewriteImports } from './rewriteImports.js';
44
44
  export { resolveLocal, resolveAlias, isBareSpecifier, splitPackageSpecifier, resolvePackage, RESOLVE_EXTENSIONS } from './resolve.js';
45
45
  export { compileScript, compileModule, isScriptPath, mapSpecifier, moduleUrl, buildDefine } from './compileScript.js';
46
46
  export { compileComponent } from './compileComponent.js';
47
- export { transformHtml, errorBridgeScript, liveReloadScript, reloadChannelName, ERROR_MESSAGE_TYPE, CONSOLE_MESSAGE_TYPE, CONSOLE_MESSAGE_LIMIT } from './html.js';
47
+ export { transformHtml, scriptTag, errorBridgeScript, liveReloadScript, reloadChannelName, ERROR_MESSAGE_TYPE, CONSOLE_MESSAGE_TYPE, CONSOLE_MESSAGE_LIMIT } from './html.js';
48
+ export { previewSocketScript, previewCookieScript, bridgeInsertionPoint, SOCKET_MESSAGE_TYPE, COOKIE_MESSAGE_TYPE, SOCKET_CONNECT_TIMEOUT_MS } from './previewBridge.js';
48
49
  export { mimeFor } from './mime.js';
49
50
  export { attributionScript, injectAttribution, ATTRIBUTION_URL, ATTRIBUTION_LABEL } from './attribution.js';
50
51
  export type { AttributionOptions } from './attribution.js';
@@ -3,12 +3,13 @@
3
3
  * the `fetch` global a program sees. Requests to localhost reach this runtime's
4
4
  * own virtual servers (`loopback`); everything else goes through the browser's
5
5
  * fetch, so CORS applies — a public API without CORS headers is unreachable
6
- * from a browser page, and no shim can change that.
6
+ * from a browser page, and no shim can change that. Response bodies stream:
7
+ * `res.on('data')` and `response.body` see chunks as the server writes them.
7
8
  */
8
9
  import { Buffer } from '../buffer.js';
9
10
  import type { EventLoop } from '../loop.js';
10
11
  import type { HttpRequestData, System } from '../../system/types.js';
11
- import { FakeSocket, IncomingMessage, OutgoingHeaders } from './server.js';
12
+ import { FakeSocket, IncomingMessage, OutgoingHeaders } from './messages.js';
12
13
 
13
14
  const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]']);
14
15
  /** Headers a browser refuses to let script set; dropping them beats a TypeError. */
@@ -70,7 +71,8 @@ export async function sendRequest(system: System, url: URL, request: HttpRequest
70
71
  const headers = new Headers();
71
72
  for (const [k, v] of Object.entries(reply.headers)) for (const item of Array.isArray(v) ? v : [v]) headers.append(k, String(item));
72
73
  const empty = reply.status === 204 || reply.status === 304 || request.method === 'HEAD';
73
- return new Response(empty ? null : (reply.body as unknown as BodyInit), { status: reply.status, statusText: reply.statusText, headers });
74
+ if (empty) void reply.body.cancel().catch(() => undefined);
75
+ return new Response(empty ? null : reply.body, { status: reply.status, statusText: reply.statusText, headers });
74
76
  }
75
77
  const headers: Record<string, string> = {};
76
78
  for (const [k, v] of Object.entries(request.headers)) if (!FORBIDDEN.has(k.toLowerCase())) headers[k] = v;
@@ -92,7 +94,9 @@ export class ClientRequest extends OutgoingHeaders {
92
94
  private readonly options: RequestOptions;
93
95
 
94
96
  constructor(private readonly deps: ClientDeps, input: unknown, extra: unknown, callback: ((res: IncomingMessage) => void) | undefined, defaultProtocol: string) {
95
- super({ decodeStrings: true });
97
+ // Not auto-destroyed on 'finish' (as in Node): the request body is done long before the
98
+ // response streams in, and destroy() aborts the request — which would cut that response off.
99
+ super({ decodeStrings: true, autoDestroy: false });
96
100
  if (typeof extra === 'function') [callback, extra] = [extra as typeof callback, undefined];
97
101
  this.options = toOptions(input, extra, defaultProtocol);
98
102
  this.method = this.options.method!;
@@ -131,6 +135,33 @@ export class ClientRequest extends OutgoingHeaders {
131
135
  setNoDelay(): void {}
132
136
  setSocketKeepAlive(): void {}
133
137
 
138
+ /** Push the body into `res` chunk by chunk; aborting the request hangs up on the server. */
139
+ private async streamBody(response: Response, res: IncomingMessage): Promise<void> {
140
+ const reader = response.body?.getReader();
141
+ if (reader) {
142
+ const stop = () => void reader.cancel().catch(() => undefined);
143
+ this.controller.signal.addEventListener('abort', stop);
144
+ try {
145
+ for (;;) {
146
+ const { value, done } = await reader.read();
147
+ if (done || this.controller.signal.aborted) break;
148
+ this.deps.loop._run(() => res.push(Buffer.from(value.buffer as ArrayBuffer, value.byteOffset, value.byteLength)));
149
+ }
150
+ } catch (error) {
151
+ if (this.controller.signal.aborted) return;
152
+ this.deps.loop._run(() => {
153
+ res.aborted = true;
154
+ res.destroy(error as Error);
155
+ });
156
+ return;
157
+ } finally {
158
+ this.controller.signal.removeEventListener('abort', stop);
159
+ }
160
+ }
161
+ if (this.controller.signal.aborted) return;
162
+ this.deps.loop._run(() => res._complete());
163
+ }
164
+
134
165
  private async perform(): Promise<void> {
135
166
  const release = this.deps.loop.hold();
136
167
  try {
@@ -144,10 +175,11 @@ export class ClientRequest extends OutgoingHeaders {
144
175
  const res = new IncomingMessage(this.socket);
145
176
  res.statusCode = response.status;
146
177
  res.statusMessage = response.statusText;
147
- res._fill(headersToRecord(response.headers), new Uint8Array(await response.arrayBuffer()));
178
+ res._setHeaders(headersToRecord(response.headers));
148
179
  this.deps.loop._run(() => {
149
180
  if (!this.emit('response', res)) res.resume();
150
181
  });
182
+ await this.streamBody(response, res);
151
183
  } catch (error) {
152
184
  if (!this.aborted) this.deps.loop._run(() => this.emit('error', error));
153
185
  } finally {
@@ -165,9 +197,40 @@ export function createFetch(deps: ClientDeps): typeof fetch {
165
197
  const url = new URL(request.url);
166
198
  if (!isLocalHost(url.hostname)) return await deps.system.fetch(url.href, init ?? (typeof input === 'object' && !(input instanceof URL) ? { method: request.method, headers: request.headers, body: request.body, signal: request.signal } : undefined));
167
199
  const body = request.method === 'GET' || request.method === 'HEAD' ? null : new Uint8Array(await request.arrayBuffer());
168
- return await sendRequest(deps.system, url, { method: request.method, url: url.pathname + url.search, headers: headersToRecord(request.headers), body });
200
+ const response = await sendRequest(deps.system, url, { method: request.method, url: url.pathname + url.search, headers: headersToRecord(request.headers), body });
201
+ return holdWhileReading(response, () => deps.loop.hold());
169
202
  } finally {
170
203
  release();
171
204
  }
172
205
  };
173
206
  }
207
+
208
+ /**
209
+ * A loopback body streams from a server that may live in another process, so
210
+ * nothing else keeps this one alive while the program awaits the next chunk:
211
+ * hold the loop for each pending read — and only then, so a body nobody reads
212
+ * does not keep the process running.
213
+ */
214
+ function holdWhileReading(response: Response, hold: () => () => void): Response {
215
+ if (!response.body) return response;
216
+ const reader = response.body.getReader();
217
+ const body = new ReadableStream<Uint8Array>(
218
+ {
219
+ async pull(controller) {
220
+ const release = hold();
221
+ try {
222
+ const { value, done } = await reader.read();
223
+ if (done) controller.close();
224
+ else controller.enqueue(value);
225
+ } catch (error) {
226
+ controller.error(error);
227
+ } finally {
228
+ release();
229
+ }
230
+ },
231
+ cancel: (reason) => reader.cancel(reason),
232
+ },
233
+ { highWaterMark: 0 },
234
+ );
235
+ return new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
236
+ }
@@ -8,6 +8,7 @@ import { EventEmitterBase } from '../events.js';
8
8
  import { ClientRequest, createFetch, type ClientDeps } from './client.js';
9
9
  import { FakeSocket, IncomingMessage, OutgoingHeaders, Server, ServerResponse } from './server.js';
10
10
  import { METHODS, STATUS_CODES } from './status.js';
11
+ import { textResponse } from '../../system/httpStream.js';
11
12
 
12
13
  export { createFetch };
13
14
 
@@ -74,7 +75,7 @@ export function createNetModule(deps: ClientDeps): Record<string, unknown> {
74
75
  const port = typeof first === 'object' && first ? Number((first as { port?: number }).port ?? 0) : Number(first ?? 0);
75
76
  if (callback) this.once('listening', callback);
76
77
  try {
77
- this.reservation = deps.system.listen(port, async () => ({ status: 502, headers: { 'content-type': 'text/plain' }, body: new TextEncoder().encode('Not an HTTP server') }));
78
+ this.reservation = deps.system.listen(port, async () => textResponse(502, 'Not an HTTP server'));
78
79
  } catch (error) {
79
80
  queueMicrotask(() => this.emit('error', error));
80
81
  return this;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The message and socket objects both halves of `http` share: the socket
3
+ * stand-in frameworks read connection details from, IncomingMessage (a request
4
+ * a server receives, or a response a client receives) and the header store
5
+ * behind ServerResponse and ClientRequest.
6
+ */
7
+ import { EventEmitterBase } from '../events.js';
8
+ import { Buffer } from '../buffer.js';
9
+ import { Readable } from '../stream/readable.js';
10
+ import { Writable } from '../stream/writable.js';
11
+
12
+ /** Enough of net.Socket for frameworks that read connection details. */
13
+ export class FakeSocket extends EventEmitterBase {
14
+ remoteAddress = '127.0.0.1';
15
+ remotePort = 50000 + Math.floor(Math.random() * 10000);
16
+ remoteFamily = 'IPv4';
17
+ localAddress = '127.0.0.1';
18
+ localPort: number;
19
+ encrypted = false;
20
+ readable = true;
21
+ writable = true;
22
+ destroyed = false;
23
+ bytesRead = 0;
24
+ bytesWritten = 0;
25
+ constructor(port = 0) {
26
+ super();
27
+ this.localPort = port;
28
+ }
29
+ setTimeout(_ms: number, cb?: () => void): this { if (cb) this.once('timeout', cb); return this; }
30
+ setNoDelay(): this { return this; }
31
+ setKeepAlive(): this { return this; }
32
+ address() { return { address: this.localAddress, family: 'IPv4', port: this.localPort }; }
33
+ ref(): this { return this; }
34
+ unref(): this { return this; }
35
+ cork(): void {}
36
+ uncork(): void {}
37
+ write(): boolean { return true; }
38
+ end(): this { return this; }
39
+ destroy(): this {
40
+ this.destroyed = true;
41
+ return this;
42
+ }
43
+ }
44
+
45
+ export function lowerHeaders(headers: Record<string, string>): { headers: Record<string, string | string[]>; raw: string[] } {
46
+ const out: Record<string, string | string[]> = {};
47
+ const raw: string[] = [];
48
+ for (const [key, value] of Object.entries(headers)) {
49
+ const k = key.toLowerCase();
50
+ raw.push(key, value);
51
+ out[k] = k === 'set-cookie' ? [value] : value;
52
+ }
53
+ return { headers: out, raw };
54
+ }
55
+
56
+ export class IncomingMessage extends Readable {
57
+ httpVersion = '1.1';
58
+ httpVersionMajor = 1;
59
+ httpVersionMinor = 1;
60
+ method?: string;
61
+ url?: string;
62
+ statusCode?: number;
63
+ statusMessage?: string;
64
+ headers: Record<string, string | string[]> = {};
65
+ rawHeaders: string[] = [];
66
+ trailers: Record<string, string> = {};
67
+ rawTrailers: string[] = [];
68
+ complete = false;
69
+ aborted = false;
70
+ socket: FakeSocket;
71
+ connection: FakeSocket;
72
+
73
+ constructor(socket: FakeSocket = new FakeSocket()) {
74
+ super();
75
+ this.socket = socket;
76
+ this.connection = socket;
77
+ }
78
+
79
+ override _read(): void {
80
+ // the body is pushed up front
81
+ }
82
+
83
+ setTimeout(_ms: number, cb?: () => void): this {
84
+ if (cb) this.once('timeout', cb);
85
+ return this;
86
+ }
87
+
88
+ /** Fill from wire data; `body` null means none. */
89
+ _fill(headers: Record<string, string>, body: Uint8Array | null): void {
90
+ this._setHeaders(headers);
91
+ if (body?.length) this.push(Buffer.from(body));
92
+ this._complete();
93
+ }
94
+
95
+ /** Headers first, for a body that streams in through `push` and ends with `_complete`. */
96
+ _setHeaders(headers: Record<string, string>): void {
97
+ const { headers: lower, raw } = lowerHeaders(headers);
98
+ this.headers = lower;
99
+ this.rawHeaders = raw;
100
+ }
101
+
102
+ _complete(): void {
103
+ this.push(null);
104
+ this.complete = true;
105
+ }
106
+ }
107
+
108
+ export class OutgoingHeaders extends Writable {
109
+ protected _headers = new Map<string, { name: string; value: string | string[] }>();
110
+ headersSent = false;
111
+ sendDate = true;
112
+
113
+ setHeader(name: string, value: string | number | readonly string[]): this {
114
+ if (this.headersSent) throw Object.assign(new Error('Cannot set headers after they are sent to the client'), { code: 'ERR_HTTP_HEADERS_SENT' });
115
+ this._headers.set(name.toLowerCase(), { name, value: Array.isArray(value) ? value.map(String) : String(value) });
116
+ return this;
117
+ }
118
+ appendHeader(name: string, value: string | readonly string[]): this {
119
+ const existing = this.getHeader(name);
120
+ const list = [...(existing === undefined ? [] : Array.isArray(existing) ? existing : [String(existing)]), ...(Array.isArray(value) ? value : [value as string])];
121
+ return this.setHeader(name, list);
122
+ }
123
+ getHeader(name: string): string | string[] | undefined {
124
+ return this._headers.get(name.toLowerCase())?.value;
125
+ }
126
+ getHeaders(): Record<string, string | string[]> {
127
+ const out: Record<string, string | string[]> = Object.create(null);
128
+ for (const [key, { value }] of this._headers) out[key] = value;
129
+ return out;
130
+ }
131
+ getHeaderNames(): string[] {
132
+ return [...this._headers.keys()];
133
+ }
134
+ getRawHeaderNames(): string[] {
135
+ return [...this._headers.values()].map((h) => h.name);
136
+ }
137
+ hasHeader(name: string): boolean {
138
+ return this._headers.has(name.toLowerCase());
139
+ }
140
+ removeHeader(name: string): void {
141
+ this._headers.delete(name.toLowerCase());
142
+ }
143
+ flushHeaders(): void {
144
+ this.headersSent = true;
145
+ }
146
+ setTimeout(_ms: number, cb?: () => void): this {
147
+ if (cb) this.once('timeout', cb);
148
+ return this;
149
+ }
150
+ }
@@ -1,205 +1,23 @@
1
1
  /**
2
2
  * `http.createServer` on a virtual port. `listen(port)` registers a handler with
3
3
  * the system; each request arrives as plain data (from the preview service
4
- * worker, or `loopback` from inside the runtime), becomes an IncomingMessage /
5
- * ServerResponse pair for the app, and the finished response goes back as data.
6
- * Responses are buffered: streaming (SSE, long-polling) arrives in one piece.
4
+ * worker, or `loopback` from inside the runtime) and becomes an IncomingMessage /
5
+ * ServerResponse pair for the app. The response streams back as it is written
6
+ * (see serverResponse.ts). Requests that ask to upgrade (WebSockets) go to the
7
+ * `upgrade` listeners with a socket over the raw connection (upgradeSocket.ts).
7
8
  */
8
9
  import { EventEmitterBase } from '../events.js';
9
10
  import { Buffer } from '../buffer.js';
10
- import { Readable } from '../stream/readable.js';
11
- import { Writable } from '../stream/writable.js';
12
11
  import type { EventLoop } from '../loop.js';
13
- import type { HttpRequestData, HttpResponseData, System } from '../../system/types.js';
14
- import { STATUS_CODES } from './status.js';
12
+ import type { ByteChannel, HttpRequestData, HttpStreamResponse, System } from '../../system/types.js';
13
+ import { pushStream } from '../../system/httpStream.js';
14
+ import { FakeSocket, IncomingMessage } from './messages.js';
15
+ import { ServerResponse } from './serverResponse.js';
16
+ import { UpgradeSocket } from './upgradeSocket.js';
15
17
 
16
- /** Enough of net.Socket for frameworks that read connection details. */
17
- export class FakeSocket extends EventEmitterBase {
18
- remoteAddress = '127.0.0.1';
19
- remotePort = 50000 + Math.floor(Math.random() * 10000);
20
- remoteFamily = 'IPv4';
21
- localAddress = '127.0.0.1';
22
- localPort: number;
23
- encrypted = false;
24
- readable = true;
25
- writable = true;
26
- destroyed = false;
27
- bytesRead = 0;
28
- bytesWritten = 0;
29
- constructor(port = 0) {
30
- super();
31
- this.localPort = port;
32
- }
33
- setTimeout(_ms: number, cb?: () => void): this { if (cb) this.once('timeout', cb); return this; }
34
- setNoDelay(): this { return this; }
35
- setKeepAlive(): this { return this; }
36
- address() { return { address: this.localAddress, family: 'IPv4', port: this.localPort }; }
37
- ref(): this { return this; }
38
- unref(): this { return this; }
39
- cork(): void {}
40
- uncork(): void {}
41
- write(): boolean { return true; }
42
- end(): this { return this; }
43
- destroy(): this {
44
- this.destroyed = true;
45
- return this;
46
- }
47
- }
48
-
49
- function lowerHeaders(headers: Record<string, string>): { headers: Record<string, string | string[]>; raw: string[] } {
50
- const out: Record<string, string | string[]> = {};
51
- const raw: string[] = [];
52
- for (const [key, value] of Object.entries(headers)) {
53
- const k = key.toLowerCase();
54
- raw.push(key, value);
55
- out[k] = k === 'set-cookie' ? [value] : value;
56
- }
57
- return { headers: out, raw };
58
- }
59
-
60
- export class IncomingMessage extends Readable {
61
- httpVersion = '1.1';
62
- httpVersionMajor = 1;
63
- httpVersionMinor = 1;
64
- method?: string;
65
- url?: string;
66
- statusCode?: number;
67
- statusMessage?: string;
68
- headers: Record<string, string | string[]> = {};
69
- rawHeaders: string[] = [];
70
- trailers: Record<string, string> = {};
71
- rawTrailers: string[] = [];
72
- complete = false;
73
- aborted = false;
74
- socket: FakeSocket;
75
- connection: FakeSocket;
76
-
77
- constructor(socket: FakeSocket = new FakeSocket()) {
78
- super();
79
- this.socket = socket;
80
- this.connection = socket;
81
- }
82
-
83
- override _read(): void {
84
- // the body is pushed up front
85
- }
86
-
87
- setTimeout(_ms: number, cb?: () => void): this {
88
- if (cb) this.once('timeout', cb);
89
- return this;
90
- }
91
-
92
- /** Fill from wire data; `body` null means none. */
93
- _fill(headers: Record<string, string>, body: Uint8Array | null): void {
94
- const { headers: lower, raw } = lowerHeaders(headers);
95
- this.headers = lower;
96
- this.rawHeaders = raw;
97
- if (body?.length) this.push(Buffer.from(body));
98
- this.push(null);
99
- this.complete = true;
100
- }
101
- }
102
-
103
- export class OutgoingHeaders extends Writable {
104
- protected _headers = new Map<string, { name: string; value: string | string[] }>();
105
- headersSent = false;
106
- sendDate = true;
107
-
108
- setHeader(name: string, value: string | number | readonly string[]): this {
109
- if (this.headersSent) throw Object.assign(new Error('Cannot set headers after they are sent to the client'), { code: 'ERR_HTTP_HEADERS_SENT' });
110
- this._headers.set(name.toLowerCase(), { name, value: Array.isArray(value) ? value.map(String) : String(value) });
111
- return this;
112
- }
113
- appendHeader(name: string, value: string | readonly string[]): this {
114
- const existing = this.getHeader(name);
115
- const list = [...(existing === undefined ? [] : Array.isArray(existing) ? existing : [String(existing)]), ...(Array.isArray(value) ? value : [value as string])];
116
- return this.setHeader(name, list);
117
- }
118
- getHeader(name: string): string | string[] | undefined {
119
- return this._headers.get(name.toLowerCase())?.value;
120
- }
121
- getHeaders(): Record<string, string | string[]> {
122
- const out: Record<string, string | string[]> = Object.create(null);
123
- for (const [key, { value }] of this._headers) out[key] = value;
124
- return out;
125
- }
126
- getHeaderNames(): string[] {
127
- return [...this._headers.keys()];
128
- }
129
- getRawHeaderNames(): string[] {
130
- return [...this._headers.values()].map((h) => h.name);
131
- }
132
- hasHeader(name: string): boolean {
133
- return this._headers.has(name.toLowerCase());
134
- }
135
- removeHeader(name: string): void {
136
- this._headers.delete(name.toLowerCase());
137
- }
138
- flushHeaders(): void {
139
- this.headersSent = true;
140
- }
141
- setTimeout(_ms: number, cb?: () => void): this {
142
- if (cb) this.once('timeout', cb);
143
- return this;
144
- }
145
- }
146
-
147
- export class ServerResponse extends OutgoingHeaders {
148
- statusCode = 200;
149
- statusMessage = '';
150
- req: IncomingMessage;
151
- socket: FakeSocket;
152
- connection: FakeSocket;
153
- chunkedEncoding = false;
154
- shouldKeepAlive = false;
155
- private readonly chunks: Uint8Array[] = [];
156
-
157
- constructor(req: IncomingMessage, private readonly onDone: (response: HttpResponseData) => void) {
158
- super({ decodeStrings: true });
159
- this.req = req;
160
- this.socket = req.socket;
161
- this.connection = req.socket;
162
- }
163
-
164
- get finished(): boolean {
165
- return this.writableEnded;
166
- }
167
-
168
- writeHead(status: number, message?: string | Record<string, unknown> | unknown[], headers?: Record<string, unknown> | unknown[]): this {
169
- if (typeof message !== 'string') [headers, message] = [message, undefined];
170
- this.statusCode = status;
171
- if (message) this.statusMessage = message;
172
- if (Array.isArray(headers)) {
173
- for (let i = 0; i + 1 < headers.length; i += 2) this.setHeader(String(headers[i]), headers[i + 1] as string);
174
- } else if (headers) {
175
- for (const [k, v] of Object.entries(headers)) if (v !== undefined) this.setHeader(k, v as string);
176
- }
177
- this.headersSent = true;
178
- return this;
179
- }
180
- writeContinue(): void {}
181
- writeProcessing(): void {}
182
- writeEarlyHints(): void {}
183
- addTrailers(): void {}
184
- assignSocket(): void {}
185
- detachSocket(): void {}
186
-
187
- override _write(chunk: Uint8Array, _encoding: string, callback: () => void): void {
188
- this.headersSent = true;
189
- this.chunks.push(chunk);
190
- callback();
191
- }
192
-
193
- override _final(callback: () => void): void {
194
- this.headersSent = true;
195
- const headers: Record<string, string | string[]> = {};
196
- for (const { name, value } of this._headers.values()) headers[name] = value;
197
- const body = Buffer.concat(this.chunks);
198
- if (this.req.method === 'HEAD') this.chunks.length = 0;
199
- this.onDone({ status: this.statusCode, statusText: this.statusMessage || STATUS_CODES[this.statusCode] || '', headers, body: this.req.method === 'HEAD' ? new Uint8Array() : body });
200
- callback();
201
- }
202
- }
18
+ export { FakeSocket, IncomingMessage, OutgoingHeaders } from './messages.js';
19
+ export { ServerResponse } from './serverResponse.js';
20
+ export { UpgradeSocket } from './upgradeSocket.js';
203
21
 
204
22
  export interface ServerDeps {
205
23
  system: System;
@@ -228,7 +46,7 @@ export class Server extends EventEmitterBase {
228
46
  const port = typeof first === 'object' && first ? Number((first as { port?: number }).port ?? 0) : Number(first ?? 0);
229
47
  if (callback) this.once('listening', callback);
230
48
  try {
231
- this.registration = this.deps.system.listen(port, (request) => this.handle(request));
49
+ this.registration = this.deps.system.listen(port, (request) => this.handle(request), (request, socket) => this.upgrade(request, socket));
232
50
  } catch (error) {
233
51
  queueMicrotask(() => this.emit('error', error));
234
52
  return this;
@@ -278,20 +96,29 @@ export class Server extends EventEmitterBase {
278
96
  cb(null, 0);
279
97
  }
280
98
 
281
- private handle(request: HttpRequestData): Promise<HttpResponseData> {
99
+ private toIncoming(request: HttpRequestData, socket: FakeSocket, headers: Record<string, string>, body: Uint8Array | null): IncomingMessage {
100
+ const req = new IncomingMessage(socket);
101
+ req.method = request.method.toUpperCase();
102
+ req.url = request.url;
103
+ req._fill(headers, body);
104
+ return req;
105
+ }
106
+
107
+ private handle(request: HttpRequestData): Promise<HttpStreamResponse> {
282
108
  return new Promise((resolve) => {
283
- const socket = new FakeSocket(this.registration?.port ?? 0);
284
- const req = new IncomingMessage(socket);
285
- req.method = request.method.toUpperCase();
286
- req.url = request.url;
287
109
  // Body parsers (body-parser/type-is) only read a body announced by
288
110
  // content-length or transfer-encoding; a browser-originated request
289
111
  // relayed by the service worker carries neither.
290
112
  const headers = { ...request.headers };
291
113
  const announced = Object.keys(headers).some((k) => /^(content-length|transfer-encoding)$/i.test(k));
292
114
  if (!announced && request.body?.length) headers['content-length'] = String(request.body.length);
293
- req._fill(headers, request.body);
294
- const res = new ServerResponse(req, resolve);
115
+ const req = this.toIncoming(request, new FakeSocket(this.registration?.port ?? 0), headers, request.body);
116
+ const body = pushStream(() => this.deps.loop._run(() => res._hangUp()));
117
+ const res: ServerResponse = new ServerResponse(req, {
118
+ head: (head) => resolve({ ...head, body: body.stream }),
119
+ chunk: (data) => body.push(data),
120
+ end: () => body.end(),
121
+ });
295
122
  this.deps.loop._run(() => {
296
123
  try {
297
124
  this.emit('request', req, res);
@@ -305,4 +132,12 @@ export class Server extends EventEmitterBase {
305
132
  });
306
133
  });
307
134
  }
135
+
136
+ /** A connection asking to upgrade: Node destroys it when nobody listens for `upgrade`. */
137
+ private upgrade(request: HttpRequestData, channel: ByteChannel): void {
138
+ if (!this.listenerCount('upgrade')) return channel.close();
139
+ const socket = new UpgradeSocket(channel, this.registration?.port ?? 0, () => this.deps.loop.hold());
140
+ const req = this.toIncoming(request, socket as unknown as FakeSocket, request.headers, null);
141
+ this.deps.loop._run(() => this.emit('upgrade', req, socket, Buffer.alloc(0)));
142
+ }
308
143
  }