diff --git a/package-lock.json b/package-lock.json index 63b3f13a..7fc0a2ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1612,45 +1612,45 @@ } }, "node_modules/@jitl/quickjs-ffi-types": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.31.0.tgz", - "integrity": "sha512-1yrgvXlmXH2oNj3eFTrkwacGJbmM0crwipA3ohCrjv52gBeDaD7PsTvFYinlAnqU8iPME3LGP437yk05a2oejw==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-ffi-types/-/quickjs-ffi-types-0.32.0.tgz", + "integrity": "sha512-v9T+GQpmk43VDJ7d72sf0Nexhk+ArvtUihW27dy7lqAl0zBObFKtSBBIm5RBjwIhE8VwsPPm9PNuvPvNqLWUEg==", "license": "MIT" }, "node_modules/@jitl/quickjs-wasmfile-debug-asyncify": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.31.0.tgz", - "integrity": "sha512-YkdzQdr1uaftFhgEnTRjTTZHk2SFZdpWO7XhOmRVbi6CEVsH9g5oNF8Ta1q3OuSJHRwwT8YsuR1YzEiEIJEk6w==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-asyncify/-/quickjs-wasmfile-debug-asyncify-0.32.0.tgz", + "integrity": "sha512-EX8zbXwGqCgAE764M+qvkHtyXDi/FUoMBea0JnES7vCM3P7a2+EOZOjGv85wtZ2sJhI1oJ+nekmqpOODFDY+hw==", "license": "MIT", "dependencies": { - "@jitl/quickjs-ffi-types": "0.31.0" + "@jitl/quickjs-ffi-types": "0.32.0" } }, "node_modules/@jitl/quickjs-wasmfile-debug-sync": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.31.0.tgz", - "integrity": "sha512-8XvloaaWBONqcHXYs5tWOjdhQVxzULilIfB2hvZfS6S+fI4m2+lFiwQy7xeP8ExHmiZ7D8gZGChNkdLgjGfknw==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-debug-sync/-/quickjs-wasmfile-debug-sync-0.32.0.tgz", + "integrity": "sha512-LeYWrPGC1uNCTBWvibo3ZLJj0CSVNYUXvJpXMCmuQ5Sap2cCACc3uvGvYV4homHHBAzfw5akoTqMMS4YFRtw+Q==", "license": "MIT", "dependencies": { - "@jitl/quickjs-ffi-types": "0.31.0" + "@jitl/quickjs-ffi-types": "0.32.0" } }, "node_modules/@jitl/quickjs-wasmfile-release-asyncify": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.31.0.tgz", - "integrity": "sha512-uz0BbQYTxNsFkvkurd7vk2dOg57ElTBLCuvNtRl4rgrtbC++NIndD5qv2+AXb6yXDD3Uy1O2PCwmoaH0eXgEOg==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-asyncify/-/quickjs-wasmfile-release-asyncify-0.32.0.tgz", + "integrity": "sha512-3oSwPfja12ICz4aIblB58cuY8JlEq5Txt8Cut4VLo+LH47QN+mzCnSgnbB03hWzg1LBcc+VyyI9UOag7a1NF+Q==", "license": "MIT", "dependencies": { - "@jitl/quickjs-ffi-types": "0.31.0" + "@jitl/quickjs-ffi-types": "0.32.0" } }, "node_modules/@jitl/quickjs-wasmfile-release-sync": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.31.0.tgz", - "integrity": "sha512-hYduecOByj9AsAfsJhZh5nA6exokmuFC8cls39+lYmTCGY51bgjJJJwReEu7Ff7vBWaQCL6TeDdVlnp2WYz0jw==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/@jitl/quickjs-wasmfile-release-sync/-/quickjs-wasmfile-release-sync-0.32.0.tgz", + "integrity": "sha512-BKNDI/TPBfGlLNGYpLrhcDGXmIk4xHm4MRAisOBnOzpXVn9HZWsfmMAc9WMBrAHjvvds6HOikKeaOBKdPdpVrg==", "license": "MIT", "dependencies": { - "@jitl/quickjs-ffi-types": "0.31.0" + "@jitl/quickjs-ffi-types": "0.32.0" } }, "node_modules/@jridgewell/gen-mapping": { @@ -2783,6 +2783,13 @@ "react-dom": ">=16.8" } }, + "node_modules/@types/atob": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@types/atob/-/atob-2.1.4.tgz", + "integrity": "sha512-FisOhG87cCFqzCgq6FUtSYsTMOHCB/p28zJbSN1QBo4ZGJfg9PEhMjdIV++NDeOnloUUe0Gz6jwBV+L1Ac00Mw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2839,6 +2846,16 @@ "@types/node": "*" } }, + "node_modules/@types/btoa": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@types/btoa/-/btoa-1.2.5.tgz", + "integrity": "sha512-BItINdjZRlcGdI2efwK4bwxY5vEAT0SnIVfMOZVT18wp4900F1Lurqk/9PNdF9hMP1zgFmWbjVEtAsQKVcbqxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -2849,6 +2866,13 @@ "@types/node": "*" } }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -3011,6 +3035,13 @@ "integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==", "license": "MIT" }, + "node_modules/@types/path-browserify": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/path-browserify/-/path-browserify-1.0.3.tgz", + "integrity": "sha512-ZmHivEbNCBtAfcrFeBCiTjdIc2dey0l7oCGNGpSuRTy8jP6UVND7oUowlvDujBy8r2Hoa8bfFUOCiPWfmtkfxw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prismjs": { "version": "1.26.6", "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", @@ -3090,6 +3121,13 @@ "integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==", "license": "MIT" }, + "node_modules/@types/tv4": { + "version": "1.2.33", + "resolved": "https://registry.npmjs.org/@types/tv4/-/tv4-1.2.33.tgz", + "integrity": "sha512-7phCVTXC6Bj50IV1iKOwqGkR4JONJyMbRZnKTSuujv1S/tO9rG5OdCt7BMSjytO+zJmYdn1/I4fd3SH0gtO99g==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -3807,6 +3845,18 @@ "node": ">= 0.4" } }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, "node_modules/autoprefixer": { "version": "10.5.4", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", @@ -4077,6 +4127,18 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/buffer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", @@ -4550,6 +4612,13 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==", + "deprecated": "Active development of CryptoJS has been discontinued. This library is no longer maintained.", + "license": "MIT" + }, "node_modules/css-select": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", @@ -8615,6 +8684,15 @@ "license": "MIT", "optional": true }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/monaco-editor": { "version": "0.53.0", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.53.0.tgz", @@ -9123,6 +9201,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -9517,28 +9601,28 @@ "license": "MIT" }, "node_modules/quickjs-emscripten": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.31.0.tgz", - "integrity": "sha512-K7Yt78aRPLjPcqv3fIuLW1jW3pvwO21B9pmFOolsjM/57ZhdVXBr51GqJpalgBlkPu9foAvhEAuuQPnvIGvLvQ==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten/-/quickjs-emscripten-0.32.0.tgz", + "integrity": "sha512-So0Sqw869y/S2oE3Nuc0uT3Dhqgvsj8FSrwBdsuTosVsG8ME5/OcudU1GxsrIFdFABgy17GHnTVO9TYV/bLQcA==", "license": "MIT", "dependencies": { - "@jitl/quickjs-wasmfile-debug-asyncify": "0.31.0", - "@jitl/quickjs-wasmfile-debug-sync": "0.31.0", - "@jitl/quickjs-wasmfile-release-asyncify": "0.31.0", - "@jitl/quickjs-wasmfile-release-sync": "0.31.0", - "quickjs-emscripten-core": "0.31.0" + "@jitl/quickjs-wasmfile-debug-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-debug-sync": "0.32.0", + "@jitl/quickjs-wasmfile-release-asyncify": "0.32.0", + "@jitl/quickjs-wasmfile-release-sync": "0.32.0", + "quickjs-emscripten-core": "0.32.0" }, "engines": { "node": ">=16.0.0" } }, "node_modules/quickjs-emscripten-core": { - "version": "0.31.0", - "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.31.0.tgz", - "integrity": "sha512-oQz8p0SiKDBc1TC7ZBK2fr0GoSHZKA0jZIeXxsnCyCs4y32FStzCW4d1h6E1sE0uHDMbGITbk2zhNaytaoJwXQ==", + "version": "0.32.0", + "resolved": "https://registry.npmjs.org/quickjs-emscripten-core/-/quickjs-emscripten-core-0.32.0.tgz", + "integrity": "sha512-QFnPfjFey8EqknSrSxe1hZrf1/8z7/6s1QzGOmKo6++02r7QRRX7ZoyNaZh7JuVjWsVW87KnQrbZqnHkOAzUyg==", "license": "MIT", "dependencies": { - "@jitl/quickjs-ffi-types": "0.31.0" + "@jitl/quickjs-ffi-types": "0.32.0" } }, "node_modules/railroad-diagrams": { @@ -11224,6 +11308,24 @@ "node": "*" } }, + "node_modules/tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha512-afizzfpJgvPr+eDkREK4MxJ/+r8nEEHcmitwgnPUqpaP+FpwQyadnxNoSACbgc/b1LsZYtODGoPiFxQrgJgjvw==", + "license": [ + { + "type": "Public Domain", + "url": "http://geraintluff.github.io/tv4/LICENSE.txt" + }, + { + "type": "MIT", + "url": "http://jsonary.com/LICENSE.txt" + } + ], + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -11574,6 +11676,20 @@ "devOptional": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -13144,9 +13260,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", @@ -13155,12 +13275,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", @@ -13170,6 +13292,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": { @@ -13179,12 +13303,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", @@ -13202,6 +13331,23 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" } + }, + "packages/bruno-api-docs/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } } } } diff --git a/packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts b/packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts new file mode 100644 index 00000000..0a72f0db --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/playground/script-execution.spec.ts @@ -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 => { + 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); + }); +}); diff --git a/packages/bruno-api-docs/package.json b/packages/bruno-api-docs/package.json index 15d514c0..2a82215c 100644 --- a/packages/bruno-api-docs/package.json +++ b/packages/bruno-api-docs/package.json @@ -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", @@ -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", @@ -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": { @@ -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", diff --git a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx index fbd7d036..dd5d1e50 100644 --- a/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx +++ b/packages/bruno-api-docs/src/components/Playground/Content/Views/PlaygroundView/PlaygroundView.tsx @@ -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'; @@ -41,7 +40,6 @@ const HttpRequestPlaygroundView: React.FC = ({ 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] @@ -114,7 +112,8 @@ const HttpRequestPlaygroundView: React.FC = ({ 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, @@ -139,7 +138,7 @@ const HttpRequestPlaygroundView: React.FC = ({ item, collec } finally { setIsLoading(false); } - }, [collection, editableItem, runner, selectedEnvironment, itemUuid]); + }, [collection, editableItem, selectedEnvironment, itemUuid, dispatch]); return ( L0 collection'); - type: tests code: |- // used by \`scripting/js/folder-collection script-tests\` diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts index 00b696e9..bd737464 100644 --- a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/bundle-entry.ts @@ -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'; +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; +(globalThis as any).assert = assert; +(globalThis as any).moment = moment; +(globalThis as any).btoa = btoa; +(globalThis as any).atob = atob; +(globalThis as any).Buffer = Buffer; +(globalThis as any).tv4 = tv4; +(globalThis as any).Ajv = Ajv; +(globalThis as any).addFormats = addFormats; +(globalThis as any).uuid = uuid; +(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 }; diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/index.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/index.ts index f40604a8..83d7e903 100644 --- a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/index.ts +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/index.ts @@ -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()); @@ -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); + + 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; @@ -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) => { diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/library-parity.spec.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/library-parity.spec.ts new file mode 100644 index 00000000..43c9b63e --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/library-parity.spec.ts @@ -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'); + }); +}); diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/axios.spec.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/axios.spec.ts new file mode 100644 index 00000000..8531f02e --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/axios.spec.ts @@ -0,0 +1,379 @@ +import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; +import { newQuickJSWASMModule } from 'quickjs-emscripten'; +import addAxiosShimToContext, { serializeAxiosParams, performAxiosRequest } from './axios'; + +const CASES: [string, Record, string][] = [ + ['flat primitive array', { ids: [1, 2] }, 'ids%5B%5D=1&ids%5B%5D=2'], + ['mixed flat with bool', { q: 'hi', ids: [1, 2], flag: true }, 'q=hi&ids%5B%5D=1&ids%5B%5D=2&flag=true'], + ['flat object', { n: { a: 1, b: 2 } }, 'n%5Ba%5D=1&n%5Bb%5D=2'], + ['nested primitive array indexed', { a: { b: [1, 2] } }, 'a%5Bb%5D%5B0%5D=1&a%5Bb%5D%5B1%5D=2'], + ['array of objects', { arr: [{ x: 1 }, { x: 2 }] }, 'arr%5B0%5D%5Bx%5D=1&arr%5B1%5D%5Bx%5D=2'], + ['mixed array', { m: [1, { x: 2 }] }, 'm%5B0%5D=1&m%5B1%5D%5Bx%5D=2'], + ['null and undefined dropped', { a: null, b: undefined, c: 0, d: '' }, 'c=0&d='], + ['space and ampersand', { q: 'a b&c' }, 'q=a+b%26c'], + ['unreserved chars kept raw', { q: 'it\'s~(x)!*' }, 'q=it\'s~(x)!*'], + ['colon dollar comma kept raw', { q: 'a:b$c,d' }, 'q=a:b$c,d'], + ['plus is encoded', { q: 'a+b' }, 'q=a%2Bb'], + ['unicode', { u: 'ünïcödé' }, 'u=%C3%BCn%C3%AFc%C3%B6d%C3%A9'], + ['date top level', { d: new Date(0) }, 'd=1970-01-01T00:00:00.000Z'], + ['date nested', { a: { d: new Date(0) } }, 'a%5Bd%5D=1970-01-01T00:00:00.000Z'], + ['bracket key scalar', { 'ids[]': 42 }, 'ids%5B%5D=42'], + ['bracket key array', { 'ids[]': [1, 2] }, 'ids%5B%5D=1&ids%5B%5D=2'], + ['bracket key non-flat array', { 'ids[]': [{ x: 1 }, 3.14] }, 'ids%5B%5D=%5Bobject+Object%5D&ids%5B%5D=3.14'], + ['empty array and object omitted', { a: [], b: {}, c: 1 }, 'c=1'], + ['deep nesting', { a: { b: { c: 1 } } }, 'a%5Bb%5D%5Bc%5D=1'] +]; + +describe('serializeAxiosParams — axios default paramsSerializer parity', () => { + it.each(CASES)('%s', (_label, params, expected) => { + expect(serializeAxiosParams(params)).toBe(expected); + }); + + it('returns an empty string for params with only null and undefined', () => { + expect(serializeAxiosParams({ a: null, b: undefined })).toBe(''); + }); +}); + +describe('performAxiosRequest — body and status handling', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const stubFetch = (status: number, body = '{}') => { + const spy = vi.fn(async (_url: string, _init?: RequestInit) => new Response(body, { + status, + headers: { 'content-type': 'application/json' } + })); + vi.stubGlobal('fetch', spy); + return spy; + }; + + it('drops the request body on GET even when data is provided', async () => { + const spy = stubFetch(200); + await performAxiosRequest({ url: 'https://x.test/a', method: 'get', data: { a: 1 } }); + const init = spy.mock.calls[0][1] as RequestInit; + expect(init.body).toBeUndefined(); + }); + + it('drops the request body on HEAD', async () => { + const spy = stubFetch(200); + await performAxiosRequest({ url: 'https://x.test/a', method: 'head', data: { a: 1 } }); + const init = spy.mock.calls[0][1] as RequestInit; + expect(init.body).toBeUndefined(); + }); + + it('sends a JSON body on POST', async () => { + const spy = stubFetch(200); + await performAxiosRequest({ url: 'https://x.test/a', method: 'post', data: { a: 1 } }); + const init = spy.mock.calls[0][1] as RequestInit; + expect(init.body).toBe('{"a":1}'); + }); + + it('throws ERR_BAD_REQUEST on 404 by default', async () => { + stubFetch(404); + await expect( + performAxiosRequest({ url: 'https://x.test/a', method: 'get' }) + ).rejects.toMatchObject({ code: 'ERR_BAD_REQUEST', response: { status: 404 } }); + }); + + it('throws ERR_BAD_RESPONSE on 500 by default', async () => { + stubFetch(500); + await expect( + performAxiosRequest({ url: 'https://x.test/a', method: 'get' }) + ).rejects.toMatchObject({ code: 'ERR_BAD_RESPONSE', response: { status: 500 } }); + }); + + it('resolves a 404 when validateStatus accepts it', async () => { + stubFetch(404, '{"err":"nope"}'); + const res = await performAxiosRequest({ + url: 'https://x.test/a', + method: 'get', + validateStatus: () => true + }); + expect(res.status).toBe(404); + expect(res.data).toEqual({ err: 'nope' }); + }); + + it('resolves any status when validateStatus is null', async () => { + stubFetch(500); + const res = await performAxiosRequest({ + url: 'https://x.test/a', + method: 'get', + validateStatus: null + }); + expect(res.status).toBe(500); + }); + + it('parses a JSON body even when content-type is text/plain', async () => { + const spy = vi.fn(async () => new Response('{"token":"abc"}', { + status: 200, + headers: { 'content-type': 'text/plain' } + })); + vi.stubGlobal('fetch', spy); + const res = await performAxiosRequest({ url: 'https://x.test/a', method: 'get' }); + expect(res.data).toEqual({ token: 'abc' }); + }); + + it('keeps a non-JSON body as a string', async () => { + const spy = vi.fn(async () => new Response('plain text', { + status: 200, + headers: { 'content-type': 'text/plain' } + })); + vi.stubGlobal('fetch', spy); + const res = await performAxiosRequest({ url: 'https://x.test/a', method: 'get' }); + expect(res.data).toBe('plain text'); + }); + + it('strips a #fragment before appending params', async () => { + const spy = stubFetch(200); + await performAxiosRequest({ url: 'https://x.test/a#top', method: 'get', params: { a: 1 } }); + expect(spy.mock.calls[0][0]).toBe('https://x.test/a?a=1'); + }); + + it('keeps the #fragment when there are no params', async () => { + const spy = stubFetch(200); + await performAxiosRequest({ url: 'https://x.test/a#top', method: 'get' }); + expect(spy.mock.calls[0][0]).toBe('https://x.test/a#top'); + }); + + it('still throws when validateStatus rejects the status', async () => { + stubFetch(200); + await expect( + performAxiosRequest({ url: 'https://x.test/a', method: 'get', validateStatus: (s) => s === 201 }) + ).rejects.toMatchObject({ code: 'ERR_BAD_RESPONSE' }); + }); + + it('rejects a non-absolute URL without touching fetch', async () => { + const spy = stubFetch(200); + await expect( + performAxiosRequest({ url: '/relative/path', method: 'get' }) + ).rejects.toMatchObject({ code: 'ERR_INVALID_URL' }); + expect(spy).not.toHaveBeenCalled(); + }); + + it('maps a fetch timeout to ECONNABORTED with the axios message', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { + const error = new Error('signal timed out'); + error.name = 'TimeoutError'; + throw error; + })); + await expect( + performAxiosRequest({ url: 'https://x.test/a', method: 'get', timeout: 100 }) + ).rejects.toMatchObject({ code: 'ECONNABORTED', message: 'timeout of 100ms exceeded' }); + }); + + it('sets a Basic Authorization header from config.auth', async () => { + const spy = stubFetch(200); + await performAxiosRequest({ url: 'https://x.test/a', method: 'get', auth: { username: 'u', password: 'p' } }); + const init = spy.mock.calls[0][1] as RequestInit; + expect((init.headers as Record)['Authorization']).toBe(`Basic ${btoa('u:p')}`); + }); + + it('keeps the request body on DELETE', async () => { + const spy = stubFetch(200); + await performAxiosRequest({ url: 'https://x.test/a', method: 'delete', data: { a: 1 } }); + const init = spy.mock.calls[0][1] as RequestInit; + expect(init.body).toBe('{"a":1}'); + }); + + it('sends string data as-is without forcing a JSON content type', async () => { + const spy = stubFetch(200); + await performAxiosRequest({ url: 'https://x.test/a', method: 'post', data: 'raw=payload' }); + const init = spy.mock.calls[0][1] as RequestInit; + expect(init.body).toBe('raw=payload'); + expect((init.headers as Record)['Content-Type']).toBeUndefined(); + }); + + it('appends params with & when the URL already has a query', async () => { + const spy = stubFetch(200); + await performAxiosRequest({ url: 'https://x.test/a?x=1', method: 'get', params: { a: 1 } }); + expect(spy.mock.calls[0][0]).toBe('https://x.test/a?x=1&a=1'); + }); + + it('returns an empty string for an empty response body', async () => { + stubFetch(200, ''); + const res = await performAxiosRequest({ url: 'https://x.test/a', method: 'get' }); + expect(res.data).toBe(''); + }); + + it('parses a bare JSON primitive body into its value', async () => { + const spy = vi.fn(async () => new Response('123', { + status: 200, + headers: { 'content-type': 'text/plain' } + })); + vi.stubGlobal('fetch', spy); + const res = await performAxiosRequest({ url: 'https://x.test/a', method: 'get' }); + expect(res.data).toBe(123); + }); + + it('throws ERR_BAD_RESPONSE for a non-4xx failing status', async () => { + stubFetch(300); + await expect( + performAxiosRequest({ url: 'https://x.test/a', method: 'get' }) + ).rejects.toMatchObject({ code: 'ERR_BAD_RESPONSE', response: { status: 300 } }); + }); +}); + +describe('validateStatus bridged through the QuickJS sandbox', () => { + let vm: any; + + beforeAll(async () => { + const module = await newQuickJSWASMModule(); + vm = module.newContext(); + addAxiosShimToContext(vm); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const stubFetch = (status: number, body = '{}') => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(body, { + status, + headers: { 'content-type': 'application/json' } + }))); + }; + + const inVmAsync = async (expression: string) => { + const result = vm.evalCode(`(async () => (${expression}))()`); + const promiseHandle = vm.unwrapResult(result); + const resolving = vm.resolvePromise(promiseHandle); + vm.runtime.executePendingJobs(); + const resolved = await resolving; + promiseHandle.dispose(); + if (resolved.error) { + const error = vm.dump(resolved.error); + resolved.error.dispose(); + throw error; + } + const value = vm.dump(resolved.value); + resolved.value.dispose(); + return value; + }; + + it('a script-supplied validateStatus function lets a 404 resolve', async () => { + stubFetch(404, '{"err":"missing"}'); + const status = await inVmAsync( + `(await axios.get('https://x.test/a', { validateStatus: (s) => s < 500 })).status` + ); + expect(status).toBe(404); + }); + + it('a script-supplied validateStatus function can reject a 2xx', async () => { + stubFetch(200); + const message = await inVmAsync( + `axios.get('https://x.test/a', { validateStatus: (s) => s === 201 }) + .then(() => 'NO-THROW', (e) => e.message)` + ); + expect(message).toBe('Request failed with status code 200'); + }); + + it('bridges validateStatus on the axios(config) shape', async () => { + stubFetch(404); + const status = await inVmAsync( + `(await axios({ url: 'https://x.test/a', validateStatus: (s) => s < 500 })).status` + ); + expect(status).toBe(404); + }); + + it('bridges validateStatus on the axios.post(url, data, config) shape', async () => { + stubFetch(404); + const status = await inVmAsync( + `(await axios.post('https://x.test/a', { a: 1 }, { validateStatus: (s) => s < 500 })).status` + ); + expect(status).toBe(404); + }); + + it('propagates an error thrown inside the script validateStatus', async () => { + stubFetch(200); + const message = await inVmAsync( + `axios.get('https://x.test/a', { validateStatus: () => { throw new Error('boom from script'); } }) + .then(() => 'NO-THROW', (e) => e.message)` + ); + expect(message).toBe('boom from script'); + }); + + it('still rejects a 404 without validateStatus', async () => { + stubFetch(404); + const message = await inVmAsync( + `axios.get('https://x.test/a').then(() => 'NO-THROW', (e) => e.message)` + ); + expect(message).toBe('Request failed with status code 404'); + }); + + it('delivers response data, status and headers into the sandbox', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('{"token":"abc","n":1}', { + status: 200, + headers: { 'content-type': 'application/json', 'x-request-id': 'r-1' } + }))); + const seen = await inVmAsync(` + axios.get('https://x.test/a').then((res) => ({ + status: res.status, + token: res.data.token, + n: res.data.n, + requestId: res.headers['x-request-id'] + })) + `); + expect(seen).toEqual({ status: 200, token: 'abc', n: 1, requestId: 'r-1' }); + }); + + it('sends the sandbox-built config through fetch on POST', async () => { + const spy = vi.fn(async (_url: string, _init?: RequestInit) => new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' } + })); + vi.stubGlobal('fetch', spy); + await inVmAsync(`axios.post('https://x.test/a', { a: 1 }, { headers: { 'X-Key': 'k1' } })`); + const [url, init] = spy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://x.test/a'); + expect(init.method).toBe('POST'); + expect(init.body).toBe('{"a":1}'); + expect((init.headers as Record)['X-Key']).toBe('k1'); + }); + + it('delivers the axios error shape into the sandbox on a 500', async () => { + stubFetch(500, '{"err":"down"}'); + const seen = await inVmAsync(` + axios.get('https://x.test/a').then(() => 'NO-THROW', (e) => ({ + isAxiosError: e.isAxiosError, + code: e.code, + status: e.response.status, + err: e.response.data.err + })) + `); + expect(seen).toEqual({ isAxiosError: true, code: 'ERR_BAD_RESPONSE', status: 500, err: 'down' }); + }); + + it('works with the plain axios(url) shape from the sandbox', async () => { + const spy = vi.fn(async (_url: string, _init?: RequestInit) => new Response('{}', { + status: 200, + headers: { 'content-type': 'application/json' } + })); + vi.stubGlobal('fetch', spy); + const status = await inVmAsync(`(await axios('https://x.test/a')).status`); + expect(status).toBe(200); + const init = spy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('GET'); + }); + + it('throws a clear error for unsupported axios methods in the sandbox', async () => { + const message = await inVmAsync(` + (() => { try { axios.head('https://x.test/a'); return 'NO-THROW'; } catch (e) { return e.message; } })() + `); + expect(message).toBe('axios.head is not supported in the docs playground; use axios(config) or axios.get/post/put/patch/delete.'); + }); + + it('delivers a network error with the CORS hint into the sandbox', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { + throw new TypeError('Failed to fetch'); + })); + const seen = await inVmAsync(` + axios.get('https://x.test/a').then(() => 'NO-THROW', (e) => ({ + code: e.code, + message: e.message, + hasHint: typeof e.hint === 'string' + })) + `); + expect(seen).toEqual({ code: 'ERR_NETWORK', message: 'Network Error', hasHint: true }); + }); +}); diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/axios.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/axios.ts new file mode 100644 index 00000000..12e5782d --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/axios.ts @@ -0,0 +1,332 @@ +import { cleanJson } from '@/scripting/utils/common'; +import { marshallToVm } from '../../utils'; + +const METHODS = ['get', 'post', 'put', 'delete', 'patch']; +const METHODS_WITH_BODY = new Set(['post', 'put', 'patch']); +const UNSUPPORTED_METHODS = ['head', 'options', 'request', 'create', 'all']; + +interface AxiosShimConfig { + url?: string; + method?: string; + headers?: Record; + params?: Record; + data?: any; + timeout?: number; + auth?: { username?: string; password?: string }; + validateStatus?: ((status: number) => boolean) | null; +} + +const normalizeArgs = (method: string | null, args: any[]): AxiosShimConfig => { + if (method === null) { + if (typeof args[0] === 'string') { + return { ...(args[1] || {}), url: args[0], method: (args[1]?.method || 'get') }; + } + return { method: 'get', ...(args[0] || {}) }; + } + + if (METHODS_WITH_BODY.has(method)) { + return { ...(args[2] || {}), url: args[0], data: args[1], method }; + } + return { ...(args[1] || {}), url: args[0], method }; +}; + +const encodeAxiosComponent = (value: string): string => + encodeURIComponent(value) + .replace(/%3A/gi, ':') + .replace(/%24/g, '$') + .replace(/%2C/gi, ',') + .replace(/%20/g, '+'); + +const isPlainObject = (value: any): boolean => + Object.prototype.toString.call(value) === '[object Object]'; + +const isVisitable = (value: any): boolean => isPlainObject(value) || Array.isArray(value); + +const isFlatArray = (value: any): boolean => Array.isArray(value) && !value.some(isVisitable); + +const removeBrackets = (key: string): string => (key.endsWith('[]') ? key.slice(0, -2) : key); + +const renderParamKey = (path: (string | number)[], key: string | number): string => { + if (path.length === 0) { + return `${key}`; + } + return [...path, key] + .map((token, index) => { + const cleaned = typeof token === 'string' ? removeBrackets(token) : token; + return index ? `[${cleaned}]` : `${cleaned}`; + }) + .join(''); +}; + +const convertParamValue = (value: any): string => { + if (value === null) return ''; + if (value instanceof Date) return value.toISOString(); + if (typeof value === 'boolean') return value.toString(); + return String(value); +}; + +const serializeAxiosParams = (params: Record): string => { + const pairs: [string, string][] = []; + + const build = (value: any, path: (string | number)[]) => { + const entries: [string | number, any][] = Array.isArray(value) + ? value.map((element, index): [number, any] => [index, element]) + : Object.keys(value).map((objectKey): [string, any] => [objectKey, value[objectKey]]); + + entries.forEach(([key, element]) => { + if (element === undefined || element === null) { + return; + } + visit(element, typeof key === 'string' ? key.trim() : key, path); + }); + }; + + const visit = (value: any, key: string | number, path: (string | number)[]) => { + if (path.length === 0 && Array.isArray(value)) { + const keyIsBracketed = typeof key === 'string' && key.endsWith('[]'); + if (isFlatArray(value) || keyIsBracketed) { + const baseKey = typeof key === 'string' ? removeBrackets(key) : key; + value.forEach((element: any) => { + if (element === undefined || element === null) { + return; + } + pairs.push([`${baseKey}[]`, convertParamValue(element)]); + }); + return; + } + } + if (isVisitable(value)) { + build(value, path.length ? [...path, key] : [key]); + return; + } + pairs.push([renderParamKey(path, key), convertParamValue(value)]); + }; + + build(params, []); + + return pairs + .map(([key, value]) => `${encodeAxiosComponent(key)}=${encodeAxiosComponent(value)}`) + .join('&'); +}; + +const buildRequestUrl = (config: AxiosShimConfig): string => { + let url = config.url || ''; + if (!config.params || typeof config.params !== 'object') { + return url; + } + const queryString = serializeAxiosParams(config.params); + if (!queryString) { + return url; + } + const hashIndex = url.indexOf('#'); + if (hashIndex !== -1) { + url = url.slice(0, hashIndex); + } + return url + (url.includes('?') ? '&' : '?') + queryString; +}; + +const buildAxiosError = (partial: Record, config: AxiosShimConfig) => { + return { + isAxiosError: true, + ...partial, + config: { + url: config.url, + method: config.method, + headers: config.headers, + data: config.data + } + }; +}; + +const parseResponseData = async (response: Response) => { + const text = await response.text(); + if (text.length) { + try { + return JSON.parse(text); + } catch { + return text; + } + } + return text; +}; + +const performAxiosRequest = async (config: AxiosShimConfig) => { + if (typeof config.url !== 'string' || !/^https?:\/\//i.test(config.url.trim())) { + throw buildAxiosError( + { message: `Only absolute http(s) URLs are supported here, received: ${String(config.url)}`, code: 'ERR_INVALID_URL' }, + config + ); + } + + const headers: Record = { ...(config.headers || {}) }; + + if (config.auth?.username !== undefined) { + headers['Authorization'] = 'Basic ' + btoa(`${config.auth.username || ''}:${config.auth.password || ''}`); + } + + const method = (config.method || 'get').toUpperCase(); + const methodAllowsBody = method !== 'GET' && method !== 'HEAD'; + + let body: string | undefined; + if (methodAllowsBody && config.data !== undefined && config.data !== null) { + if (typeof config.data === 'object') { + body = JSON.stringify(config.data); + const hasContentType = Object.keys(headers).some((key) => key.toLowerCase() === 'content-type'); + if (!hasContentType) { + headers['Content-Type'] = 'application/json'; + } + } else { + body = String(config.data); + } + } + + const fetchOptions: RequestInit = { + method, + headers, + // 'omit' so a published script can never use the reader's cookies to call the docs site itself + credentials: 'omit', + ...(body !== undefined && { body }), + ...(config.timeout && { signal: AbortSignal.timeout(config.timeout) }) + }; + + let response: Response; + try { + response = await fetch(buildRequestUrl(config), fetchOptions); + } catch (err: any) { + if (err?.name === 'TimeoutError') { + throw buildAxiosError({ message: `timeout of ${config.timeout}ms exceeded`, code: 'ECONNABORTED' }, config); + } + throw buildAxiosError( + { + message: 'Network Error', + code: 'ERR_NETWORK', + cause: `${err?.name || 'Error'}: ${err?.message || String(err)}`, + hint: 'The browser blocked or failed the request. If the API works in this Bruno app, the server may not allow cross-origin (CORS) requests from this docs site.' + }, + config + ); + } + + const responseHeaders: Record = {}; + response.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + const data = await parseResponseData(response); + + const acceptStatus = (status: number): boolean => { + if (typeof config.validateStatus === 'function') { + return config.validateStatus(status); + } + if (config.validateStatus === null) { + return true; + } + return status >= 200 && status < 300; + }; + + if (!acceptStatus(response.status)) { + throw buildAxiosError( + { + message: `Request failed with status code ${response.status}`, + code: response.status >= 400 && response.status < 500 ? 'ERR_BAD_REQUEST' : 'ERR_BAD_RESPONSE', + response: { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + data + } + }, + config + ); + } + + return { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + data, + config: { url: config.url, method: config.method, headers: config.headers, data: config.data } + }; +}; + +const configArgIndex = (method: string | null, nativeArgs: any[]): number => { + if (method === null) { + return typeof nativeArgs[0] === 'string' ? 1 : 0; + } + return METHODS_WITH_BODY.has(method) ? 2 : 1; +}; + +const addAxiosShimToContext = (vm: any) => { + const bridgeValidateStatus = (config: AxiosShimConfig, configHandle: any, nativeConfig: any): any => { + if (!configHandle || !nativeConfig || typeof nativeConfig !== 'object') { + return null; + } + const propHandle = vm.getProp(configHandle, 'validateStatus'); + if (vm.typeof(propHandle) !== 'function') { + propHandle.dispose(); + return null; + } + config.validateStatus = (status: number): boolean => { + const statusHandle = vm.newNumber(status); + const callResult = vm.callFunction(propHandle, vm.undefined, statusHandle); + statusHandle.dispose(); + if (callResult.error) { + const error = vm.dump(callResult.error); + callResult.error.dispose(); + throw error; + } + const accepted = vm.dump(callResult.value); + callResult.value.dispose(); + return Boolean(accepted); + }; + return propHandle; + }; + + const registerAxiosFunction = (name: string, method: string | null) => { + const fnHandle = vm.newFunction(name, (...args: any[]) => { + const nativeArgs = args.map(vm.dump); + const config = normalizeArgs(method, nativeArgs); + const configIndex = configArgIndex(method, nativeArgs); + const validateStatusHandle = bridgeValidateStatus(config, args[configIndex], nativeArgs[configIndex]); + const promise = vm.newPromise(); + performAxiosRequest(config) + .then((response) => { + promise.resolve(marshallToVm(cleanJson(response), vm)); + }) + .catch((err) => { + promise.reject(marshallToVm(cleanJson(err), vm)); + }) + .finally(() => { + if (validateStatusHandle) { + validateStatusHandle.dispose(); + } + }); + promise.settled.then(vm.runtime.executePendingJobs); + return promise.handle; + }); + fnHandle.consume((handle: any) => vm.setProp(vm.global, name, handle)); + }; + + registerAxiosFunction('__bruno__axios', null); + METHODS.forEach((method) => registerAxiosFunction(`__bruno__axios__${method}`, method)); + + const bootResult = vm.evalCode(` + globalThis.axios = __bruno__axios; + ${METHODS.map((method) => `globalThis.axios.${method} = __bruno__axios__${method};`).join('\n')} + ${UNSUPPORTED_METHODS.map((method) => `globalThis.axios.${method} = () => { + throw new Error("axios.${method} is not supported in the docs playground; use axios(config) or axios.get/post/put/patch/delete."); + };`).join('\n')} + globalThis.requireObject = { + ...globalThis.requireObject, + axios: globalThis.axios, + }; + `); + if (bootResult.error) { + const error = vm.dump(bootResult.error); + bootResult.error.dispose(); + throw new Error(`Failed to install axios shim: ${error?.message || String(error)}`); + } + bootResult.value.dispose(); +}; + +export default addAxiosShimToContext; +export { serializeAxiosParams, performAxiosRequest }; diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/crypto-utils.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/crypto-utils.ts new file mode 100644 index 00000000..12c8d930 --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/crypto-utils.ts @@ -0,0 +1,97 @@ +import { marshallToVm } from '../../utils'; +import { serializeTypedArray, deserializeTypedArray, SUPPORTED_TYPED_ARRAYS } from './utils'; + +const MAX_RANDOM_BYTES_PER_CALL = 65536; + +const addCryptoUtilsShimToContext = (vm: any) => { + const randomBytesHandle = vm.newFunction('randomBytes', (sizeHandle: any) => { + try { + let size = vm.dump(sizeHandle); + if (typeof size !== 'number') { + throw new TypeError('The "size" argument must be of type number'); + } + + size = Math.trunc(size); + + if (size < 0) { + throw new RangeError('The "size" argument must be >= 0'); + } + + if (size > MAX_RANDOM_BYTES_PER_CALL) { + throw new RangeError('The "size" argument is too large'); + } + + if (size === 0) { + return marshallToVm([], vm); + } + + const bytes = new Uint8Array(size); + globalThis.crypto.getRandomValues(bytes); + + return marshallToVm(Array.from(bytes), vm); + } catch (error: any) { + const vmError = vm.newError(error.message); + vm.setProp(vmError, 'name', vm.newString(error.name)); + throw vmError; + } + }); + + const getRandomValuesHandle = vm.newFunction('getRandomValues', (arrayHandle: any) => { + try { + const serializedArray = vm.dump(arrayHandle); + const typedArray = deserializeTypedArray(serializedArray); + + if (typedArray.length === 0) { + return marshallToVm([], vm); + } + + if (typedArray.length > MAX_RANDOM_BYTES_PER_CALL) { + throw new Error('getRandomValues: ArrayBufferView byte length exceeds 65536'); + } + + globalThis.crypto.getRandomValues(typedArray); + + return marshallToVm(Array.from(typedArray), vm); + } catch (error: any) { + const vmError = vm.newError(error.message); + vm.setProp(vmError, 'name', vm.newString(error.name)); + throw vmError; + } + }); + + vm.setProp(vm.global, '__bruno__crypto__randomBytes', randomBytesHandle); + vm.setProp(vm.global, '__bruno__crypto__getRandomValues', getRandomValuesHandle); + randomBytesHandle.dispose(); + getRandomValuesHandle.dispose(); + + const bootResult = vm.evalCode(` + const serializeTypedArray = ${serializeTypedArray.toString()}; + const SUPPORTED_TYPED_ARRAYS = ${JSON.stringify(SUPPORTED_TYPED_ARRAYS)}; + + const cryptoModule = { + randomBytes: function(size) { + const byteArray = globalThis.__bruno__crypto__randomBytes(size); + return Buffer.from(Array.from(byteArray)); + }, + getRandomValues: function(typedArray) { + const type = typedArray && typedArray.constructor && typedArray.constructor.name; + if (!SUPPORTED_TYPED_ARRAYS.includes(type)) { + throw new Error(\`getRandomValues: unsupported typed array type: \${type}\`); + } + const serializedTypedArray = serializeTypedArray(typedArray); + typedArray.set(globalThis.__bruno__crypto__getRandomValues(serializedTypedArray)); + return typedArray; + }, + }; + + globalThis.crypto = cryptoModule; + `); + if (bootResult.error) { + const error = vm.dump(bootResult.error); + bootResult.error.dispose(); + throw new Error(`Failed to install crypto shim: ${error?.message || String(error)}`); + } + bootResult.value.dispose(); +}; + +export default addCryptoUtilsShimToContext; diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/utils.spec.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/utils.spec.ts new file mode 100644 index 00000000..9dad1c0b --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/utils.spec.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from 'vitest'; +import { serializeTypedArray, deserializeTypedArray, SUPPORTED_TYPED_ARRAYS } from './utils'; + +describe('SUPPORTED_TYPED_ARRAYS', () => { + it('lists only the integer typed arrays web crypto accepts', () => { + expect(SUPPORTED_TYPED_ARRAYS).toEqual([ + 'Int8Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Int16Array', + 'Uint16Array', + 'Int32Array', + 'Uint32Array' + ]); + }); +}); + +describe('deserializeTypedArray', () => { + it('round-trips a serialized Uint8Array', () => { + const original = new Uint8Array([1, 2, 3]); + const rebuilt = deserializeTypedArray(serializeTypedArray(original)); + expect(rebuilt).toBeInstanceOf(Uint8Array); + expect(Array.from(rebuilt)).toEqual([1, 2, 3]); + }); + + it('accepts an array at exactly the 65536 cap', () => { + const rebuilt = deserializeTypedArray({ + type: 'Uint8Array', + array: new Array(65536).fill(0), + length: 65536 + }); + expect(rebuilt.length).toBe(65536); + }); + + it('rejects a non-array faking a huge length before allocating', () => { + expect(() => deserializeTypedArray({ + type: 'Uint8Array', + array: { length: 50_000_000 }, + length: 5 + })).toThrow('getRandomValues: Invalid typed array properties'); + }); + + it('rejects a real array over the cap', () => { + expect(() => deserializeTypedArray({ + type: 'Uint8Array', + array: new Array(65537).fill(0), + length: 65537 + })).toThrow('getRandomValues: ArrayBufferView byte length exceeds 65536'); + }); + + it('rejects a length larger than the array', () => { + expect(() => deserializeTypedArray({ + type: 'Uint8Array', + array: [1, 2], + length: 9 + })).toThrow('getRandomValues: Invalid typed array properties'); + }); + + it('rejects a non-integer length', () => { + expect(() => deserializeTypedArray({ + type: 'Uint8Array', + array: [1, 2], + length: 1.5 + })).toThrow('getRandomValues: Invalid typed array properties'); + }); + + it('rejects float typed arrays', () => { + expect(() => deserializeTypedArray({ + type: 'Float32Array', + array: [1], + length: 1 + })).toThrow('getRandomValues: Invalid or unsupported typed array type: Float32Array'); + }); + + it('rejects bigint typed arrays', () => { + expect(() => deserializeTypedArray({ + type: 'BigInt64Array', + array: [1], + length: 1 + })).toThrow('getRandomValues: Invalid or unsupported typed array type: BigInt64Array'); + }); + + it('rejects a non-object input', () => { + expect(() => deserializeTypedArray('nope')).toThrow('getRandomValues: Invalid typed array object'); + }); +}); diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/utils.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/utils.ts new file mode 100644 index 00000000..7c45ccfc --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/lib/utils.ts @@ -0,0 +1,46 @@ +const SUPPORTED_TYPED_ARRAYS = [ + 'Int8Array', + 'Uint8Array', + 'Uint8ClampedArray', + 'Int16Array', + 'Uint16Array', + 'Int32Array', + 'Uint32Array' +]; + +const MAX_TYPED_ARRAY_LENGTH = 65536; + +function serializeTypedArray(typedArray: any) { + return { + type: typedArray.constructor.name, + array: Array.from(typedArray), + length: typedArray.length + }; +} + +function deserializeTypedArray(obj: any) { + if (!obj || typeof obj !== 'object') { + throw new TypeError('getRandomValues: Invalid typed array object'); + } + + if (typeof obj.type !== 'string' || !SUPPORTED_TYPED_ARRAYS.includes(obj.type)) { + throw new TypeError(`getRandomValues: Invalid or unsupported typed array type: ${obj.type}`); + } + + if (!Array.isArray(obj.array) || !Number.isInteger(obj.length) || obj.length < 0 || obj.length > obj.array.length) { + throw new TypeError('getRandomValues: Invalid typed array properties'); + } + + if (obj.array.length > MAX_TYPED_ARRAY_LENGTH) { + throw new Error('getRandomValues: ArrayBufferView byte length exceeds 65536'); + } + + const TypedArrayConstructor = (globalThis as any)[obj.type]; + if (typeof TypedArrayConstructor !== 'function') { + throw new TypeError(`getRandomValues: Constructor ${obj.type} is not available`); + } + + return new TypedArrayConstructor(obj.array, 0, obj.length); +} + +export { serializeTypedArray, deserializeTypedArray, SUPPORTED_TYPED_ARRAYS }; diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/require.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/require.ts new file mode 100644 index 00000000..38d60a6d --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/shims/require.ts @@ -0,0 +1,55 @@ +const NODE_BUILTIN_MODULES = [ + 'assert', 'buffer', 'child_process', 'cluster', 'crypto', 'dgram', 'dns', 'events', + 'fs', 'http', 'http2', 'https', 'net', 'os', 'path', 'perf_hooks', 'process', 'querystring', + 'readline', 'stream', 'string_decoder', 'timers', 'tls', 'url', 'util', 'v8', 'vm', + 'worker_threads', 'zlib' +]; + +const DEVELOPER_MODE_LIBRARIES = [ + 'lodash', 'cheerio', 'xml2js', 'node-fetch', 'yaml', 'json-query', + 'xml-formatter', 'chai-string', 'handlebars' +]; + +const UNSUPPORTED_LIBRARIES = ['jsonwebtoken']; + +export const getRequireCode = () => ` + globalThis.require = (mod) => { + const hasOwn = (name) => Object.prototype.hasOwnProperty.call(globalThis.requireObject, name); + if (hasOwn(mod)) { + return globalThis.requireObject[mod]; + } + + if (mod?.startsWith?.('.') || mod?.startsWith?.('/')) { + throw new Error( + "Local file require ('" + mod + "') is not available in the docs playground; " + + 'only the built-in safe-mode libraries can be required here.' + ); + } + + const bareName = mod?.startsWith?.('node:') ? mod.slice(5) : mod; + const nodeBuiltins = ${JSON.stringify(NODE_BUILTIN_MODULES)}; + if (nodeBuiltins.includes(bareName)) { + if (hasOwn(bareName)) { + return globalThis.requireObject[bareName]; + } + if (bareName === 'crypto') { + throw new Error( + \`'\${mod}' cannot be required in the docs playground; use the crypto global instead (crypto.randomBytes, crypto.getRandomValues).\` + ); + } + throw new Error( + \`'\${mod}' is a Node.js builtin and is not available in the docs playground; it requires the Bruno desktop app's developer mode.\` + ); + } + if (${JSON.stringify(DEVELOPER_MODE_LIBRARIES)}.includes(bareName)) { + const available = Object.keys(globalThis.requireObject).sort().join(', '); + throw new Error( + \`'\${mod}' is only available in the Bruno desktop app's developer mode; the docs playground supports the safe-mode library set (\${available}).\` + ); + } + if (${JSON.stringify(UNSUPPORTED_LIBRARIES)}.includes(bareName)) { + throw new Error(\`'\${mod}' is not currently supported in the docs playground. Please use the Bruno desktop app.\`); + } + throw new Error(\`Cannot find module \${mod}\`); + } +`; diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/utils/index.spec.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/utils/index.spec.ts new file mode 100644 index 00000000..529caf97 --- /dev/null +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/utils/index.spec.ts @@ -0,0 +1,41 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { newQuickJSWASMModule } from 'quickjs-emscripten'; +import { marshallToVm } from './index'; + +let vm: any; + +const dump = (handle: any) => { + const value = vm.dump(handle); + handle.dispose(); + return value; +}; + +describe('marshallToVm', () => { + beforeAll(async () => { + const module = await newQuickJSWASMModule(); + vm = module.newContext(); + }); + + it('marshals primitives, arrays and nested objects', () => { + expect(dump(marshallToVm('x', vm))).toBe('x'); + expect(dump(marshallToVm(42, vm))).toBe(42); + expect(dump(marshallToVm(true, vm))).toBe(true); + expect(dump(marshallToVm([1, 'a', { b: 2 }], vm))).toEqual([1, 'a', { b: 2 }]); + }); + + it('does not carry a hostile __proto__ key onto the sandbox object', () => { + const hostile = JSON.parse('{"__proto__": {"polluted": true}, "safe": 1}'); + const handle = marshallToVm(hostile, vm); + vm.setProp(vm.global, 'marshalled', handle); + handle.dispose(); + + expect(dump(vm.evalCode('marshalled.safe').value)).toBe(1); + expect(dump(vm.evalCode('marshalled.polluted').value)).toBeUndefined(); + expect(dump(vm.evalCode('({}).polluted').value)).toBeUndefined(); + }); + + it('throws on unsupported value types instead of returning undefined', () => { + expect(() => marshallToVm(10n, vm)).toThrowError('marshallToVm: unsupported value of type bigint'); + expect(() => marshallToVm(Symbol('x'), vm)).toThrowError('marshallToVm: unsupported value of type symbol'); + }); +}); diff --git a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/utils/index.ts b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/utils/index.ts index a587615d..9446dc0d 100644 --- a/packages/bruno-api-docs/src/scripting/sandbox/quickjs/utils/index.ts +++ b/packages/bruno-api-docs/src/scripting/sandbox/quickjs/utils/index.ts @@ -21,6 +21,9 @@ const marshallToVm = (value: any, vm: any) => { } else { const obj = vm.newObject(); for (const key in value) { + if (key === '__proto__') { + continue; + } vm.setProp(obj, key, marshallToVm(value[key], vm)); } return obj; @@ -28,6 +31,7 @@ const marshallToVm = (value: any, vm: any) => { } else if (typeof value === 'function') { return vm.newString('[Function (anonymous)]'); } + throw new TypeError(`marshallToVm: unsupported value of type ${typeof value}`); }; export { marshallToVm }; diff --git a/packages/bruno-api-docs/src/vite-env.d.ts b/packages/bruno-api-docs/src/vite-env.d.ts index 11f02fe2..f718c9a8 100644 --- a/packages/bruno-api-docs/src/vite-env.d.ts +++ b/packages/bruno-api-docs/src/vite-env.d.ts @@ -1 +1,6 @@ /// + +declare module 'atob/node-atob' { + const atob: (input: string) => string; + export default atob; +}