@volter/tabnode 0.5.27 → 0.5.29

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/src/runtime.ts CHANGED
@@ -117,6 +117,29 @@ function __substrateGuestConstructor(Constructor: unknown, process: Process): un
117
117
  return __substrateFunctionScope(__substrateGuestGlobal(process), String(compiled));
118
118
  } });
119
119
  }
120
+ /**
121
+ * `vm.runInThisContext` runs its script in the calling process's global scope,
122
+ * as Node's does. The shim evaluated it in the host's, so a script saw the
123
+ * worker's own timers: jiti evaluates every module it loads this way, and
124
+ * pi-mcp-adapter's `setInterval(...).unref()` threw "unref is not a function"
125
+ * because the browser's `setInterval` returns a number. The script runs inside
126
+ * the process's guest global, as a guest `Function` does
127
+ * (`__substrateGuestConstructor`).
128
+ */
129
+ const __substrateScriptScope = Function("__substrateScriptGlobals", "__substrateScriptSource", "with (__substrateScriptGlobals) { return eval(__substrateScriptSource); }");
130
+ const __substrateGuestVms = new WeakMap<Process, unknown>();
131
+ function __substrateGuestVm(process: Process): unknown {
132
+ let vm = __substrateGuestVms.get(process);
133
+ if (vm) return vm;
134
+ const run = (code: string): unknown => __substrateScriptScope(__substrateGuestGlobal(process), String(code));
135
+ class Script extends vmShim.Script {
136
+ override runInThisContext(_options?: object): unknown { return run((this as unknown as { code: string }).code); }
137
+ }
138
+ vm = { ...vmShim, Script, runInThisContext: (code: string, _options?: object) => run(code), default: undefined };
139
+ (vm as { default: unknown }).default = vm;
140
+ __substrateGuestVms.set(process, vm);
141
+ return vm;
142
+ }
120
143
  /**
121
144
  * A `.ts`, `.mts` or `.cts` file runs with its types stripped, as Node runs
122
145
  * one since 22.18, before the module's imports are read -- the entry a `node`
@@ -413,6 +436,97 @@ function collectDirectEval(node: any, replacements: Array<[number, number, strin
413
436
  replacements.push([source.end, source.end, ")"]);
414
437
  }
415
438
 
439
+ /**
440
+ * Node's AsyncLocalStorage context follows an `await`; in a tab nothing can
441
+ * observe one (see shims/async_hooks), so the body follows it itself. An async
442
+ * function takes its frame on entry and puts it back wherever it resumes:
443
+ * after each `await`, at the start of a `catch` or `finally` (a rejected
444
+ * `await` resumes there), and in and after a `for await`. An async arrow with
445
+ * an expression body takes the frame at each `await`, which is the same
446
+ * frame. Playwright names each API call from a zone it keeps in one; a zone
447
+ * left over from another call made its errors lose `locator.click:`. Only
448
+ * insertions, and no line breaks, so a stack frame keeps its line.
449
+ */
450
+ const ASYNC_FRAME = '__substrateAsyncFrame';
451
+ // Every fragment the pass inserts is one of these strings or ends in the
452
+ // marker, so a function's source can be given back as it was written (below).
453
+ const FOLLOW_MARK = '/*substrate*/';
454
+ const FOLLOW_TAKE = `const ${ASYNC_FRAME}=__substrateContext();`;
455
+ const FOLLOW_RESTORE = `__substrateRestore(${ASYNC_FRAME});`;
456
+ const FOLLOW_RESUME_FRAMED = `__substrateResume(${ASYNC_FRAME},`;
457
+ const FOLLOW_RESUME_TAKEN = '__substrateResume(__substrateContext(),';
458
+ function __substrateFollowAwaits(code: string): string {
459
+ if (!/\bawait\b/.test(code)) return code;
460
+ let ast;
461
+ try {
462
+ ast = acorn.parse(code, { ecmaVersion: "latest", sourceType: "script", allowReturnOutsideFunction: true, allowHashBang: true, allowAwaitOutsideFunction: true });
463
+ } catch {
464
+ return code;
465
+ }
466
+ const edits: Array<[number, number, string]> = [];
467
+ const insert = (at: number, text: string): void => { edits.push([at, at, text]); };
468
+ const isFunction = (node: any): boolean => node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression';
469
+ // `framed`: the nearest function is async and holds the frame in ASYNC_FRAME.
470
+ const visit = (node: any, framed: boolean, parent: any): void => {
471
+ if (isFunction(node)) {
472
+ framed = node.async && node.body.type === 'BlockStatement';
473
+ if (framed) {
474
+ const directives = node.body.body.filter((statement: any) => statement.directive !== undefined);
475
+ const at = directives.length ? directives[directives.length - 1].end : node.body.start + 1;
476
+ insert(at, FOLLOW_TAKE);
477
+ }
478
+ } else if (node.type === 'AwaitExpression') {
479
+ insert(node.start, framed ? FOLLOW_RESUME_FRAMED : FOLLOW_RESUME_TAKEN);
480
+ insert(node.end, `${FOLLOW_MARK})`);
481
+ } else if (framed && node.type === 'CatchClause') {
482
+ insert(node.body.start + 1, FOLLOW_RESTORE);
483
+ } else if (framed && node.type === 'TryStatement' && node.finalizer) {
484
+ insert(node.finalizer.start + 1, FOLLOW_RESTORE);
485
+ } else if (framed && node.type === 'ForOfStatement' && node.await) {
486
+ // The loop's own wrap first: where its end is the body's, an insertion
487
+ // made later lands before it, so the body's brace closes inside.
488
+ const whole = parent?.type === 'LabeledStatement' ? parent : node;
489
+ insert(whole.start, `{${FOLLOW_MARK}`);
490
+ insert(whole.end, `;${FOLLOW_RESTORE}${FOLLOW_MARK}}`);
491
+ if (node.body.type === 'BlockStatement') insert(node.body.start + 1, FOLLOW_RESTORE);
492
+ else { insert(node.body.start, `{${FOLLOW_MARK}${FOLLOW_RESTORE}`); insert(node.body.end, `${FOLLOW_MARK}}`); }
493
+ }
494
+ for (const key of Object.keys(node)) {
495
+ if (key === 'type' || key === 'start' || key === 'end' || key === 'loc' || key === 'range') continue;
496
+ const child = node[key];
497
+ if (!child || typeof child !== 'object') continue;
498
+ if (Array.isArray(child)) {
499
+ for (const item of child) if (item && typeof item.type === 'string') visit(item, framed, node);
500
+ } else if (typeof child.type === 'string') visit(child, framed, node);
501
+ }
502
+ };
503
+ visit(ast, false, undefined);
504
+ return edits.length ? applyReplacements(code, edits) : code;
505
+ }
506
+
507
+ /**
508
+ * A function's source as it was written. Playwright sends the source of the
509
+ * function a test evaluates to the browser, where the pass's calls do not
510
+ * exist; the pass only inserts, so removing its fragments gives the text back.
511
+ */
512
+ function __substrateUnfollowAwaits(source: string): string {
513
+ if (!source.includes('__substrate')) return source;
514
+ return source
515
+ .replaceAll(`;${FOLLOW_RESTORE}${FOLLOW_MARK}}`, '')
516
+ .replaceAll(`{${FOLLOW_MARK}${FOLLOW_RESTORE}`, '')
517
+ .replaceAll(`{${FOLLOW_MARK}`, '')
518
+ .replaceAll(`${FOLLOW_MARK}}`, '')
519
+ .replaceAll(`${FOLLOW_MARK})`, '')
520
+ .replaceAll(FOLLOW_TAKE, '')
521
+ .replaceAll(FOLLOW_RESTORE, '')
522
+ .replaceAll(FOLLOW_RESUME_FRAMED, '')
523
+ .replaceAll(FOLLOW_RESUME_TAKEN, '');
524
+ }
525
+ forGuestRealm(() => {
526
+ const native = Function.prototype.toString;
527
+ takeFromHost(Function.prototype, 'toString', function toString(this: unknown): string { return __substrateUnfollowAwaits(native.call(this)); });
528
+ });
529
+
416
530
  /** Applies insertions and replacements from the end, so earlier offsets hold. */
