@usebruno/js 0.51.0 → 0.52.0

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.
@@ -2,8 +2,214 @@ const vm = require('node:vm');
2
2
  const fs = require('node:fs');
3
3
  const path = require('node:path');
4
4
  const nodeModule = require('node:module');
5
+ const { AsyncLocalStorage } = require('node:async_hooks');
5
6
 
6
7
  const { isBuiltinModule, isPathWithinAllowedRoots } = require('./utils');
8
+ const { safeGlobals } = require('./constants');
9
+ const { mixinTypedArrays } = require('../mixins/typed-arrays');
10
+
11
+ // Shared npm context (once per process) so modules aren't re-eval'd per script. #9074
12
+ const activeScriptContext = new AsyncLocalStorage();
13
+ const npmModuleEval = new AsyncLocalStorage();
14
+ const sharedNpmModuleCache = new Map();
15
+ const contextBoundModulePaths = new Set();
16
+ // path -> paths required while that module was evaluating (for context-bound eviction)
17
+ const moduleRequireGraph = new Map();
18
+ let sharedNpmSandbox = null;
19
+ let sharedNpmContext = null;
20
+
21
+ const BRUNO_CONTEXT_KEYS = [
22
+ 'bru',
23
+ 'req',
24
+ 'res',
25
+ 'test',
26
+ 'expect',
27
+ 'assert',
28
+ '__brunoTestResults',
29
+ '__bruSetScope',
30
+ 'jwt',
31
+ 'console',
32
+ 'scriptingConfig'
33
+ ];
34
+
35
+ const facades = new Map();
36
+
37
+ const RUN_LOADER_STATE = Symbol('brunoRunLoaderState');
38
+
39
+ function attachRunLoaderState(scriptContext, { localModuleCache, vmContext }) {
40
+ scriptContext[RUN_LOADER_STATE] = { localModuleCache, vmContext };
41
+ }
42
+
43
+ function getRunLoaderState(store) {
44
+ return store?.[RUN_LOADER_STATE];
45
+ }
46
+
47
+ /**
48
+ * Late-bound facade for a Bruno global in the shared npm context.
49
+ * @param {string} key - The Bruno global's name
50
+ * @param {boolean} callable - Whether the current value is a function
51
+ */
52
+ function facadeFor(key, callable) {
53
+ const cacheKey = `${key}:${callable ? 'fn' : 'obj'}`;
54
+ if (facades.has(cacheKey)) {
55
+ return facades.get(cacheKey);
56
+ }
57
+ const current = () => activeScriptContext.getStore()?.[key];
58
+ const isMissing = (value) => value === undefined || value === null;
59
+ const target = callable ? () => {} : {};
60
+
61
+ // Cache wrappers so `bru.setVar === bru.setVar` remains true.
62
+ const methods = new Map();
63
+ const lateBoundMethod = (prop) => {
64
+ if (!methods.has(prop)) {
65
+ methods.set(prop, (...args) => {
66
+ const value = current();
67
+ const member = isMissing(value) ? undefined : value[prop];
68
+ if (typeof member !== 'function') {
69
+ throw new TypeError(`${key}.${String(prop)} is not available outside of a script execution`);
70
+ }
71
+ return Reflect.apply(member, value, args);
72
+ });
73
+ }
74
+ return methods.get(prop);
75
+ };
76
+ const facade = new Proxy(target, {
77
+ get: (_, prop) => {
78
+ const targetDescriptor = Reflect.getOwnPropertyDescriptor(target, prop);
79
+ if (targetDescriptor && !targetDescriptor.configurable && !targetDescriptor.writable) {
80
+ return targetDescriptor.value;
81
+ }
82
+ const value = current();
83
+ if (isMissing(value)) {
84
+ return undefined;
85
+ }
86
+ const member = value[prop];
87
+ return typeof member === 'function' ? lateBoundMethod(prop) : member;
88
+ },
89
+ set: (_, prop, newValue) => {
90
+ const targetDescriptor = Reflect.getOwnPropertyDescriptor(target, prop);
91
+ if (targetDescriptor && !targetDescriptor.configurable && !targetDescriptor.writable) {
92
+ return Object.is(targetDescriptor.value, newValue);
93
+ }
94
+ const value = current();
95
+ if (isMissing(value)) {
96
+ return false;
97
+ }
98
+ value[prop] = newValue;
99
+ return true;
100
+ },
101
+ has: (_, prop) => {
102
+ const targetDescriptor = Reflect.getOwnPropertyDescriptor(target, prop);
103
+ if (targetDescriptor && !targetDescriptor.configurable) {
104
+ return true;
105
+ }
106
+ if (!Object.isExtensible(target)) {
107
+ return false;
108
+ }
109
+ const value = current();
110
+ return !isMissing(value) && prop in Object(value);
111
+ },
112
+ ownKeys: () => {
113
+ // Once freeze/preventExtensions runs, only report keys on the inert target
114
+ // or Proxy throws and the poisoned facade breaks every later script.
115
+ if (!Object.isExtensible(target)) {
116
+ return Reflect.ownKeys(target);
117
+ }
118
+ const value = current();
119
+ const currentKeys = isMissing(value) ? [] : Reflect.ownKeys(Object(value));
120
+ return [...new Set([...Reflect.ownKeys(target), ...currentKeys])];
121
+ },
122
+ getOwnPropertyDescriptor: (_, prop) => {
123
+ const targetDescriptor = Reflect.getOwnPropertyDescriptor(target, prop);
124
+ if (targetDescriptor && !targetDescriptor.configurable) {
125
+ return targetDescriptor;
126
+ }
127
+ if (!Object.isExtensible(target)) {
128
+ return undefined;
129
+ }
130
+ const value = current();
131
+ const descriptor = isMissing(value) ? undefined : Object.getOwnPropertyDescriptor(Object(value), prop);
132
+ return descriptor ? { ...descriptor, configurable: true } : undefined;
133
+ },
134
+ getPrototypeOf: () => {
135
+ const value = current();
136
+ return isMissing(value) ? null : Object.getPrototypeOf(Object(value));
137
+ },
138
+ apply: (_, thisArg, args) => {
139
+ const value = current();
140
+ if (typeof value !== 'function') {
141
+ throw new TypeError(`${key} is not available outside of a script execution`);
142
+ }
143
+ return Reflect.apply(value, thisArg, args);
144
+ }
145
+ });
146
+ facades.set(cacheKey, facade);
147
+ return facade;
148
+ }
149
+
150
+ function defineDynamicGlobal(key) {
151
+ if (Object.prototype.hasOwnProperty.call(sharedNpmSandbox, key)) {
152
+ return;
153
+ }
154
+ Object.defineProperty(sharedNpmSandbox, key, {
155
+ enumerable: true,
156
+ configurable: true,
157
+ get: () => {
158
+ const evalStore = npmModuleEval.getStore();
159
+ if (evalStore) {
160
+ evalStore.touched.add(key);
161
+ }
162
+ const value = activeScriptContext.getStore()?.[key];
163
+ if (value === undefined || value === null) {
164
+ return undefined;
165
+ }
166
+ if (typeof value !== 'object' && typeof value !== 'function') {
167
+ return value;
168
+ }
169
+ // Custom script globals (arrays, plain data, callbacks, require) stay raw so
170
+ // Array.isArray / identity work. Bruno APIs need facades for late-binding.
171
+ if (!BRUNO_CONTEXT_KEYS.includes(key)) {
172
+ return value;
173
+ }
174
+ return facadeFor(key, typeof value === 'function');
175
+ }
176
+ });
177
+ }
178
+
179
+ function getSharedNpmContext() {
180
+ if (!sharedNpmContext) {
181
+ sharedNpmSandbox = Object.fromEntries(
182
+ safeGlobals
183
+ .filter((key) => global[key] !== undefined)
184
+ .map((key) => [key, global[key]])
185
+ );
186
+ mixinTypedArrays(sharedNpmSandbox);
187
+ sharedNpmContext = vm.createContext(sharedNpmSandbox);
188
+ sharedNpmSandbox.global = sharedNpmSandbox;
189
+ sharedNpmSandbox.globalThis = sharedNpmSandbox;
190
+ BRUNO_CONTEXT_KEYS.forEach(defineDynamicGlobal);
191
+ }
192
+ return sharedNpmContext;
193
+ }
194
+
195
+ /**
196
+ * Runs `fn` with `scriptContext` as the currently executing script context, so
197
+ * npm modules called from it (or from async work it starts) resolve `bru`,
198
+ * `req`, `res`, ... to it. Safe for interleaved executions; nested executions
199
+ * (`bru.runRequest`) see their own context.
200
+ * @param {Object} scriptContext - The script's vm global object
201
+ * @param {Function} fn - The execution, typically `() => script.runInContext(...)`
202
+ * @returns {*} Whatever `fn` returns
203
+ */
204
+ function runWithScriptContext(scriptContext, fn) {
205
+ getSharedNpmContext();
206
+ for (const key of Object.keys(scriptContext)) {
207
+ if (key !== 'global' && key !== 'globalThis') {
208
+ defineDynamicGlobal(key);
209
+ }
210
+ }
211
+ return activeScriptContext.run(scriptContext, fn);
212
+ }
7
213
 
