Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4c03c89
chore(playground): add script library dependencies
sundram-bruno Aug 23, 2026
9af53bb
feat(playground): support desktop safe-mode script libraries in the q…
sundram-bruno Aug 23, 2026
bd1e0f3
feat(playground): lazy-load the request runner on first send
sundram-bruno Aug 23, 2026
209d909
fix(playground): keep script-set headers over the auth config
sundram-bruno Aug 23, 2026
47dcec3
test(playground): add script library coverage
sundram-bruno Aug 23, 2026
7ddd5c0
chore(playground): restore uuid type declarations for the build
sundram-bruno Aug 23, 2026
88a2411
fix(playground): harden the script sandbox per review
sundram-bruno Aug 26, 2026
632ce24
test(playground): extend sandbox coverage and simplify the script e2e
sundram-bruno Aug 26, 2026
288610e
Merge remote-tracking branch 'upstream/main' into feat/playground-scr…
sundram-bruno Aug 26, 2026
3ce2ad9
fix(playground): match jsonwebtoken behaviour for string payloads and…
sundram-bruno Aug 26, 2026
a1d2ec7
test(playground): type the jwt sign result before string ops
sundram-bruno Aug 26, 2026
7a1cb6d
chore(playground): drop redundant comment in the typed-array helper
sundram-bruno Aug 27, 2026
9e476d6
Merge remote-tracking branch 'upstream/main' into feat/playground-scr…
sundram-bruno Aug 27, 2026
08a2398
feat(playground): scope jsonwebtoken out of the sandbox
sundram-bruno Aug 28, 2026
b6c44dc
fix(playground): address code review feedback on the sandbox
sundram-bruno Aug 28, 2026
dec1f57
build(playground): rebuild the library bundle before the standalone b…
sundram-bruno Aug 28, 2026
6136871
refactor(docs): address review comments on script-library shims
sundram-bruno Aug 31, 2026
5af7539
chore(docs): drop redundant prebuild:standalone hook
sundram-bruno Aug 31, 2026
5d9da39
refactor(docs): address review comments on the sandbox shims
sundram-bruno Sep 2, 2026
0687393
chore(docs): code cleanup
sundram-bruno Sep 2, 2026
7683669
chore(docs): code cleanup
sundram-bruno Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
210 changes: 178 additions & 32 deletions package-lock.json

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { test, expect } from '../../playwright';
import type { Page } from '@playwright/test';
import type { CodeEditorComponent } from '../../components/code-editor/code-editor.component';

const LIBRARY_TESTS_SCRIPT = `
const moment = require('moment');
const CryptoJS = require('crypto-js');
const { v4, validate } = require('uuid');
const { nanoid } = require('nanoid');
const tv4 = require('tv4');

test('moment formats a date', function () {
expect(moment('2026-01-02').format('YYYY-MM-DD')).to.equal('2026-01-02');
});

test('crypto-js hashes and uuid validates', function () {
expect(CryptoJS.SHA256('abc').toString()).to.equal('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
expect(validate(v4())).to.equal(true);
expect(nanoid(10)).to.have.lengthOf(10);
});

test('tv4 validates against a schema', function () {
expect(tv4.validate({ a: 1 }, { type: 'object' })).to.equal(true);
});
`;

const setEditorScript = async (page: Page, editor: CodeEditorComponent, script: string): Promise<void> => {
await editor.focus();
await page.keyboard.press('ControlOrMeta+a');
await page.keyboard.insertText(script);
};

