@usebruno/js 0.48.0 → 0.49.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usebruno/js",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "license": "MIT",
5
5
  "main": "src/index.js",
6
6
  "files": [
@@ -9,16 +9,17 @@
9
9
  ],
10
10
  "scripts": {
11
11
  "test": "node --experimental-vm-modules $(npx which jest) --testPathIgnorePatterns test.js",
12
+ "test:ci": "node --experimental-vm-modules ../../node_modules/jest/bin/jest.js --testPathIgnorePatterns test.js",
12
13
  "sandbox:bundle-libraries": "node ./src/sandbox/bundle-libraries.js",
13
14
  "prepack": "npm run test"
14
15
  },
15
16
  "dependencies": {
16
- "@usebruno/common": "0.22.0",
17
+ "@usebruno/common": "0.23.0",
17
18
  "@usebruno/query": "0.2.2",
18
19
  "ajv": "^8.12.0",
19
20
  "ajv-formats": "^2.1.1",
20
21
  "atob": "^2.1.2",
21
- "axios": "1.13.6",
22
+ "axios": "1.16.0",
22
23
  "btoa": "^1.2.1",
23
24
  "chai": "^4.3.7",
24
25
  "chai-string": "^1.5.0",
package/src/bru.js CHANGED
@@ -2,7 +2,7 @@ const { cloneDeep } = require('lodash');
2
2
  const { uuid } = require('./utils');
3
3
  const xmlFormat = require('xml-formatter');
4
4
  const { interpolate: _interpolate } = require('@usebruno/common');
5
- const { sendRequest, createSendRequest } = require('@usebruno/requests').scripting;
5
+ const { createSendRequest } = require('@usebruno/requests').scripting;
6
6
  const { jar: createCookieJar, getCookiesForUrl } = require('@usebruno/requests').cookies;
7
7
  const CookieList = require('./cookie-list');
8
8
  const Handlebars = require('handlebars');
@@ -102,8 +102,17 @@ class Bru {
102
102
  this.setVisualizations = setVisualizations;
103
103
  this.onConsoleLog = onConsoleLog || null;
104
104
  this.collectionName = collectionName;
105
- // Use createSendRequest with config if provided, otherwise use default sendRequest
106
- this.sendRequest = certsAndProxyConfig ? createSendRequest(certsAndProxyConfig) : sendRequest;
105
+ // Set by the host-side __bruSetScope global at the top of each segment's IIFE.
106
+ this._currentScope = null;
107
+ this.scriptedRequestEntries = [];
108
+ this.sendRequest = (...args) => {
109
+ const scopeSnapshot = this._currentScope ? { ...this._currentScope } : null;
110
+ const send = createSendRequest(certsAndProxyConfig, {
111
+ onComplete: (entry) =>
112
+ this._recordScriptedRequest({ source: 'sendRequest', scope: scopeSnapshot, ...entry })
113
+ });
114
+ return send(...args);
115
+ };
107
116
  this.runtime = runtime;
108
117
  this.requestUrl = requestUrl;
109
118
  this.cookies = new CookieList({
@@ -205,6 +214,16 @@ class Bru {
205
214
  return this.collectionPath;
206
215
  }
207
216
 
217
+ _recordScriptedRequest(entry) {
218
+ // Prefer scope passed in by the caller (snapshot at call time). Fall back to
219
+ // _currentScope for callers that don't supply one (e.g. bru.runRequest).
220
+ const { scope: providedScope, ...rest } = entry;
221
+ const scope = providedScope !== undefined
222
+ ? providedScope
223
+ : (this._currentScope ? { ...this._currentScope } : null);
224
+ this.scriptedRequestEntries.push({ ...rest, scope });
225
+ }
226
+
208
227
  getEnvName() {
209
228
  return this.envVariables.__name__;
210
229
  }
@@ -270,6 +289,10 @@ class Bru {
270
289
  }
271
290
  }
272
291
 
292
+ hasGlobalEnvVar(key) {
293
+ return Object.hasOwn(this.globalEnvironmentVariables, key);
294
+ }
295
+
273
296
  getGlobalEnvVar(key) {
274
297
  return this.interpolate(this.globalEnvironmentVariables[key]);
275
298
  }
@@ -8,6 +8,7 @@ const { createBruTestResultMethods } = require('../utils/results');
8
8
  const { runScriptInNodeVm } = require('../sandbox/node-vm');
9
9
  const { executeQuickJsVmAsync } = require('../sandbox/quickjs');
10
10
  const { SANDBOX } = require('../utils/sandbox');
11
+ const { bindRunRequest, createScopeSetter } = require('./scripted-entries');
11
12
 
12
13
  class ScriptRuntime {
13
14
  constructor(props) {
@@ -80,7 +81,8 @@ class ScriptRuntime {
80
81
  test,
81
82
  expect: chai.expect,
82
83
  assert: chai.assert,
83
- __brunoTestResults: __brunoTestResults
84
+ __brunoTestResults: __brunoTestResults,
85
+ __bruSetScope: createScopeSetter(bru)
84
86
  };
85
87
 
86
88
  if (onConsoleLog && typeof onConsoleLog === 'function') {
@@ -98,9 +100,7 @@ class ScriptRuntime {
98
100
  };
99
101
  }
100
102
 
101
- if (runRequestByItemPathname) {
102
- context.bru.runRequest = runRequestByItemPathname;
103
- }
103
+ bindRunRequest(bru, runRequestByItemPathname);
104
104
 
105
105
  // Helper to build the result object for pre-request scripts
106
106
  // Extracted to avoid duplication across runtime branches
@@ -115,7 +115,8 @@ class ScriptRuntime {
115
115
  results: cleanJson(__brunoTestResults.getResults()),
116
116
  nextRequestName: bru.nextRequest,
117
117
  skipRequest: bru.skipRequest,
118
- stopExecution: bru.stopExecution
118
+ stopExecution: bru.stopExecution,
119
+ scriptedRequestEntries: cleanJson(bru.scriptedRequestEntries || [])
119
120
  });
120
121
 
121
122
  // Track script errors to attach partial results before re-throwing
@@ -233,7 +234,8 @@ class ScriptRuntime {
233
234
  test,
234
235
  expect: chai.expect,
235
236
  assert: chai.assert,
236
- __brunoTestResults: __brunoTestResults
237
+ __brunoTestResults: __brunoTestResults,
238
+ __bruSetScope: createScopeSetter(bru)
237
239
  };
238
240
 
239
241
  if (onConsoleLog && typeof onConsoleLog === 'function') {
@@ -251,9 +253,7 @@ class ScriptRuntime {
251
253
  };
252
254
  }
253
255
 
254
- if (runRequestByItemPathname) {
255
- context.bru.runRequest = runRequestByItemPathname;
256
- }
256
+ bindRunRequest(bru, runRequestByItemPathname);
257
257
 
258
258
  // Helper to build the result object for post-response scripts
259
259
  // Extracted to avoid duplication across runtime branches
@@ -268,7 +268,8 @@ class ScriptRuntime {
268
268
  results: cleanJson(__brunoTestResults.getResults()),
269
269
  nextRequestName: bru.nextRequest,
270
270
  skipRequest: bru.skipRequest,
271
- stopExecution: bru.stopExecution
271
+ stopExecution: bru.stopExecution,
272
+ scriptedRequestEntries: cleanJson(bru.scriptedRequestEntries || [])
272
273
  });
273
274
 
274
275
  // Track script errors to attach partial results before re-throwing
@@ -0,0 +1,16 @@
1
+ // Forwards the caller's bru as a second arg so the host can attribute the call.
2
+ const bindRunRequest = (bru, runRequestByItemPathname) => {
3
+ if (!runRequestByItemPathname) return;
4
+ bru.runRequest = (relativePathname) =>
5
+ runRequestByItemPathname(relativePathname, bru);
6
+ };
7
+
8
+ // Kept off bru to stay out of user-facing autocomplete.
9
+ const createScopeSetter = (bru) => (scope) => {
10
+ bru._currentScope = scope || null;
11
+ };
12
+
13
+ module.exports = {
14
+ bindRunRequest,
15
+ createScopeSetter
16
+ };
@@ -9,6 +9,7 @@ const { runScriptInNodeVm } = require('../sandbox/node-vm');
9
9
  const jsonwebtoken = require('jsonwebtoken');
10
10
  const { executeQuickJsVmAsync } = require('../sandbox/quickjs');
11
11
  const { SANDBOX } = require('../utils/sandbox');
12
+ const { bindRunRequest, createScopeSetter } = require('./scripted-entries');
12
13
 
13
14
  class TestRuntime {
14
15
  constructor(props) {
@@ -84,7 +85,8 @@ class TestRuntime {
84
85
  expect: chai.expect,
85
86
  assert: chai.assert,
86
87
  __brunoTestResults: __brunoTestResults,
87
- jwt: jsonwebtoken
88
+ jwt: jsonwebtoken,
89
+ __bruSetScope: createScopeSetter(bru)
88
90
  };
89
91
 
90
92
  if (onConsoleLog && typeof onConsoleLog === 'function') {
@@ -102,9 +104,7 @@ class TestRuntime {
102
104
  };
103
105
  }
104
106
 
105
- if (runRequestByItemPathname) {
106
- context.bru.runRequest = runRequestByItemPathname;
107
- }
107
+ bindRunRequest(bru, runRequestByItemPathname);
108
108
 
109
109
  let scriptError = null;
110
110
 
@@ -147,7 +147,8 @@ class TestRuntime {
147
147
  persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
148
148
  oauth2CredentialsToReset: bru.oauth2CredentialsToReset,
149
149
  results: cleanJson(__brunoTestResults.getResults()),
150
- nextRequestName: bru.nextRequest
150
+ nextRequestName: bru.nextRequest,
151
+ scriptedRequestEntries: cleanJson(bru.scriptedRequestEntries || [])
151
152
  };
152
153
 
153
154
  if (scriptError) {
@@ -11,7 +11,7 @@ const { newQuickJSWASMModule, memoizePromiseFactory } = require('quickjs-emscrip
11
11
  // execute `npm run sandbox:bundle-libraries` if the below file doesn't exist
12
12
  const getBundledCode = require('../bundle-browser-rollup');
13
13
  const addPathShimToContext = require('./shims/lib/path');
14
- const { marshallToVm } = require('./utils');
14
+ const { marshallToVm, createManagedQuickJsContext } = require('./utils');
15
15
  const addCryptoUtilsShimToContext = require('./shims/lib/crypto-utils');
16
16
  const { wrapScriptInClosure, SANDBOX } = require('../../utils/sandbox');
17
17
 
@@ -56,9 +56,10 @@ const executeQuickJsVm = ({ script: externalScript, context: externalContext, sc
56
56
 
57
57
  externalScript = removeQuotes(externalScript);
58
58
  }
59
-
59
+ let managedQuickJsContext;
60
60
  try {
61
- const vm = QuickJSModule.newContext();
61
+ managedQuickJsContext = createManagedQuickJsContext(QuickJSModule);
62
+ const vm = managedQuickJsContext.vm;
62
63
  const { bru, req, res, ...variables } = externalContext;
63
64
 
64
65
  bru && addBruShimToContext(vm, bru);
@@ -74,7 +75,7 @@ const executeQuickJsVm = ({ script: externalScript, context: externalContext, sc
74
75
 
75
76
  let scriptText = scriptType === 'template-literal' ? templateLiteralText : jsExpressionText;
76
77
 
77
- const result = vm.evalCode(scriptText);
78
+ const result = vm.evalCodeRetained(scriptText);
78
79
  if (result.error) {
79
80
  let e = vm.dump(result.error);
80
81
  result.error.dispose();
@@ -86,6 +87,8 @@ const executeQuickJsVm = ({ script: externalScript, context: externalContext, sc
86
87
  }
87
88
  } catch (error) {
88
89
  console.error('Error executing the script!', error);
90
+ } finally {
91
+ managedQuickJsContext?.dispose();
89
92
  }
90
93
  };
91
94
 
@@ -95,9 +98,11 @@ const executeQuickJsVmAsync = async ({ script: externalScript, context: external
95
98
  }
96
99
  externalScript = externalScript?.trim();
97
100
 
101
+ let managedQuickJsContext;
98
102
  try {
99
103
  const module = await loader();
100
- const vm = module.newContext();
104
+ managedQuickJsContext = createManagedQuickJsContext(module);
105
+ const vm = managedQuickJsContext.vm;
101
106
 
102
107
  // add crypto utilities required by the crypto-js library in bundledCode
103
108
  await addCryptoUtilsShimToContext(vm);
@@ -126,17 +131,27 @@ const executeQuickJsVmAsync = async ({ script: externalScript, context: external
126
131
 
127
132
  const script = wrapScriptInClosure(externalScript, SANDBOX.QUICKJS);
128
133
 
129
- const result = vm.evalCode(script, scriptPath);
134
+ const result = vm.evalCodeRetained(script, scriptPath);
130
135
  const promiseHandle = vm.unwrapResult(result);
131
136
  const resolvedResult = await vm.resolvePromise(promiseHandle);
132
137
  promiseHandle.dispose();
133
138
  const resolvedHandle = vm.unwrapResult(resolvedResult);
134
139
  resolvedHandle.dispose();
135
- // vm.dispose();
136
140
  return;
137
141
  } catch (error) {
138
142
  error.__isQuickJS = true;
139
143
  throw error;
144
+ } finally {
145
+ // Wait for any in-flight async work (sendRequest, axios, cookie jar, timers,
146
+ // un-awaited promises) to settle before tearing down the VM. Disposing while
147
+ // a deferred is still pending lets its later host callback touch a freed
148
+ // context, throwing `QuickJSUseAfterFree`.
149
+ try {
150
+ await managedQuickJsContext?.waitForPendingDeferreds?.();
151
+ managedQuickJsContext?.dispose();
152
+ } catch (teardownError) {
153
+ throw teardownError;
154
+ }
140
155
  }
141
156
  };
142
157
 
@@ -117,6 +117,12 @@ const addBruShimToContext = (vm, bru) => {
117
117
  vm.setProp(bruObject, 'getAllGlobalEnvVars', getAllGlobalEnvVars);
118
118
  getAllGlobalEnvVars.dispose();
119
119
 
120
+ let hasGlobalEnvVar = vm.newFunction('hasGlobalEnvVar', function (key) {
121
+ return marshallToVm(bru.hasGlobalEnvVar(vm.dump(key)), vm);
122
+ });
123
+ vm.setProp(bruObject, 'hasGlobalEnvVar', hasGlobalEnvVar);
124
+ hasGlobalEnvVar.dispose();
125
+
120
126
  // TODO: deleteAllGlobalEnvVars works in the request lifecycle but does not update the UI.
121
127
  // Re-enable once the UI sync issue is resolved.
122
128
  // let deleteAllGlobalEnvVars = vm.newFunction('deleteAllGlobalEnvVars', function () {
@@ -401,10 +407,20 @@ const addBruShimToContext = (vm, bru) => {
401
407
  });
402
408
  sendRequestHandle.consume((handle) => vm.setProp(bruObject, '_sendRequest', handle));
403
409
 
410
+ // On vm.global, not bru, to stay off user-facing autocomplete.
411
+ let setScopeHandle = vm.newFunction('__bruSetScope', (scopeArg) => {
412
+ bru._currentScope = vm.dump(scopeArg) || null;
413
+ });
414
+ setScopeHandle.consume((handle) => vm.setProp(vm.global, '__bruSetScope', handle));
415
+
404
416
  const sleep = vm.newFunction('sleep', (timer) => {
405
417
  const t = vm.getString(timer);
406
418
  const promise = vm.newPromise();
407
419
  setTimeout(() => {
420
+ // The VM may have been disposed while this native timer was pending
421
+ // (e.g. a setTimeout/sleep whose promise the script never awaited).
422
+ // Touching the VM after teardown throws QuickJSUseAfterFree, so bail out.
423
+ if (!vm.alive) return;
408
424
  promise.resolve(vm.newString('slept'));
409
425
  }, t);
410
426
  promise.settled.then(vm.runtime.executePendingJobs);
@@ -1,3 +1,130 @@
1
+ /**
2
+ * Creates a QuickJS context with centralized lifecycle management:
3
+ * - vm.evalCode() auto-disposes result handles (for shim setup code)
4
+ * - vm.evalCodeRetained() returns the raw result (for user script execution)
5
+ * - all newObject/newFunction/newArray handles are tracked and disposed on teardown
6
+ */
7
+ const createManagedQuickJsContext = (module) => {
8
+ const vm = module.newContext();
9
+ const disposeTracked = trackQuickJsContext(vm);
10
+ const evalCodeRetained = vm.evalCode.bind(vm);
11
+ const waitForPendingDeferreds = trackPendingDeferreds(vm);
12
+
13
+ vm.evalCode = (code, filename = 'eval.js') => {
14
+ const result = evalCodeRetained(code, filename);
15
+ if (result.error) {
16
+ const error = vm.dump(result.error);
17
+ result.error.dispose();
18
+ throw error;
19
+ }
20
+ result.value.dispose();
21
+ };
22
+
23
+ vm.evalCodeRetained = evalCodeRetained;
24
+
25
+ return {
26
+ vm,
27
+ waitForPendingDeferreds,
28
+ dispose: () => disposeQuickJsContext(vm, disposeTracked)
29
+ };
30
+ };
31
+
32
+ /**
33
+ * Track every deferred created by the async shims (sendRequest, axios, cookie
34
+ * jar, sleep, ...) so teardown can wait for them to settle. A user script that
35
+ * fires-and-forgets async work (e.g. an un-awaited setTimeout) resolves the
36
+ * wrapping closure immediately; without this, the VM is disposed before the
37
+ * deferred's host callback runs, and touching the freed context throws
38
+ * `QuickJSUseAfterFree`. Each `.settled` resolves once the deferred is
39
+ * resolved/rejected, so awaiting them keeps the context alive long enough.
40
+ *
41
+ * The hook is installed now (at context creation) so it captures promises as
42
+ * the script runs. Returns a function that drains the captured deferreds at
43
+ * teardown; new deferreds can be created while we wait (a chained timer), so it
44
+ * drains in place until none remain.
45
+ */
46
+
47
+ const trackPendingDeferreds = (vm) => {
48
+ const pendingDeferreds = [];
49
+ const originalNewPromise = vm.newPromise.bind(vm);
50
+ vm.newPromise = (...args) => {
51
+ const deferred = originalNewPromise(...args);
52
+ pendingDeferreds.push(deferred.settled.catch(() => { }));
53
+ return deferred;
54
+ };
55
+
56
+ return async () => {
57
+ while (pendingDeferreds.length) {
58
+ const batch = pendingDeferreds.splice(0);
59
+ await Promise.all(batch);
60
+ }
61
+ };
62
+ };
63
+
64
+ /**
65
+ * Tracks handles created via newObject/newFunction/newArray so they can all be
66
+ * disposed before the context. quickjs-emscripten requires every heap handle to
67
+ * be disposed individually; shims attach then drop their ref via .dispose().
68
+ */
69
+ const trackQuickJsContext = (vm) => {
70
+ const handles = [];
71
+
72
+ const track = (handle) => {
73
+ handles.push(handle);
74
+ return handle;
75
+ };
76
+
77
+ // Replace an allocator with a wrapper that records every handle it returns,
78
+ // so teardown can dispose them all. Behaviour is otherwise identical.
79
+ const trackAllocations = (method) => {
80
+ const original = vm[method]?.bind(vm);
81
+ if (!original) {
82
+ return;
83
+ }
84
+
85
+ vm[method] = (...args) => track(original(...args));
86
+ };
87
+
88
+ ['newObject', 'newFunction', 'newArray'].forEach(trackAllocations);
89
+
90
+ // Dispose newest-first: later handles may reference earlier ones.
91
+ return () => {
92
+ for (const handle of handles.reverse()) {
93
+ if (handle?.alive) {
94
+ handle.dispose();
95
+ }
96
+ }
97
+ };
98
+ };
99
+
100
+ /**
101
+ * Clears shim globals, drains pending QuickJS jobs, and disposes the context.
102
+ * Pass disposeTracked from trackQuickJsContext() to free shim handles first.
103
+ */
104
+ const disposeQuickJsContext = (vm, disposeTracked) => {
105
+ if (!vm?.alive) {
106
+ return;
107
+ }
108
+
109
+ if (typeof disposeTracked === 'function') {
110
+ disposeTracked();
111
+ }
112
+
113
+ // Drain the runtime's pending job queue (resolved/rejected promise callbacks)
114
+ // before disposing. Executing a job can schedule more jobs (chained `.then()`s),
115
+ // so we keep going until `hasPendingJob()` reports the queue is empty or a job
116
+ // throws.
117
+ while (vm.runtime?.hasPendingJob?.()) {
118
+ const result = vm.runtime.executePendingJobs();
119
+ // On error, dispose the error handle and stop draining.
120
+ if (result.error) {
121
+ result.error.dispose();
122
+ break;
123
+ }
124
+ }
125
+ vm.dispose();
126
+ };
127
+
1
128
  const marshallToVm = (value, vm) => {
2
129
  if (value === undefined) {
3
130
  return vm.undefined;
@@ -79,5 +206,8 @@ async function invokeFunction(vm, quickFn, args = []) {
79
206
 
80
207
  module.exports = {
81
208
  marshallToVm,
82
- invokeFunction
209
+ invokeFunction,
210
+ createManagedQuickJsContext,
211
+ disposeQuickJsContext,
212
+ trackQuickJsContext
83
213
  };