8
214
  /**
9
215
  * Resolve a local module path, handling files and directories
@@ -74,7 +280,8 @@ function createCustomRequire({
74
280
  isolatedContext,
75
281
  currentModuleDir = collectionPath,
76
282
  localModuleCache = new Map(),
77
- additionalContextRootsAbsolute = []
283
+ additionalContextRootsAbsolute = [],
284
+ cacheModules = false
78
285
  }) {
79
286
  return (moduleName) => {
80
287
  const normalizedModuleName = moduleName.replace(/\\/g, '/');
@@ -87,7 +294,8 @@ function createCustomRequire({
87
294
  isolatedContext,
88
295
  localModuleCache,
89
296
  currentModuleDir,
90
- additionalContextRootsAbsolute
297
+ additionalContextRootsAbsolute,
298
+ cacheModules
91
299
  });
92
300
  }
93
301
 
@@ -100,7 +308,8 @@ function createCustomRequire({
100
308
  isolatedContext,
101
309
  localModuleCache,
102
310
  currentModuleDir,
103
- additionalContextRootsAbsolute
311
+ additionalContextRootsAbsolute,
312
+ cacheModules
104
313
  });
105
314
  }
106
315
 
@@ -117,7 +326,8 @@ function createCustomRequire({
117
326
  collectionPath,
118
327
  currentModuleDir,
119
328
  isolatedContext,
120
- localModuleCache
329
+ localModuleCache,
330
+ cacheModules
121
331
  });
122
332
  };
123
333
  }
@@ -134,7 +344,8 @@ function loadLocalModule({
134
344
  isolatedContext,
135
345
  localModuleCache,
136
346
  currentModuleDir,
137
- additionalContextRootsAbsolute = []
347
+ additionalContextRootsAbsolute = [],
348
+ cacheModules = false
138
349
  }) {
139
350
  // Validate the raw module name doesn't try to escape allowed roots
140
351
  const preliminaryPath = path.resolve(currentModuleDir, moduleName);
@@ -182,7 +393,8 @@ function loadLocalModule({
182
393
  isolatedContext,
183
394
  currentModuleDir: moduleDir,
184
395
  localModuleCache,
185
- additionalContextRootsAbsolute
396
+ additionalContextRootsAbsolute,
397
+ cacheModules
186
398
  });
187
399
 
188
400
  try {
@@ -199,6 +411,38 @@ function loadLocalModule({
199
411
  }
200
412
  }
201
413
 
414
+ function recordRequireEdge(resolvedPath) {
415
+ const parent = npmModuleEval.getStore();
416
+ if (!parent?.resolvedPath) {
417
+ return;
418
+ }
419
+ if (!moduleRequireGraph.has(parent.resolvedPath)) {
420
+ moduleRequireGraph.set(parent.resolvedPath, new Set());
421
+ }
422
+ moduleRequireGraph.get(parent.resolvedPath).add(resolvedPath);
423
+ }
424
+
425
+ function evictContextBoundGraph(rootPath, rootModuleObj, localModuleCache) {
426
+ const stack = [rootPath];
427
+ const visited = new Set();
428
+ while (stack.length) {
429
+ const modulePath = stack.pop();
430
+ if (visited.has(modulePath)) {
431
+ continue;
432
+ }
433
+ visited.add(modulePath);
434
+ contextBoundModulePaths.add(modulePath);
435
+ const moduleObj = modulePath === rootPath ? rootModuleObj : sharedNpmModuleCache.get(modulePath);
436
+ sharedNpmModuleCache.delete(modulePath);
437
+ if (moduleObj) {
438
+ localModuleCache.set(modulePath, moduleObj);
439
+ }
440
+ for (const dep of moduleRequireGraph.get(modulePath) || []) {
441
+ stack.push(dep);
442
+ }
443
+ }
444
+ }
445
+
202
446
  /**
203
447
  * Executes a module in the VM context with caching and special file handling
204
448
  * @param {Object} options - Configuration options
@@ -208,22 +452,82 @@ function loadLocalModule({
208
452
  function executeModuleInVmContext({
209
453
  resolvedPath,
210
454
  moduleName,
211
- isolatedContext,
212
455
  collectionPath,
213
- localModuleCache
456
+ isolatedContext,
457
+ localModuleCache,
458
+ cacheModules = false
214
459
  }) {
215
- // Check cache - we cache moduleObj, return its exports
216
- if (localModuleCache.has(resolvedPath)) {
217
- return localModuleCache.get(resolvedPath).exports;
460
+ if (cacheModules) {
461
+ recordRequireEdge(resolvedPath);
462
+ }
463
+
464
+ if (!cacheModules) {
465
+ if (localModuleCache.has(resolvedPath)) {
466
+ return localModuleCache.get(resolvedPath).exports;
467
+ }
468
+ return evaluateNpmModule({
469
+ resolvedPath,
470
+ moduleName,
471
+ collectionPath,
472
+ isolatedContext,
473
+ localModuleCache,
474
+ useSharedContext: false,
475
+ cacheModules: false
476
+ });
477
+ }
478
+
479
+ const isContextBound = contextBoundModulePaths.has(resolvedPath);
480
+
481
+ if (isContextBound) {
482
+ if (localModuleCache?.has(resolvedPath)) {
483
+ return localModuleCache.get(resolvedPath).exports;
484
+ }
485
+ return evaluateNpmModule({
486
+ resolvedPath,
487
+ moduleName,
488
+ collectionPath,
489
+ isolatedContext,
490
+ localModuleCache,
491
+ useSharedContext: false,
492
+ cacheModules: true
493
+ });
218
494
  }
219
495
 
496
+ if (sharedNpmModuleCache.has(resolvedPath)) {
497
+ return sharedNpmModuleCache.get(resolvedPath).exports;
498
+ }
499
+
500
+ return evaluateNpmModule({
501
+ resolvedPath,
502
+ moduleName,
503
+ collectionPath,
504
+ isolatedContext,
505
+ localModuleCache,
506
+ useSharedContext: true,
507
+ cacheModules: true
508
+ });
509
+ }
510
+
511
+ function evaluateNpmModule({
512
+ resolvedPath,
513
+ moduleName,
514
+ collectionPath,
515
+ isolatedContext,
516
+ localModuleCache,
517
+ useSharedContext,
518
+ cacheModules = false
519
+ }) {
220
520
  // Native modules (.node files) - fall back to host require
221
521
  // Note: This bypasses VM isolation for native addons.
222
522
  // This is intentional - [`developer` mode] node-vm isolation need not be strict for native modules.
223
523
  if (resolvedPath.endsWith('.node')) {
224
524
  const result = require(resolvedPath);
225
- // Wrap in moduleObj format for consistent cache retrieval
226
- localModuleCache.set(resolvedPath, { exports: result });
525
+ const moduleObj = { exports: result };
526
+ if (useSharedContext) {
527
+ sharedNpmModuleCache.set(resolvedPath, moduleObj);
528
+ } else {
529
+ localModuleCache.set(resolvedPath, moduleObj);
530
+ }
227
531
  return result;
228
532
  }
229
533
 
@@ -231,37 +535,50 @@ function executeModuleInVmContext({
231
535
  if (resolvedPath.endsWith('.json')) {
232
536
  const jsonContent = fs.readFileSync(resolvedPath, 'utf8');
233
537
  const result = JSON.parse(jsonContent);
234
- // Wrap in moduleObj format for consistent cache retrieval
235
- localModuleCache.set(resolvedPath, { exports: result });
538
+ const moduleObj = { exports: result };
539
+ if (useSharedContext) {
540
+ sharedNpmModuleCache.set(resolvedPath, moduleObj);
541
+ } else {
542
+ localModuleCache.set(resolvedPath, moduleObj);
543
+ }
236
544
  return result;
237
545
  }
238
546
 
239
- // JavaScript files
240
547
  const moduleSource = fs.readFileSync(resolvedPath, 'utf8');
241
548
  const moduleDir = path.dirname(resolvedPath);
242
549
  const moduleObj = { exports: {} };
550
+ const moduleCache = useSharedContext ? sharedNpmModuleCache : localModuleCache;
243
551
 
244
- // Pre-populate cache with moduleObj BEFORE execution to handle circular dependencies
245
- // This allows re-entrant requires to get partial exports (Node.js behavior)
246
- // We cache moduleObj (not moduleObj.exports) so that module.exports reassignment works
247
- localModuleCache.set(resolvedPath, moduleObj);
552
+ moduleCache.set(resolvedPath, moduleObj);
248
553
 
249
554
  const moduleRequire = createNpmModuleRequire({
250
555
  collectionPath,
251
- isolatedContext,
252
556
  currentModuleDir: moduleDir,
253
- localModuleCache
557
+ isolatedContext,
558
+ localModuleCache,
559
+ cacheModules
254
560
  });
255
561
 
562
+ const vmContext = useSharedContext ? getSharedNpmContext() : isolatedContext;
563
+ const evalStore = { resolvedPath, touched: new Set() };
564
+
256
565
  try {
257
- // Wrap module code in a function that receives CJS parameters
258
566
  const wrappedCode = `(function(module, exports, require, __filename, __dirname) {\n${moduleSource}\n})`;
259
567
  const compiledScript = new vm.Script(wrappedCode, { filename: resolvedPath });
260
- const moduleFunction = compiledScript.runInContext(isolatedContext);
261
- moduleFunction(moduleObj, moduleObj.exports, moduleRequire, resolvedPath, moduleDir);
568
+ const moduleFunction = compiledScript.runInContext(vmContext);
569
+ const runModule = () => {
570
+ moduleFunction(moduleObj, moduleObj.exports, moduleRequire, resolvedPath, moduleDir);
571
+ };
572
+ if (cacheModules && useSharedContext) {
573
+ npmModuleEval.run(evalStore, runModule);
574
+ if (evalStore.touched.size > 0) {
575
+ evictContextBoundGraph(resolvedPath, moduleObj, localModuleCache);
576
+ }
577
+ } else {
578
+ runModule();
579
+ }
262
580
  } catch (error) {
263
- // Remove failed module from cache to allow retry
264
- localModuleCache.delete(resolvedPath);
581
+ moduleCache.delete(resolvedPath);
265
582
  const stack = error.stack || '';
266
583
  throw new Error(`Error loading module ${moduleName}: ${error.message}\nStack: ${stack}`);
267
584
  }
@@ -286,7 +603,8 @@ function loadNpmModule({
286
603
  collectionPath,
287
604
  currentModuleDir,
288
605
  isolatedContext,
289
- localModuleCache
606
+ localModuleCache,
607
+ cacheModules = false
290
608
  }) {
291
609
  let resolvedPath;
292
610
 
@@ -323,9 +641,10 @@ function loadNpmModule({
323
641
  return executeModuleInVmContext({
324
642
  resolvedPath,
325
643
  moduleName,
326
- isolatedContext,
327
644
  collectionPath,
328
- localModuleCache
645
+ isolatedContext,
646
+ localModuleCache,
647
+ cacheModules
329
648
  });
330
649
  }
331
650
 
@@ -338,25 +657,43 @@ function loadNpmModule({
338
657
  * @param {Object} options - Configuration options
339
658
  * @returns {Function} Custom require function for npm module dependencies
340
659
  */