test.describe('playground script execution', () => {
test.use({ viewport: { width: 1280, height: 900 } });

test('runs a tests script using the safe-mode libraries on Send', async ({ page, playground, responsePane }) => {
await page.route('**/api/users**', (route) =>
route.fulfill({
status: 200,
headers: { 'content-type': 'application/json', 'access-control-allow-origin': '*' },
body: JSON.stringify({ users: [{ id: 1, name: 'Ada' }] })
})
);

await page.goto('/#/?pg=1&dock=bottom');
await playground.openSidebarItem('get users');

await playground.selectTab('tests');
await setEditorScript(page, playground.testsEditor, LIBRARY_TESTS_SCRIPT);

await responsePane.send();
await responsePane.switchToTab('tests');

await expect(page.getByText(/Passed: [1-9]\d*, Failed: 0/).first()).toBeVisible();
await expect(page.getByText(/Failed: [1-9]/)).toHaveCount(0);
});
});
15 changes: 14 additions & 1 deletion packages/bruno-api-docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,13 @@
"@types/markdown-it": "^14.1.2",
"@types/prismjs": "^1.26.5",
"ajv": "^8.12.0",
"ajv-formats": "^2.1.1",
"atob": "^2.1.2",
"btoa": "^1.2.1",
"buffer": "^6.0.3",
"chai": "~5.3.3",
"codemirror": "^6.0.2",
"crypto-js": "^4.2.0",
"fast-json-format": "~0.4.0",
"fuse.js": "^7.5.0",
"js-md5": "^0.9.2",
Expand All @@ -84,12 +88,14 @@
"jsonpath-plus": "^10.3.0",
"lodash-es": "~4.17.21",
"markdown-it": "^14.1.0",
"moment": "^2.30.1",
"monaco-editor": "^0.53.0",
"nanoid": "~3.3.11",
"node-html-parser": "^8.0.4",
"path-browserify": "^1.0.1",
"prettier": "^2.7.1",
"prismjs": "^1.29.0",
"quickjs-emscripten": "~0.31.0",
"quickjs-emscripten": "~0.32.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react-markdown": "^10.0.0",
Expand All @@ -99,6 +105,8 @@
"react-router-dom": "^7.3.0",
"remark-gfm": "^4.0.1",
"strip-json-comments": "^3.1.1",
"tv4": "^1.3.0",
"uuid": "^10.0.0",
"xml-formatter": "^3.5.0"
},
"devDependencies": {
Expand All @@ -108,12 +116,17 @@
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4.1.13",
"@tailwindcss/typography": "^0.5.10",
"@types/atob": "^2.1.4",
"@types/btoa": "^1.2.5",
"@types/crypto-js": "^4.2.2",
"@types/express": "^4.17.21",
"@types/lodash-es": "~4.17.12",
"@types/node": "^26.2.0",
"@types/path-browserify": "^1.0.3",
"@types/prismjs": "^1.26.3",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@types/tv4": "^1.2.33",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import type { HttpRequest, HttpRequestHeader } from '@opencollection/types/reque
import type { OpenCollection as OpenCollectionCollection } from '@opencollection/types';
import type { Item } from '@opencollection/types/collection/item';
import type { Auth } from '@opencollection/types/common/auth';
import { requestRunner } from '@/runner';
import { getAncestorsByUuid } from '@/utils/fileUtils';
import { ItemVariableResolverProvider } from '@/hooks';
import TitleLabel from '@/components/TitleLabel/TitleLabel';
Expand Down Expand Up @@ -41,7 +40,6 @@ const HttpRequestPlaygroundView: React.FC<PlaygroundViewProps> = ({ item, collec
// The request/response split is one draggable divider whose axis follows the
// orientation: horizontal layout resizes width, vertical layout resizes height.
const { size: paneSize, isResizing, containerRef, startResize } = useSplitPane(orientation);
const runner = useMemo(() => requestRunner, []);
const ancestry = useMemo(
() => (collection && itemUuid ? getAncestorsByUuid(collection, itemUuid) : []),
[collection, itemUuid]
Expand Down Expand Up @@ -114,7 +112,8 @@ const HttpRequestPlaygroundView: React.FC<PlaygroundViewProps> = ({ item, collec
const environment = envs.find(
(env: any) => env.name === selectedEnvironment
);
const result = await runner.runRequest({
const { requestRunner } = await import('@/runner');
const result = await requestRunner.runRequest({
item: editableItem,
collection,
environment,
Expand All @@ -139,7 +138,7 @@ const HttpRequestPlaygroundView: React.FC<PlaygroundViewProps> = ({ item, collec
} finally {
setIsLoading(false);
}
}, [collection, editableItem, runner, selectedEnvironment, itemUuid]);
}, [collection, editableItem, selectedEnvironment, itemUuid, dispatch]);

return (
<ItemVariableResolverProvider
Expand Down
4 changes: 3 additions & 1 deletion packages/bruno-api-docs/src/sampleCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,9 @@ request:
bru.setVar('collection-var-set-by-collection-script', 'collection-var-value-set-by-collection-script');
}
- type: after-response
code: wefewfewfewfewfwefwefewfewfewfewfewfewfewfewf
code: |-
// Collection · post-response (L0)
console.log('POST > L0 collection');
- type: tests
code: |-
// used by \`scripting/js/folder-collection script-tests\`
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,43 @@
import { expect, assert } from 'chai';
// todo: add all the supported libraries
import { Buffer } from 'buffer';
import moment from 'moment';
import btoa from 'btoa';
// import atob's node file directly: the default 'atob' import is a browser build that
// reads window, and window does not exist inside the QuickJS sandbox
import atob from 'atob/node-atob';
Comment thread
sundram-bruno marked this conversation as resolved.
import CryptoJS from 'crypto-js';
import tv4 from 'tv4';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
import * as uuid from 'uuid';
import * as nanoid from 'nanoid';
import path from 'path-browserify';

(globalThis as any).expect = expect;

Check warning on line 16 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).assert = assert;

Check warning on line 17 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).moment = moment;

Check warning on line 18 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).btoa = btoa;

Check warning on line 19 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).atob = atob;

Check warning on line 20 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).Buffer = Buffer;

Check warning on line 21 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).tv4 = tv4;

Check warning on line 22 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).Ajv = Ajv;

