@seanhogg/builderforce-webcontainers 2026.10.6 → 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/check/worker.js +8 -8
- package/dist/host.d.ts +25 -9
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +39 -25
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/node/index.d.ts +5 -9
- 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 +26 -2
- package/dist/node/processHost.d.ts.map +1 -1
- package/dist/node/processHost.js +67 -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 +143 -127
- 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 +24 -0
- package/dist/previewMessages.d.ts.map +1 -0
- package/dist/previewMessages.js +59 -0
- package/dist/previewMessages.js.map +1 -0
- package/dist/process-worker.js +143 -127
- 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 +62 -31
- package/src/index.ts +8 -3
- package/src/node/index.ts +5 -24
- package/src/node/portResponder.ts +175 -0
- package/src/node/processHost.ts +92 -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 +79 -0
- package/src/protocol.ts +35 -1
- package/src/snapshotStore.ts +135 -0
- package/src/sw.ts +2 -0
- package/src/swRouter.ts +35 -6
package/src/index.ts
CHANGED
|
@@ -5,17 +5,22 @@ export type {
|
|
|
5
5
|
RelayOptions,
|
|
6
6
|
CommonRuntimeOptions,
|
|
7
7
|
PreviewRuntime,
|
|
8
|
-
PreviewError,
|
|
9
8
|
RuntimeBuildOptions,
|
|
10
9
|
} from './host.js';
|
|
10
|
+
export { listenToPreview } from './previewMessages.js';
|
|
11
|
+
export { createSnapshotStore } from './snapshotStore.js';
|
|
12
|
+
export type { SnapshotStore, SnapshotStoreOptions } from './snapshotStore.js';
|
|
13
|
+
export type { PreviewError, PreviewConsoleMessage, ConsoleLevel, PreviewMessages } from './previewMessages.js';
|
|
14
|
+
export { listenToPreviewBridge } from './previewBridge.js';
|
|
15
|
+
export type { PreviewBridge, PreviewBridgeOptions, BridgePort } from './previewBridge.js';
|
|
11
16
|
export type { PreviewTransport } from './transport.js';
|
|
12
17
|
export { createEsbuildWasmTransformer, createEsbuildWasmBundler, loadEsbuildWasm } from './esbuildWasm.js';
|
|
13
18
|
export type { EsbuildWasmOptions } from './esbuildWasm.js';
|
|
14
19
|
export { createCdnComponentCompilers, defaultVueCompilerUrl, defaultSvelteCompilerUrl } from './componentCompilers.js';
|
|
15
20
|
export type { CdnComponentCompilerOptions } from './componentCompilers.js';
|
|
16
21
|
export { createCacheStoragePackageCache } from './packageCache.js';
|
|
17
|
-
export { ProcessHost, createWorkerProcess } from './node/index.js';
|
|
18
|
-
export type { NodeRuntimeOptions, ProcessHostOptions, WorkerLike } from './node/index.js';
|
|
22
|
+
export { ProcessHost, createWorkerProcess, servePortRequest, createPortResponder, openPortResponse, injectBridge } from './node/index.js';
|
|
23
|
+
export type { NodeRuntimeOptions, ProcessHostOptions, ProcessInfo, WorkerLike, PortResponder, PortResponderOptions, OpenedPortResponse } from './node/index.js';
|
|
19
24
|
export { createSwRouter } from './swRouter.js';
|
|
20
25
|
export type { SwRouter, SwRouterOptions, PortLike, PreviewTarget, RoutedResponse, RequestDetails } from './swRouter.js';
|
|
21
26
|
export { previewBase, previewPrefix, PREVIEW_SEGMENT, PREVIEW_ID, PORT_SEGMENT, isPortPath } from './protocol.js';
|
package/src/node/index.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Glue between the preview runtime and the Node process host: options, the
|
|
3
3
|
* default Worker, and answering `/__port/<n>/…` preview requests from the
|
|
4
|
-
* virtual server listening on that port.
|
|
4
|
+
* virtual server listening on that port (portResponder.ts).
|
|
5
5
|
*/
|
|
6
|
-
import {
|
|
7
|
-
import type { WireRequest, WireResponse } from '../protocol.js';
|
|
6
|
+
import type { VirtualFileSystem } from '@seanhogg/builderforce-webcontainers-core';
|
|
8
7
|
import { ProcessHost, type WorkerLike } from './processHost.js';
|
|
9
8
|
|
|
10
9
|
export { ProcessHost } from './processHost.js';
|
|
11
|
-
export type { ProcessHostOptions, WorkerLike } from './processHost.js';
|
|
10
|
+
export type { ProcessHostOptions, ProcessInfo, WorkerLike } from './processHost.js';
|
|
12
11
|
export { createWorkerProcess } from './workerProcess.js';
|
|
12
|
+
export { servePortRequest, createPortResponder, openPortResponse, injectBridge } from './portResponder.js';
|
|
13
|
+
export type { PortResponder, PortResponderOptions, OpenedPortResponse } from './portResponder.js';
|
|
13
14
|
|
|
14
15
|
export interface NodeRuntimeOptions {
|
|
15
16
|
/**
|
|
@@ -47,23 +48,3 @@ export function createProcessHost(args: { fs: VirtualFileSystem; base: string; o
|
|
|
47
48
|
killGraceMs: options.killGraceMs,
|
|
48
49
|
});
|
|
49
50
|
}
|
|
50
|
-
|
|
51
|
-
const TEXT = { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' };
|
|
52
|
-
|
|
53
|
-
/** Answer a preview request addressed to a virtual server. */
|
|
54
|
-
export async function servePortRequest(host: ProcessHost, request: WireRequest): Promise<{ message: WireResponse; transfer: Transferable[] }> {
|
|
55
|
-
const target = parsePortPath(request.path)!;
|
|
56
|
-
const response = await host.request(target.port, {
|
|
57
|
-
method: request.method ?? 'GET',
|
|
58
|
-
url: target.path + request.search,
|
|
59
|
-
headers: request.headers ?? {},
|
|
60
|
-
body: request.body ? new Uint8Array(request.body) : null,
|
|
61
|
-
});
|
|
62
|
-
if (!response) {
|
|
63
|
-
return { message: { type: 'response', reqId: request.reqId, status: 502, headers: TEXT, body: `Nothing is listening on port ${target.port}. Start a server (node server.js) first.` }, transfer: [] };
|
|
64
|
-
}
|
|
65
|
-
const headers: Record<string, string> = {};
|
|
66
|
-
for (const [key, value] of Object.entries(response.headers)) headers[key] = Array.isArray(value) ? value.join(', ') : String(value);
|
|
67
|
-
const body = response.body.slice().buffer as ArrayBuffer;
|
|
68
|
-
return { message: { type: 'response', reqId: request.reqId, status: response.status, headers, body }, transfer: [body] };
|
|
69
|
-
}
|
|
@@ -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 {
|
|
@@ -50,8 +58,21 @@ export interface ProcessHostOptions {
|
|
|
50
58
|
|
|
51
59
|
const SIGNAL_CODES: Record<string, number> = { SIGINT: 130, SIGTERM: 143, SIGKILL: 137, SIGHUP: 129 };
|
|
52
60
|
|
|
61
|
+
/** A running process, as `listProcesses()` reports it. */
|
|
62
|
+
export interface ProcessInfo {
|
|
63
|
+
pid: number;
|
|
64
|
+
command: string;
|
|
65
|
+
args: string[];
|
|
66
|
+
cwd: string;
|
|
67
|
+
/** Epoch ms. */
|
|
68
|
+
startedAt: number;
|
|
69
|
+
/** Ports its servers listen on. */
|
|
70
|
+
ports: number[];
|
|
71
|
+
}
|
|
72
|
+
|
|
53
73
|
interface Proc {
|
|
54
74
|
pid: number;
|
|
75
|
+
info: Omit<ProcessInfo, 'ports'>;
|
|
55
76
|
worker: WorkerLike;
|
|
56
77
|
stdout: Set<(chunk: string) => void>;
|
|
57
78
|
stderr: Set<(chunk: string) => void>;
|
|
@@ -59,10 +80,17 @@ interface Proc {
|
|
|
59
80
|
resolveExit(code: number): void;
|
|
60
81
|
registrations: Map<number, { close(): void }>;
|
|
61
82
|
children: Map<number, ChildHandle>;
|
|
62
|
-
|
|
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>;
|
|
63
89
|
outbox: FsOp[];
|
|
64
90
|
}
|
|
65
91
|
|
|
92
|
+
const EXITED = 'The server process exited.';
|
|
93
|
+
|
|
66
94
|
export class ProcessHost {
|
|
67
95
|
readonly ports = new PortRegistry();
|
|
68
96
|
/** Servers listening, ports opening and closing. */
|
|
@@ -71,6 +99,7 @@ export class ProcessHost {
|
|
|
71
99
|
private applyingFrom?: Proc;
|
|
72
100
|
private flushScheduled = false;
|
|
73
101
|
private nextReq = 1;
|
|
102
|
+
private nextConn = 1;
|
|
74
103
|
private readonly unwatch: () => void;
|
|
75
104
|
private readonly stopPorts: () => void;
|
|
76
105
|
|
|
@@ -102,6 +131,7 @@ export class ProcessHost {
|
|
|
102
131
|
const exit = new Promise<number>((resolve) => (resolveExit = resolve));
|
|
103
132
|
const proc: Proc = {
|
|
104
133
|
pid,
|
|
134
|
+
info: { pid, command, args: [...args], cwd: options.cwd ?? '/', startedAt: Date.now() },
|
|
105
135
|
worker,
|
|
106
136
|
stdout: new Set(),
|
|
107
137
|
stderr: new Set(),
|
|
@@ -114,7 +144,9 @@ export class ProcessHost {
|
|
|
114
144
|
},
|
|
115
145
|
registrations: new Map(),
|
|
116
146
|
children: new Map(),
|
|
117
|
-
|
|
147
|
+
responses: new ResponseReceiver(),
|
|
148
|
+
loopbacks: new Map(),
|
|
149
|
+
sockets: new Map(),
|
|
118
150
|
outbox: [],
|
|
119
151
|
};
|
|
120
152
|
this.procs.add(proc);
|
|
@@ -154,11 +186,26 @@ export class ProcessHost {
|
|
|
154
186
|
};
|
|
155
187
|
}
|
|
156
188
|
|
|
157
|
-
/**
|
|
189
|
+
/** Every process still running, oldest first — children started by a shell included. */
|
|
190
|
+
list(): ProcessInfo[] {
|
|
191
|
+
return [...this.procs].map((proc) => ({ ...proc.info, args: [...proc.info.args], ports: [...proc.registrations.keys()] }));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Dispatch an HTTP request to the server on `port` and wait for the whole response. */
|
|
158
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> {
|
|
159
201
|
return this.ports.request(port, request);
|
|
160
202
|
}
|
|
161
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
|
+
|
|
162
209
|
dispose(): void {
|
|
163
210
|
for (const proc of [...this.procs]) {
|
|
164
211
|
proc.worker.terminate();
|
|
@@ -187,8 +234,11 @@ export class ProcessHost {
|
|
|
187
234
|
private cleanup(proc: Proc): void {
|
|
188
235
|
for (const registration of proc.registrations.values()) registration.close();
|
|
189
236
|
proc.registrations.clear();
|
|
190
|
-
|
|
191
|
-
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();
|
|
192
242
|
this.procs.delete(proc);
|
|
193
243
|
queueMicrotask(() => proc.worker.terminate());
|
|
194
244
|
}
|
|
@@ -268,7 +318,11 @@ export class ProcessHost {
|
|
|
268
318
|
return proc.children.get(message.childId)?.resize?.(message.cols, message.rows);
|
|
269
319
|
case 'listen': {
|
|
270
320
|
try {
|
|
271
|
-
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
|
+
);
|
|
272
326
|
proc.registrations.set(message.port, registration);
|
|
273
327
|
} catch (error) {
|
|
274
328
|
for (const l of proc.stderr) l(`${(error as Error).message}\n`);
|
|
@@ -279,12 +333,23 @@ export class ProcessHost {
|
|
|
279
333
|
proc.registrations.get(message.port)?.close();
|
|
280
334
|
proc.registrations.delete(message.port);
|
|
281
335
|
return;
|
|
282
|
-
case '
|
|
283
|
-
|
|
284
|
-
|
|
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);
|
|
285
344
|
return;
|
|
286
|
-
|
|
287
|
-
|
|
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);
|
|
288
353
|
return;
|
|
289
354
|
case 'listening':
|
|
290
355
|
this.servers.announce({ port: message.port, url: message.url });
|
|
@@ -292,11 +357,23 @@ export class ProcessHost {
|
|
|
292
357
|
}
|
|
293
358
|
}
|
|
294
359
|
|
|
295
|
-
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);
|
|
296
362
|
const reqId = this.nextReq++;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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 });
|
|
300
376
|
});
|
|
377
|
+
this.post(proc, { type: 'socket-open', connId, port, request });
|
|
301
378
|
}
|
|
302
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
|
+
}
|