@seanhogg/builderforce-webcontainers 2026.10.1 → 2026.10.3
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/README.md +11 -0
- package/dist/assets.d.ts +6 -0
- package/dist/assets.js +6 -0
- package/dist/check/cachedFetch.d.ts +3 -0
- package/dist/check/cachedFetch.d.ts.map +1 -0
- package/dist/check/cachedFetch.js +39 -0
- package/dist/check/cachedFetch.js.map +1 -0
- package/dist/check/index.d.ts +41 -0
- package/dist/check/index.d.ts.map +1 -0
- package/dist/check/index.js +74 -0
- package/dist/check/index.js.map +1 -0
- package/dist/check/protocol.d.ts +29 -0
- package/dist/check/protocol.d.ts.map +1 -0
- package/dist/check/protocol.js +13 -0
- package/dist/check/protocol.js.map +1 -0
- package/dist/check/worker.js +20 -0
- package/dist/componentCompilers.d.ts +21 -0
- package/dist/componentCompilers.d.ts.map +1 -0
- package/dist/componentCompilers.js +35 -0
- package/dist/componentCompilers.js.map +1 -0
- package/dist/esbuildWasm.d.ts +10 -4
- package/dist/esbuildWasm.d.ts.map +1 -1
- package/dist/esbuildWasm.js +14 -6
- package/dist/esbuildWasm.js.map +1 -1
- package/dist/host.d.ts +61 -6
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +61 -42
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/node/index.d.ts +42 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +45 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/processHost.d.ts +65 -0
- package/dist/node/processHost.d.ts.map +1 -0
- package/dist/node/processHost.js +269 -0
- package/dist/node/processHost.js.map +1 -0
- package/dist/node/protocol.d.ts +139 -0
- package/dist/node/protocol.d.ts.map +1 -0
- package/dist/node/protocol.js +5 -0
- package/dist/node/protocol.js.map +1 -0
- package/dist/node/worker.js +197 -0
- package/dist/node/workerProcess.d.ts +22 -0
- package/dist/node/workerProcess.d.ts.map +1 -0
- package/dist/node/workerProcess.js +210 -0
- package/dist/node/workerProcess.js.map +1 -0
- package/dist/packageCache.d.ts +12 -0
- package/dist/packageCache.d.ts.map +1 -0
- package/dist/packageCache.js +28 -0
- package/dist/packageCache.js.map +1 -0
- package/dist/process-worker.js +197 -0
- package/dist/protocol.d.ts +27 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +25 -0
- package/dist/protocol.js.map +1 -1
- package/dist/relay.html +2 -0
- package/dist/relayWorker.d.ts +19 -0
- package/dist/relayWorker.d.ts.map +1 -0
- package/dist/relayWorker.js +39 -0
- package/dist/relayWorker.js.map +1 -0
- package/dist/sw.js +1 -1
- package/dist/swRouter.d.ts +9 -1
- package/dist/swRouter.d.ts.map +1 -1
- package/dist/swRouter.js +29 -5
- package/dist/swRouter.js.map +1 -1
- package/dist/transport.d.ts +23 -0
- package/dist/transport.d.ts.map +1 -0
- package/dist/transport.js +112 -0
- package/dist/transport.js.map +1 -0
- package/dist/worker.d.ts +5 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +29 -0
- package/dist/worker.js.map +1 -0
- package/package.json +17 -3
- package/src/check/cachedFetch.ts +47 -0
- package/src/check/index.ts +98 -0
- package/src/check/protocol.ts +34 -0
- package/src/check/worker.ts +49 -0
- package/src/componentCompilers.ts +66 -0
- package/src/esbuildWasm.ts +19 -7
- package/src/host.ts +125 -42
- package/src/index.ts +18 -4
- package/src/node/index.ts +69 -0
- package/src/node/processHost.ts +307 -0
- package/src/node/protocol.ts +63 -0
- package/src/node/worker.ts +46 -0
- package/src/node/workerProcess.ts +219 -0
- package/src/packageCache.ts +28 -0
- package/src/protocol.ts +33 -0
- package/src/relay.ts +44 -0
- package/src/relayWorker.ts +53 -0
- package/src/sw.ts +25 -6
- package/src/swRouter.ts +36 -5
- package/src/transport.ts +141 -0
- package/src/worker.ts +25 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page side of the Node runtime: one Web Worker per process. The page owns
|
|
3
|
+
* the master file system and the port table; each worker gets a snapshot of
|
|
4
|
+
* the files at start, and writes flow both ways as batched ops (a worker's
|
|
5
|
+
* write reaches the page, then every other worker). Children a worker spawns
|
|
6
|
+
* (the shell running `node`, `child_process`) are started here too, so every
|
|
7
|
+
* process — however deep — can be killed by terminating its worker.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_ENV,
|
|
11
|
+
PortRegistry,
|
|
12
|
+
allocatePid,
|
|
13
|
+
toWebContainerProcess,
|
|
14
|
+
type ChildHandle,
|
|
15
|
+
type FsChange,
|
|
16
|
+
type HttpRequestData,
|
|
17
|
+
type HttpResponseData,
|
|
18
|
+
type SpawnOptions,
|
|
19
|
+
type VirtualFileSystem,
|
|
20
|
+
type WebContainerProcess,
|
|
21
|
+
} from '@seanhogg/builderforce-webcontainers-core';
|
|
22
|
+
import type { FsOp, HostToWorker, WorkerToHost } from './protocol.js';
|
|
23
|
+
import { portPath } from './protocol.js';
|
|
24
|
+
|
|
25
|
+
/** The part of `Worker` the host uses — a real Worker, or an in-thread fake in tests. */
|
|
26
|
+
export interface WorkerLike {
|
|
27
|
+
postMessage(message: unknown): void;
|
|
28
|
+
onmessage: ((event: { data: unknown }) => void) | null;
|
|
29
|
+
onerror?: ((event: { message?: string }) => void) | null;
|
|
30
|
+
terminate(): void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ProcessHostOptions {
|
|
34
|
+
fs: VirtualFileSystem;
|
|
35
|
+
createWorker(): WorkerLike;
|
|
36
|
+
/** Origin and base path previews are served under (`/__bfwc/<id>/`). */
|
|
37
|
+
previewOrigin: string;
|
|
38
|
+
previewBase: string;
|
|
39
|
+
/** The dev server's preview URL, for the `vite`/`react-scripts start` handoff. */
|
|
40
|
+
previewUrl?: string;
|
|
41
|
+
registry?: string;
|
|
42
|
+
/** Cache npm tarballs in Cache Storage. Default true. */
|
|
43
|
+
packageCache?: boolean;
|
|
44
|
+
env?: Record<string, string>;
|
|
45
|
+
/** How long a killed process may take to exit before its worker is terminated. Default 2000ms. */
|
|
46
|
+
killGraceMs?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const SIGNAL_CODES: Record<string, number> = { SIGINT: 130, SIGTERM: 143, SIGKILL: 137, SIGHUP: 129 };
|
|
50
|
+
|
|
51
|
+
interface Proc {
|
|
52
|
+
pid: number;
|
|
53
|
+
worker: WorkerLike;
|
|
54
|
+
stdout: Set<(chunk: string) => void>;
|
|
55
|
+
stderr: Set<(chunk: string) => void>;
|
|
56
|
+
exited: boolean;
|
|
57
|
+
resolveExit(code: number): void;
|
|
58
|
+
registrations: Map<number, { close(): void }>;
|
|
59
|
+
children: Map<number, ChildHandle>;
|
|
60
|
+
pendingHttp: Map<number, (response: HttpResponseData) => void>;
|
|
61
|
+
outbox: FsOp[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class ProcessHost {
|
|
65
|
+
readonly ports = new PortRegistry();
|
|
66
|
+
private readonly procs = new Set<Proc>();
|
|
67
|
+
private readonly serverReady = new Set<(port: number, url: string) => void>();
|
|
68
|
+
private readonly portListeners = new Set<(port: number, type: 'open' | 'close', url: string) => void>();
|
|
69
|
+
private applyingFrom?: Proc;
|
|
70
|
+
private flushScheduled = false;
|
|
71
|
+
private nextReq = 1;
|
|
72
|
+
private readonly unwatch: () => void;
|
|
73
|
+
private readonly stopPorts: () => void;
|
|
74
|
+
|
|
75
|
+
constructor(private readonly options: ProcessHostOptions) {
|
|
76
|
+
this.unwatch = options.fs.watch((change) => this.onFsChange(change));
|
|
77
|
+
this.stopPorts = this.ports.watch(({ type, port }) => {
|
|
78
|
+
const url = this.serverUrl(port);
|
|
79
|
+
for (const proc of this.procs) this.post(proc, { type: 'ports', open: type === 'open' ? [port] : [], closed: type === 'close' ? [port] : [] });
|
|
80
|
+
for (const l of this.portListeners) l(port, type, url);
|
|
81
|
+
if (type === 'open') for (const l of this.serverReady) l(port, url);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
serverUrl(port: number): string {
|
|
86
|
+
return new URL(portPath(this.options.previewBase, port), this.options.previewOrigin).href;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Same shape as `WebContainer#spawn`. */
|
|
90
|
+
spawn(command: string, args: string[] | SpawnOptions = [], options: SpawnOptions = {}): WebContainerProcess {
|
|
91
|
+
if (!Array.isArray(args)) [options, args] = [args, []];
|
|
92
|
+
return toWebContainerProcess(this.spawnChild(command, args, options), !!options.terminal);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
spawnChild(command: string, args: string[] = [], options: SpawnOptions = {}): ChildHandle {
|
|
96
|
+
const pid = allocatePid();
|
|
97
|
+
const worker = this.options.createWorker();
|
|
98
|
+
let resolveExit!: (code: number) => void;
|
|
99
|
+
const exit = new Promise<number>((resolve) => (resolveExit = resolve));
|
|
100
|
+
const proc: Proc = {
|
|
101
|
+
pid,
|
|
102
|
+
worker,
|
|
103
|
+
stdout: new Set(),
|
|
104
|
+
stderr: new Set(),
|
|
105
|
+
exited: false,
|
|
106
|
+
resolveExit: (code) => {
|
|
107
|
+
if (proc.exited) return;
|
|
108
|
+
proc.exited = true;
|
|
109
|
+
this.cleanup(proc);
|
|
110
|
+
resolveExit(code);
|
|
111
|
+
},
|
|
112
|
+
registrations: new Map(),
|
|
113
|
+
children: new Map(),
|
|
114
|
+
pendingHttp: new Map(),
|
|
115
|
+
outbox: [],
|
|
116
|
+
};
|
|
117
|
+
this.procs.add(proc);
|
|
118
|
+
worker.onmessage = (event) => this.onWorkerMessage(proc, event.data as WorkerToHost);
|
|
119
|
+
worker.onerror = (event) => {
|
|
120
|
+
for (const l of proc.stderr) l(`Process worker failed: ${event?.message ?? 'unknown error'}\n`);
|
|
121
|
+
proc.resolveExit(1);
|
|
122
|
+
};
|
|
123
|
+
const { fs } = this.options;
|
|
124
|
+
this.post(proc, {
|
|
125
|
+
type: 'start',
|
|
126
|
+
pid,
|
|
127
|
+
command,
|
|
128
|
+
args,
|
|
129
|
+
cwd: options.cwd ?? '/',
|
|
130
|
+
env: { ...DEFAULT_ENV, ...this.options.env, ...options.env },
|
|
131
|
+
terminal: options.terminal,
|
|
132
|
+
files: fs.list().map((path) => [path, fs.readFile(path)!]),
|
|
133
|
+
dirs: fs.explicitDirectories(),
|
|
134
|
+
ports: this.ports.ports(),
|
|
135
|
+
previewOrigin: this.options.previewOrigin,
|
|
136
|
+
previewBase: this.options.previewBase,
|
|
137
|
+
previewUrl: this.options.previewUrl,
|
|
138
|
+
registry: this.options.registry,
|
|
139
|
+
packageCache: this.options.packageCache !== false,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
pid,
|
|
144
|
+
onStdout: (l) => (proc.stdout.add(l), () => proc.stdout.delete(l)),
|
|
145
|
+
onStderr: (l) => (proc.stderr.add(l), () => proc.stderr.delete(l)),
|
|
146
|
+
write: (data) => this.post(proc, { type: 'stdin', data }),
|
|
147
|
+
closeStdin: () => this.post(proc, { type: 'stdin', data: null }),
|
|
148
|
+
kill: (signal = 'SIGTERM') => this.kill(proc, signal),
|
|
149
|
+
resize: (cols, rows) => this.post(proc, { type: 'resize', cols, rows }),
|
|
150
|
+
exit,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Dispatch an HTTP request to the server on `port` (the preview service worker's `/__port/<port>/` path). */
|
|
155
|
+
request(port: number, request: HttpRequestData): Promise<HttpResponseData | undefined> {
|
|
156
|
+
return this.ports.request(port, request);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
on(event: 'server-ready', listener: (port: number, url: string) => void): () => void;
|
|
160
|
+
on(event: 'port', listener: (port: number, type: 'open' | 'close', url: string) => void): () => void;
|
|
161
|
+
on(event: 'server-ready' | 'port', listener: (...args: any[]) => void): () => void {
|
|
162
|
+
const set = (event === 'server-ready' ? this.serverReady : this.portListeners) as Set<(...args: any[]) => void>;
|
|
163
|
+
set.add(listener);
|
|
164
|
+
return () => set.delete(listener);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
dispose(): void {
|
|
168
|
+
for (const proc of [...this.procs]) {
|
|
169
|
+
proc.worker.terminate();
|
|
170
|
+
proc.resolveExit(SIGNAL_CODES.SIGKILL!);
|
|
171
|
+
}
|
|
172
|
+
this.unwatch();
|
|
173
|
+
this.stopPorts();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
private kill(proc: Proc, signal: string): void {
|
|
177
|
+
if (proc.exited) return;
|
|
178
|
+
if (signal === 'SIGKILL') {
|
|
179
|
+
proc.worker.terminate();
|
|
180
|
+
proc.resolveExit(SIGNAL_CODES.SIGKILL!);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
this.post(proc, { type: 'kill', signal });
|
|
184
|
+
// A process stuck in a loop never sees the signal: terminate its worker.
|
|
185
|
+
setTimeout(() => {
|
|
186
|
+
if (proc.exited) return;
|
|
187
|
+
proc.worker.terminate();
|
|
188
|
+
proc.resolveExit(SIGNAL_CODES[signal] ?? 1);
|
|
189
|
+
}, this.options.killGraceMs ?? 2000);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private cleanup(proc: Proc): void {
|
|
193
|
+
for (const registration of proc.registrations.values()) registration.close();
|
|
194
|
+
proc.registrations.clear();
|
|
195
|
+
for (const respond of proc.pendingHttp.values()) respond({ status: 502, headers: { 'content-type': 'text/plain' }, body: new TextEncoder().encode('The server process exited.') });
|
|
196
|
+
proc.pendingHttp.clear();
|
|
197
|
+
this.procs.delete(proc);
|
|
198
|
+
queueMicrotask(() => proc.worker.terminate());
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private post(proc: Proc, message: HostToWorker): void {
|
|
202
|
+
if (!proc.exited || message.type === 'start') proc.worker.postMessage(message);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private onFsChange(change: FsChange): void {
|
|
206
|
+
const op: FsOp = change.type === 'write' ? { op: 'write', path: change.path, content: this.options.fs.readFile(change.path)! } : { op: change.type, path: change.path };
|
|
207
|
+
for (const proc of this.procs) if (proc !== this.applyingFrom) proc.outbox.push(op);
|
|
208
|
+
if (this.flushScheduled) return;
|
|
209
|
+
this.flushScheduled = true;
|
|
210
|
+
queueMicrotask(() => this.flushOutboxes());
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private flushOutboxes(): void {
|
|
214
|
+
this.flushScheduled = false;
|
|
215
|
+
for (const proc of this.procs) {
|
|
216
|
+
if (!proc.outbox.length) continue;
|
|
217
|
+
const ops = proc.outbox;
|
|
218
|
+
proc.outbox = [];
|
|
219
|
+
this.post(proc, { type: 'fs', ops });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
private onWorkerMessage(proc: Proc, message: WorkerToHost): void {
|
|
224
|
+
switch (message.type) {
|
|
225
|
+
case 'stdout':
|
|
226
|
+
for (const l of proc.stdout) l(message.data);
|
|
227
|
+
return;
|
|
228
|
+
case 'stderr':
|
|
229
|
+
for (const l of proc.stderr) l(message.data);
|
|
230
|
+
return;
|
|
231
|
+
case 'exit':
|
|
232
|
+
this.flushOutboxes();
|
|
233
|
+
return proc.resolveExit(message.code);
|
|
234
|
+
case 'fs': {
|
|
235
|
+
this.applyingFrom = proc;
|
|
236
|
+
try {
|
|
237
|
+
for (const op of message.ops) {
|
|
238
|
+
if (op.op === 'write') this.options.fs.writeFile(op.path, op.content);
|
|
239
|
+
else if (op.op === 'mkdir') this.options.fs.mkdir(op.path);
|
|
240
|
+
else this.options.fs.rm(op.path);
|
|
241
|
+
}
|
|
242
|
+
} finally {
|
|
243
|
+
this.applyingFrom = undefined;
|
|
244
|
+
}
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
case 'spawn': {
|
|
248
|
+
this.flushOutboxes();
|
|
249
|
+
let child: ChildHandle;
|
|
250
|
+
try {
|
|
251
|
+
child = this.spawnChild(message.command, message.args, { cwd: message.cwd, env: message.env, terminal: message.terminal });
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return this.post(proc, { type: 'child-error', childId: message.childId, code: (error as { code?: string }).code ?? 'EFAIL', message: (error as Error).message });
|
|
254
|
+
}
|
|
255
|
+
proc.children.set(message.childId, child);
|
|
256
|
+
child.onStdout((data) => this.post(proc, { type: 'child-out', childId: message.childId, stream: 'stdout', data }));
|
|
257
|
+
child.onStderr((data) => this.post(proc, { type: 'child-out', childId: message.childId, stream: 'stderr', data }));
|
|
258
|
+
void child.exit.then((code) => {
|
|
259
|
+
proc.children.delete(message.childId);
|
|
260
|
+
this.post(proc, { type: 'child-exit', childId: message.childId, code });
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
case 'child-stdin': {
|
|
265
|
+
const child = proc.children.get(message.childId);
|
|
266
|
+
if (message.data === null) child?.closeStdin();
|
|
267
|
+
else child?.write(message.data);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
case 'child-kill':
|
|
271
|
+
return proc.children.get(message.childId)?.kill(message.signal);
|
|
272
|
+
case 'child-resize':
|
|
273
|
+
return proc.children.get(message.childId)?.resize?.(message.cols, message.rows);
|
|
274
|
+
case 'listen': {
|
|
275
|
+
try {
|
|
276
|
+
const registration = this.ports.listen(message.port, (request) => this.forward(proc, message.port, request));
|
|
277
|
+
proc.registrations.set(message.port, registration);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
for (const l of proc.stderr) l(`${(error as Error).message}\n`);
|
|
280
|
+
}
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
case 'unlisten':
|
|
284
|
+
proc.registrations.get(message.port)?.close();
|
|
285
|
+
proc.registrations.delete(message.port);
|
|
286
|
+
return;
|
|
287
|
+
case 'http-response':
|
|
288
|
+
proc.pendingHttp.get(message.reqId)?.(message.response);
|
|
289
|
+
proc.pendingHttp.delete(message.reqId);
|
|
290
|
+
return;
|
|
291
|
+
case 'loopback':
|
|
292
|
+
void this.ports.request(message.port, message.request).then((response) => this.post(proc, { type: 'loopback-response', reqId: message.reqId, response: response ?? null }));
|
|
293
|
+
return;
|
|
294
|
+
case 'server-ready':
|
|
295
|
+
for (const l of this.serverReady) l(message.port, message.url);
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private forward(proc: Proc, port: number, request: HttpRequestData): Promise<HttpResponseData> {
|
|
301
|
+
const reqId = this.nextReq++;
|
|
302
|
+
return new Promise((resolve) => {
|
|
303
|
+
proc.pendingHttp.set(reqId, resolve);
|
|
304
|
+
this.post(proc, { type: 'http-request', reqId, port, request });
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messages between the page (the process host, which owns the master file
|
|
3
|
+
* system and the port table) and one process Worker. Everything is plain
|
|
4
|
+
* structured-clone data; file contents travel as strings or bytes.
|
|
5
|
+
*/
|
|
6
|
+
import type { FileContent, HttpRequestData, HttpResponseData } from '@seanhogg/builderforce-webcontainers-core';
|
|
7
|
+
|
|
8
|
+
export type FsOp = { op: 'write'; path: string; content: FileContent } | { op: 'remove'; path: string } | { op: 'mkdir'; path: string };
|
|
9
|
+
|
|
10
|
+
export interface StartMessage {
|
|
11
|
+
type: 'start';
|
|
12
|
+
pid: number;
|
|
13
|
+
command: string;
|
|
14
|
+
args: string[];
|
|
15
|
+
cwd: string;
|
|
16
|
+
env: Record<string, string>;
|
|
17
|
+
terminal?: { cols: number; rows: number };
|
|
18
|
+
files: Array<[string, FileContent]>;
|
|
19
|
+
dirs: string[];
|
|
20
|
+
/** Ports already listening anywhere in the runtime (so `listen` can fail fast with EADDRINUSE). */
|
|
21
|
+
ports: number[];
|
|
22
|
+
/** `/__bfwc/<id>/` on the preview origin, for `serverUrl`. */
|
|
23
|
+
previewOrigin: string;
|
|
24
|
+
previewBase: string;
|
|
25
|
+
previewUrl?: string;
|
|
26
|
+
registry?: string;
|
|
27
|
+
/** Use Cache Storage for npm tarballs. */
|
|
28
|
+
packageCache: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** page → worker */
|
|
32
|
+
export type HostToWorker =
|
|
33
|
+
| StartMessage
|
|
34
|
+
| { type: 'stdin'; data: string | null }
|
|
35
|
+
| { type: 'kill'; signal: string }
|
|
36
|
+
| { type: 'resize'; cols: number; rows: number }
|
|
37
|
+
| { type: 'fs'; ops: FsOp[] }
|
|
38
|
+
| { type: 'ports'; open: number[]; closed: number[] }
|
|
39
|
+
| { type: 'child-out'; childId: number; stream: 'stdout' | 'stderr'; data: string }
|
|
40
|
+
| { type: 'child-exit'; childId: number; code: number }
|
|
41
|
+
| { type: 'child-error'; childId: number; code: string; message: string }
|
|
42
|
+
| { type: 'http-request'; reqId: number; port: number; request: HttpRequestData }
|
|
43
|
+
| { type: 'loopback-response'; reqId: number; response: HttpResponseData | null };
|
|
44
|
+
|
|
45
|
+
/** worker → page */
|
|
46
|
+
export type WorkerToHost =
|
|
47
|
+
| { type: 'stdout' | 'stderr'; data: string }
|
|
48
|
+
| { type: 'exit'; code: number }
|
|
49
|
+
| { type: 'fs'; ops: FsOp[] }
|
|
50
|
+
| { type: 'spawn'; childId: number; command: string; args: string[]; cwd?: string; env?: Record<string, string>; terminal?: { cols: number; rows: number } }
|
|
51
|
+
| { type: 'child-stdin'; childId: number; data: string | null }
|
|
52
|
+
| { type: 'child-kill'; childId: number; signal: string }
|
|
53
|
+
| { type: 'child-resize'; childId: number; cols: number; rows: number }
|
|
54
|
+
| { type: 'listen'; port: number }
|
|
55
|
+
| { type: 'unlisten'; port: number }
|
|
56
|
+
| { type: 'http-response'; reqId: number; response: HttpResponseData }
|
|
57
|
+
| { type: 'loopback'; reqId: number; port: number; request: HttpRequestData }
|
|
58
|
+
| { type: 'server-ready'; port: number; url: string };
|
|
59
|
+
|
|
60
|
+
/** The preview-relative URL a virtual server on `port` is reachable at. */
|
|
61
|
+
export function portPath(previewBase: string, port: number): string {
|
|
62
|
+
return `${previewBase}__port/${port}/`;
|
|
63
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The process Worker (bundled to dist/node/worker.js): one program per worker,
|
|
3
|
+
* so a busy loop blocks only itself and `kill` can always terminate it.
|
|
4
|
+
*
|
|
5
|
+
* Worker globals are typed locally rather than through `lib.webworker`, which
|
|
6
|
+
* cannot share a compilation with the DOM lib the rest of the package needs.
|
|
7
|
+
*/
|
|
8
|
+
import { createWorkerProcess } from './workerProcess.js';
|
|
9
|
+
import { createCacheStoragePackageCache } from '../packageCache.js';
|
|
10
|
+
import type { HostToWorker } from './protocol.js';
|
|
11
|
+
|
|
12
|
+
interface WorkerScope {
|
|
13
|
+
postMessage(message: unknown, transfer?: Transferable[]): void;
|
|
14
|
+
addEventListener(type: 'message', listener: (event: { data: HostToWorker }) => void): void;
|
|
15
|
+
addEventListener(type: 'error', listener: (event: { error: unknown; message: string; preventDefault(): void }) => void): void;
|
|
16
|
+
addEventListener(type: 'unhandledrejection', listener: (event: { reason: unknown; preventDefault(): void }) => void): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const scope = globalThis as unknown as WorkerScope;
|
|
20
|
+
const nativeFetch = globalThis.fetch.bind(globalThis);
|
|
21
|
+
let cache: ReturnType<typeof createCacheStoragePackageCache> | undefined;
|
|
22
|
+
const uncaught = new Set<(error: unknown) => void>();
|
|
23
|
+
|
|
24
|
+
scope.addEventListener('error', (event) => {
|
|
25
|
+
if (!uncaught.size) return;
|
|
26
|
+
event.preventDefault();
|
|
27
|
+
for (const handler of uncaught) handler(event.error ?? new Error(event.message));
|
|
28
|
+
});
|
|
29
|
+
scope.addEventListener('unhandledrejection', (event) => {
|
|
30
|
+
if (!uncaught.size) return;
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
for (const handler of uncaught) handler(event.reason);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const process = createWorkerProcess({
|
|
36
|
+
post: (message) => scope.postMessage(message),
|
|
37
|
+
fetch: (input, init) => nativeFetch(input, init),
|
|
38
|
+
packageCache: () => (cache ??= createCacheStoragePackageCache()),
|
|
39
|
+
ownsRealm: true,
|
|
40
|
+
trapUncaught(handler) {
|
|
41
|
+
uncaught.add(handler);
|
|
42
|
+
return () => uncaught.delete(handler);
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
scope.addEventListener('message', (event) => process.receive(event.data));
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker side of one process: mirror the file system, build a `System`
|
|
3
|
+
* whose spawn/listen/loopback are relayed to the page, run the program, and
|
|
4
|
+
* stream its output back. No worker globals in here — `post` and the message
|
|
5
|
+
* feed are injected — so the same code runs in a real Worker (worker.ts) and
|
|
6
|
+
* in-thread in tests.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
VirtualFileSystem,
|
|
10
|
+
commandNotFound,
|
|
11
|
+
defaultPrograms,
|
|
12
|
+
resolveCommand,
|
|
13
|
+
runProgram,
|
|
14
|
+
type ChildHandle,
|
|
15
|
+
type FsChange,
|
|
16
|
+
type HttpHandler,
|
|
17
|
+
type HttpResponseData,
|
|
18
|
+
type PackageCache,
|
|
19
|
+
type System,
|
|
20
|
+
} from '@seanhogg/builderforce-webcontainers-core';
|
|
21
|
+
import type { FsOp, HostToWorker, StartMessage, WorkerToHost } from './protocol.js';
|
|
22
|
+
import { portPath } from './protocol.js';
|
|
23
|
+
|
|
24
|
+
export interface WorkerProcessDeps {
|
|
25
|
+
post(message: WorkerToHost): void;
|
|
26
|
+
fetch: System['fetch'];
|
|
27
|
+
packageCache?: () => PackageCache | undefined;
|
|
28
|
+
/** Register for the realm's uncaught errors (a real worker's `error`/`unhandledrejection`). */
|
|
29
|
+
trapUncaught?(handler: (error: unknown) => void): () => void;
|
|
30
|
+
/** This process has the realm to itself (true in a real Worker). */
|
|
31
|
+
ownsRealm?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const EPHEMERAL = 49152;
|
|
35
|
+
|
|
36
|
+
export function createWorkerProcess(deps: WorkerProcessDeps): { receive(message: HostToWorker): void } {
|
|
37
|
+
const fs = new VirtualFileSystem();
|
|
38
|
+
const handlers = new Map<number, HttpHandler>();
|
|
39
|
+
const knownPorts = new Set<number>();
|
|
40
|
+
const children = new Map<number, { stdout: Set<(s: string) => void>; stderr: Set<(s: string) => void>; exit: (code: number) => void }>();
|
|
41
|
+
const loopbacks = new Map<number, (response: HttpResponseData | undefined) => void>();
|
|
42
|
+
let nextChild = 1;
|
|
43
|
+
let nextReq = 1;
|
|
44
|
+
let applying = false;
|
|
45
|
+
let pending: FsOp[] = [];
|
|
46
|
+
let flushScheduled = false;
|
|
47
|
+
let process: ChildHandle | undefined;
|
|
48
|
+
const programs = defaultPrograms();
|
|
49
|
+
|
|
50
|
+
const flush = () => {
|
|
51
|
+
flushScheduled = false;
|
|
52
|
+
if (!pending.length) return;
|
|
53
|
+
const ops = pending;
|
|
54
|
+
pending = [];
|
|
55
|
+
deps.post({ type: 'fs', ops });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
fs.watch((change: FsChange) => {
|
|
59
|
+
if (applying) return;
|
|
60
|
+
if (change.type === 'write') pending.push({ op: 'write', path: change.path, content: fs.readFile(change.path)! });
|
|
61
|
+
else pending.push({ op: change.type, path: change.path });
|
|
62
|
+
if (!flushScheduled) {
|
|
63
|
+
flushScheduled = true;
|
|
64
|
+
queueMicrotask(flush);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const apply = (ops: FsOp[]) => {
|
|
69
|
+
applying = true;
|
|
70
|
+
try {
|
|
71
|
+
for (const op of ops) {
|
|
72
|
+
if (op.op === 'write') fs.writeFile(op.path, op.content);
|
|
73
|
+
else if (op.op === 'mkdir') fs.mkdir(op.path);
|
|
74
|
+
else fs.rm(op.path);
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
applying = false;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function remoteChild(command: string, args: string[], options: { cwd?: string; env?: Record<string, string>; terminal?: { cols: number; rows: number } } = {}): ChildHandle {
|
|
82
|
+
flush(); // the child must see every write made so far
|
|
83
|
+
const childId = nextChild++;
|
|
84
|
+
const stdout = new Set<(s: string) => void>();
|
|
85
|
+
const stderr = new Set<(s: string) => void>();
|
|
86
|
+
let resolveExit!: (code: number) => void;
|
|
87
|
+
const exit = new Promise<number>((resolve) => (resolveExit = resolve));
|
|
88
|
+
children.set(childId, { stdout, stderr, exit: resolveExit });
|
|
89
|
+
deps.post({ type: 'spawn', childId, command, args, cwd: options.cwd, env: options.env, terminal: options.terminal });
|
|
90
|
+
return {
|
|
91
|
+
pid: childId,
|
|
92
|
+
onStdout: (l) => (stdout.add(l), () => stdout.delete(l)),
|
|
93
|
+
onStderr: (l) => (stderr.add(l), () => stderr.delete(l)),
|
|
94
|
+
write: (data) => deps.post({ type: 'child-stdin', childId, data }),
|
|
95
|
+
closeStdin: () => deps.post({ type: 'child-stdin', childId, data: null }),
|
|
96
|
+
kill: (signal = 'SIGTERM') => deps.post({ type: 'child-kill', childId, signal }),
|
|
97
|
+
resize: (cols, rows) => deps.post({ type: 'child-resize', childId, cols, rows }),
|
|
98
|
+
exit,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function start(message: StartMessage): void {
|
|
103
|
+
applying = true;
|
|
104
|
+
for (const dir of message.dirs) fs.mkdir(dir);
|
|
105
|
+
for (const [path, content] of message.files) fs.writeFile(path, content);
|
|
106
|
+
applying = false;
|
|
107
|
+
for (const port of message.ports) knownPorts.add(port);
|
|
108
|
+
|
|
109
|
+
const system: System = {
|
|
110
|
+
fs,
|
|
111
|
+
fetch: deps.fetch,
|
|
112
|
+
spawn(command, args, options = {}) {
|
|
113
|
+
// Resolve here, against the mirror, so a missing command fails synchronously
|
|
114
|
+
// with ENOENT exactly as it does in-process.
|
|
115
|
+
const cwd = options.cwd ?? message.cwd;
|
|
116
|
+
const env = { ...message.env, ...options.env };
|
|
117
|
+
if (!resolveCommand(fs, programs, command, args, cwd, env)) throw commandNotFound(command);
|
|
118
|
+
return remoteChild(command, args, { ...options, cwd, env });
|
|
119
|
+
},
|
|
120
|
+
listen(port, handler) {
|
|
121
|
+
let actual = port;
|
|
122
|
+
if (!actual) for (actual = EPHEMERAL + (message.pid % 1000) * 8; knownPorts.has(actual); actual++);
|
|
123
|
+
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
|
+
knownPorts.add(actual);
|
|
125
|
+
handlers.set(actual, handler);
|
|
126
|
+
deps.post({ type: 'listen', port: actual });
|
|
127
|
+
return {
|
|
128
|
+
port: actual,
|
|
129
|
+
close() {
|
|
130
|
+
if (handlers.get(actual) !== handler) return;
|
|
131
|
+
handlers.delete(actual);
|
|
132
|
+
knownPorts.delete(actual);
|
|
133
|
+
deps.post({ type: 'unlisten', port: actual });
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
loopback(port, request) {
|
|
138
|
+
const local = handlers.get(port);
|
|
139
|
+
if (local) return local(request);
|
|
140
|
+
const reqId = nextReq++;
|
|
141
|
+
return new Promise((resolve) => {
|
|
142
|
+
loopbacks.set(reqId, resolve);
|
|
143
|
+
deps.post({ type: 'loopback', reqId, port, request });
|
|
144
|
+
});
|
|
145
|
+
},
|
|
146
|
+
serverUrl: (port) => new URL(portPath(message.previewBase, port), message.previewOrigin).href,
|
|
147
|
+
announceServer: (port, url) => deps.post({ type: 'server-ready', port, url }),
|
|
148
|
+
previewUrl: message.previewUrl,
|
|
149
|
+
registry: message.registry,
|
|
150
|
+
packageCache: message.packageCache ? deps.packageCache?.() : undefined,
|
|
151
|
+
ownsRealm: deps.ownsRealm,
|
|
152
|
+
trapUncaught: deps.trapUncaught,
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const resolved = resolveCommand(fs, programs, message.command, message.args, message.cwd, message.env);
|
|
156
|
+
if (!resolved) {
|
|
157
|
+
deps.post({ type: 'stderr', data: `jsh: command not found: ${message.command}\n` });
|
|
158
|
+
deps.post({ type: 'exit', code: 127 });
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
process = runProgram({ program: resolved.program, args: resolved.args, system, cwd: message.cwd, env: message.env, terminal: message.terminal, pid: message.pid });
|
|
162
|
+
process.onStdout((data) => deps.post({ type: 'stdout', data }));
|
|
163
|
+
process.onStderr((data) => deps.post({ type: 'stderr', data }));
|
|
164
|
+
void process.exit.then((code) => {
|
|
165
|
+
flush();
|
|
166
|
+
deps.post({ type: 'exit', code });
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
receive(message) {
|
|
172
|
+
switch (message.type) {
|
|
173
|
+
case 'start':
|
|
174
|
+
return start(message);
|
|
175
|
+
case 'stdin':
|
|
176
|
+
if (message.data === null) process?.closeStdin();
|
|
177
|
+
else process?.write(message.data);
|
|
178
|
+
return;
|
|
179
|
+
case 'kill':
|
|
180
|
+
return process?.kill(message.signal);
|
|
181
|
+
case 'resize':
|
|
182
|
+
return process?.resize?.(message.cols, message.rows);
|
|
183
|
+
case 'fs':
|
|
184
|
+
return apply(message.ops);
|
|
185
|
+
case 'ports':
|
|
186
|
+
for (const port of message.open) knownPorts.add(port);
|
|
187
|
+
for (const port of message.closed) if (!handlers.has(port)) knownPorts.delete(port);
|
|
188
|
+
return;
|
|
189
|
+
case 'child-out':
|
|
190
|
+
for (const l of children.get(message.childId)?.[message.stream] ?? []) l(message.data);
|
|
191
|
+
return;
|
|
192
|
+
case 'child-exit':
|
|
193
|
+
children.get(message.childId)?.exit(message.code);
|
|
194
|
+
children.delete(message.childId);
|
|
195
|
+
return;
|
|
196
|
+
case 'child-error': {
|
|
197
|
+
const child = children.get(message.childId);
|
|
198
|
+
for (const l of child?.stderr ?? []) l(`${message.message}\n`);
|
|
199
|
+
child?.exit(message.code === 'ENOENT' ? 127 : 1);
|
|
200
|
+
children.delete(message.childId);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
case 'http-request': {
|
|
204
|
+
const handler = handlers.get(message.port);
|
|
205
|
+
const respond = (response: HttpResponseData) => deps.post({ type: 'http-response', reqId: message.reqId, response });
|
|
206
|
+
if (!handler) return respond({ status: 502, headers: { 'content-type': 'text/plain' }, body: new TextEncoder().encode(`Nothing is listening on port ${message.port}.`) });
|
|
207
|
+
void handler(message.request).then(respond, (error: unknown) =>
|
|
208
|
+
respond({ status: 500, headers: { 'content-type': 'text/plain' }, body: new TextEncoder().encode(String(error instanceof Error ? error.stack : error)) }),
|
|
209
|
+
);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
case 'loopback-response':
|
|
213
|
+
loopbacks.get(message.reqId)?.(message.response ?? undefined);
|
|
214
|
+
loopbacks.delete(message.reqId);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PackageCache } from '@seanhogg/builderforce-webcontainers-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The installer's cache backed by Cache Storage, so a second `npm install` (or a
|
|
5
|
+
* reload) reuses tarballs instead of downloading them again. Cache Storage keys
|
|
6
|
+
* must be http(s) requests, so each key is folded into a URL on a reserved
|
|
7
|
+
* `.invalid` host that can never be fetched for real.
|
|
8
|
+
*
|
|
9
|
+
* Falls back to a no-op cache where Cache Storage is unavailable (an insecure
|
|
10
|
+
* context, or a browser that blocks it in private mode).
|
|
11
|
+
*/
|
|
12
|
+
export function createCacheStoragePackageCache(name = 'bfwc-packages'): PackageCache {
|
|
13
|
+
const storage = (globalThis as { caches?: CacheStorage }).caches;
|
|
14
|
+
if (!storage) return { get: async () => undefined, put: async () => undefined };
|
|
15
|
+
let opened: Promise<Cache | undefined> | undefined;
|
|
16
|
+
const open = () => (opened ??= storage.open(name).catch(() => undefined));
|
|
17
|
+
const keyUrl = (key: string) => `https://bfwc-cache.invalid/${encodeURIComponent(key)}`;
|
|
18
|
+
return {
|
|
19
|
+
async get(key) {
|
|
20
|
+
const response = await (await open())?.match(keyUrl(key)).catch(() => undefined);
|
|
21
|
+
return response ? new Uint8Array(await response.arrayBuffer()) : undefined;
|
|
22
|
+
},
|
|
23
|
+
async put(key, data) {
|
|
24
|
+
const body = data.slice().buffer as ArrayBuffer;
|
|
25
|
+
await (await open())?.put(keyUrl(key), new Response(body)).catch(() => undefined); // quota errors just skip caching
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|