@scelar/nodepod 1.0.3 → 1.0.5

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