Check warning on line 23 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).addFormats = addFormats;

Check warning on line 24 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).uuid = uuid;

Check warning on line 25 in packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts

View workflow job for this annotation

GitHub Actions / lint_unit_tests_and_builds

Unexpected any. Specify a different type
(globalThis as any).nanoid = nanoid;
(globalThis as any).path = path;

(globalThis as any).requireObject = {
...((globalThis as any).requireObject || {}),
chai: { expect, assert }
'chai': { expect, assert },
'moment': moment,
'buffer': { Buffer },
'btoa': btoa,
'atob': atob,
'crypto-js': CryptoJS,
'tv4': tv4,
'ajv': Ajv,
'ajv-formats': addFormats,
'uuid': uuid,
'nanoid': nanoid,
'path': path
};
59 changes: 21 additions & 38 deletions packages/bruno-api-docs/src/scripting/sandbox/quickjs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ import addBrunoRequestShimToContext from './shims/bruno-request';
import addConsoleShimToContext from './shims/console';
import addBrunoResponseShimToContext from './shims/bruno-response';
import addTestShimToContext from './shims/test';
import addCryptoUtilsShimToContext from './shims/lib/crypto-utils';
import addAxiosShimToContext from './shims/lib/axios';
import { newQuickJSWASMModule, memoizePromiseFactory } from 'quickjs-emscripten';
import { marshallToVm } from './utils';
import { getBundledCode } from './bundled-libraries.iife.js';
import { getRequireCode } from './shims/require';