417
531
  function applyReplacements(code: string, replacements: Array<[number, number, string]>): string {
418
532
  replacements.sort((a, b) => b[0] - a[0] || b[1] - a[1]);
@@ -425,7 +539,7 @@ Object.defineProperty(globalThis, "__substrateEvalSource", {
425
539
  configurable: true,
426
540
  value: (source: unknown): unknown => typeof source === "string" ? __substrateRewriteDynamicImportsInScript(source) : source,
427
541
  });
428
- /** A file's source made a body: shebang stripped, types stripped, ESM lowered, dynamic imports rewritten. */
542
+ /** A file's source made a body: shebang stripped, types stripped, ESM lowered, dynamic imports rewritten, awaits followed. */
429
543
  function __substratePrepareBody(rawCode: string, resolvedPath: string, format: string | undefined, process: unknown): string {
430
544
  let code = rawCode;
431
545
 
@@ -456,11 +570,15 @@ function __substratePrepareBody(rawCode: string, resolvedPath: string, format: s
456
570
  // A `.cjs` module skips the ESM transform, and with it the rewrite of
457
571
  // `import(...)` to the engine's dynamic import, so its
458
572
  // `import("fs/promises")` reached the browser's own import and failed on
459
- // the bare specifier. The dynamic-import rewrite applies to `.cjs` too.
460
- code = transformDynamicImportsRegex(code);
573
+ // the bare specifier. The dynamic-import rewrite applies to `.cjs` too,
574
+ // from the syntax tree as for every other CommonJS module: the text
575
+ // rewrite also renamed a method called `import`, and jiti's
576
+ // `async import(e,t){...}` became `__dynamicImport`, so `jiti.import` was
577
+ // undefined and every Pi extension failed to load.
578
+ code = __substrateRewriteDynamicImportsInScript(code);
461
579
  }
462
580
 
463
- return code;
581
+ return __substrateFollowAwaits(code);
464
582
  }
465
583
 
466
584
  /**
@@ -472,7 +590,7 @@ function __substratePrepareBody(rawCode: string, resolvedPath: string, format: s
472
590
  * place of the passes when the file it read hashes to one.
473
591
  */
474
592
  export const PREPARED_MODULES_DIR = '/.tabnode/prepared';
475
- const PREPARED_MODULES_FORMAT = 'tabnode-prepared-1';
593
+ const PREPARED_MODULES_FORMAT = 'tabnode-prepared-2';
476
594
  /** The name a prepared body goes under: the hash of the file as read, and how it is compiled. Undefined for a file no body is prepared for. */
477
595
  export function preparedModuleKey(rawCode: string, resolvedPath: string): string | undefined {
478
596
  const extension = /\.(js|cjs|mjs)$/u.exec(resolvedPath)?.[1];
@@ -726,6 +844,27 @@ async function __substrateImportChain(
726
844
  return doors.__loadFromURL(resolved.url, loaded.format ?? resolved.format, loaded.source ?? void 0);
727
845
  }
728
846
 
847
+ /**
848
+ * A module's `import.meta`: `url`, `dirname`, `filename` and Node's synchronous
849
+ * `resolve` (Node 20.6+). Pi's extension loader resolves its own packages with
850
+ * `import.meta.resolve(specifier)`, and without it every Pi extension failed to
851
+ * load with "import_meta.resolve is not a function". A relative or URL specifier
852
+ * resolves against the module's URL, a builtin to `node:<name>`, and a bare one
853
+ * through the module's own resolver, the one `import()` uses here. Unlike Node,
854
+ * a relative specifier naming a missing file resolves instead of throwing.
855
+ */
856
+ function createImportMeta(moduleRequire: RequireFunction, url: string, dirname: string, filename: string): Record<string, unknown> {
857
+ const resolve = (specifier: unknown, parent?: unknown): string => {
858
+ const id = typeof specifier === 'string' ? specifier : String(specifier);
859
+ const base = parent === undefined ? url : String(parent);
860
+ if (id.startsWith('./') || id.startsWith('../') || id.startsWith('/')) return new URL(id, base).href;
861
+ if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(id)) return id.startsWith('node:') ? id : new URL(id).href;
862
+ if (Object.prototype.hasOwnProperty.call(builtinModules, id)) return `node:${id}`;
863
+ return 'file://' + moduleRequire.resolve(id);
864
+ };
865
+ return { url, dirname, filename, resolve };
866
+ }
867
+
729
868
  function createDynamicImport(moduleRequire: RequireFunction, process: Process, parentURL?: string): (specifier: unknown) => Promise<unknown> {
730
869
  return async (specifier: unknown): Promise<unknown> => {
731
870
  try {
@@ -1962,7 +2101,7 @@ function createRequire(
1962
2101
  dirname,
1963
2102
  process,
1964
2103
  consoleWrapper,
1965
- { url: importMetaUrl, dirname, filename: resolvedPath },
2104
+ createImportMeta(moduleRequire, importMetaUrl, dirname, resolvedPath),
1966
2105
  dynamicImport,
1967
2106
  __substrateGuestGlobal,
1968
2107
  __substrateGuestConstructor
@@ -2130,6 +2269,11 @@ function createRequire(
2130
2269
  if (id === 'module') {
2131
2270
  return __substrateModule();
2132
2271
  }
2272
+ // Ahead of the per-process builtin table: node-lib's own modules require
2273
+ // `vm` first and that table keeps the first answer for the process.
2274
+ if (id === 'vm') {
2275
+ return __substrateGuestVm(process);
2276
+ }
2133
2277
  if (id === 'wasi') {
2134
2278
  return __substrateGuestWasi(fsShim, process);
2135
2279
  }
@@ -2445,8 +2589,12 @@ export class Runtime {
2445
2589
  // A `.cjs` module skips the ESM transform, and with it the rewrite of
2446
2590
  // `import(...)` to the engine's dynamic import, so its
2447
2591
  // `import("fs/promises")` reached the browser's own import and failed on
2448
- // the bare specifier. The dynamic-import rewrite applies to `.cjs` too.
2449
- code = transformDynamicImportsRegex(code);
2592
+ // the bare specifier. The dynamic-import rewrite applies to `.cjs` too,
2593
+ // from the syntax tree as for every other CommonJS module: the text
2594
+ // rewrite also renamed a method called `import`, and jiti's
2595
+ // `async import(e,t){...}` became `__dynamicImport`, so `jiti.import` was
2596
+ // undefined and every Pi extension failed to load.
2597
+ code = __substrateRewriteDynamicImportsInScript(code);
2450
2598
  }
2451
2599
 
2452
2600
  // Execute code
@@ -2492,7 +2640,7 @@ export class Runtime {
2492
2640
  dirname,
2493
2641
  this.process,
2494
2642
  consoleWrapper,
2495
- { url: importMetaUrl, dirname, filename },
2643
+ createImportMeta(require, importMetaUrl, dirname, filename),
2496
2644
  dynamicImport,
2497
2645
  __substrateGuestGlobal,
2498
2646
  __substrateGuestConstructor
@@ -1,109 +1,105 @@
1
1
  /**
2
- * async_hooks shim - Async tracking is not available in browser
2
+ * async_hooks shim: AsyncLocalStorage and AsyncResource carry context the way
3
+ * Node's do; the hooks themselves are not available in a browser.
3
4
  */
4
5
 
5
6
  import { forGuestRealm, takeFromHost } from '../host-globals';
6
7
 
7
- export class AsyncResource {
8
- constructor(_type: string, _options?: object) {}
9
-
10
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
- runInAsyncScope<T>(fn: (...args: any[]) => T, thisArg?: unknown, ...args: any[]): T {
12
- return fn.apply(thisArg, args);
13
- }
14
-
15
- emitDestroy(): this { return this; }
16
- asyncId(): number { return 0; }
17
- triggerAsyncId(): number { return 0; }
18
-
19
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
20
- static bind<T extends (...args: any[]) => any>(fn: T, _type?: string): T {
21
- return fn;
22
- }
23
- }
24
-
25
8
  /**
26
- * `AsyncLocalStorage.snapshot()` captures every storage's current store and
27
- * answers a function that runs a call inside that capture, which Next's
28
- * request-store constructors use to carry a request's context into the work
29
- * they schedule. These storages are store slots with no such capture; every
30
- * storage registers itself, and a snapshot is the slots as they are, restored
31
- * around the call and put back after.
9
+ * The context a continuation runs in: every storage's store, as one frame.
10
+ * `run` makes a new frame for its callback and puts the old one back when the
11
+ * callback returns, as Node's does; a frame is never changed after it is
12
+ * made, so a snapshot is the frame itself.
13
+ *
14
+ * A tab has no async hooks and no AsyncContext, and an `await` of a native
15
+ * promise bypasses any `then` a patch installs. The engine follows it at the
16
+ * source instead: each module body it compiles captures its async function's
17
+ * frame on entry and restores it where the function resumes (after each
18
+ * `await`, in a `catch` or `finally`, around a `for await`), through
19
+ * `__substrateResume` and `__substrateRestore` below. A continuation's frame
20
+ * holds only for the turn it runs in; the next turn starts from the root, as
21
+ * a macrotask of Node's does. Code the engine did not compile (its own) runs
22
+ * in whichever frame is current when it is called.
32
23
  */
33
- const __substrateAsyncLocalStorages = new Set<AsyncLocalStorage<unknown>>();
34
-
35
- export class AsyncLocalStorage<T> {
36
- private store: T | undefined;
37
-
38
- constructor() {
39
- __substrateAsyncLocalStorages.add(this as AsyncLocalStorage<unknown>);
24
+ type ContextFrame = ReadonlyMap<AsyncLocalStorage<unknown>, unknown>;
25
+ const ROOT_FRAME: ContextFrame = new Map();
26
+ let currentFrame: ContextFrame = ROOT_FRAME;
27
+ const nativeQueueMicrotask = globalThis.queueMicrotask.bind(globalThis);
28
+ let resetQueued = false;
29
+ const resetFrame = (): void => { resetQueued = false; currentFrame = ROOT_FRAME; };
30
+ /** A resumed continuation's frame, current until the turn it runs in ends. */
31
+ const resumeFrame = (frame: ContextFrame): void => {
32
+ if (frame === currentFrame) return;
33
+ currentFrame = frame;
34
+ if (!resetQueued) { resetQueued = true; nativeQueueMicrotask(resetFrame); }
35
+ };
36
+ const withFrame = <R>(frame: ContextFrame, callback: () => R): R => {
37
+ const previous = currentFrame;
38
+ currentFrame = frame;
39
+ try {
40
+ return callback();
41
+ } finally {
42
+ currentFrame = previous;
40
43
  }
44
+ };
41
45
 
46
+ export class AsyncLocalStorage<T> {
42
47
  static snapshot() {
43
- const captured = new Map<AsyncLocalStorage<unknown>, unknown>();
44
- for (const storage of __substrateAsyncLocalStorages) captured.set(storage, storage.store);
45
- return (callback: (...args: unknown[]) => unknown, ...args: unknown[]) => {
46
- const previous = new Map<AsyncLocalStorage<unknown>, unknown>();
47
- for (const [storage, store] of captured) { previous.set(storage, storage.store); storage.store = store; }
48
- try {
49
- return callback(...args);
50
- } finally {
51
- for (const [storage, store] of previous) storage.store = store;
52
- }
53
- };
48
+ const captured = currentFrame;
49
+ return (callback: (...args: unknown[]) => unknown, ...args: unknown[]) => withFrame(captured, () => callback(...args));
54
50
  }
55
51
  static bind(callback: (...args: unknown[]) => unknown) {
56
52
  const snapshot = AsyncLocalStorage.snapshot();
57
53
  return (...args: unknown[]) => snapshot(callback, ...args);
58
54
  }
59
55
 
60
- disable(): void {}
56
+ disable(): void {
57
+ if (!currentFrame.has(this as AsyncLocalStorage<unknown>)) return;
58
+ const next = new Map(currentFrame);
59
+ next.delete(this as AsyncLocalStorage<unknown>);
60
+ currentFrame = next;
61
+ }
61
62
 
62
63
  getStore(): T | undefined {
63
- return this.store;
64
+ return currentFrame.get(this as AsyncLocalStorage<unknown>) as T | undefined;
64
65
  }
65
66
 
66
- /**
67
- * A store entered for an async callback used to be left the moment the
68
- * callback returned its promise, so the first `await` inside a Server Action
69
- * lost Next's request store and `cookies()` was "outside a request scope". A
70
- * tab has no async_hooks and no AsyncContext, and an `await` of a native
71
- * promise bypasses any `then` a patch could install, so the continuations
72
- * cannot be followed. What can be done honestly is keep the store current
73
- * until the callback's promise settles: exact for one run at a time, and
74
- * last-entered-wins where two async runs overlap.
75
- */
76
67
  run<R>(store: T, callback: (...args: unknown[]) => R, ...args: unknown[]): R {
77
- const prev = this.store;
78
- this.store = store;
79
- let result;
80
- try {
81
- result = callback(...args);
82
- } catch (error) {
83
- this.store = prev;
84
- throw error;
85
- }
86
- if (result && typeof (result as { then?: unknown }).then === 'function') {
87
- const restore = () => { if (this.store === store) this.store = prev; };
88
- (result as unknown as Promise<unknown>).then(restore, restore);
89
- return result;
90
- }
91
- this.store = prev;
92
- return result;
68
+ const next = new Map(currentFrame);
69
+ next.set(this as AsyncLocalStorage<unknown>, store);
70
+ return withFrame(next, () => callback(...args));
93
71
  }
94
72
 
95
73
  exit<R>(callback: () => R): R {
96
- const prev = this.store;
97
- this.store = undefined;
98
- try {
99
- return callback();
100
- } finally {
101
- this.store = prev;
102
- }
74
+ const next = new Map(currentFrame);
75
+ next.delete(this as AsyncLocalStorage<unknown>);
76
+ return withFrame(next, callback);
103
77
  }
104
78
 
105
79
  enterWith(store: T): void {
106
- this.store = store;
80
+ const next = new Map(currentFrame);
81
+ next.set(this as AsyncLocalStorage<unknown>, store);
82
+ currentFrame = next;
83
+ }
84
+ }
85
+
86
+ /** Runs its callbacks in the context it was made in, as Node's does. */
87
+ export class AsyncResource {
88
+ private readonly frame = currentFrame;
89
+ constructor(_type: string, _options?: object) {}
90
+
91
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
92
+ runInAsyncScope<T>(fn: (...args: any[]) => T, thisArg?: unknown, ...args: any[]): T {
93
+ return withFrame(this.frame, () => fn.apply(thisArg, args));
94
+ }
95
+
96
+ emitDestroy(): this { return this; }
97
+ asyncId(): number { return 0; }
98
+ triggerAsyncId(): number { return 0; }
99
+
100
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
101
+ static bind<T extends (...args: any[]) => any>(fn: T, _type?: string): T {
102
+ return AsyncLocalStorage.bind(fn) as T;
107
103
  }
108
104
  }
109
105
 
@@ -112,10 +108,10 @@ export class AsyncLocalStorage<T> {
112
108
  * schedules: a timer, an immediate, a microtask, a nextTick, a promise
113
109
  * callback. A tab has no async hooks, so each of those is wrapped once to
114
110
  * capture the stores current when it was scheduled and restore them when it
115
- * runs. A native `await` is the one continuation this cannot follow, which
116
- * `run` covers by holding its store until the callback settles. React's
117
- * server renderer schedules its work on immediates and promises, and read
118
- * Next's request store through them.
111
+ * runs. A native `await` is the one continuation this cannot follow: the
112
+ * compiled module body follows it, through the three globals installed here.
113
+ * React's server renderer schedules its work on immediates and promises, and
114
+ * read Next's request store through them.
119
115
  *
120
116
  * The wrapping is a guest's, not the realm's. It used to run at load, so
121
117
  * importing the engine into a Node process replaced that process's own
@@ -145,6 +141,10 @@ forGuestRealm(() => {
145
141
  ): Promise<TFulfilled | TRejected> { return then.call(this, carried(onFulfilled), carried(onRejected)) as Promise<TFulfilled | TRejected>; };
146
142
  takeFromHost(Promise.prototype, 'then', carriedThen);
147
143
  takeFromHost(globalThis, '__substrateCarried', carried);
144
+ // An async function's frame, taken on entry; put back where it resumes.
145
+ takeFromHost(globalThis, '__substrateContext', () => currentFrame);
146
+ takeFromHost(globalThis, '__substrateResume', <V>(frame: ContextFrame, value: V): V => { resumeFrame(frame); return value; });
147
+ takeFromHost(globalThis, '__substrateRestore', (frame: ContextFrame): void => { resumeFrame(frame); });
148
148
  });
149
149
 
150
150
  export interface AsyncHook {
@@ -1143,7 +1143,26 @@ function readPackageJson(cwd: string): { pkgJson: PackageJson; error?: undefined
1143
1143
  * Handle `npm run [script]` — execute a script from package.json
1144
1144
  */
1145
1145
  async function handleNpmRun(args: string[], ctx: CommandContext): Promise<JustBashExecResult> {
1146
- const scriptName = args[0];
1146
+ // npm's own options come before the script's name and are npm's
1147
+ // (`npm run --silent vgai -- mcp .`, as a project's `.mcp.json` launches its
1148
+ // server); what follows the name goes to the script, everything after `--`
1149
+ // untouched. The name used to be `args[0]`, so `--silent` was looked up as a
1150
+ // script, and the script's own arguments were dropped.
1151
+ let scriptName: string | undefined;
1152
+ let silent = false;
1153
+ let ifPresent = false;
1154
+ const passed: string[] = [];
1155
+ for (let index = 0; index < args.length; index++) {
1156
+ const arg = args[index]!;
1157
+ if (arg === '--') { if (scriptName !== undefined) passed.push(...args.slice(index + 1)); break; }
1158
+ if (arg.startsWith('-') && arg !== '-') {
1159
+ if (arg === '--silent' || arg === '-s' || arg === '--quiet' || arg === '-q' || arg === '--loglevel=silent') silent = true;
1160
+ else if (arg === '--if-present') ifPresent = true;
1161
+ continue;
1162
+ }
1163
+ if (scriptName === undefined) scriptName = arg;
1164
+ else passed.push(arg);
1165
+ }
1147
1166
 
1148
1167
  // "npm run" with no script name: list available scripts
1149
1168
  if (!scriptName) {
@@ -1158,6 +1177,7 @@ async function handleNpmRun(args: string[], ctx: CommandContext): Promise<JustBa
1158
1177
  const scriptCommand = scripts[scriptName];
1159
1178
 
1160
1179
  if (!scriptCommand) {
1180
+ if (ifPresent) return { stdout: '', stderr: '', exitCode: 0 };
1161
1181
  const available = Object.keys(scripts);
1162
1182
  let msg = `npm ERR! Missing script: "${scriptName}"\n`;
1163
1183
  if (available.length > 0) {
@@ -1193,7 +1213,7 @@ async function handleNpmRun(args: string[], ctx: CommandContext): Promise<JustBa
1193
1213
  // Run pre<script> if it exists
1194
1214
  const preScript = scripts[`pre${scriptName}`];
1195
1215
  if (preScript) {
1196
- allStderr += `\n> ${label} pre${scriptName}\n> ${preScript}\n\n`;
1216
+ if (!silent) allStderr += `\n> ${label} pre${scriptName}\n> ${preScript}\n\n`;
1197
1217
  const preResult = await ctx.exec(preScript, { cwd: ctx.cwd, env: npmEnv });
1198
1218
  allStdout += preResult.stdout;
1199
1219
  allStderr += preResult.stderr;
@@ -1202,9 +1222,14 @@ async function handleNpmRun(args: string[], ctx: CommandContext): Promise<JustBa
1202
1222
  }
1203
1223
  }
1204
1224
 
1205
- // Run the main script
1206
- allStderr += `\n> ${label} ${scriptName}\n> ${scriptCommand}\n\n`;
1207
- const mainResult = await ctx.exec(scriptCommand, { cwd: ctx.cwd, env: npmEnv });
1225
+ // Run the main script, with the arguments npm passes it, each quoted.
1226
+ const command = passed.length > 0
1227
+ ? `${scriptCommand} ${passed.map((arg) => /^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`).join(' ')}`
1228
+ : scriptCommand;
1229
+ if (!silent) allStderr += `\n> ${label} ${scriptName}\n> ${command}\n\n`;
1230
+ // The script reads npm's standard input: a `.mcp.json` server launched as
1231
+ // `npm run … -- serve` speaks JSON-RPC on it.
1232
+ const mainResult = await ctx.exec(command, { cwd: ctx.cwd, env: npmEnv, ...(typeof ctx.stdin === 'string' ? { stdin: ctx.stdin } : {}) });
1208
1233
  allStdout += mainResult.stdout;
1209
1234
  allStderr += mainResult.stderr;
1210
1235
 
@@ -1215,7 +1240,7 @@ async function handleNpmRun(args: string[], ctx: CommandContext): Promise<JustBa
1215
1240
  // Run post<script> if it exists
1216
1241
  const postScript = scripts[`post${scriptName}`];
1217
1242
  if (postScript) {
1218
- allStderr += `\n> ${label} post${scriptName}\n> ${postScript}\n\n`;
1243
+ if (!silent) allStderr += `\n> ${label} post${scriptName}\n> ${postScript}\n\n`;
1219
1244
  const postResult = await ctx.exec(postScript, { cwd: ctx.cwd, env: npmEnv });
1220
1245
  allStdout += postResult.stdout;
1221
1246
  allStderr += postResult.stderr;