660
+ function markParentContextBoundIfNeeded(resolvedPath) {
661
+ const evalStore = npmModuleEval.getStore();
662
+ if (evalStore && contextBoundModulePaths.has(resolvedPath)) {
663
+ evalStore.touched.add('*');
664
+ }
665
+ }
666
+
341
667
  function createNpmModuleRequire({
342
668
  collectionPath,
343
- isolatedContext,
344
669
  currentModuleDir,
345
- localModuleCache
670
+ isolatedContext,
671
+ localModuleCache,
672
+ cacheModules = false
346
673
  }) {
347
674
  const moduleRequire = nodeModule.createRequire(path.join(currentModuleDir, 'index.js'));
348
675
 
349
676
  return (moduleName) => {
677
+ // Shared parents keep this require for the process lifetime; always prefer the
678
+ // active script run's cache/context so lazy context-bound leaves re-eval.
679
+ const store = cacheModules ? activeScriptContext.getStore() : null;
680
+ const runState = getRunLoaderState(store);
681
+ const runLocalCache = runState?.localModuleCache ?? localModuleCache;
682
+ const runContext = runState?.vmContext ?? isolatedContext;
683
+
350
684
  // Handle relative imports within npm module
351
685
  if (moduleName.startsWith('./') || moduleName.startsWith('../')) {
352
686
  const resolvedPath = moduleRequire.resolve(moduleName);
353
- return executeModuleInVmContext({
687
+ const exports = executeModuleInVmContext({
354
688
  resolvedPath,
355
689
  moduleName,
356
- isolatedContext,
357
690
  collectionPath,
358
- localModuleCache
691
+ isolatedContext: runContext,
692
+ localModuleCache: runLocalCache,
693
+ cacheModules
359
694
  });
695
+ markParentContextBoundIfNeeded(resolvedPath);
696
+ return exports;
360
697
  }
361
698
 
362
699
  // Handle builtins
@@ -368,16 +705,30 @@ function createNpmModuleRequire({
368
705
 
369
706
  // Handle npm dependencies - resolve from current module's directory
370
707
  const resolvedPath = moduleRequire.resolve(moduleName);
371
- return executeModuleInVmContext({
708
+ const exports = executeModuleInVmContext({
372
709
  resolvedPath,
373
710
  moduleName,
374
- isolatedContext,
375
711
  collectionPath,
376
- localModuleCache
712
+ isolatedContext: runContext,
713
+ localModuleCache: runLocalCache,
714
+ cacheModules
377
715
  });
716
+ markParentContextBoundIfNeeded(resolvedPath);
717
+ return exports;
378
718
  };
379
719
  }
380
720
 
381
721
  module.exports = {
382
- createCustomRequire
722
+ createCustomRequire,
723
+ runWithScriptContext,
724
+ getSharedNpmContext,
725
+ attachRunLoaderState,
726
+ __resetNpmModuleStateForTests: () => {
727
+ sharedNpmModuleCache.clear();
728
+ contextBoundModulePaths.clear();
729
+ moduleRequireGraph.clear();
730
+ facades.clear();
731
+ sharedNpmSandbox = null;
732
+ sharedNpmContext = null;
733
+ }
383
734
  };