let QuickJSSyncContext: any;
const loader = memoizePromiseFactory(() => newQuickJSWASMModule());
Expand Down Expand Up @@ -95,51 +98,29 @@ const executeQuickJsVmAsync = async ({
externalScript = externalScript?.trim();

try {
const module = await newQuickJSWASMModule();
const module = await loader();
const vm = module.newContext();

const bundledCode = getBundledCode?.toString() || '';

const moduleLoaderCode = function () {
return `
globalThis.require = (mod) => {
let lib = globalThis.requireObject[mod];
let isModuleAPath = (module) => (module?.startsWith('.') || module?.startsWith?.(''))
if (lib) {
return lib;
}
else if (isModuleAPath(mod)) {
// fetch local module
let localModuleCode = globalThis.__brunoLoadLocalModule(mod);

// compile local module as iife
(function (){
const initModuleExportsCode = "const module = { exports: {} };"
const copyModuleExportsCode = "\\n;globalThis.requireObject[mod] = module.exports;";
const patchedRequire = ${`
"\\n;" +
"let require = (subModule) => isModuleAPath(subModule) ? globalThis.require(path.resolve('', mod, '..', subModule)) : globalThis.require(subModule)" +
"\\n;"
`}
eval(initModuleExportsCode + patchedRequire + localModuleCode + copyModuleExportsCode);
})();

// resolve module
return globalThis.requireObject[mod];
}
else {
throw new Error("Cannot find module " + mod);
}
}
`;
};
// must run before the bundle eval: uuid and nanoid grab crypto.getRandomValues at load time
addCryptoUtilsShimToContext(vm);
Comment thread
sundram-bruno marked this conversation as resolved.

if (typeof getBundledCode !== 'function') {
throw new Error('Sandbox library bundle is missing; run build:lib-bundle before executing scripts.');
}
const bundledCode = getBundledCode.toString();

vm.evalCode(
const bootResult = vm.evalCode(
`
(${bundledCode})();
${moduleLoaderCode()}
${getRequireCode()}
`
);
if (bootResult.error) {
const bootError = vm.dump(bootResult.error);
bootResult.error.dispose();
throw new Error(`Failed to load sandbox libraries: ${bootError?.message || String(bootError)}`);
}
bootResult.value.dispose();

const { bru, req, res, test, __brunoTestResults, console: consoleFn } = externalContext;

Expand All @@ -149,6 +130,8 @@ const executeQuickJsVmAsync = async ({
if (res) addBrunoResponseShimToContext(vm, res);
if (test && __brunoTestResults) addTestShimToContext(vm, __brunoTestResults);

addAxiosShimToContext(vm);

const script = `
(async () => {
const setTimeout = async(fn, timer) => {
Expand Down
Comment thread
sundram-bruno marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { newQuickJSWASMModule } from 'quickjs-emscripten';
import addCryptoUtilsShimToContext from './shims/lib/crypto-utils';
import addAxiosShimToContext from './shims/lib/axios';
import { getRequireCode } from './shims/require';
import { getBundledCode } from './bundled-libraries.iife.js';

const SUPPORTED_MODULES = [
'ajv', 'ajv-formats', 'atob', 'axios', 'btoa', 'buffer', 'chai', 'crypto-js',
'moment', 'nanoid', 'path', 'tv4', 'uuid'
];

let vm: any;

const inVm = (expression: string) => {
const result = vm.evalCode(expression);
if (result.error) {
const error = vm.dump(result.error);
result.error.dispose();
throw new Error(error.message);
}
const value = vm.dump(result.value);
result.value.dispose();
return value;
};

const errorMessageOf = (expression: string) =>
inVm(`(() => { try { ${expression}; return 'NO-THROW'; } catch (e) { return e.message; } })()`);

describe('sandbox library parity with desktop safe mode', () => {
beforeAll(async () => {
const module = await newQuickJSWASMModule();
vm = module.newContext();
addCryptoUtilsShimToContext(vm);
const boot = vm.evalCode(
`(${getBundledCode.toString()})(); ${getRequireCode()}; `
+ `globalThis.console = { log() {}, debug() {}, info() {}, warn() {}, error() {} };`
);
expect(boot.error).toBeUndefined();
boot.value.dispose();
addAxiosShimToContext(vm);
});

it('exposes exactly the supported safe-mode modules', () => {
expect(inVm('Object.keys(globalThis.requireObject).sort()')).toEqual(SUPPORTED_MODULES);
});

it('exposes the supported safe-mode globals', () => {
const globals = ['expect', 'assert', 'moment', 'btoa', 'atob', 'Buffer', 'tv4', 'Ajv', 'addFormats', 'crypto', 'axios', 'path', 'require', 'uuid', 'nanoid'];
for (const name of globals) {
expect(inVm(`typeof globalThis['${name}']`), name).not.toBe('undefined');
}
});

it('every module does real work inside the VM', () => {
expect(inVm(`require('moment')('2026-08-20T10:00:00Z').utc().format('YYYY-MM-DD')`)).toBe('2026-08-20');
expect(inVm(`require('crypto-js').SHA256('abc').toString()`)).toBe('ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad');
expect(inVm(`require('uuid').validate(require('uuid').v4())`)).toBe(true);
expect(inVm(`require('nanoid').nanoid(10).length`)).toBe(10);
expect(inVm(`require('buffer').Buffer.from('hello').toString('base64')`)).toBe('aGVsbG8=');
expect(inVm(`require('btoa')('hello')`)).toBe('aGVsbG8=');
expect(inVm(`require('atob')('aGVsbG8=')`)).toBe('hello');
expect(inVm(`require('tv4').validate({ a: 1 }, { type: 'object' })`)).toBe(true);
expect(inVm(`new (require('ajv'))().compile({ type: 'number' })(5)`)).toBe(true);
expect(inVm(`(() => { const Ajv = require('ajv'); const ajv = new Ajv(); require('ajv-formats')(ajv); return ajv.compile({ type: 'string', format: 'email' })('a@b.co'); })()`)).toBe(true);
expect(inVm(`require('path').resolve('/a/b', '../c')`)).toBe('/a/c');
expect(inVm(`(() => { const { expect } = require('chai'); expect(1).to.eql(1); return 'ok'; })()`)).toBe('ok');
expect(inVm(`typeof require('axios').get`)).toBe('function');
});

it('gives explanatory errors for developer-mode-only and node builtin modules', () => {
expect(errorMessageOf(`require('lodash')`)).toContain('only available in the Bruno desktop app\'s developer mode');
expect(errorMessageOf(`require('fs')`)).toContain('is a Node.js builtin');
expect(errorMessageOf(`require('node:fs')`)).toContain('is a Node.js builtin');
expect(errorMessageOf(`require('./helper.js')`)).toContain('Local file require');
expect(errorMessageOf(`require('./helper.js')`)).toContain('is not available in the docs playground');
expect(inVm(`typeof require('node:buffer').Buffer`)).toBe('function');
expect(inVm(`typeof require('node:path').resolve`)).toBe('function');
expect(errorMessageOf(`require('node:chai')`)).toContain('Cannot find module node:chai');
expect(errorMessageOf(`require('left-pad-9000')`)).toBe('Cannot find module left-pad-9000');
expect(errorMessageOf(`require('jsonwebtoken')`)).toBe('\'jsonwebtoken\' is not currently supported in the docs playground. Please use the Bruno desktop app.');
expect(errorMessageOf(`require('crypto')`)).toContain('use the crypto global instead');
expect(errorMessageOf(`require('constructor')`)).toBe('Cannot find module constructor');
expect(errorMessageOf(`require('__proto__')`)).toBe('Cannot find module __proto__');
expect(errorMessageOf(`require('toString')`)).toBe('Cannot find module toString');
});

it('generates randomness for supported typed arrays and rejects unsupported ones', () => {
expect(inVm(`crypto.getRandomValues(new Uint8Array(4)).length`)).toBe(4);
expect(inVm(`crypto.getRandomValues(new Uint32Array(2)).length`)).toBe(2);
expect(inVm(`crypto.randomBytes(8).length`)).toBe(8);
expect(errorMessageOf(`crypto.getRandomValues(new BigInt64Array(2))`)).toBe('getRandomValues: unsupported typed array type: BigInt64Array');
expect(errorMessageOf(`crypto.getRandomValues(new Float32Array(2))`)).toBe('getRandomValues: unsupported typed array type: Float32Array');
});
});
Loading
Loading