From ec9ee266c3a0b3adec0bd29ec85e1729bd59eba6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADcolas=20Carvalho?= Date: Sat, 27 Jun 2026 01:44:38 -0300 Subject: [PATCH 1/8] feat(node-vm): support dynamic esm imports in scripts --- .../bruno-js/src/sandbox/node-vm/index.js | 10 ++++++-- .../src/sandbox/node-vm/index.spec.js | 24 ++++++++++++++++++- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/packages/bruno-js/src/sandbox/node-vm/index.js b/packages/bruno-js/src/sandbox/node-vm/index.js index a140b784aa3..d5ec5da8a09 100644 --- a/packages/bruno-js/src/sandbox/node-vm/index.js +++ b/packages/bruno-js/src/sandbox/node-vm/index.js @@ -72,9 +72,15 @@ async function runScriptInNodeVm({ const wrappedScript = wrapScriptInClosure(script, SANDBOX.NODEVM); let compiledScript; try { - compiledScript = new vm.Script(wrappedScript, { + const scriptOptions = { filename: vmFilename - }); + }; + + if (vm.constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER) { + scriptOptions.importModuleDynamically = vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER; + } + + compiledScript = new vm.Script(wrappedScript, scriptOptions); } catch (error) { // V8 puts "filename:line" as the first line of syntax error stacks. // Parse it so the error formatter can map to the correct source location. diff --git a/packages/bruno-js/src/sandbox/node-vm/index.spec.js b/packages/bruno-js/src/sandbox/node-vm/index.spec.js index bf74ae19e14..873dc74687d 100644 --- a/packages/bruno-js/src/sandbox/node-vm/index.spec.js +++ b/packages/bruno-js/src/sandbox/node-vm/index.spec.js @@ -2,6 +2,7 @@ const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals') const fs = require('fs'); const path = require('path'); const os = require('os'); +const vm = require('node:vm'); const { runScriptInNodeVm } = require('./index'); describe('node-vm sandbox', () => { @@ -777,7 +778,7 @@ describe('node-vm sandbox', () => { expect(context.bru.setVar).toHaveBeenCalledWith('result', 'cjs-100'); }); - it('should fail when loading .mjs files (ES modules)', async () => { + it('should fail when requiring .mjs files (ES modules)', async () => { const nodeModulesDir = path.join(collectionPath, 'node_modules', 'mjs-ext-module'); fs.mkdirSync(nodeModulesDir, { recursive: true }); fs.writeFileSync( @@ -800,6 +801,27 @@ describe('node-vm sandbox', () => { ).rejects.toThrow(); }); + it('should dynamically import .mjs files from node-vm scripts', async () => { + fs.writeFileSync( + path.join(collectionPath, 'esm-helper.mjs'), + 'export function makeGreeting(name) { return `hello ${name} from esm`; }' + ); + + const script = ` + const helper = await import('./esm-helper.mjs'); + bru.setVar('result', helper.makeGreeting('bruno')); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'hello bruno from esm'); + }); + it('should load module with package.json main field', async () => { const nodeModulesDir = path.join(collectionPath, 'node_modules', 'custom-main'); fs.mkdirSync(path.join(nodeModulesDir, 'lib'), { recursive: true }); From 7cbe5572aef1a663f48b84df8519a6dd3a0a3f6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADcolas=20Carvalho?= Date: Sat, 27 Jun 2026 02:07:32 -0300 Subject: [PATCH 2/8] fix(app): allow dynamic imports in script editor linting --- .../src/utils/codemirror/javascript-lint.js | 16 ++++++++ .../utils/codemirror/javascript-lint.spec.js | 40 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 packages/bruno-app/src/utils/codemirror/javascript-lint.spec.js diff --git a/packages/bruno-app/src/utils/codemirror/javascript-lint.js b/packages/bruno-app/src/utils/codemirror/javascript-lint.js index 385479e9937..6eb5fbb3825 100644 --- a/packages/bruno-app/src/utils/codemirror/javascript-lint.js +++ b/packages/bruno-app/src/utils/codemirror/javascript-lint.js @@ -14,6 +14,16 @@ if (!SERVER_RENDERED) { CodeMirror = require('codemirror'); const { filter } = require('lodash'); + const isTopLevelDynamicImportError = (error) => { + return ( + error.code === 'E033' + && error.a === 'import' + && error.evidence + && error.evidence.includes('import(') + && error.scope === '(main)' + ); + }; + function validator(text, options) { if (!window.JSHINT) { if (window.console) { @@ -61,11 +71,17 @@ if (!SERVER_RENDERED) { * codemirror error: "Missing semicolon." * - W024: 'await' used as identifier (JSHint doesn't recognize top-level await syntax) * codemirror error: "Expected an identifier and instead saw 'await' (a reserved word)." + * - E033: 'import' in dynamic import after top-level await + * codemirror error: "Expected an operator and instead saw 'import'." * * Once JSHINT top level await support is added, this file can be removed * and we can use the default javascript-lint addon from codemirror */ errors = filter(errors, (error) => { + if (isTopLevelDynamicImportError(error)) { + return false; + } + if (error.code === 'E058' || error.code === 'W024') { if ( error.evidence diff --git a/packages/bruno-app/src/utils/codemirror/javascript-lint.spec.js b/packages/bruno-app/src/utils/codemirror/javascript-lint.spec.js new file mode 100644 index 00000000000..e24b76ab559 --- /dev/null +++ b/packages/bruno-app/src/utils/codemirror/javascript-lint.spec.js @@ -0,0 +1,40 @@ +const { describe, it, expect, beforeEach, jest } = require('@jest/globals'); +const { JSHINT } = require('jshint'); + +jest.mock('codemirror', () => { + const codemirror = require('test-utils/mocks/codemirror'); + return codemirror; +}); + +describe('javascript lint', () => { + let CodeMirror; + let lintJavascript; + + beforeEach(() => { + jest.resetModules(); + window.JSHINT = JSHINT; + + require('./javascript-lint'); + CodeMirror = require('codemirror'); + CodeMirror.Pos = (line, ch) => ({ line, ch }); + lintJavascript = CodeMirror.lint.javascript; + }); + + it('does not report top-level await dynamic import errors', () => { + const result = lintJavascript('const helper = await import("./helper.mjs");'); + + expect(result).toEqual([]); + }); + + it('continues to report unrelated syntax errors', () => { + const result = lintJavascript('const value = ;'); + + expect(result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + severity: 'error' + }) + ]) + ); + }); +}); From a53814eca5314bcbc8f90aa96171f1db7d1f4d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADcolas=20Carvalho?= Date: Sat, 27 Jun 2026 16:55:57 -0300 Subject: [PATCH 3/8] test(app): cover dynamic import lint edge cases --- .../src/utils/codemirror/javascript-lint.js | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/bruno-app/src/utils/codemirror/javascript-lint.js b/packages/bruno-app/src/utils/codemirror/javascript-lint.js index 6eb5fbb3825..8cf92274640 100644 --- a/packages/bruno-app/src/utils/codemirror/javascript-lint.js +++ b/packages/bruno-app/src/utils/codemirror/javascript-lint.js @@ -14,15 +14,7 @@ if (!SERVER_RENDERED) { CodeMirror = require('codemirror'); const { filter } = require('lodash'); - const isTopLevelDynamicImportError = (error) => { - return ( - error.code === 'E033' - && error.a === 'import' - && error.evidence - && error.evidence.includes('import(') - && error.scope === '(main)' - ); - }; + const TOP_LEVEL_AWAIT_DYNAMIC_IMPORT_PATTERN = /\bawait\s+import\s*\(/; function validator(text, options) { if (!window.JSHINT) { @@ -78,8 +70,17 @@ if (!SERVER_RENDERED) { * and we can use the default javascript-lint addon from codemirror */ errors = filter(errors, (error) => { - if (isTopLevelDynamicImportError(error)) { - return false; + if ( + error.code === 'E033' + ) { + if ( + error.a === 'import' + && error.evidence + && TOP_LEVEL_AWAIT_DYNAMIC_IMPORT_PATTERN.test(error.evidence) + && error.scope === '(main)' + ) { + return false; + } } if (error.code === 'E058' || error.code === 'W024') { From 06842b6ce2f3b76f00c4011deeef901aa901685c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADcolas=20Carvalho?= Date: Sat, 27 Jun 2026 17:05:33 -0300 Subject: [PATCH 4/8] test(app): cover dynamic import lint comments edge cases --- .../src/utils/codemirror/javascript-lint.js | 2 +- .../src/utils/codemirror/javascript-lint.spec.js | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/bruno-app/src/utils/codemirror/javascript-lint.js b/packages/bruno-app/src/utils/codemirror/javascript-lint.js index 8cf92274640..91bf06900c0 100644 --- a/packages/bruno-app/src/utils/codemirror/javascript-lint.js +++ b/packages/bruno-app/src/utils/codemirror/javascript-lint.js @@ -14,7 +14,7 @@ if (!SERVER_RENDERED) { CodeMirror = require('codemirror'); const { filter } = require('lodash'); - const TOP_LEVEL_AWAIT_DYNAMIC_IMPORT_PATTERN = /\bawait\s+import\s*\(/; + const TOP_LEVEL_AWAIT_DYNAMIC_IMPORT_PATTERN = /\bawait(?:\s+|\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\/\s*)+import\s*\(/; function validator(text, options) { if (!window.JSHINT) { diff --git a/packages/bruno-app/src/utils/codemirror/javascript-lint.spec.js b/packages/bruno-app/src/utils/codemirror/javascript-lint.spec.js index e24b76ab559..7a59e3bb613 100644 --- a/packages/bruno-app/src/utils/codemirror/javascript-lint.spec.js +++ b/packages/bruno-app/src/utils/codemirror/javascript-lint.spec.js @@ -26,6 +26,18 @@ describe('javascript lint', () => { expect(result).toEqual([]); }); + it('does not report top-level await dynamic import errors split across lines', () => { + const result = lintJavascript('const helper = await\n import("./helper.mjs");'); + + expect(result).toEqual([]); + }); + + it('does not report top-level await dynamic import errors with a comment between await and import', () => { + const result = lintJavascript('const helper = await /* load esm */ import("./helper.mjs");'); + + expect(result).toEqual([]); + }); + it('continues to report unrelated syntax errors', () => { const result = lintJavascript('const value = ;'); From d0330f66d86610787577e53358ac7cbc1868f717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADcolas=20Carvalho?= Date: Sat, 27 Jun 2026 23:06:17 -0300 Subject: [PATCH 5/8] refactor(bruno-js): share local module path resolver --- .../src/sandbox/node-vm/cjs-loader.js | 56 +------------------ .../bruno-js/src/sandbox/node-vm/utils.js | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+), 55 deletions(-) diff --git a/packages/bruno-js/src/sandbox/node-vm/cjs-loader.js b/packages/bruno-js/src/sandbox/node-vm/cjs-loader.js index 04405648059..a39c1729e62 100644 --- a/packages/bruno-js/src/sandbox/node-vm/cjs-loader.js +++ b/packages/bruno-js/src/sandbox/node-vm/cjs-loader.js @@ -3,61 +3,7 @@ const fs = require('node:fs'); const path = require('node:path'); const nodeModule = require('node:module'); -const { isBuiltinModule, isPathWithinAllowedRoots } = require('./utils'); - -/** - * Resolve a local module path, handling files and directories - * Follows Node.js resolution algorithm: - * 1. Exact path (with extension) - * 2. Path + .js extension - * 3. Directory with package.json (main field) - * 4. Directory with index.js - * @param {string} fromDir - Directory to resolve from - * @param {string} moduleName - Module name/path - * @returns {string} Resolved absolute path - */ -function resolveLocalModulePath(fromDir, moduleName) { - const basePath = path.resolve(fromDir, moduleName); - - // 1. If has extension, use as-is - if (path.extname(moduleName)) { - return path.normalize(basePath); - } - - // 2. Try with .js extension - const withJs = basePath + '.js'; - if (fs.existsSync(withJs)) { - return path.normalize(withJs); - } - - // 3. Check if it's a directory - if (fs.existsSync(basePath) && fs.statSync(basePath).isDirectory()) { - // 3a. Check for package.json with main field - const pkgPath = path.join(basePath, 'package.json'); - if (fs.existsSync(pkgPath)) { - try { - const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); - if (pkg.main) { - const mainPath = path.resolve(basePath, pkg.main); - if (fs.existsSync(mainPath)) { - return path.normalize(mainPath); - } - } - } catch { - // Ignore JSON parse errors, fall through to index.js - } - } - - // 3b. Check for index.js - const indexPath = path.join(basePath, 'index.js'); - if (fs.existsSync(indexPath)) { - return path.normalize(indexPath); - } - } - - // 4. Fall back to original path (will likely fail with file not found) - return path.normalize(basePath); -} +const { isBuiltinModule, isPathWithinAllowedRoots, resolveLocalModulePath } = require('./utils'); /** * Creates a custom require function with enhanced security and local module support diff --git a/packages/bruno-js/src/sandbox/node-vm/utils.js b/packages/bruno-js/src/sandbox/node-vm/utils.js index 7480e918217..a28f1b11c53 100644 --- a/packages/bruno-js/src/sandbox/node-vm/utils.js +++ b/packages/bruno-js/src/sandbox/node-vm/utils.js @@ -1,3 +1,4 @@ +const fs = require('node:fs'); const path = require('node:path'); const nodeModule = require('node:module'); @@ -25,6 +26,60 @@ function isPathWithinAllowedRoots(normalizedPath, additionalContextRootsAbsolute }); } +/** + * Resolve a local module path, handling files and directories + * Follows Node.js resolution algorithm: + * 1. Exact path (with extension) + * 2. Path + .js extension + * 3. Directory with package.json (main field) + * 4. Directory with index.js + * @param {string} fromDir - Directory to resolve from + * @param {string} moduleName - Module name/path + * @returns {string} Resolved absolute path + */ +function resolveLocalModulePath(fromDir, moduleName) { + const basePath = path.resolve(fromDir, moduleName); + + // 1. If has extension, use as-is + if (path.extname(moduleName)) { + return path.normalize(basePath); + } + + // 2. Try with .js extension + const withJs = basePath + '.js'; + if (fs.existsSync(withJs)) { + return path.normalize(withJs); + } + + // 3. Check if it's a directory + if (fs.existsSync(basePath) && fs.statSync(basePath).isDirectory()) { + // 3a. Check for package.json with main field + const pkgPath = path.join(basePath, 'package.json'); + if (fs.existsSync(pkgPath)) { + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); + if (pkg.main) { + const mainPath = path.resolve(basePath, pkg.main); + if (fs.existsSync(mainPath)) { + return path.normalize(mainPath); + } + } + } catch { + // Ignore JSON parse errors, fall through to index.js + } + } + + // 3b. Check for index.js + const indexPath = path.join(basePath, 'index.js'); + if (fs.existsSync(indexPath)) { + return path.normalize(indexPath); + } + } + + // 4. Fall back to original path (will likely fail with file not found) + return path.normalize(basePath); +} + /** * Resolve the VM filename for the script * @param {string|null} scriptPath - Path to the source file @@ -52,6 +107,7 @@ class ScriptError extends Error { module.exports = { isBuiltinModule, isPathWithinAllowedRoots, + resolveLocalModulePath, resolveVmFilename, ScriptError }; From 5f1b9aede4fa413c3f670f2e0dd44e90d1e974ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADcolas=20Carvalho?= Date: Sat, 27 Jun 2026 23:09:31 -0300 Subject: [PATCH 6/8] feat(bruno-js): add custom ESM imports to node VM --- .../src/sandbox/node-vm/esm-loader.js | 416 ++++++++++++++++++ .../bruno-js/src/sandbox/node-vm/index.js | 16 +- .../src/sandbox/node-vm/index.spec.js | 69 +++ 3 files changed, 496 insertions(+), 5 deletions(-) create mode 100644 packages/bruno-js/src/sandbox/node-vm/esm-loader.js diff --git a/packages/bruno-js/src/sandbox/node-vm/esm-loader.js b/packages/bruno-js/src/sandbox/node-vm/esm-loader.js new file mode 100644 index 00000000000..51df62ef9d9 --- /dev/null +++ b/packages/bruno-js/src/sandbox/node-vm/esm-loader.js @@ -0,0 +1,416 @@ +const vm = require('node:vm'); +const fs = require('node:fs'); +const path = require('node:path'); +const nodeModule = require('node:module'); +const { pathToFileURL } = require('node:url'); + +const { isBuiltinModule, isPathWithinAllowedRoots, resolveLocalModulePath } = require('./utils'); +const { createCustomRequire } = require('./cjs-loader'); + +// ESM modules share the same backing cache map as CJS modules. Prefix keys so a +// CJS require cache entry and an ESM import cache entry for the same file do not collide. +const ESM_CACHE_PREFIX = 'esm:'; + +/** + * Creates a custom import function with enhanced security and local module support + * @param {Object} options - Configuration options + * @param {string} options.collectionPath - Path to the collection directory + * @param {Object} options.isolatedContext - The VM isolated context created with vm.createContext() + * @param {string} options.currentModuleDir - Current module directory for resolving relative paths + * @param {Map} options.localModuleCache - Cache for loaded modules + * @param {string[]} options.additionalContextRootsAbsolute - Additional allowed root paths + * @returns {Function} Custom import function + */ +function createCustomImport({ + collectionPath, + isolatedContext, + currentModuleDir = collectionPath, + localModuleCache = new Map(), + additionalContextRootsAbsolute = [] +}) { + return async (moduleName, referrer) => { + const normalizedModuleName = moduleName.replace(/\\/g, '/'); + const referrerDir = getReferrerDir(referrer, currentModuleDir); + + // 1. Handle local modules (./path, ../path) + if (normalizedModuleName.startsWith('./') || normalizedModuleName.startsWith('../')) { + return loadLocalModule({ + moduleName: normalizedModuleName, + collectionPath, + isolatedContext, + currentModuleDir: referrerDir, + localModuleCache, + additionalContextRootsAbsolute + }); + } + + // 2. Handle absolute paths - route through local module security checks + // This prevents bypassing additionalContextRoots by using absolute paths. + if (path.isAbsolute(normalizedModuleName)) { + return loadLocalModule({ + moduleName: normalizedModuleName, + collectionPath, + isolatedContext, + currentModuleDir: referrerDir, + localModuleCache, + additionalContextRootsAbsolute + }); + } + + // 3. Handle Node.js builtin modules + // Note: Builtins are loaded via native require, bypassing VM isolation. + // This is intentional - [`developer` mode] node-vm isolation need not be strict for builtins. + if (isBuiltinModule(moduleName)) { + return loadBuiltinModule({ + moduleName, + isolatedContext, + localModuleCache + }); + } + + // 4. Handle npm modules - resolve collection dependencies before Bruno's bundled dependencies. + return loadNpmModule({ + moduleName, + collectionPath, + isolatedContext, + localModuleCache, + additionalContextRootsAbsolute + }); + }; +} + +function getReferrerDir(referrer, fallbackDir) { + if (referrer?.identifier && path.isAbsolute(referrer.identifier)) { + return path.dirname(referrer.identifier); + } + + return fallbackDir; +} + +/** + * Loads a local ESM or CJS module from the filesystem with security checks and caching + * @param {Object} options - Configuration options + * @returns {Promise} VM module instance + * @throws {Error} When module is outside collection path or cannot be loaded + */ +async function loadLocalModule({ + moduleName, + collectionPath, + isolatedContext, + currentModuleDir, + localModuleCache, + additionalContextRootsAbsolute = [] +}) { + // Validate the raw module name doesn't try to escape allowed roots + const preliminaryPath = path.resolve(currentModuleDir, moduleName); + if (!isPathWithinAllowedRoots(path.normalize(preliminaryPath), additionalContextRootsAbsolute)) { + const allowedRootsDisplay = additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n'); + throw new Error( + `Access to files outside of the allowed context roots is not allowed: ${moduleName}\n\n` + + `Allowed context roots:\n${allowedRootsDisplay}` + ); + } + + // Resolve the module path, handling files and directories + const resolvedPath = resolveLocalModulePath(currentModuleDir, moduleName); + + // Final security check after resolution + if (!isPathWithinAllowedRoots(resolvedPath, additionalContextRootsAbsolute)) { + const allowedRootsDisplay = additionalContextRootsAbsolute.map((root) => ` - ${root}`).join('\n'); + throw new Error( + `Access to files outside of the allowed context roots is not allowed: ${moduleName}\n\n` + + `Allowed context roots:\n${allowedRootsDisplay}` + ); + } + + if (!fs.existsSync(resolvedPath)) { + throw new Error(`Cannot find module ${moduleName}`); + } + + return executeModuleInVmContext({ + resolvedPath, + moduleName, + collectionPath, + isolatedContext, + localModuleCache, + additionalContextRootsAbsolute + }); +} + +async function loadBuiltinModule({ + moduleName, + isolatedContext, + localModuleCache +}) { + const normalizedName = moduleName.replace(/^node:/, ''); + + // Dynamic import must resolve to a vm.Module. Builtins are native CommonJS-ish + // values here, so expose them through a synthetic ESM namespace. + return createSyntheticModuleFromExports({ + identifier: `node:${normalizedName}`, + exportsValue: require(moduleName), + isolatedContext, + localModuleCache + }); +} + +/** + * Loads an npm module into the VM context + * @param {Object} options - Configuration options + * @returns {Promise} VM module instance + * @throws {Error} When module cannot be resolved or loaded + */ +async function loadNpmModule({ + moduleName, + collectionPath, + isolatedContext, + localModuleCache, + additionalContextRootsAbsolute +}) { + let resolvedPath; + + // Module resolution order: + // 1. Collection's node_modules (user-installed packages for their collection) + // 2. Bruno's node_modules (fallback for built-in dependencies) + // + // This order ensures user packages take precedence, allowing users to: + // - Override Bruno's bundled package versions + // - Install collection-specific dependencies + if (collectionPath) { + try { + const collectionRequire = nodeModule.createRequire(path.join(collectionPath, 'package.json')); + resolvedPath = collectionRequire.resolve(moduleName); + } catch { + // Module not found in collection, continue to fallback. + } + } + + // Fall back to Bruno's node_modules + if (!resolvedPath) { + try { + resolvedPath = require.resolve(moduleName, { paths: module.paths }); + } catch (mainError) { + throw new Error( + `Could not resolve module "${moduleName}": ${mainError.message}\n\n` + + `Install it with: npm install ${moduleName}` + ); + } + } + + return executeModuleInVmContext({ + resolvedPath, + moduleName, + collectionPath, + isolatedContext, + localModuleCache, + additionalContextRootsAbsolute + }); +} + +/** + * Executes a resolved module and returns a VM module instance + * @param {Object} options - Configuration options + * @returns {Promise} VM module instance + * @throws {Error} When module cannot be loaded + */ +async function executeModuleInVmContext({ + resolvedPath, + moduleName, + collectionPath, + isolatedContext, + localModuleCache, + additionalContextRootsAbsolute +}) { + // JSON files are exposed as default-only ESM modules, matching Node's import + // shape closely enough for Bruno scripts without parsing import attributes. + if (resolvedPath.endsWith('.json')) { + const jsonValue = JSON.parse(fs.readFileSync(resolvedPath, 'utf8')); + return createSyntheticModuleFromExports({ + identifier: resolvedPath, + exportsValue: jsonValue, + isolatedContext, + localModuleCache, + defaultOnly: true + }); + } + + if (shouldLoadAsEsm(resolvedPath)) { + return loadSourceTextModule({ + resolvedPath, + collectionPath, + isolatedContext, + localModuleCache, + additionalContextRootsAbsolute + }); + } + + const moduleRequire = createCustomRequire({ + collectionPath, + isolatedContext, + currentModuleDir: path.dirname(resolvedPath), + localModuleCache, + additionalContextRootsAbsolute + }); + const exportsValue = moduleRequire(resolvedPath); + + // CJS dependencies can still be imported from ESM scripts. Execute them through + // Bruno's CJS loader, then bridge their exports into a synthetic ESM namespace. + return createSyntheticModuleFromExports({ + identifier: resolvedPath, + exportsValue, + isolatedContext, + localModuleCache + }); +} + +async function loadSourceTextModule({ + resolvedPath, + collectionPath, + isolatedContext, + localModuleCache, + additionalContextRootsAbsolute +}) { + const cacheKey = getEsmCacheKey(resolvedPath); + if (localModuleCache.has(cacheKey)) { + const cachedModule = localModuleCache.get(cacheKey); + await evaluateModuleIfNeeded(cachedModule); + return cachedModule; + } + + const moduleSource = fs.readFileSync(resolvedPath, 'utf8'); + const moduleDir = path.dirname(resolvedPath); + + // Real ESM files are evaluated as SourceTextModule instances inside the same + // isolated context as the parent Bruno script. + const sourceModule = new vm.SourceTextModule(moduleSource, { + context: isolatedContext, + identifier: resolvedPath, + initializeImportMeta(meta) { + meta.url = pathToFileURL(resolvedPath).href; + }, + importModuleDynamically: createCustomImport({ + collectionPath, + isolatedContext, + currentModuleDir: moduleDir, + localModuleCache, + additionalContextRootsAbsolute + }) + }); + + localModuleCache.set(cacheKey, sourceModule); + + // Static imports inside imported ESM modules must go through the same Bruno + // resolver so allowed roots and collection-first package resolution are preserved. + await sourceModule.link((nestedModuleName, referencingModule) => { + return createCustomImport({ + collectionPath, + isolatedContext, + currentModuleDir: path.dirname(referencingModule.identifier), + localModuleCache, + additionalContextRootsAbsolute + })(nestedModuleName, referencingModule); + }); + await evaluateModuleIfNeeded(sourceModule); + + return sourceModule; +} + +async function createSyntheticModuleFromExports({ + identifier, + exportsValue, + isolatedContext, + localModuleCache, + defaultOnly = false +}) { + const cacheKey = getEsmCacheKey(identifier); + if (localModuleCache.has(cacheKey)) { + const cachedModule = localModuleCache.get(cacheKey); + await evaluateModuleIfNeeded(cachedModule); + return cachedModule; + } + + const exportNames = getSyntheticExportNames(exportsValue, defaultOnly); + // SyntheticModule lets non-ESM values satisfy the vm importModuleDynamically + // contract, which requires a vm.Module rather than a plain object. + const syntheticModule = new vm.SyntheticModule(exportNames, function () { + this.setExport('default', exportsValue); + + if (!defaultOnly && exportsValue && (typeof exportsValue === 'object' || typeof exportsValue === 'function')) { + Object.keys(exportsValue).forEach((key) => { + this.setExport(key, exportsValue[key]); + }); + } + }, { + context: isolatedContext, + identifier + }); + + localModuleCache.set(cacheKey, syntheticModule); + + await syntheticModule.link(() => {}); + await evaluateModuleIfNeeded(syntheticModule); + + return syntheticModule; +} + +function shouldLoadAsEsm(resolvedPath) { + if (resolvedPath.endsWith('.mjs')) { + return true; + } + + if (!resolvedPath.endsWith('.js')) { + return false; + } + + const packageJsonPath = findNearestPackageJson(path.dirname(resolvedPath)); + if (!packageJsonPath) { + return false; + } + + try { + const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + return pkg.type === 'module'; + } catch { + return false; + } +} + +function findNearestPackageJson(startDir) { + let currentDir = startDir; + + while (currentDir && currentDir !== path.dirname(currentDir)) { + const packageJsonPath = path.join(currentDir, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + return packageJsonPath; + } + currentDir = path.dirname(currentDir); + } + + return null; +} + +function getSyntheticExportNames(exportsValue, defaultOnly) { + const exportNames = new Set(['default']); + + if (!defaultOnly && exportsValue && (typeof exportsValue === 'object' || typeof exportsValue === 'function')) { + Object.keys(exportsValue).forEach((key) => { + exportNames.add(key); + }); + } + + return Array.from(exportNames); +} + +async function evaluateModuleIfNeeded(moduleInstance) { + if (moduleInstance.status !== 'evaluated') { + await moduleInstance.evaluate(); + } +} + +function getEsmCacheKey(identifier) { + return ESM_CACHE_PREFIX + identifier; +} + +module.exports = { + createCustomImport +}; diff --git a/packages/bruno-js/src/sandbox/node-vm/index.js b/packages/bruno-js/src/sandbox/node-vm/index.js index d5ec5da8a09..9ea1a6ad673 100644 --- a/packages/bruno-js/src/sandbox/node-vm/index.js +++ b/packages/bruno-js/src/sandbox/node-vm/index.js @@ -5,6 +5,7 @@ const lodash = require('lodash'); const { wrapConsoleWithSerializers } = require('./console'); const { ScriptError, resolveVmFilename } = require('./utils'); const { createCustomRequire } = require('./cjs-loader'); +const { createCustomImport } = require('./esm-loader'); const { safeGlobals } = require('./constants'); const { mixinTypedArrays } = require('../mixins/typed-arrays'); const { wrapScriptInClosure, SANDBOX } = require('../../utils/sandbox'); @@ -66,6 +67,14 @@ async function runScriptInNodeVm({ additionalContextRootsAbsolute }); + const customImport = createCustomImport({ + collectionPath, + isolatedContext, + currentModuleDir: collectionPath, + localModuleCache, + additionalContextRootsAbsolute + }); + const vmFilename = resolveVmFilename(scriptPath, collectionPath); // Execute the script in the isolated context @@ -73,13 +82,10 @@ async function runScriptInNodeVm({ let compiledScript; try { const scriptOptions = { - filename: vmFilename + filename: vmFilename, + importModuleDynamically: customImport }; - if (vm.constants?.USE_MAIN_CONTEXT_DEFAULT_LOADER) { - scriptOptions.importModuleDynamically = vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER; - } - compiledScript = new vm.Script(wrappedScript, scriptOptions); } catch (error) { // V8 puts "filename:line" as the first line of syntax error stacks. diff --git a/packages/bruno-js/src/sandbox/node-vm/index.spec.js b/packages/bruno-js/src/sandbox/node-vm/index.spec.js index 873dc74687d..0c1162ad4c6 100644 --- a/packages/bruno-js/src/sandbox/node-vm/index.spec.js +++ b/packages/bruno-js/src/sandbox/node-vm/index.spec.js @@ -822,6 +822,75 @@ describe('node-vm sandbox', () => { expect(context.bru.setVar).toHaveBeenCalledWith('result', 'hello bruno from esm'); }); + it('should resolve static imports inside dynamically imported .mjs files', async () => { + fs.writeFileSync( + path.join(collectionPath, 'message.mjs'), + 'export const message = "nested-esm";' + ); + fs.writeFileSync( + path.join(collectionPath, 'esm-helper.mjs'), + 'import { message } from "./message.mjs"; export function getMessage() { return message; }' + ); + + const script = ` + const helper = await import('./esm-helper.mjs'); + bru.setVar('result', helper.getMessage()); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'nested-esm'); + }); + + it('should dynamically import collection npm dependencies as synthetic modules', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'esm-cjs-dependency'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'package.json'), + '{"name": "esm-cjs-dependency", "main": "index.js"}' + ); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + 'exports.message = "from-cjs-dependency";' + ); + + const script = ` + const dependency = await import('esm-cjs-dependency'); + bru.setVar('result', dependency.message); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'from-cjs-dependency'); + }); + + it('should block dynamic imports outside allowed roots', async () => { + fs.writeFileSync( + path.join(testDir, 'outside.mjs'), + 'export const secret = "nope";' + ); + + const script = ` + const outside = await import('../outside.mjs'); + `; + + const context = { console: console }; + + await expect( + runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }) + ).rejects.toThrow('Access to files outside of the allowed context roots is not allowed'); + }); + it('should load module with package.json main field', async () => { const nodeModulesDir = path.join(collectionPath, 'node_modules', 'custom-main'); fs.mkdirSync(path.join(nodeModulesDir, 'lib'), { recursive: true }); From 82ef085aff7ed2f4f9b49bade41c63c7dbc3d0ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADcolas=20Carvalho?= Date: Sat, 27 Jun 2026 23:30:12 -0300 Subject: [PATCH 7/8] docs(bruno-js): document node VM ESM loader helpers --- .../src/sandbox/node-vm/esm-loader.js | 127 +++++++++++++++--- 1 file changed, 107 insertions(+), 20 deletions(-) diff --git a/packages/bruno-js/src/sandbox/node-vm/esm-loader.js b/packages/bruno-js/src/sandbox/node-vm/esm-loader.js index 51df62ef9d9..d40279939b1 100644 --- a/packages/bruno-js/src/sandbox/node-vm/esm-loader.js +++ b/packages/bruno-js/src/sandbox/node-vm/esm-loader.js @@ -12,14 +12,16 @@ const { createCustomRequire } = require('./cjs-loader'); const ESM_CACHE_PREFIX = 'esm:'; /** - * Creates a custom import function with enhanced security and local module support - * @param {Object} options - Configuration options - * @param {string} options.collectionPath - Path to the collection directory - * @param {Object} options.isolatedContext - The VM isolated context created with vm.createContext() - * @param {string} options.currentModuleDir - Current module directory for resolving relative paths - * @param {Map} options.localModuleCache - Cache for loaded modules - * @param {string[]} options.additionalContextRootsAbsolute - Additional allowed root paths - * @returns {Function} Custom import function + * Creates the dynamic import callback used by vm.Script and vm.SourceTextModule. + * The callback resolves local files, Node builtins, and npm dependencies through + * Bruno's allowed-root checks before returning a vm.Module instance. + * @param {Object} options - Configuration options. + * @param {string} options.collectionPath - Path to the collection directory. + * @param {vm.Context} options.isolatedContext - VM context created with vm.createContext(). + * @param {string} [options.currentModuleDir=collectionPath] - Directory used to resolve relative imports. + * @param {Map} [options.localModuleCache] - Shared CJS/ESM module cache. + * @param {string[]} [options.additionalContextRootsAbsolute] - Absolute roots allowed for local module access. + * @returns {(moduleName: string, referrer?: vm.Module) => Promise} Custom dynamic import callback. */ function createCustomImport({ collectionPath, @@ -79,6 +81,12 @@ function createCustomImport({ }; } +/** + * Gets the directory imports should resolve from for a referrer module. + * @param {vm.Module|undefined} referrer - Module requesting the import. + * @param {string} fallbackDir - Directory used when the referrer has no absolute identifier. + * @returns {string} Directory for resolving relative import specifiers. + */ function getReferrerDir(referrer, fallbackDir) { if (referrer?.identifier && path.isAbsolute(referrer.identifier)) { return path.dirname(referrer.identifier); @@ -88,10 +96,18 @@ function getReferrerDir(referrer, fallbackDir) { } /** - * Loads a local ESM or CJS module from the filesystem with security checks and caching - * @param {Object} options - Configuration options - * @returns {Promise} VM module instance - * @throws {Error} When module is outside collection path or cannot be loaded + * Loads a local ESM, CJS, or JSON module from the filesystem. + * The module specifier is checked before and after path resolution so extension + * inference or package main resolution cannot escape the allowed roots. + * @param {Object} options - Configuration options. + * @param {string} options.moduleName - Local or absolute module specifier. + * @param {string} options.collectionPath - Path to the collection directory. + * @param {vm.Context} options.isolatedContext - VM context created with vm.createContext(). + * @param {string} options.currentModuleDir - Directory used to resolve relative paths. + * @param {Map} options.localModuleCache - Shared CJS/ESM module cache. + * @param {string[]} [options.additionalContextRootsAbsolute] - Absolute roots allowed for local module access. + * @returns {Promise} VM module instance. + * @throws {Error} When the module is outside allowed roots or cannot be found. */ async function loadLocalModule({ moduleName, @@ -137,6 +153,14 @@ async function loadLocalModule({ }); } +/** + * Loads a Node.js builtin module and exposes it as a synthetic ESM namespace. + * @param {Object} options - Configuration options. + * @param {string} options.moduleName - Builtin module name, with or without the node: prefix. + * @param {vm.Context} options.isolatedContext - VM context created with vm.createContext(). + * @param {Map} options.localModuleCache - Shared CJS/ESM module cache. + * @returns {Promise} Synthetic VM module for the builtin exports. + */ async function loadBuiltinModule({ moduleName, isolatedContext, @@ -155,10 +179,16 @@ async function loadBuiltinModule({ } /** - * Loads an npm module into the VM context - * @param {Object} options - Configuration options - * @returns {Promise} VM module instance - * @throws {Error} When module cannot be resolved or loaded + * Resolves and loads an npm dependency into the VM context. + * Collection-local dependencies are preferred over Bruno's own dependencies. + * @param {Object} options - Configuration options. + * @param {string} options.moduleName - Package specifier to resolve. + * @param {string} options.collectionPath - Path to the collection directory. + * @param {vm.Context} options.isolatedContext - VM context created with vm.createContext(). + * @param {Map} options.localModuleCache - Shared CJS/ESM module cache. + * @param {string[]} options.additionalContextRootsAbsolute - Absolute roots allowed for local module access. + * @returns {Promise} VM module instance. + * @throws {Error} When the dependency cannot be resolved or loaded. */ async function loadNpmModule({ moduleName, @@ -208,10 +238,18 @@ async function loadNpmModule({ } /** - * Executes a resolved module and returns a VM module instance - * @param {Object} options - Configuration options - * @returns {Promise} VM module instance - * @throws {Error} When module cannot be loaded + * Executes or wraps a resolved module path and returns a VM module instance. + * ESM files are loaded as SourceTextModule instances; CJS and JSON values are + * bridged into synthetic ESM modules. + * @param {Object} options - Configuration options. + * @param {string} options.resolvedPath - Absolute path resolved for the module. + * @param {string} options.moduleName - Original module specifier. + * @param {string} options.collectionPath - Path to the collection directory. + * @param {vm.Context} options.isolatedContext - VM context created with vm.createContext(). + * @param {Map} options.localModuleCache - Shared CJS/ESM module cache. + * @param {string[]} options.additionalContextRootsAbsolute - Absolute roots allowed for local module access. + * @returns {Promise} VM module instance. + * @throws {Error} When the module cannot be loaded. */ async function executeModuleInVmContext({ resolvedPath, @@ -263,6 +301,18 @@ async function executeModuleInVmContext({ }); } +/** + * Creates, links, evaluates, and caches a real ESM source module. + * Static imports are delegated back through the custom import resolver so nested + * dependencies keep the same security and collection-resolution behavior. + * @param {Object} options - Configuration options. + * @param {string} options.resolvedPath - Absolute path to the ESM file. + * @param {string} options.collectionPath - Path to the collection directory. + * @param {vm.Context} options.isolatedContext - VM context created with vm.createContext(). + * @param {Map} options.localModuleCache - Shared CJS/ESM module cache. + * @param {string[]} options.additionalContextRootsAbsolute - Absolute roots allowed for local module access. + * @returns {Promise} Linked and evaluated source text module. + */ async function loadSourceTextModule({ resolvedPath, collectionPath, @@ -315,6 +365,17 @@ async function loadSourceTextModule({ return sourceModule; } +/** + * Wraps a plain JavaScript value in a SyntheticModule so dynamic import can + * return CJS, JSON, and builtin modules through an ESM-compatible namespace. + * @param {Object} options - Configuration options. + * @param {string} options.identifier - Stable module identifier used as the ESM cache key. + * @param {*} options.exportsValue - Value to expose through synthetic exports. + * @param {vm.Context} options.isolatedContext - VM context created with vm.createContext(). + * @param {Map} options.localModuleCache - Shared CJS/ESM module cache. + * @param {boolean} [options.defaultOnly=false] - Whether to expose only the default export. + * @returns {Promise} Linked and evaluated synthetic module. + */ async function createSyntheticModuleFromExports({ identifier, exportsValue, @@ -353,6 +414,11 @@ async function createSyntheticModuleFromExports({ return syntheticModule; } +/** + * Determines whether a resolved path should be evaluated as ESM. + * @param {string} resolvedPath - Absolute resolved module path. + * @returns {boolean} True for .mjs files and .js files inside type=module packages. + */ function shouldLoadAsEsm(resolvedPath) { if (resolvedPath.endsWith('.mjs')) { return true; @@ -375,6 +441,11 @@ function shouldLoadAsEsm(resolvedPath) { } } +/** + * Finds the nearest package.json by walking up from a starting directory. + * @param {string} startDir - Directory to begin searching from. + * @returns {string|null} Absolute package.json path, or null when none is found. + */ function findNearestPackageJson(startDir) { let currentDir = startDir; @@ -389,6 +460,12 @@ function findNearestPackageJson(startDir) { return null; } +/** + * Builds the export names exposed by a SyntheticModule wrapper. + * @param {*} exportsValue - CJS, JSON, or builtin export value to wrap. + * @param {boolean} defaultOnly - Whether to expose only the default export. + * @returns {string[]} Export names for the synthetic namespace. + */ function getSyntheticExportNames(exportsValue, defaultOnly) { const exportNames = new Set(['default']); @@ -401,12 +478,22 @@ function getSyntheticExportNames(exportsValue, defaultOnly) { return Array.from(exportNames); } +/** + * Evaluates a VM module when it has not already been evaluated. + * @param {vm.Module} moduleInstance - SourceTextModule or SyntheticModule instance. + * @returns {Promise} Resolves after evaluation completes. + */ async function evaluateModuleIfNeeded(moduleInstance) { if (moduleInstance.status !== 'evaluated') { await moduleInstance.evaluate(); } } +/** + * Creates the ESM cache key for a module identifier. + * @param {string} identifier - Module path or synthetic identifier. + * @returns {string} Cache key namespaced for ESM entries. + */ function getEsmCacheKey(identifier) { return ESM_CACHE_PREFIX + identifier; } From f7a3f70d9bea4b82c5c0020a603f5a10dd40e100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?N=C3=ADcolas=20Carvalho?= Date: Sat, 27 Jun 2026 23:38:09 -0300 Subject: [PATCH 8/8] test(bruno-js): cover collection ESM dependency imports --- .../src/sandbox/node-vm/esm-loader.js | 3 +++ .../src/sandbox/node-vm/index.spec.js | 27 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/packages/bruno-js/src/sandbox/node-vm/esm-loader.js b/packages/bruno-js/src/sandbox/node-vm/esm-loader.js index d40279939b1..b0b416f2f23 100644 --- a/packages/bruno-js/src/sandbox/node-vm/esm-loader.js +++ b/packages/bruno-js/src/sandbox/node-vm/esm-loader.js @@ -398,6 +398,9 @@ async function createSyntheticModuleFromExports({ if (!defaultOnly && exportsValue && (typeof exportsValue === 'object' || typeof exportsValue === 'function')) { Object.keys(exportsValue).forEach((key) => { + if (key === 'default') { + return; + } this.setExport(key, exportsValue[key]); }); } diff --git a/packages/bruno-js/src/sandbox/node-vm/index.spec.js b/packages/bruno-js/src/sandbox/node-vm/index.spec.js index 0c1162ad4c6..2ec1ef81b1a 100644 --- a/packages/bruno-js/src/sandbox/node-vm/index.spec.js +++ b/packages/bruno-js/src/sandbox/node-vm/index.spec.js @@ -874,6 +874,33 @@ describe('node-vm sandbox', () => { expect(context.bru.setVar).toHaveBeenCalledWith('result', 'from-cjs-dependency'); }); + it('should dynamically import collection ESM npm dependencies', async () => { + const nodeModulesDir = path.join(collectionPath, 'node_modules', 'esm-package-dependency'); + fs.mkdirSync(nodeModulesDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeModulesDir, 'package.json'), + '{"name": "esm-package-dependency", "type": "module", "main": "index.js"}' + ); + fs.writeFileSync( + path.join(nodeModulesDir, 'index.js'), + 'export const message = "from-esm-dependency"; export default { message };' + ); + + const script = ` + const dependency = await import('esm-package-dependency'); + bru.setVar('result', dependency.message); + `; + + const context = { + bru: { setVar: jest.fn() }, + console: console + }; + + await runScriptInNodeVm({ script, context, collectionPath, scriptingConfig: {} }); + + expect(context.bru.setVar).toHaveBeenCalledWith('result', 'from-esm-dependency'); + }); + it('should block dynamic imports outside allowed roots', async () => { fs.writeFileSync( path.join(testDir, 'outside.mjs'),