@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.
- package/dist/assets.js +3 -3
- package/dist/host.d.ts +7 -1
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +27 -8
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/node/index.d.ts +4 -8
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -25
- package/dist/node/index.js.map +1 -1
- package/dist/node/portResponder.d.ts +45 -0
- package/dist/node/portResponder.d.ts.map +1 -0
- package/dist/node/portResponder.js +136 -0
- package/dist/node/portResponder.js.map +1 -0
- package/dist/node/processHost.d.ts +13 -2
- package/dist/node/processHost.d.ts.map +1 -1
- package/dist/node/processHost.js +62 -16
- package/dist/node/processHost.js.map +1 -1
- package/dist/node/protocol.d.ts +42 -9
- package/dist/node/protocol.d.ts.map +1 -1
- package/dist/node/protocol.js.map +1 -1
- package/dist/node/responseWire.d.ts +27 -0
- package/dist/node/responseWire.d.ts.map +1 -0
- package/dist/node/responseWire.js +99 -0
- package/dist/node/responseWire.js.map +1 -0
- package/dist/node/worker.js +141 -125
- package/dist/node/workerProcess.d.ts +3 -1
- package/dist/node/workerProcess.d.ts.map +1 -1
- package/dist/node/workerProcess.js +49 -19
- package/dist/node/workerProcess.js.map +1 -1
- package/dist/previewBridge.d.ts +37 -0
- package/dist/previewBridge.d.ts.map +1 -0
- package/dist/previewBridge.js +116 -0
- package/dist/previewBridge.js.map +1 -0
- package/dist/previewMessages.d.ts +5 -0
- package/dist/previewMessages.d.ts.map +1 -1
- package/dist/previewMessages.js +18 -11
- package/dist/previewMessages.js.map +1 -1
- package/dist/process-worker.js +141 -125
- package/dist/protocol.d.ts +23 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +8 -0
- package/dist/protocol.js.map +1 -1
- package/dist/relay.html +1 -1
- package/dist/snapshotStore.d.ts +30 -0
- package/dist/snapshotStore.d.ts.map +1 -0
- package/dist/snapshotStore.js +115 -0
- package/dist/snapshotStore.js.map +1 -0
- package/dist/sw.js +1 -1
- package/dist/swRouter.d.ts +8 -1
- package/dist/swRouter.d.ts.map +1 -1
- package/dist/swRouter.js +40 -5
- package/dist/swRouter.js.map +1 -1
- package/package.json +3 -2
- package/src/host.ts +31 -8
- package/src/index.ts +6 -2
- package/src/node/index.ts +4 -23
- package/src/node/portResponder.ts +175 -0
- package/src/node/processHost.ts +73 -15
- package/src/node/protocol.ts +25 -4
- package/src/node/responseWire.ts +97 -0
- package/src/node/workerProcess.ts +52 -21
- package/src/previewBridge.ts +148 -0
- package/src/previewMessages.ts +17 -9
- package/src/protocol.ts +35 -1
- package/src/snapshotStore.ts +135 -0
- package/src/sw.ts +2 -0
- 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
|
+
}
|
package/src/node/processHost.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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`
|
|
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
|
-
|
|
210
|
-
proc.
|
|
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(
|
|
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 '
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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<
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
}
|
package/src/node/protocol.ts
CHANGED
|
@@ -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,
|
|
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: '
|
|
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: '
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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) !==
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
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 '
|
|
213
|
-
|
|
214
|
-
|
|
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
|
},
|