@usebruno/js 0.48.0 → 0.50.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.50.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.24.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",
@@ -27,7 +28,7 @@
27
28
  "handlebars": "^4.7.9",
28
29
  "json-query": "^2.2.2",
29
30
  "jsonwebtoken": "^9.0.3",
30
- "lodash": "^4.17.21",
31
+ "lodash": "4.18.1",
31
32
  "moment": "^2.29.4",
32
33
  "nanoid": "3.3.8",
33
34
  "node-fetch": "^2.7.0",
package/src/bru.js CHANGED
@@ -1,8 +1,8 @@
1
- const { cloneDeep } = require('lodash');
1
+ const { cloneDeep, isEqual } = 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({
@@ -112,8 +121,11 @@ class Bru {
112
121
  createCookieJar,
113
122
  getCookiesForUrl
114
123
  });
115
- // Holds variables that are marked as persistent by scripts
116
- this.persistentEnvVariables = {};
124
+ // Dirty flags set by mutators so runtimes can skip IPC/disk writes for unchanged scopes
125
+ this._envDirty = false;
126
+ this._globalEnvDirty = false;
127
+ this._collVarsDirty = false;
128
+ this._runtimeVarsDirty = false;
117
129
  // Holds credential IDs to be reset after script execution
118
130
  this.oauth2CredentialsToReset = [];
119
131
  this.runner = {
@@ -205,6 +217,16 @@ class Bru {
205
217
  return this.collectionPath;
206
218
  }
207
219
 
220
+ _recordScriptedRequest(entry) {
221
+ // Prefer scope passed in by the caller (snapshot at call time). Fall back to
222
+ // _currentScope for callers that don't supply one (e.g. bru.runRequest).
223
+ const { scope: providedScope, ...rest } = entry;
224
+ const scope = providedScope !== undefined
225
+ ? providedScope
226
+ : (this._currentScope ? { ...this._currentScope } : null);
227
+ this.scriptedRequestEntries.push({ ...rest, scope });
228
+ }
229
+
208
230
  getEnvName() {
209
231
  return this.envVariables.__name__;
210
232
  }
@@ -221,7 +243,7 @@ class Bru {
221
243
  return this.interpolate(this.envVariables[key]);
222
244
  }
223
245
 
224
- setEnvVar(key, value, options = {}) {
246
+ setEnvVar(key, value) {
225
247
  if (!key) {
226
248
  throw new Error('Creating a env variable without specifying a name is not allowed.');
227
249
  }
@@ -232,24 +254,21 @@ class Bru {
232
254
  );
233
255
  }
234
256
 
235
- // When persist is true, only string values are allowed
236
- if (options?.persist && typeof value !== 'string') {
237
- throw new Error(`Persistent environment variables must be strings. Received ${typeof value} for key "${key}".`);
238
- }
239
-
240
- this.envVariables[key] = value;
241
-
242
- if (options?.persist) {
243
- this.persistentEnvVariables[key] = value;
244
- } else {
245
- if (this.persistentEnvVariables[key]) {
246
- delete this.persistentEnvVariables[key];
247
- }
257
+ // Deep-equal compare so object/array writes that mutate in place
258
+ // (e.g. `const c = bru.getEnvVar('cfg'); c.port = 4000; bru.setEnvVar('cfg', c);`)
259
+ // still flip the dirty flag strict `!==` returned false for same-reference writes.
260
+ if (!Object.hasOwn(this.envVariables, key) || !isEqual(this.envVariables[key], value)) {
261
+ this.envVariables[key] = value;
262
+ this._envDirty = true;
248
263
  }
249
264
  }
250
265
 
251
266
  deleteEnvVar(key) {
252
- delete this.envVariables[key];
267
+ if (key === '__name__') return;
268
+ if (Object.hasOwn(this.envVariables, key)) {
269
+ delete this.envVariables[key];
270
+ this._envDirty = true;
271
+ }
253
272
  }
254
273
 
255
274
  getAllEnvVars() {
@@ -259,15 +278,19 @@ class Bru {
259
278
  }
260
279
 
261
280
  deleteAllEnvVars() {
262
- const envName = this.envVariables.__name__;
263
- for (let key in this.envVariables) {
264
- if (this.envVariables.hasOwnProperty(key)) {
265
- delete this.envVariables[key];
266
- }
267
- }
268
- if (envName !== undefined) {
269
- this.envVariables.__name__ = envName;
281
+ // Iterate via Object.keys (own enumerable) so a user-set `hasOwnProperty` var
282
+ // can't shadow Object.prototype.hasOwnProperty and crash the loop.
283
+ let removed = false;
284
+ for (const key of Object.keys(this.envVariables)) {
285
+ if (key === '__name__') continue;
286
+ delete this.envVariables[key];
287
+ removed = true;
270
288
  }
289
+ if (removed) this._envDirty = true;
290
+ }
291
+
292
+ hasGlobalEnvVar(key) {
293
+ return Object.hasOwn(this.globalEnvironmentVariables, key);
271
294
  }
272
295
 
273
296
  getGlobalEnvVar(key) {
@@ -279,28 +302,31 @@ class Bru {
279
302
  throw new Error('Creating a env variable without specifying a name is not allowed.');
280
303
  }
281
304
 
282
- this.globalEnvironmentVariables[key] = value;
305
+ if (!Object.hasOwn(this.globalEnvironmentVariables, key) || !isEqual(this.globalEnvironmentVariables[key], value)) {
306
+ this.globalEnvironmentVariables[key] = value;
307
+ this._globalEnvDirty = true;
308
+ }
283
309
  }
284
310
 
285
- // TODO: deleteGlobalEnvVar works in the request lifecycle but does not update the UI.
286
- // Re-enable once the UI sync issue is resolved.
287
- // deleteGlobalEnvVar(key) {
288
- // delete this.globalEnvironmentVariables[key];
289
- // }
311
+ deleteGlobalEnvVar(key) {
312
+ if (Object.hasOwn(this.globalEnvironmentVariables, key)) {
313
+ delete this.globalEnvironmentVariables[key];
314
+ this._globalEnvDirty = true;
315
+ }
316
+ }
290
317
 
291
318
  getAllGlobalEnvVars() {
292
319
  return Object.assign({}, this.globalEnvironmentVariables);
293
320
  }
294
321
 
295
- // TODO: deleteAllGlobalEnvVars works in the request lifecycle but does not update the UI.
296
- // Re-enable once the UI sync issue is resolved.
297
- // deleteAllGlobalEnvVars() {
298
- // for (let key in this.globalEnvironmentVariables) {
299
- // if (this.globalEnvironmentVariables.hasOwnProperty(key)) {
300
- // delete this.globalEnvironmentVariables[key];
301
- // }
302
- // }
303
- // }
322
+ deleteAllGlobalEnvVars() {
323
+ const keys = Object.keys(this.globalEnvironmentVariables);
324
+ if (!keys.length) return;
325
+ for (const key of keys) {
326
+ delete this.globalEnvironmentVariables[key];
327
+ }
328
+ this._globalEnvDirty = true;
329
+ }
304
330
 
305
331
  getOauth2CredentialVar(key) {
306
332
  return this.interpolate(this.oauth2CredentialVariables[key]);
@@ -340,7 +366,10 @@ class Bru {
340
366
  );
341
367
  }
342
368
 
343
- this.runtimeVariables[key] = value;
369
+ if (!Object.hasOwn(this.runtimeVariables, key) || !isEqual(this.runtimeVariables[key], value)) {
370
+ this.runtimeVariables[key] = value;
371
+ this._runtimeVarsDirty = true;
372
+ }
344
373
  }
345
374
 
346
375
  getVar(key) {
@@ -355,15 +384,19 @@ class Bru {
355
384
  }
356
385
 
357
386
  deleteVar(key) {
358
- delete this.runtimeVariables[key];
387
+ if (Object.hasOwn(this.runtimeVariables, key)) {
388
+ delete this.runtimeVariables[key];
389
+ this._runtimeVarsDirty = true;
390
+ }
359
391
  }
360
392
 
361
393
  deleteAllVars() {
362
- for (let key in this.runtimeVariables) {
363
- if (this.runtimeVariables.hasOwnProperty(key)) {
364
- delete this.runtimeVariables[key];
365
- }
394
+ const keys = Object.keys(this.runtimeVariables);
395
+ if (!keys.length) return;
396
+ for (const key of keys) {
397
+ delete this.runtimeVariables[key];
366
398
  }
399
+ this._runtimeVarsDirty = true;
367
400
  }
368
401
 
369
402
  getAllVars() {
@@ -374,48 +407,47 @@ class Bru {
374
407
  return this.interpolate(this.collectionVariables[key]);
375
408
  }
376
409
 
377
- // TODO: setCollectionVar works in the request lifecycle but does not update the UI.
378
- // Re-enable once the UI sync issue is resolved.
379
- // setCollectionVar(key, value) {
380
- // if (!key) {
381
- // throw new Error('Creating a variable without specifying a name is not allowed.');
382
- // }
383
- //
384
- // if (variableNameRegex.test(key) === false) {
385
- // throw new Error(
386
- // `Variable name: "${key}" contains invalid characters!`
387
- // + ' Names must only contain alpha-numeric characters, "-", "_", "."'
388
- // );
389
- // }
390
- //
391
- // this.collectionVariables[key] = value;
392
- // }
410
+ setCollectionVar(key, value) {
411
+ if (!key) {
412
+ throw new Error('Creating a variable without specifying a name is not allowed.');
413
+ }
414
+
415
+ if (variableNameRegex.test(key) === false) {
416
+ throw new Error(
417
+ `Variable name: "${key}" contains invalid characters!`
418
+ + ' Names must only contain alpha-numeric characters, "-", "_", "."'
419
+ );
420
+ }
421
+
422
+ if (!Object.hasOwn(this.collectionVariables, key) || !isEqual(this.collectionVariables[key], value)) {
423
+ this.collectionVariables[key] = value;
424
+ this._collVarsDirty = true;
425
+ }
426
+ }
393
427
 
394
428
  hasCollectionVar(key) {
395
429
  return Object.hasOwn(this.collectionVariables, key);
396
430
  }
397
431
 
398
- // TODO: deleteCollectionVar works in the request lifecycle but does not update the UI.
399
- // Re-enable once the UI sync issue is resolved.
400
- // deleteCollectionVar(key) {
401
- // delete this.collectionVariables[key];
402
- // }
403
-
404
- // TODO: deleteAllCollectionVars works in the request lifecycle but does not update the UI.
405
- // Re-enable once the UI sync issue is resolved.
406
- // deleteAllCollectionVars() {
407
- // for (let key in this.collectionVariables) {
408
- // if (this.collectionVariables.hasOwnProperty(key)) {
409
- // delete this.collectionVariables[key];
410
- // }
411
- // }
412
- // }
413
-
414
- // TODO: getAllCollectionVars works in the request lifecycle but does not update the UI.
415
- // Re-enable once the UI sync issue is resolved.
416
- // getAllCollectionVars() {
417
- // return Object.assign({}, this.collectionVariables);
418
- // }
432
+ deleteCollectionVar(key) {
433
+ if (Object.hasOwn(this.collectionVariables, key)) {
434
+ delete this.collectionVariables[key];
435
+ this._collVarsDirty = true;
436
+ }
437
+ }
438
+
439
+ deleteAllCollectionVars() {
440
+ const keys = Object.keys(this.collectionVariables);
441
+ if (!keys.length) return;
442
+ for (const key of keys) {
443
+ delete this.collectionVariables[key];
444
+ }
445
+ this._collVarsDirty = true;
446
+ }
447
+
448
+ getAllCollectionVars() {
449
+ return Object.assign({}, this.collectionVariables);
450
+ }
419
451
 
420
452
  getFolderVar(key) {
421
453
  return this.interpolate(this.folderVariables[key]);
@@ -70,7 +70,13 @@ class BrunoRequest {
70
70
  if (segment.startsWith(':')) {
71
71
  const paramName = segment.slice(1);
72
72
  const pathParam = this.req.pathParams.find((param) => param.name === paramName);
73
- if (pathParam && pathParam.value) {
73
+ if (
74
+ pathParam
75
+ && pathParam.enabled !== false
76
+ && pathParam.value !== null
77
+ && pathParam.value !== undefined
78
+ && (typeof pathParam.value !== 'string' || pathParam.value.trim() !== '')
79
+ ) {
74
80
  return pathParam.value;
75
81
  }
76
82
  }
@@ -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,24 +100,23 @@ 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
107
107
  const buildRequestScriptResult = () => ({
108
108
  request,
109
- envVariables: cleanJson(envVariables),
110
- runtimeVariables: cleanJson(runtimeVariables),
109
+ envVariables: bru._envDirty ? cleanJson(envVariables) : null,
110
+ runtimeVariables: bru._runtimeVarsDirty ? cleanJson(runtimeVariables) : null,
111
+ collectionVariables: bru._collVarsDirty ? cleanJson(collectionVariables) : null,
112
+ globalEnvironmentVariables: bru._globalEnvDirty ? cleanJson(globalEnvironmentVariables) : null,
111
113
  visualizations,
112
- persistentEnvVariables: bru.persistentEnvVariables,
113
- globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
114
114
  oauth2CredentialsToReset: bru.oauth2CredentialsToReset,
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,24 +253,23 @@ 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
260
260
  const buildResponseScriptResult = () => ({
261
261
  response,
262
- envVariables: cleanJson(envVariables),
263
- persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
264
- runtimeVariables: cleanJson(runtimeVariables),
262
+ envVariables: bru._envDirty ? cleanJson(envVariables) : null,
263
+ runtimeVariables: bru._runtimeVarsDirty ? cleanJson(runtimeVariables) : null,
264
+ collectionVariables: bru._collVarsDirty ? cleanJson(collectionVariables) : null,
265
+ globalEnvironmentVariables: bru._globalEnvDirty ? cleanJson(globalEnvironmentVariables) : null,
265
266
  visualizations,
266
- globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
267
267
  oauth2CredentialsToReset: bru.oauth2CredentialsToReset,
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) {
@@ -68,9 +69,10 @@ class TestRuntime {
68
69
  if (!testsFile || !testsFile.length) {
69
70
  return {
70
71
  request,
71
- envVariables,
72
- runtimeVariables,
73
- globalEnvironmentVariables,
72
+ envVariables: null,
73
+ runtimeVariables: null,
74
+ collectionVariables: null,
75
+ globalEnvironmentVariables: null,
74
76
  results: __brunoTestResults.getResults(),
75
77
  nextRequestName: bru.nextRequest
76
78
  };
@@ -84,7 +86,8 @@ class TestRuntime {
84
86
  expect: chai.expect,
85
87
  assert: chai.assert,
86
88
  __brunoTestResults: __brunoTestResults,
87
- jwt: jsonwebtoken
89
+ jwt: jsonwebtoken,
90
+ __bruSetScope: createScopeSetter(bru)
88
91
  };
89
92
 
90
93
  if (onConsoleLog && typeof onConsoleLog === 'function') {
@@ -102,9 +105,7 @@ class TestRuntime {
102
105
  };
103
106
  }
104
107
 
105
- if (runRequestByItemPathname) {
106
- context.bru.runRequest = runRequestByItemPathname;
107
- }
108
+ bindRunRequest(bru, runRequestByItemPathname);
108
109
 
109
110
  let scriptError = null;
110
111
 
@@ -141,13 +142,14 @@ class TestRuntime {
141
142
 
142
143
  const result = {
143
144
  request,
144
- envVariables: cleanJson(envVariables),
145
- runtimeVariables: cleanJson(runtimeVariables),
146
- globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
147
- persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
145
+ envVariables: bru._envDirty ? cleanJson(envVariables) : null,
146
+ runtimeVariables: bru._runtimeVarsDirty ? cleanJson(runtimeVariables) : null,
147
+ collectionVariables: bru._collVarsDirty ? cleanJson(collectionVariables) : null,
148
+ globalEnvironmentVariables: bru._globalEnvDirty ? cleanJson(globalEnvironmentVariables) : null,
148
149
  oauth2CredentialsToReset: bru.oauth2CredentialsToReset,
149
150
  results: cleanJson(__brunoTestResults.getResults()),
150
- nextRequestName: bru.nextRequest
151
+ nextRequestName: bru.nextRequest,
152
+ scriptedRequestEntries: cleanJson(bru.scriptedRequestEntries || [])
151
153
  };
152
154
 
153
155
  if (scriptError) {
@@ -92,10 +92,10 @@ class VarsRuntime {
92
92
  }
93
93
 
94
94
  return {
95
- envVariables,
96
- runtimeVariables,
97
- globalEnvironmentVariables: cleanJson(globalEnvironmentVariables),
98
- persistentEnvVariables: cleanJson(bru.persistentEnvVariables),
95
+ envVariables: bru._envDirty ? cleanJson(envVariables) : null,
96
+ runtimeVariables: bru._runtimeVarsDirty ? cleanJson(runtimeVariables) : null,
97
+ collectionVariables: bru._collVarsDirty ? cleanJson(collectionVariables) : null,
98
+ globalEnvironmentVariables: bru._globalEnvDirty ? cleanJson(globalEnvironmentVariables) : null,
99
99
  error
100
100
  };
101
101
  }
@@ -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
 
@@ -55,8 +55,8 @@ const addBruShimToContext = (vm, bru) => {
55
55
  vm.setProp(bruObject, 'getEnvVar', getEnvVar);
56
56
  getEnvVar.dispose();
57
57
 
58
- let setEnvVar = vm.newFunction('setEnvVar', function (key, value, options = {}) {
59
- bru.setEnvVar(vm.dump(key), vm.dump(value), vm.dump(options));
58
+ let setEnvVar = vm.newFunction('setEnvVar', function (key, value) {
59
+ bru.setEnvVar(vm.dump(key), vm.dump(value));
60
60
  });
61
61
  vm.setProp(bruObject, 'setEnvVar', setEnvVar);
62
62
  setEnvVar.dispose();
@@ -103,13 +103,11 @@ const addBruShimToContext = (vm, bru) => {
103
103
  vm.setProp(bruObject, 'setGlobalEnvVar', setGlobalEnvVar);
104
104
  setGlobalEnvVar.dispose();
105
105
 
106
- // TODO: deleteGlobalEnvVar works in the request lifecycle but does not update the UI.
107
- // Re-enable once the UI sync issue is resolved.
108
- // let deleteGlobalEnvVar = vm.newFunction('deleteGlobalEnvVar', function (key) {
109
- // bru.deleteGlobalEnvVar(vm.dump(key));
110
- // });
111
- // vm.setProp(bruObject, 'deleteGlobalEnvVar', deleteGlobalEnvVar);
112
- // deleteGlobalEnvVar.dispose();
106
+ let deleteGlobalEnvVar = vm.newFunction('deleteGlobalEnvVar', function (key) {
107
+ bru.deleteGlobalEnvVar(vm.dump(key));
108
+ });
109
+ vm.setProp(bruObject, 'deleteGlobalEnvVar', deleteGlobalEnvVar);
110
+ deleteGlobalEnvVar.dispose();
113
111
 
114
112
  let getAllGlobalEnvVars = vm.newFunction('getAllGlobalEnvVars', function () {
115
113
  return marshallToVm(bru.getAllGlobalEnvVars(), vm);
@@ -117,13 +115,17 @@ const addBruShimToContext = (vm, bru) => {
117
115
  vm.setProp(bruObject, 'getAllGlobalEnvVars', getAllGlobalEnvVars);
118
116
  getAllGlobalEnvVars.dispose();
119
117
 
120
- // TODO: deleteAllGlobalEnvVars works in the request lifecycle but does not update the UI.
121
- // Re-enable once the UI sync issue is resolved.
122
- // let deleteAllGlobalEnvVars = vm.newFunction('deleteAllGlobalEnvVars', function () {
123
- // bru.deleteAllGlobalEnvVars();
124
- // });
125
- // vm.setProp(bruObject, 'deleteAllGlobalEnvVars', deleteAllGlobalEnvVars);
126
- // deleteAllGlobalEnvVars.dispose();
118
+ let hasGlobalEnvVar = vm.newFunction('hasGlobalEnvVar', function (key) {
119
+ return marshallToVm(bru.hasGlobalEnvVar(vm.dump(key)), vm);
120
+ });
121
+ vm.setProp(bruObject, 'hasGlobalEnvVar', hasGlobalEnvVar);
122
+ hasGlobalEnvVar.dispose();
123
+
124
+ let deleteAllGlobalEnvVars = vm.newFunction('deleteAllGlobalEnvVars', function () {
125
+ bru.deleteAllGlobalEnvVars();
126
+ });
127
+ vm.setProp(bruObject, 'deleteAllGlobalEnvVars', deleteAllGlobalEnvVars);
128
+ deleteAllGlobalEnvVars.dispose();
127
129
 
128
130
  let hasVar = vm.newFunction('hasVar', function (key) {
129
131
  return marshallToVm(bru.hasVar(vm.dump(key)), vm);
@@ -277,13 +279,11 @@ const addBruShimToContext = (vm, bru) => {
277
279
  vm.setProp(bruObject, 'getCollectionVar', getCollectionVar);
278
280
  getCollectionVar.dispose();
279
281
 
280
- // TODO: setCollectionVar works in the request lifecycle but does not update the UI.
281
- // Re-enable once the UI sync issue is resolved.
282
- // let setCollectionVar = vm.newFunction('setCollectionVar', function (key, value) {
283
- // bru.setCollectionVar(vm.dump(key), vm.dump(value));
284
- // });
285
- // vm.setProp(bruObject, 'setCollectionVar', setCollectionVar);
286
- // setCollectionVar.dispose();
282
+ let setCollectionVar = vm.newFunction('setCollectionVar', function (key, value) {
283
+ bru.setCollectionVar(vm.dump(key), vm.dump(value));
284
+ });
285
+ vm.setProp(bruObject, 'setCollectionVar', setCollectionVar);
286
+ setCollectionVar.dispose();
287
287
 
288
288
  let hasCollectionVar = vm.newFunction('hasCollectionVar', function (key) {
289
289
  return marshallToVm(bru.hasCollectionVar(vm.dump(key)), vm);
@@ -291,29 +291,23 @@ const addBruShimToContext = (vm, bru) => {
291
291
  vm.setProp(bruObject, 'hasCollectionVar', hasCollectionVar);
292
292
  hasCollectionVar.dispose();
293
293
 
294
- // TODO: deleteCollectionVar works in the request lifecycle but does not update the UI.
295
- // Re-enable once the UI sync issue is resolved.
296
- // let deleteCollectionVar = vm.newFunction('deleteCollectionVar', function (key) {
297
- // bru.deleteCollectionVar(vm.dump(key));
298
- // });
299
- // vm.setProp(bruObject, 'deleteCollectionVar', deleteCollectionVar);
300
- // deleteCollectionVar.dispose();
301
-
302
- // TODO: deleteAllCollectionVars works in the request lifecycle but does not update the UI.
303
- // Re-enable once the UI sync issue is resolved.
304
- // let deleteAllCollectionVars = vm.newFunction('deleteAllCollectionVars', function () {
305
- // bru.deleteAllCollectionVars();
306
- // });
307
- // vm.setProp(bruObject, 'deleteAllCollectionVars', deleteAllCollectionVars);
308
- // deleteAllCollectionVars.dispose();
309
-
310
- // TODO: getAllCollectionVars works in the request lifecycle but does not update the UI.
311
- // Re-enable once the UI sync issue is resolved.
312
- // let getAllCollectionVars = vm.newFunction('getAllCollectionVars', function () {
313
- // return marshallToVm(bru.getAllCollectionVars(), vm);
314
- // });
315
- // vm.setProp(bruObject, 'getAllCollectionVars', getAllCollectionVars);
316
- // getAllCollectionVars.dispose();
294
+ let deleteCollectionVar = vm.newFunction('deleteCollectionVar', function (key) {
295
+ bru.deleteCollectionVar(vm.dump(key));
296
+ });
297
+ vm.setProp(bruObject, 'deleteCollectionVar', deleteCollectionVar);
298
+ deleteCollectionVar.dispose();
299
+
300
+ let deleteAllCollectionVars = vm.newFunction('deleteAllCollectionVars', function () {
301
+ bru.deleteAllCollectionVars();
302
+ });
303
+ vm.setProp(bruObject, 'deleteAllCollectionVars', deleteAllCollectionVars);
304
+ deleteAllCollectionVars.dispose();
305
+
306
+ let getAllCollectionVars = vm.newFunction('getAllCollectionVars', function () {
307
+ return marshallToVm(bru.getAllCollectionVars(), vm);
308
+ });
309
+ vm.setProp(bruObject, 'getAllCollectionVars', getAllCollectionVars);
310
+ getAllCollectionVars.dispose();
317
311
 
318
312
  let getTestResults = vm.newFunction('getTestResults', () => {
319
313
  const promise = vm.newPromise();
@@ -401,10 +395,20 @@ const addBruShimToContext = (vm, bru) => {
401
395
  });
402
396
  sendRequestHandle.consume((handle) => vm.setProp(bruObject, '_sendRequest', handle));
403
397
 
398
+ // On vm.global, not bru, to stay off user-facing autocomplete.
399
+ let setScopeHandle = vm.newFunction('__bruSetScope', (scopeArg) => {
400
+ bru._currentScope = vm.dump(scopeArg) || null;
401
+ });
402
+ setScopeHandle.consume((handle) => vm.setProp(vm.global, '__bruSetScope', handle));
403
+
404
404
  const sleep = vm.newFunction('sleep', (timer) => {
405
405
  const t = vm.getString(timer);
406
406
  const promise = vm.newPromise();
407
407
  setTimeout(() => {
408
+ // The VM may have been disposed while this native timer was pending
409
+ // (e.g. a setTimeout/sleep whose promise the script never awaited).
410
+ // Touching the VM after teardown throws QuickJSUseAfterFree, so bail out.
411
+ if (!vm.alive) return;
408
412
  promise.resolve(vm.newString('slept'));
409
413
  }, t);
410
414
  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
  };