@scelar/nodepod 1.0.2 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/dist/__sw__.js +642 -642
  2. package/dist/__tests__/bench/integration.bench.d.ts +1 -0
  3. package/dist/__tests__/bench/memory-volume.bench.d.ts +1 -0
  4. package/dist/__tests__/bench/polyfills.bench.d.ts +1 -0
  5. package/dist/__tests__/bench/script-engine.bench.d.ts +1 -0
  6. package/dist/__tests__/bench/shell.bench.d.ts +1 -0
  7. package/dist/__tests__/bench/syntax-transforms.bench.d.ts +1 -0
  8. package/dist/__tests__/bench/version-resolver.bench.d.ts +1 -0
  9. package/dist/__tests__/buffer.test.d.ts +1 -0
  10. package/dist/__tests__/byte-encoding.test.d.ts +1 -0
  11. package/dist/__tests__/digest.test.d.ts +1 -0
  12. package/dist/__tests__/events.test.d.ts +1 -0
  13. package/dist/__tests__/memory-volume.test.d.ts +1 -0
  14. package/dist/__tests__/path.test.d.ts +1 -0
  15. package/dist/__tests__/process.test.d.ts +1 -0
  16. package/dist/__tests__/script-engine.test.d.ts +1 -0
  17. package/dist/__tests__/shell-builtins.test.d.ts +1 -0
  18. package/dist/__tests__/shell-interpreter.test.d.ts +1 -0
  19. package/dist/__tests__/shell-parser.test.d.ts +1 -0
  20. package/dist/__tests__/stream.test.d.ts +1 -0
  21. package/dist/__tests__/syntax-transforms.test.d.ts +1 -0
  22. package/dist/__tests__/version-resolver.test.d.ts +1 -0
  23. package/dist/{child_process-Dopvyd-E.js → child_process-53fMkug_.js} +4 -4
  24. package/dist/child_process-53fMkug_.js.map +1 -0
  25. package/dist/{child_process-B38qoN6R.cjs → child_process-lxSKECHq.cjs} +5 -5
  26. package/dist/child_process-lxSKECHq.cjs.map +1 -0
  27. package/dist/{index--Qr8LVpQ.js → index-B8lyh_ti.js} +1316 -559
  28. package/dist/index-B8lyh_ti.js.map +1 -0
  29. package/dist/{index-cnitc68U.cjs → index-C-TQIrdG.cjs} +1422 -612
  30. package/dist/index-C-TQIrdG.cjs.map +1 -0
  31. package/dist/index.cjs +1 -1
  32. package/dist/index.mjs +1 -1
  33. package/dist/memory-volume.d.ts +1 -1
  34. package/dist/polyfills/wasi.d.ts +45 -4
  35. package/dist/script-engine.d.ts +2 -0
  36. package/dist/sdk/nodepod.d.ts +4 -3
  37. package/dist/sdk/types.d.ts +6 -0
  38. package/dist/syntax-transforms.d.ts +1 -0
  39. package/dist/threading/process-manager.d.ts +1 -1
  40. package/dist/threading/worker-protocol.d.ts +1 -1
  41. package/package.json +5 -3
  42. package/src/__tests__/bench/integration.bench.ts +117 -0
  43. package/src/__tests__/bench/memory-volume.bench.ts +115 -0
  44. package/src/__tests__/bench/polyfills.bench.ts +147 -0
  45. package/src/__tests__/bench/script-engine.bench.ts +104 -0
  46. package/src/__tests__/bench/shell.bench.ts +101 -0
  47. package/src/__tests__/bench/syntax-transforms.bench.ts +82 -0
  48. package/src/__tests__/bench/version-resolver.bench.ts +95 -0
  49. package/src/__tests__/buffer.test.ts +273 -0
  50. package/src/__tests__/byte-encoding.test.ts +98 -0
  51. package/src/__tests__/digest.test.ts +44 -0
  52. package/src/__tests__/events.test.ts +245 -0
  53. package/src/__tests__/memory-volume.test.ts +443 -0
  54. package/src/__tests__/path.test.ts +181 -0
  55. package/src/__tests__/process.test.ts +129 -0
  56. package/src/__tests__/script-engine.test.ts +229 -0
  57. package/src/__tests__/shell-builtins.test.ts +357 -0
  58. package/src/__tests__/shell-interpreter.test.ts +157 -0
  59. package/src/__tests__/shell-parser.test.ts +204 -0
  60. package/src/__tests__/stream.test.ts +142 -0
  61. package/src/__tests__/syntax-transforms.test.ts +158 -0
  62. package/src/__tests__/version-resolver.test.ts +184 -0
  63. package/src/constants/cdn-urls.ts +18 -18
  64. package/src/helpers/byte-encoding.ts +51 -39
  65. package/src/index.ts +192 -192
  66. package/src/memory-volume.ts +968 -941
  67. package/src/module-transformer.ts +368 -368
  68. package/src/packages/installer.ts +396 -396
  69. package/src/packages/version-resolver.ts +12 -2
  70. package/src/polyfills/buffer.ts +633 -628
  71. package/src/polyfills/child_process.ts +2288 -2288
  72. package/src/polyfills/esbuild.ts +854 -854
  73. package/src/polyfills/events.ts +282 -276
  74. package/src/polyfills/fs.ts +2888 -2888
  75. package/src/polyfills/http.ts +1450 -1449
  76. package/src/polyfills/process.ts +721 -690
  77. package/src/polyfills/readline.ts +692 -692
  78. package/src/polyfills/stream.ts +1620 -1620
  79. package/src/polyfills/tty.ts +71 -71
  80. package/src/polyfills/wasi.ts +1284 -22
  81. package/src/request-proxy.ts +716 -716
  82. package/src/script-engine.ts +465 -146
  83. package/src/sdk/nodepod.ts +525 -509
  84. package/src/sdk/types.ts +7 -0
  85. package/src/syntax-transforms.ts +543 -561
  86. package/src/threading/offload-worker.ts +383 -383
  87. package/src/threading/offload.ts +271 -271
  88. package/src/threading/process-manager.ts +956 -956
  89. package/src/threading/process-worker-entry.ts +858 -854
  90. package/src/threading/worker-protocol.ts +1 -1
  91. package/dist/child_process-B38qoN6R.cjs.map +0 -1
  92. package/dist/child_process-Dopvyd-E.js.map +0 -1
  93. package/dist/index--Qr8LVpQ.js.map +0 -1
  94. package/dist/index-cnitc68U.cjs.map +0 -1
@@ -1,509 +1,525 @@
1
- import { MemoryVolume } from "../memory-volume";
2
- import { ScriptEngine } from "../script-engine";
3
- import { DependencyInstaller } from "../packages/installer";
4
- import { RequestProxy, getProxyInstance, type IVirtualServer } from "../request-proxy";
5
- import type { VolumeSnapshot } from "../engine-types";
6
- import { Buffer } from "../polyfills/buffer";
7
- import type {
8
- NodepodOptions,
9
- TerminalOptions,
10
- Snapshot,
11
- SpawnOptions,
12
- } from "./types";
13
- import { NodepodFS } from "./nodepod-fs";
14
- import { NodepodProcess } from "./nodepod-process";
15
- import { NodepodTerminal } from "./nodepod-terminal";
16
- import { ProcessManager } from "../threading/process-manager";
17
- import type { ProcessHandle } from "../threading/process-handle";
18
- import { VFSBridge } from "../threading/vfs-bridge";
19
- import { isSharedArrayBufferAvailable, SharedVFSController } from "../threading/shared-vfs";
20
- import { SyncChannelController } from "../threading/sync-channel";
21
-
22
- // Lazy-load child_process so the shell doesn't get pulled in at import time
23
- let _shellMod: typeof import("../polyfills/child_process") | null = null;
24
- async function getShellMod() {
25
- if (!_shellMod) _shellMod = await import("../polyfills/child_process");
26
- return _shellMod;
27
- }
28
-
29
- export class Nodepod {
30
- readonly fs: NodepodFS;
31
-
32
- private _volume: MemoryVolume;
33
- private _engine: ScriptEngine;
34
- private _packages: DependencyInstaller;
35
- private _proxy: RequestProxy;
36
- private _cwd: string;
37
-
38
- private _processManager: ProcessManager;
39
- private _vfsBridge: VFSBridge;
40
- private _sharedVFS: SharedVFSController | null = null;
41
- private _syncChannel: SyncChannelController | null = null;
42
- private _unwatchVFS: (() => void) | null = null;
43
-
44
- /* ---- Construction (use Nodepod.boot()) ---- */
45
-
46
- private constructor(
47
- volume: MemoryVolume,
48
- engine: ScriptEngine,
49
- packages: DependencyInstaller,
50
- proxy: RequestProxy,
51
- cwd: string,
52
- ) {
53
- this._volume = volume;
54
- this._engine = engine;
55
- this._packages = packages;
56
- this._proxy = proxy;
57
- this._cwd = cwd;
58
- this.fs = new NodepodFS(volume);
59
- this._processManager = new ProcessManager(volume);
60
- this._vfsBridge = new VFSBridge(volume);
61
-
62
- this._vfsBridge.setBroadcaster((path, content, excludePid) => {
63
- const isDirectory = content !== null && content.byteLength === 0;
64
- this._processManager.broadcastVFSChange(path, content, isDirectory, excludePid);
65
- });
66
-
67
- this._processManager.setVFSBridge(this._vfsBridge);
68
-
69
- // VFS watcher broadcasts main-thread file changes to workers (needed for HMR)
70
- this._unwatchVFS = this._vfsBridge.watch();
71
-
72
- if (isSharedArrayBufferAvailable()) {
73
- try {
74
- this._sharedVFS = new SharedVFSController();
75
- this._processManager.setSharedBuffer(this._sharedVFS.buffer);
76
- this._vfsBridge.setSharedVFS(this._sharedVFS);
77
- } catch (e) {
78
- // COOP/COEP headers probably missing
79
- }
80
-
81
- try {
82
- this._syncChannel = new SyncChannelController();
83
- this._processManager.setSyncBuffer(this._syncChannel.buffer);
84
- } catch (e) {
85
- // SyncChannel init failed
86
- }
87
- }
88
-
89
- // Bridge worker HTTP servers to the RequestProxy for preview URLs
90
- this._processManager.on("server-listen", (_pid: number, port: number, _hostname: string) => {
91
- const proxyServer: IVirtualServer = {
92
- listening: true,
93
- address: () => ({ port, address: "0.0.0.0", family: "IPv4" }),
94
- dispatchRequest: async (method, url, headers, body) => {
95
- const bodyStr = body ? (typeof body === "string" ? body : body.toString("utf8")) : null;
96
- const result = await this._processManager.dispatchHttpRequest(
97
- port, method, url, headers, bodyStr,
98
- );
99
- return {
100
- statusCode: result.statusCode,
101
- statusMessage: result.statusMessage,
102
- headers: result.headers,
103
- body: Buffer.from(result.body),
104
- };
105
- },
106
- };
107
- this._proxy.register(proxyServer, port);
108
- });
109
-
110
- this._processManager.on("server-close", (_pid: number, port: number) => {
111
- this._proxy.unregister(port);
112
- });
113
-
114
- this._proxy.setProcessManager(this._processManager);
115
- }
116
-
117
- /* ---- Static factory ---- */
118
-
119
- static async boot(opts: NodepodOptions = {}): Promise<Nodepod> {
120
- if (typeof Worker === "undefined") {
121
- throw new Error("[Nodepod] Web Workers are required. Nodepod cannot run without Web Worker support.");
122
- }
123
- if (typeof SharedArrayBuffer === "undefined") {
124
- throw new Error("[Nodepod] SharedArrayBuffer is required. Ensure Cross-Origin-Isolation headers are set (Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Embedder-Policy: credentialless).");
125
- }
126
-
127
- const cwd = opts.workdir ?? "/";
128
- const volume = new MemoryVolume();
129
- const engine = new ScriptEngine(volume, {
130
- cwd,
131
- env: opts.env,
132
- });
133
- const packages = new DependencyInstaller(volume);
134
- const proxy = getProxyInstance({
135
- onServerReady: opts.onServerReady,
136
- });
137
-
138
- const nodepod = new Nodepod(volume, engine, packages, proxy, cwd);
139
-
140
- if (opts.files) {
141
- for (const [path, content] of Object.entries(opts.files)) {
142
- const dir = path.substring(0, path.lastIndexOf("/")) || "/";
143
- if (dir !== "/" && !volume.existsSync(dir)) {
144
- volume.mkdirSync(dir, { recursive: true });
145
- }
146
- volume.writeFileSync(path, content as any);
147
- }
148
- }
149
-
150
- if (cwd !== "/" && !volume.existsSync(cwd)) {
151
- volume.mkdirSync(cwd, { recursive: true });
152
- }
153
-
154
- for (const dir of ["/tmp", "/home"]) {
155
- if (!volume.existsSync(dir)) {
156
- volume.mkdirSync(dir, { recursive: true });
157
- }
158
- }
159
-
160
- const shell = await getShellMod();
161
- shell.initShellExec(volume, { cwd, env: opts.env });
162
-
163
- if (
164
- opts.swUrl &&
165
- typeof navigator !== "undefined" &&
166
- "serviceWorker" in navigator
167
- ) {
168
- try {
169
- await proxy.initServiceWorker({ swUrl: opts.swUrl });
170
- // Watermark is on by default — only disable if explicitly set to false
171
- if (opts.watermark === false) {
172
- proxy.setWatermark(false);
173
- }
174
- } catch (e) {
175
- // SW registration failed, non-fatal
176
- }
177
- }
178
-
179
- return nodepod;
180
- }
181
-
182
- /* ---- spawn() ---- */
183
-
184
- // Each spawn gets a dedicated worker with its own engine + shell
185
- async spawn(
186
- cmd: string,
187
- args?: string[],
188
- opts?: SpawnOptions,
189
- ): Promise<NodepodProcess> {
190
- const proc = new NodepodProcess();
191
- const execCwd = opts?.cwd ?? this._cwd;
192
-
193
- const handle = this._processManager.spawn({
194
- command: cmd,
195
- args: args ?? [],
196
- cwd: execCwd,
197
- env: opts?.env ?? {},
198
- });
199
-
200
- handle.on("stdout", (data: string) => {
201
- if (!proc.exited) proc._pushStdout(data);
202
- });
203
-
204
- handle.on("stderr", (data: string) => {
205
- if (!proc.exited) proc._pushStderr(data);
206
- });
207
-
208
- handle.on("exit", (exitCode: number) => {
209
- if (!proc.exited) proc._finish(exitCode);
210
- });
211
-
212
- handle.on("worker-error", (message: string) => {
213
- if (!proc.exited) {
214
- proc._pushStderr(`Worker error: ${message}\n`);
215
- proc._finish(1);
216
- }
217
- });
218
-
219
- proc._setSendStdin((data: string) => handle.sendStdin(data));
220
- proc._setKillFn(() => handle.kill("SIGINT"));
221
-
222
- if (opts?.signal) {
223
- opts.signal.addEventListener("abort", () => {
224
- handle.kill("SIGINT");
225
- }, { once: true });
226
- }
227
-
228
- await new Promise<void>((resolve) => {
229
- if (handle.state === "running") {
230
- resolve();
231
- } else {
232
- handle.on("ready", () => resolve());
233
- }
234
- });
235
-
236
- const isNodeCmd = cmd === "node" && args?.length;
237
- if (isNodeCmd) {
238
- const filePath = this._resolveCommand(cmd, args);
239
- handle.exec({
240
- type: "exec",
241
- filePath,
242
- args: args ?? [],
243
- cwd: execCwd,
244
- env: opts?.env,
245
- isShell: false,
246
- });
247
- } else {
248
- const fullCmd = args?.length ? `${cmd} ${args.join(" ")}` : cmd;
249
- handle.exec({
250
- type: "exec",
251
- filePath: "",
252
- args: args ?? [],
253
- cwd: execCwd,
254
- env: opts?.env,
255
- isShell: true,
256
- shellCommand: fullCmd,
257
- });
258
- }
259
-
260
- return proc;
261
- }
262
-
263
- private _resolveCommand(cmd: string, args?: string[]): string {
264
- if (cmd === "node" && args?.length) {
265
- const filePath = args[0];
266
- if (filePath.startsWith("/")) return filePath;
267
- return `${this._cwd}/${filePath}`.replace(/\/+/g, "/");
268
- }
269
- return cmd;
270
- }
271
-
272
- /* ---- createTerminal() ---- */
273
-
274
- createTerminal(opts: TerminalOptions): NodepodTerminal {
275
- const terminal = new NodepodTerminal(opts);
276
- terminal.setCwd(this._cwd);
277
-
278
- let activeAbort: AbortController | null = null;
279
- let currentSendStdin: ((data: string) => void) | null = null;
280
- let activeCommandId = 0;
281
- const nextCommandId = () => { activeCommandId = (activeCommandId + 1) % Number.MAX_SAFE_INTEGER; return activeCommandId; };
282
- let isStdinRaw = false;
283
-
284
- // Persistent shell worker -- reused across commands so VFS state persists
285
- // and we skip the ~1s worker creation overhead per command
286
- let shellHandle: ProcessHandle | null = null;
287
- let shellReady: Promise<void> | null = null;
288
-
289
- const ensureShellWorker = (): Promise<void> => {
290
- if (shellHandle && shellHandle.state !== "exited") {
291
- return shellReady!;
292
- }
293
- shellHandle = this._processManager.spawn({
294
- command: "shell",
295
- args: [],
296
- cwd: this._cwd,
297
- env: {},
298
- });
299
- shellReady = new Promise<void>((resolve) => {
300
- if (shellHandle!.state === "running") {
301
- resolve();
302
- } else {
303
- shellHandle!.on("ready", () => resolve());
304
- }
305
- });
306
-
307
- shellHandle.on("cwd-change", (cwd: string) => {
308
- this._cwd = cwd;
309
- terminal.setCwd(cwd);
310
- });
311
-
312
- shellHandle.on("stdin-raw-status", (raw: boolean) => {
313
- isStdinRaw = raw;
314
- });
315
-
316
- // Worker died -- next command will spawn a fresh one
317
- shellHandle.on("exit", () => {
318
- shellHandle = null;
319
- shellReady = null;
320
- });
321
-
322
- return shellReady;
323
- };
324
-
325
- terminal._wireExecution({
326
- onCommand: async (cmd: string) => {
327
- const myAbort = new AbortController();
328
- activeAbort = myAbort;
329
- const myCommandId = nextCommandId();
330
-
331
- let streamed = false;
332
- let wroteNewline = false;
333
-
334
- function ensureNewline() {
335
- if (!wroteNewline) {
336
- wroteNewline = true;
337
- terminal.write("\r\n");
338
- }
339
- }
340
-
341
- // Ensure persistent shell worker is running
342
- await ensureShellWorker();
343
- const handle = shellHandle!;
344
-
345
- // Ignore output from previous commands or before exec is sent (stale child output)
346
- let execSent = false;
347
- const onStdout = (data: string) => {
348
- if (myCommandId !== activeCommandId) return;
349
- if (!execSent) return;
350
- streamed = true;
351
- ensureNewline();
352
- terminal._writeOutput(data);
353
- };
354
- const onStderr = (data: string) => {
355
- if (myCommandId !== activeCommandId) return;
356
- if (!execSent) return;
357
- streamed = true;
358
- ensureNewline();
359
- terminal._writeOutput(data, true);
360
- };
361
-
362
- handle.on("stdout", onStdout);
363
- handle.on("stderr", onStderr);
364
-
365
- currentSendStdin = (data: string) => handle.sendStdin(data);
366
-
367
- // PM.kill() recursively kills descendants + cleans up server ports
368
- myAbort.signal.addEventListener("abort", () => {
369
- this._processManager.kill(handle.pid, "SIGINT");
370
- }, { once: true });
371
-
372
- handle.exec({
373
- type: "exec",
374
- filePath: "",
375
- args: [],
376
- cwd: this._cwd,
377
- isShell: true,
378
- shellCommand: cmd,
379
- persistent: true,
380
- });
381
- execSent = true;
382
-
383
- return new Promise<void>((resolve) => {
384
- const cleanup = () => {
385
- handle.removeListener("shell-done", onDone);
386
- handle.removeListener("exit", onExit);
387
- handle.removeListener("stdout", onStdout);
388
- handle.removeListener("stderr", onStderr);
389
- };
390
-
391
- const onDone = (exitCode: number, stdout: string, stderr: string) => {
392
- cleanup();
393
- const isStale = myCommandId !== activeCommandId;
394
- if (!isStale) {
395
- currentSendStdin = null;
396
- }
397
-
398
- const aborted = myAbort.signal.aborted;
399
-
400
- if (!aborted && !streamed && !isStale) {
401
- const outStr = String(stdout ?? "");
402
- const errStr = String(stderr ?? "");
403
- if (outStr || errStr) ensureNewline();
404
- if (outStr) terminal._writeOutput(outStr);
405
- if (errStr) terminal._writeOutput(errStr, true);
406
- }
407
-
408
- if (activeAbort === myAbort) activeAbort = null;
409
-
410
- if (!aborted && !isStale) {
411
- if (!wroteNewline) terminal.write("\r\n");
412
- terminal._setRunning(false);
413
- terminal._writePrompt();
414
- }
415
- resolve();
416
- };
417
-
418
- const onExit = (exitCode: number, stdout: string, stderr: string) => {
419
- cleanup();
420
- const isStale = myCommandId !== activeCommandId;
421
- if (!isStale) currentSendStdin = null;
422
- const aborted = myAbort.signal.aborted;
423
- if (!aborted && !streamed && !isStale) {
424
- const outStr = String(stdout ?? "");
425
- const errStr = String(stderr ?? "");
426
- if (outStr || errStr) ensureNewline();
427
- if (outStr) terminal._writeOutput(outStr);
428
- if (errStr) terminal._writeOutput(errStr, true);
429
- }
430
- if (activeAbort === myAbort) activeAbort = null;
431
- if (!aborted && !isStale) {
432
- if (!wroteNewline) terminal.write("\r\n");
433
- terminal._setRunning(false);
434
- terminal._writePrompt();
435
- }
436
- resolve();
437
- };
438
-
439
- handle.on("shell-done", onDone);
440
- handle.on("exit", onExit);
441
- });
442
- },
443
-
444
- getSendStdin: () => currentSendStdin,
445
- getIsStdinRaw: () => isStdinRaw,
446
- getActiveAbort: () => activeAbort,
447
- setActiveAbort: (ac) => {
448
- activeAbort = ac;
449
- },
450
- });
451
-
452
- return terminal;
453
- }
454
-
455
- /* ---- setPreviewScript() ---- */
456
-
457
- // Inject a script into every preview iframe before any page content loads.
458
- // Useful for setting up a communication bridge between the main window and
459
- // the preview iframe, injecting polyfills, analytics, etc.
460
- async setPreviewScript(script: string): Promise<void> {
461
- this._proxy.setPreviewScript(script);
462
- }
463
-
464
- async clearPreviewScript(): Promise<void> {
465
- this._proxy.setPreviewScript(null);
466
- }
467
-
468
- /* ---- port() ---- */
469
-
470
- // Returns the preview URL for a server on this port, or null
471
- port(num: number): string | null {
472
- if (this._proxy.activePorts().includes(num)) {
473
- return this._proxy.serverUrl(num);
474
- }
475
- return null;
476
- }
477
-
478
- /* ---- snapshot / restore ---- */
479
-
480
- snapshot(): Snapshot {
481
- return this._volume.toSnapshot();
482
- }
483
-
484
- restore(snapshot: Snapshot): void {
485
- // No clearAll on MemoryVolume, so just swap the internal tree
486
- const fresh = MemoryVolume.fromSnapshot(snapshot);
487
- (this._volume as any).tree = (fresh as any).tree;
488
- }
489
-
490
- /* ---- teardown ---- */
491
-
492
- teardown(): void {
493
- if (this._unwatchVFS) {
494
- this._unwatchVFS();
495
- this._unwatchVFS = null;
496
- }
497
- this._engine.clearCache();
498
- this._processManager.teardown();
499
- }
500
-
501
- /* ---- Escape hatches ---- */
502
-
503
- get volume(): MemoryVolume { return this._volume; }
504
- get engine(): ScriptEngine { return this._engine; }
505
- get packages(): DependencyInstaller { return this._packages; }
506
- get proxy(): RequestProxy { return this._proxy; }
507
- get processManager(): ProcessManager { return this._processManager; }
508
- get cwd(): string { return this._cwd; }
509
- }
1
+ import { MemoryVolume } from "../memory-volume";
2
+ import { ScriptEngine } from "../script-engine";
3
+ import { DependencyInstaller } from "../packages/installer";
4
+ import { RequestProxy, getProxyInstance, type IVirtualServer } from "../request-proxy";
5
+ import type { VolumeSnapshot } from "../engine-types";
6
+ import { Buffer } from "../polyfills/buffer";
7
+ import type {
8
+ NodepodOptions,
9
+ TerminalOptions,
10
+ Snapshot,
11
+ SnapshotOptions,
12
+ SpawnOptions,
13
+ } from "./types";
14
+ import { NodepodFS } from "./nodepod-fs";
15
+ import { NodepodProcess } from "./nodepod-process";
16
+ import { NodepodTerminal } from "./nodepod-terminal";
17
+ import { ProcessManager } from "../threading/process-manager";
18
+ import type { ProcessHandle } from "../threading/process-handle";
19
+ import { VFSBridge } from "../threading/vfs-bridge";
20
+ import { isSharedArrayBufferAvailable, SharedVFSController } from "../threading/shared-vfs";
21
+ import { SyncChannelController } from "../threading/sync-channel";
22
+
23
+ // Lazy-load child_process so the shell doesn't get pulled in at import time
24
+ let _shellMod: typeof import("../polyfills/child_process") | null = null;
25
+ async function getShellMod() {
26
+ if (!_shellMod) _shellMod = await import("../polyfills/child_process");
27
+ return _shellMod;
28
+ }
29
+
30
+ export class Nodepod {
31
+ readonly fs: NodepodFS;
32
+
33
+ private _volume: MemoryVolume;
34
+ private _engine: ScriptEngine;
35
+ private _packages: DependencyInstaller;
36
+ private _proxy: RequestProxy;
37
+ private _cwd: string;
38
+
39
+ private _processManager: ProcessManager;
40
+ private _vfsBridge: VFSBridge;
41
+ private _sharedVFS: SharedVFSController | null = null;
42
+ private _syncChannel: SyncChannelController | null = null;
43
+ private _unwatchVFS: (() => void) | null = null;
44
+
45
+ /* ---- Construction (use Nodepod.boot()) ---- */
46
+
47
+ private constructor(
48
+ volume: MemoryVolume,
49
+ engine: ScriptEngine,
50
+ packages: DependencyInstaller,
51
+ proxy: RequestProxy,
52
+ cwd: string,
53
+ ) {
54
+ this._volume = volume;
55
+ this._engine = engine;
56
+ this._packages = packages;
57
+ this._proxy = proxy;
58
+ this._cwd = cwd;
59
+ this.fs = new NodepodFS(volume);
60
+ this._processManager = new ProcessManager(volume);
61
+ this._vfsBridge = new VFSBridge(volume);
62
+
63
+ this._vfsBridge.setBroadcaster((path, content, excludePid) => {
64
+ const isDirectory = content !== null && content.byteLength === 0;
65
+ this._processManager.broadcastVFSChange(path, content, isDirectory, excludePid);
66
+ });
67
+
68
+ this._processManager.setVFSBridge(this._vfsBridge);
69
+
70
+ // VFS watcher broadcasts main-thread file changes to workers (needed for HMR)
71
+ this._unwatchVFS = this._vfsBridge.watch();
72
+
73
+ if (isSharedArrayBufferAvailable()) {
74
+ try {
75
+ this._sharedVFS = new SharedVFSController();
76
+ this._processManager.setSharedBuffer(this._sharedVFS.buffer);
77
+ this._vfsBridge.setSharedVFS(this._sharedVFS);
78
+ } catch (e) {
79
+ // COOP/COEP headers probably missing
80
+ }
81
+
82
+ try {
83
+ this._syncChannel = new SyncChannelController();
84
+ this._processManager.setSyncBuffer(this._syncChannel.buffer);
85
+ } catch (e) {
86
+ // SyncChannel init failed
87
+ }
88
+ }
89
+
90
+ // Bridge worker HTTP servers to the RequestProxy for preview URLs
91
+ this._processManager.on("server-listen", (_pid: number, port: number, _hostname: string) => {
92
+ const proxyServer: IVirtualServer = {
93
+ listening: true,
94
+ address: () => ({ port, address: "0.0.0.0", family: "IPv4" }),
95
+ dispatchRequest: async (method, url, headers, body) => {
96
+ const bodyStr = body ? (typeof body === "string" ? body : body.toString("utf8")) : null;
97
+ const result = await this._processManager.dispatchHttpRequest(
98
+ port, method, url, headers, bodyStr,
99
+ );
100
+ // Body can be ArrayBuffer (binary) or string (text)
101
+ const respBody = result.body instanceof ArrayBuffer
102
+ ? Buffer.from(new Uint8Array(result.body))
103
+ : Buffer.from(result.body);
104
+ return {
105
+ statusCode: result.statusCode,
106
+ statusMessage: result.statusMessage,
107
+ headers: result.headers,
108
+ body: respBody,
109
+ };
110
+ },
111
+ };
112
+ this._proxy.register(proxyServer, port);
113
+ });
114
+
115
+ this._processManager.on("server-close", (_pid: number, port: number) => {
116
+ this._proxy.unregister(port);
117
+ });
118
+
119
+ this._proxy.setProcessManager(this._processManager);
120
+ }
121
+
122
+ /* ---- Static factory ---- */
123
+
124
+ static async boot(opts: NodepodOptions = {}): Promise<Nodepod> {
125
+ if (typeof Worker === "undefined") {
126
+ throw new Error("[Nodepod] Web Workers are required. Nodepod cannot run without Web Worker support.");
127
+ }
128
+ if (typeof SharedArrayBuffer === "undefined") {
129
+ throw new Error("[Nodepod] SharedArrayBuffer is required. Ensure Cross-Origin-Isolation headers are set (Cross-Origin-Opener-Policy: same-origin, Cross-Origin-Embedder-Policy: credentialless).");
130
+ }
131
+
132
+ const cwd = opts.workdir ?? "/";
133
+ const volume = new MemoryVolume();
134
+ const engine = new ScriptEngine(volume, {
135
+ cwd,
136
+ env: opts.env,
137
+ });
138
+ const packages = new DependencyInstaller(volume);
139
+ const proxy = getProxyInstance({
140
+ onServerReady: opts.onServerReady,
141
+ });
142
+
143
+ const nodepod = new Nodepod(volume, engine, packages, proxy, cwd);
144
+
145
+ if (opts.files) {
146
+ for (const [path, content] of Object.entries(opts.files)) {
147
+ const dir = path.substring(0, path.lastIndexOf("/")) || "/";
148
+ if (dir !== "/" && !volume.existsSync(dir)) {
149
+ volume.mkdirSync(dir, { recursive: true });
150
+ }
151
+ volume.writeFileSync(path, content as any);
152
+ }
153
+ }
154
+
155
+ if (cwd !== "/" && !volume.existsSync(cwd)) {
156
+ volume.mkdirSync(cwd, { recursive: true });
157
+ }
158
+
159
+ for (const dir of ["/tmp", "/home"]) {
160
+ if (!volume.existsSync(dir)) {
161
+ volume.mkdirSync(dir, { recursive: true });
162
+ }
163
+ }
164
+
165
+ const shell = await getShellMod();
166
+ shell.initShellExec(volume, { cwd, env: opts.env });
167
+
168
+ if (
169
+ opts.swUrl &&
170
+ typeof navigator !== "undefined" &&
171
+ "serviceWorker" in navigator
172
+ ) {
173
+ try {
174
+ await proxy.initServiceWorker({ swUrl: opts.swUrl });
175
+ // Watermark is on by default — only disable if explicitly set to false
176
+ if (opts.watermark === false) {
177
+ proxy.setWatermark(false);
178
+ }
179
+ } catch (e) {
180
+ // SW registration failed, non-fatal
181
+ }
182
+ }
183
+
184
+ return nodepod;
185
+ }
186
+
187
+ /* ---- spawn() ---- */
188
+
189
+ // Each spawn gets a dedicated worker with its own engine + shell
190
+ async spawn(
191
+ cmd: string,
192
+ args?: string[],
193
+ opts?: SpawnOptions,
194
+ ): Promise<NodepodProcess> {
195
+ const proc = new NodepodProcess();
196
+ const execCwd = opts?.cwd ?? this._cwd;
197
+
198
+ const handle = this._processManager.spawn({
199
+ command: cmd,
200
+ args: args ?? [],
201
+ cwd: execCwd,
202
+ env: opts?.env ?? {},
203
+ });
204
+
205
+ handle.on("stdout", (data: string) => {
206
+ if (!proc.exited) proc._pushStdout(data);
207
+ });
208
+
209
+ handle.on("stderr", (data: string) => {
210
+ if (!proc.exited) proc._pushStderr(data);
211
+ });
212
+
213
+ handle.on("exit", (exitCode: number) => {
214
+ if (!proc.exited) proc._finish(exitCode);
215
+ });
216
+
217
+ handle.on("worker-error", (message: string) => {
218
+ if (!proc.exited) {
219
+ proc._pushStderr(`Worker error: ${message}\n`);
220
+ proc._finish(1);
221
+ }
222
+ });
223
+
224
+ proc._setSendStdin((data: string) => handle.sendStdin(data));
225
+ proc._setKillFn(() => handle.kill("SIGINT"));
226
+
227
+ if (opts?.signal) {
228
+ opts.signal.addEventListener("abort", () => {
229
+ handle.kill("SIGINT");
230
+ }, { once: true });
231
+ }
232
+
233
+ await new Promise<void>((resolve) => {
234
+ if (handle.state === "running") {
235
+ resolve();
236
+ } else {
237
+ handle.on("ready", () => resolve());
238
+ }
239
+ });
240
+
241
+ const isNodeCmd = cmd === "node" && args?.length;
242
+ if (isNodeCmd) {
243
+ const filePath = this._resolveCommand(cmd, args);
244
+ handle.exec({
245
+ type: "exec",
246
+ filePath,
247
+ args: args ?? [],
248
+ cwd: execCwd,
249
+ env: opts?.env,
250
+ isShell: false,
251
+ });
252
+ } else {
253
+ const fullCmd = args?.length ? `${cmd} ${args.join(" ")}` : cmd;
254
+ handle.exec({
255
+ type: "exec",
256
+ filePath: "",
257
+ args: args ?? [],
258
+ cwd: execCwd,
259
+ env: opts?.env,
260
+ isShell: true,
261
+ shellCommand: fullCmd,
262
+ });
263
+ }
264
+
265
+ return proc;
266
+ }
267
+
268
+ private _resolveCommand(cmd: string, args?: string[]): string {
269
+ if (cmd === "node" && args?.length) {
270
+ const filePath = args[0];
271
+ if (filePath.startsWith("/")) return filePath;
272
+ return `${this._cwd}/${filePath}`.replace(/\/+/g, "/");
273
+ }
274
+ return cmd;
275
+ }
276
+
277
+ /* ---- createTerminal() ---- */
278
+
279
+ createTerminal(opts: TerminalOptions): NodepodTerminal {
280
+ const terminal = new NodepodTerminal(opts);
281
+ terminal.setCwd(this._cwd);
282
+
283
+ let activeAbort: AbortController | null = null;
284
+ let currentSendStdin: ((data: string) => void) | null = null;
285
+ let activeCommandId = 0;
286
+ const nextCommandId = () => { activeCommandId = (activeCommandId + 1) % Number.MAX_SAFE_INTEGER; return activeCommandId; };
287
+ let isStdinRaw = false;
288
+
289
+ // Persistent shell worker -- reused across commands so VFS state persists
290
+ // and we skip the ~1s worker creation overhead per command
291
+ let shellHandle: ProcessHandle | null = null;
292
+ let shellReady: Promise<void> | null = null;
293
+
294
+ const ensureShellWorker = (): Promise<void> => {
295
+ if (shellHandle && shellHandle.state !== "exited") {
296
+ return shellReady!;
297
+ }
298
+ shellHandle = this._processManager.spawn({
299
+ command: "shell",
300
+ args: [],
301
+ cwd: this._cwd,
302
+ env: {},
303
+ });
304
+ shellReady = new Promise<void>((resolve) => {
305
+ if (shellHandle!.state === "running") {
306
+ resolve();
307
+ } else {
308
+ shellHandle!.on("ready", () => resolve());
309
+ }
310
+ });
311
+
312
+ shellHandle.on("cwd-change", (cwd: string) => {
313
+ this._cwd = cwd;
314
+ terminal.setCwd(cwd);
315
+ });
316
+
317
+ shellHandle.on("stdin-raw-status", (raw: boolean) => {
318
+ isStdinRaw = raw;
319
+ });
320
+
321
+ // Worker died -- next command will spawn a fresh one
322
+ shellHandle.on("exit", () => {
323
+ shellHandle = null;
324
+ shellReady = null;
325
+ });
326
+
327
+ return shellReady;
328
+ };
329
+
330
+ terminal._wireExecution({
331
+ onCommand: async (cmd: string) => {
332
+ const myAbort = new AbortController();
333
+ activeAbort = myAbort;
334
+ const myCommandId = nextCommandId();
335
+
336
+ let streamed = false;
337
+ let wroteNewline = false;
338
+
339
+ function ensureNewline() {
340
+ if (!wroteNewline) {
341
+ wroteNewline = true;
342
+ terminal.write("\r\n");
343
+ }
344
+ }
345
+
346
+ // Ensure persistent shell worker is running
347
+ await ensureShellWorker();
348
+ const handle = shellHandle!;
349
+
350
+ // Ignore output from previous commands or before exec is sent (stale child output)
351
+ let execSent = false;
352
+ const onStdout = (data: string) => {
353
+ if (myCommandId !== activeCommandId) return;
354
+ if (!execSent) return;
355
+ streamed = true;
356
+ ensureNewline();
357
+ terminal._writeOutput(data);
358
+ };
359
+ const onStderr = (data: string) => {
360
+ if (myCommandId !== activeCommandId) return;
361
+ if (!execSent) return;
362
+ streamed = true;
363
+ ensureNewline();
364
+ terminal._writeOutput(data, true);
365
+ };
366
+
367
+ handle.on("stdout", onStdout);
368
+ handle.on("stderr", onStderr);
369
+
370
+ currentSendStdin = (data: string) => handle.sendStdin(data);
371
+
372
+ // PM.kill() recursively kills descendants + cleans up server ports
373
+ myAbort.signal.addEventListener("abort", () => {
374
+ this._processManager.kill(handle.pid, "SIGINT");
375
+ }, { once: true });
376
+
377
+ handle.exec({
378
+ type: "exec",
379
+ filePath: "",
380
+ args: [],
381
+ cwd: this._cwd,
382
+ isShell: true,
383
+ shellCommand: cmd,
384
+ persistent: true,
385
+ });
386
+ execSent = true;
387
+
388
+ return new Promise<void>((resolve) => {
389
+ const cleanup = () => {
390
+ handle.removeListener("shell-done", onDone);
391
+ handle.removeListener("exit", onExit);
392
+ handle.removeListener("stdout", onStdout);
393
+ handle.removeListener("stderr", onStderr);
394
+ };
395
+
396
+ const onDone = (exitCode: number, stdout: string, stderr: string) => {
397
+ cleanup();
398
+ const isStale = myCommandId !== activeCommandId;
399
+ if (!isStale) {
400
+ currentSendStdin = null;
401
+ }
402
+
403
+ const aborted = myAbort.signal.aborted;
404
+
405
+ if (!aborted && !streamed && !isStale) {
406
+ const outStr = String(stdout ?? "");
407
+ const errStr = String(stderr ?? "");
408
+ if (outStr || errStr) ensureNewline();
409
+ if (outStr) terminal._writeOutput(outStr);
410
+ if (errStr) terminal._writeOutput(errStr, true);
411
+ }
412
+
413
+ if (activeAbort === myAbort) activeAbort = null;
414
+
415
+ if (!aborted && !isStale) {
416
+ if (!wroteNewline) terminal.write("\r\n");
417
+ terminal._setRunning(false);
418
+ terminal._writePrompt();
419
+ }
420
+ resolve();
421
+ };
422
+
423
+ const onExit = (exitCode: number, stdout: string, stderr: string) => {
424
+ cleanup();
425
+ const isStale = myCommandId !== activeCommandId;
426
+ if (!isStale) currentSendStdin = null;
427
+ const aborted = myAbort.signal.aborted;
428
+ if (!aborted && !streamed && !isStale) {
429
+ const outStr = String(stdout ?? "");
430
+ const errStr = String(stderr ?? "");
431
+ if (outStr || errStr) ensureNewline();
432
+ if (outStr) terminal._writeOutput(outStr);
433
+ if (errStr) terminal._writeOutput(errStr, true);
434
+ }
435
+ if (activeAbort === myAbort) activeAbort = null;
436
+ if (!aborted && !isStale) {
437
+ if (!wroteNewline) terminal.write("\r\n");
438
+ terminal._setRunning(false);
439
+ terminal._writePrompt();
440
+ }
441
+ resolve();
442
+ };
443
+
444
+ handle.on("shell-done", onDone);
445
+ handle.on("exit", onExit);
446
+ });
447
+ },
448
+
449
+ getSendStdin: () => currentSendStdin,
450
+ getIsStdinRaw: () => isStdinRaw,
451
+ getActiveAbort: () => activeAbort,
452
+ setActiveAbort: (ac) => {
453
+ activeAbort = ac;
454
+ },
455
+ });
456
+
457
+ return terminal;
458
+ }
459
+
460
+ /* ---- setPreviewScript() ---- */
461
+
462
+ // Inject a script into every preview iframe before any page content loads.
463
+ // Useful for setting up a communication bridge between the main window and
464
+ // the preview iframe, injecting polyfills, analytics, etc.
465
+ async setPreviewScript(script: string): Promise<void> {
466
+ this._proxy.setPreviewScript(script);
467
+ }
468
+
469
+ async clearPreviewScript(): Promise<void> {
470
+ this._proxy.setPreviewScript(null);
471
+ }
472
+
473
+ /* ---- port() ---- */
474
+
475
+ // Returns the preview URL for a server on this port, or null
476
+ port(num: number): string | null {
477
+ if (this._proxy.activePorts().includes(num)) {
478
+ return this._proxy.serverUrl(num);
479
+ }
480
+ return null;
481
+ }
482
+
483
+ /* ---- snapshot / restore ---- */
484
+
485
+ private static readonly SHALLOW_EXCLUDE = ['/node_modules'];
486
+
487
+ snapshot(opts?: SnapshotOptions): Snapshot {
488
+ const shallow = opts?.shallow ?? true;
489
+ const excludes = shallow ? Nodepod.SHALLOW_EXCLUDE : undefined;
490
+ return this._volume.toSnapshot(excludes);
491
+ }
492
+
493
+ async restore(snapshot: Snapshot, opts?: SnapshotOptions): Promise<void> {
494
+ const autoInstall = opts?.autoInstall ?? true;
495
+
496
+ // Swap the internal tree
497
+ const fresh = MemoryVolume.fromSnapshot(snapshot);
498
+ (this._volume as any).tree = (fresh as any).tree;
499
+
500
+ // Auto-install deps from package.json if requested and manifest exists
501
+ if (autoInstall && this._volume.existsSync('/package.json')) {
502
+ await this._packages.installFromManifest();
503
+ }
504
+ }
505
+
506
+ /* ---- teardown ---- */
507
+
508
+ teardown(): void {
509
+ if (this._unwatchVFS) {
510
+ this._unwatchVFS();
511
+ this._unwatchVFS = null;
512
+ }
513
+ this._engine.clearCache();
514
+ this._processManager.teardown();
515
+ }
516
+
517
+ /* ---- Escape hatches ---- */
518
+
519
+ get volume(): MemoryVolume { return this._volume; }
520
+ get engine(): ScriptEngine { return this._engine; }
521
+ get packages(): DependencyInstaller { return this._packages; }
522
+ get proxy(): RequestProxy { return this._proxy; }
523
+ get processManager(): ProcessManager { return this._processManager; }
524
+ get cwd(): string { return this._cwd; }
525
+ }