diff --git a/.changeset/top-level-await.md b/.changeset/top-level-await.md new file mode 100644 index 00000000..78ac7703 --- /dev/null +++ b/.changeset/top-level-await.md @@ -0,0 +1,15 @@ +--- +'@cloudflare/sandbox': patch +--- + +Add top-level await support for JavaScript code execution + +JavaScript code can now use `await` at the top level without wrapping in an async IIFE. Variables declared with `const`, `let`, or `var` persist across executions, enabling multi-step workflows like: + +```javascript +// Execution 1 +const data = await fetch('https://api.example.com').then((r) => r.json()); + +// Execution 2 +console.log(data); // Works - data persists +``` diff --git a/package-lock.json b/package-lock.json index bd909cba..a4a76dcc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -401,6 +401,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -422,6 +423,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -561,6 +563,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1506,7 +1509,8 @@ "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-4.20251126.0.tgz", "integrity": "sha512-DSeI1Q7JYmh5/D/tw5eZCjrKY34v69rwj63hHt60nSQW5QLwWCbj/lLtNz9f2EPa+JCACwpLXHgCXfzJ29x66w==", "devOptional": true, - "license": "MIT OR Apache-2.0" + "license": "MIT OR Apache-2.0", + "peer": true }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -2820,6 +2824,7 @@ "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -4149,6 +4154,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/acorn": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@types/acorn/-/acorn-4.0.6.tgz", + "integrity": "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4289,6 +4304,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4304,6 +4320,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4313,6 +4330,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4439,6 +4457,7 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -4454,6 +4473,7 @@ "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/pretty-format": "3.2.4", "magic-string": "^0.30.17", @@ -4482,6 +4502,7 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -4629,6 +4650,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5374,6 +5396,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5667,6 +5690,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -7601,6 +7625,7 @@ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -7865,6 +7890,7 @@ "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "devOptional": true, "license": "MPL-2.0", + "peer": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -7901,7 +7927,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7922,7 +7947,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7943,7 +7967,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7964,7 +7987,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -7985,7 +8007,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8006,7 +8027,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8027,7 +8047,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8048,7 +8067,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8069,7 +8087,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8090,7 +8107,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8111,7 +8127,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -8161,7 +8176,6 @@ "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", "license": "MIT", - "peer": true, "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, @@ -9730,6 +9744,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -9946,6 +9961,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9955,6 +9971,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -9966,8 +9983,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-katex": { "version": "3.1.0", @@ -10341,6 +10357,7 @@ "integrity": "sha512-ZRLgPlS91l4JztLYEZnmMcd3Umcla1hkXJgiEiR4HloRJBBoeaX8qogTu5Jfu36rRMVLndzqYv0h+M5gJAkUfg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@oxc-project/types": "=0.98.0", "@rolldown/pluginutils": "1.0.0-beta.51" @@ -11156,6 +11173,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -11355,6 +11373,7 @@ "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -11514,6 +11533,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -11583,6 +11603,7 @@ "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", "license": "MIT", + "peer": true, "dependencies": { "pathe": "^2.0.3" } @@ -12024,6 +12045,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.4.tgz", "integrity": "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -12138,6 +12160,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -12170,6 +12193,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -12595,6 +12619,7 @@ "integrity": "sha512-Om5ns0Lyx/LKtYI04IV0bjIrkBgoFNg0p6urzr2asekJlfP18RqFzyqMFZKf0i9Gnjtz/JfAS/Ol6tjCe5JJsQ==", "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "bin": { "workerd": "bin/workerd" }, @@ -13358,6 +13383,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -13428,11 +13454,13 @@ "version": "0.0.2", "dependencies": { "@repo/shared": "*", + "acorn": "^8.14.0", "async-mutex": "^0.5.0", "zod": "^3.22.3" }, "devDependencies": { "@repo/typescript-config": "*", + "@types/acorn": "^4.0.6", "@types/bun": "^1.3.3", "typescript": "^5.9.3" } diff --git a/packages/sandbox-container/package.json b/packages/sandbox-container/package.json index 53f19b72..f2c6cad2 100644 --- a/packages/sandbox-container/package.json +++ b/packages/sandbox-container/package.json @@ -13,11 +13,13 @@ }, "dependencies": { "@repo/shared": "*", + "acorn": "^8.14.0", "async-mutex": "^0.5.0", "zod": "^3.22.3" }, "devDependencies": { "@repo/typescript-config": "*", + "@types/acorn": "^4.0.6", "@types/bun": "^1.3.3", "typescript": "^5.9.3" } diff --git a/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts b/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts index d64a5c06..15eea242 100644 --- a/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts +++ b/packages/sandbox-container/src/runtime/executors/javascript/node_executor.ts @@ -7,6 +7,8 @@ import { fileURLToPath } from 'node:url'; import * as util from 'node:util'; import * as vm from 'node:vm'; import type { RichOutput } from '../../process-pool'; +import { transformForAsyncExecution } from '../shared/code-transformer'; +import { isThenable } from '../shared/thenable'; // Create CommonJS-like globals for the sandbox const __filename = fileURLToPath(import.meta.url); @@ -83,7 +85,27 @@ rl.on('line', async (line: string) => { options.timeout = timeout; } - result = vm.runInContext(code, context, options); + // Transform code to support top-level await + const transformedCode = transformForAsyncExecution(code); + result = vm.runInContext(transformedCode, context, options); + + // Await the result if it's a Promise (from async IIFE) + // Apply timeout to async execution as well (vm.timeout only covers sync) + if (isThenable(result)) { + if (timeout !== undefined) { + result = await Promise.race([ + result, + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Script execution timed out')), + timeout + ) + ) + ]); + } else { + result = await result; + } + } } catch (error: unknown) { const err = error as Error; stderr += err.stack || err.toString(); diff --git a/packages/sandbox-container/src/runtime/executors/shared/code-transformer.ts b/packages/sandbox-container/src/runtime/executors/shared/code-transformer.ts new file mode 100644 index 00000000..80c48b0f --- /dev/null +++ b/packages/sandbox-container/src/runtime/executors/shared/code-transformer.ts @@ -0,0 +1,315 @@ +import type { + ArrayPattern, + AssignmentProperty, + ClassDeclaration, + ExpressionStatement, + FunctionDeclaration, + Identifier, + Node, + ObjectPattern, + Pattern, + Program, + RestElement, + Statement, + VariableDeclaration, + VariableDeclarator +} from 'acorn'; +import * as acorn from 'acorn'; + +/** + * Represents a variable declaration that needs to be hoisted. + */ +interface HoistedDeclaration { + /** Variable names to declare in outer scope */ + names: string[]; + /** The transformed assignment code (or empty for declarations without init) */ + assignment: string; +} + +/** + * Extracts all identifier names from a pattern (handles destructuring). + * Recursively processes ObjectPattern, ArrayPattern, RestElement, and AssignmentPattern. + */ +function extractIdentifiersFromPattern(pattern: Pattern): string[] { + const names: string[] = []; + + switch (pattern.type) { + case 'Identifier': + names.push((pattern as Identifier).name); + break; + + case 'ObjectPattern': { + const objPattern = pattern as ObjectPattern; + for (const prop of objPattern.properties) { + if (prop.type === 'RestElement') { + names.push(...extractIdentifiersFromPattern(prop.argument)); + } else { + // AssignmentProperty + const assignProp = prop as AssignmentProperty; + names.push( + ...extractIdentifiersFromPattern(assignProp.value as Pattern) + ); + } + } + break; + } + + case 'ArrayPattern': { + const arrPattern = pattern as ArrayPattern; + for (const element of arrPattern.elements) { + if (element !== null) { + names.push(...extractIdentifiersFromPattern(element)); + } + } + break; + } + + case 'RestElement': { + const restElement = pattern as RestElement; + names.push(...extractIdentifiersFromPattern(restElement.argument)); + break; + } + + case 'AssignmentPattern': { + // Default value pattern: { a = 1 } or [a = 1] + // The identifier is on the left side + const assignPattern = pattern as { left: Pattern; right: Node }; + names.push(...extractIdentifiersFromPattern(assignPattern.left)); + break; + } + + case 'MemberExpression': + // MemberExpression can appear in patterns but doesn't introduce new bindings + break; + } + + return names; +} + +/** + * Processes a variable declaration and returns hoisting info. + * Converts: const x = 1; -> hoisted "let x;" + assignment "x = 1;" + * Handles destructuring: const {a, b} = obj; -> hoisted "let a, b;" + assignment "({a, b} = obj);" + */ +function processVariableDeclaration( + decl: VariableDeclaration, + source: string +): HoistedDeclaration { + const allNames: string[] = []; + const assignments: string[] = []; + + for (const declarator of decl.declarations) { + const names = extractIdentifiersFromPattern(declarator.id); + allNames.push(...names); + + if (declarator.init !== null && declarator.init !== undefined) { + // Get the pattern text from source + const patternText = source.slice(declarator.id.start, declarator.id.end); + const initText = source.slice(declarator.init.start, declarator.init.end); + + // For destructuring patterns, wrap in parentheses to make it a valid expression + if ( + declarator.id.type === 'ObjectPattern' || + declarator.id.type === 'ArrayPattern' + ) { + assignments.push(`(${patternText} = ${initText})`); + } else { + assignments.push(`${patternText} = ${initText}`); + } + } + } + + return { + names: allNames, + assignment: assignments.length > 0 ? assignments.join(', ') : '' + }; +} + +/** + * Processes a function declaration for hoisting. + * Converts: function foo() {} -> hoisted "var foo;" + assignment "foo = function foo() {}" + */ +function processFunctionDeclaration( + decl: FunctionDeclaration, + source: string +): HoistedDeclaration { + // Anonymous functions (e.g., export default function() {}) have no id + if (!decl.id) { + return { names: [], assignment: '' }; + } + const name = decl.id.name; + const funcText = source.slice(decl.start, decl.end); + + return { + names: [name], + // Convert declaration to expression and assign + assignment: `${name} = ${funcText}` + }; +} + +/** + * Processes a class declaration for hoisting. + * Converts: class Foo {} -> hoisted "let Foo;" + assignment "Foo = class Foo {}" + */ +function processClassDeclaration( + decl: ClassDeclaration, + source: string +): HoistedDeclaration { + // Anonymous classes (e.g., export default class {}) have no id + if (!decl.id) { + return { names: [], assignment: '' }; + } + const name = decl.id.name; + const classText = source.slice(decl.start, decl.end); + + return { + names: [name], + // Convert declaration to expression and assign + assignment: `${name} = ${classText}` + }; +} + +/** + * Transforms code to support top-level await with proper variable hoisting. + * This implements REPL-style semantics where variables declared with const/let/var + * persist across executions by hoisting declarations outside the async IIFE wrapper. + + */ +export function transformForAsyncExecution(code: string): string { + const trimmed = code.trim(); + if (!trimmed) { + return '(async () => {})()'; + } + + try { + const ast: Program = acorn.parse(trimmed, { + ecmaVersion: 'latest', + sourceType: 'script', + allowAwaitOutsideFunction: true + }); + + const body = ast.body as Statement[]; + if (body.length === 0) { + return '(async () => {})()'; + } + + // Collect hoisted declarations and transformed body parts + const hoistedVars: string[] = []; // Variables declared with let in outer scope + const hoistedFuncs: string[] = []; // Functions use var for hoisting semantics + const bodyParts: string[] = []; + + for (let i = 0; i < body.length; i++) { + const node = body[i]; + const isLast = i === body.length - 1; + + switch (node.type) { + case 'VariableDeclaration': { + const varDecl = node as VariableDeclaration; + const hoisted = processVariableDeclaration(varDecl, trimmed); + + // Hoist all variable names (use let for const/let, var would also work) + if (hoisted.names.length > 0) { + hoistedVars.push(...hoisted.names); + } + + // Add assignment to body (or void 0 if no initializer) + if (hoisted.assignment) { + if (isLast) { + // Return the assignment result for last statement + bodyParts.push(`return (${hoisted.assignment})`); + } else { + bodyParts.push(`void (${hoisted.assignment})`); + } + } + // If no assignment (e.g., "let x;"), nothing to add to body + break; + } + + case 'FunctionDeclaration': { + const funcDecl = node as FunctionDeclaration; + const hoisted = processFunctionDeclaration(funcDecl, trimmed); + + // Use var for function hoisting semantics + hoistedFuncs.push(...hoisted.names); + + if (hoisted.assignment) { + if (isLast) { + bodyParts.push(`return (${hoisted.assignment})`); + } else { + bodyParts.push(`void (${hoisted.assignment})`); + } + } + break; + } + + case 'ClassDeclaration': { + const classDecl = node as ClassDeclaration; + const hoisted = processClassDeclaration(classDecl, trimmed); + + hoistedVars.push(...hoisted.names); + + if (hoisted.assignment) { + if (isLast) { + bodyParts.push(`return (${hoisted.assignment})`); + } else { + bodyParts.push(`void (${hoisted.assignment})`); + } + } + break; + } + + case 'ExpressionStatement': { + const exprStmt = node as ExpressionStatement; + const exprText = trimmed.slice(exprStmt.start, exprStmt.end); + // Remove trailing semicolon if present (we add our own when joining) + const cleanedExpr = exprText.replace(/;$/, ''); + + if (isLast) { + bodyParts.push(`return (${cleanedExpr})`); + } else { + bodyParts.push(cleanedExpr); + } + break; + } + + default: { + // For other statements (if, for, while, try, etc.), include as-is + const stmtText = trimmed.slice(node.start, node.end); + bodyParts.push(stmtText); + break; + } + } + } + + // Build the final code + const parts: string[] = []; + + // Add hoisted variable declarations + if (hoistedVars.length > 0) { + parts.push(`let ${hoistedVars.join(', ')};`); + } + + // Add hoisted function declarations (use var for proper hoisting) + if (hoistedFuncs.length > 0) { + parts.push(`var ${hoistedFuncs.join(', ')};`); + } + + // Add the async IIFE with transformed body + if (bodyParts.length > 0) { + const bodyCode = bodyParts.join(';\n'); + parts.push(`(async () => {\n${bodyCode};\n})()`); + } else { + // No body statements (e.g., just "let x;") - empty IIFE + parts.push('(async () => {})()'); + } + + return parts.join('\n'); + } catch { + // If acorn parsing fails (e.g., syntax error), wrap the original code anyway. + // When vm.runInContext() executes this invalid code, it throws a SyntaxError + // which is caught in node_executor.ts:109-112 and written to stderr. + // This defers error reporting to V8, which provides better error messages + // with accurate line/column information. + return `(async () => {\n${trimmed}\n})()`; + } +} diff --git a/packages/sandbox-container/src/runtime/executors/shared/thenable.ts b/packages/sandbox-container/src/runtime/executors/shared/thenable.ts new file mode 100644 index 00000000..790a3818 --- /dev/null +++ b/packages/sandbox-container/src/runtime/executors/shared/thenable.ts @@ -0,0 +1,24 @@ +/** + * Represents a Promise-like object with a then method. + * Used for detecting async results from vm.runInContext(). + */ +export interface Thenable { + then: ( + onfulfilled?: (value: T) => unknown, + onrejected?: (reason: unknown) => unknown + ) => unknown; +} + +/** + * Type guard to check if a value is a thenable (Promise-like). + * This is used to detect when vm.runInContext() returns a Promise + * that needs to be awaited. + */ +export function isThenable(value: unknown): value is Thenable { + return ( + value !== null && + typeof value === 'object' && + 'then' in value && + typeof (value as Thenable).then === 'function' + ); +} diff --git a/packages/sandbox-container/tests/runtime/code-transformer.test.ts b/packages/sandbox-container/tests/runtime/code-transformer.test.ts new file mode 100644 index 00000000..88c621ac --- /dev/null +++ b/packages/sandbox-container/tests/runtime/code-transformer.test.ts @@ -0,0 +1,379 @@ +import { describe, expect, it } from 'bun:test'; +import { transformForAsyncExecution } from '../../src/runtime/executors/shared/code-transformer'; + +describe('transformForAsyncExecution', () => { + describe('empty code handling', () => { + it('handles empty string', () => { + const result = transformForAsyncExecution(''); + expect(result).toBe('(async () => {})()'); + }); + + it('handles whitespace-only string', () => { + const result = transformForAsyncExecution(' \n\t '); + expect(result).toBe('(async () => {})()'); + }); + }); + + describe('expression statements', () => { + it('returns last expression value for simple expression', () => { + const result = transformForAsyncExecution('42'); + expect(result).toBe('(async () => {\nreturn (42);\n})()'); + }); + + it('returns last expression value for string literal', () => { + const result = transformForAsyncExecution('"hello"'); + expect(result).toBe('(async () => {\nreturn ("hello");\n})()'); + }); + + it('returns function call result', () => { + const result = transformForAsyncExecution('Math.max(1, 2)'); + expect(result).toBe('(async () => {\nreturn (Math.max(1, 2));\n})()'); + }); + }); + + describe('variable declaration hoisting', () => { + it('hoists const declaration and returns assignment', () => { + const result = transformForAsyncExecution('const x = 1;'); + expect(result).toBe('let x;\n(async () => {\nreturn (x = 1);\n})()'); + }); + + it('hoists let declaration and returns assignment', () => { + const result = transformForAsyncExecution('let x = 1;'); + expect(result).toBe('let x;\n(async () => {\nreturn (x = 1);\n})()'); + }); + + it('hoists var declaration and returns assignment', () => { + const result = transformForAsyncExecution('var x = 1;'); + expect(result).toBe('let x;\n(async () => {\nreturn (x = 1);\n})()'); + }); + + it('hoists multiple declarations', () => { + const result = transformForAsyncExecution('const x = 1, y = 2;'); + expect(result).toBe( + 'let x, y;\n(async () => {\nreturn (x = 1, y = 2);\n})()' + ); + }); + + it('hoists declaration without initializer', () => { + const result = transformForAsyncExecution('let x;'); + // No assignment, just hoisting - empty IIFE body + expect(result).toBe('let x;\n(async () => {})()'); + }); + + it('hoists declaration followed by expression and returns expression', () => { + const result = transformForAsyncExecution('const x = 1;\nx + 1'); + expect(result).toBe( + 'let x;\n(async () => {\nvoid (x = 1);\nreturn (x + 1);\n})()' + ); + }); + + it('removes trailing semicolon from returned expression', () => { + const result = transformForAsyncExecution('const x = 1;\nx + 1;'); + expect(result).toBe( + 'let x;\n(async () => {\nvoid (x = 1);\nreturn (x + 1);\n})()' + ); + }); + }); + + describe('destructuring pattern hoisting', () => { + it('hoists object destructuring', () => { + const result = transformForAsyncExecution('const { a, b } = obj;'); + expect(result).toBe( + 'let a, b;\n(async () => {\nreturn (({ a, b } = obj));\n})()' + ); + }); + + it('hoists array destructuring', () => { + const result = transformForAsyncExecution('const [a, b] = arr;'); + expect(result).toBe( + 'let a, b;\n(async () => {\nreturn (([a, b] = arr));\n})()' + ); + }); + + it('hoists nested destructuring', () => { + const result = transformForAsyncExecution('const { a: { b } } = obj;'); + expect(result).toBe( + 'let b;\n(async () => {\nreturn (({ a: { b } } = obj));\n})()' + ); + }); + + it('hoists rest pattern in object', () => { + const result = transformForAsyncExecution('const { a, ...rest } = obj;'); + expect(result).toBe( + 'let a, rest;\n(async () => {\nreturn (({ a, ...rest } = obj));\n})()' + ); + }); + + it('hoists rest pattern in array', () => { + const result = transformForAsyncExecution( + 'const [first, ...rest] = arr;' + ); + expect(result).toBe( + 'let first, rest;\n(async () => {\nreturn (([first, ...rest] = arr));\n})()' + ); + }); + + it('hoists default value patterns', () => { + const result = transformForAsyncExecution('const { a = 1 } = obj;'); + expect(result).toBe( + 'let a;\n(async () => {\nreturn (({ a = 1 } = obj));\n})()' + ); + }); + }); + + describe('function declaration hoisting', () => { + it('hoists function declaration with var', () => { + const result = transformForAsyncExecution('function foo() { return 1; }'); + expect(result).toBe( + 'var foo;\n(async () => {\nreturn (foo = function foo() { return 1; });\n})()' + ); + }); + + it('hoists function followed by call', () => { + const result = transformForAsyncExecution( + 'function add(a, b) { return a + b; }\nadd(1, 2)' + ); + expect(result).toContain('var add;'); + expect(result).toContain( + 'void (add = function add(a, b) { return a + b; })' + ); + expect(result).toContain('return (add(1, 2))'); + }); + }); + + describe('class declaration hoisting', () => { + it('hoists class declaration', () => { + const result = transformForAsyncExecution('class Foo {}'); + expect(result).toBe( + 'let Foo;\n(async () => {\nreturn (Foo = class Foo {});\n})()' + ); + }); + + it('hoists class with methods', () => { + const result = transformForAsyncExecution( + 'class Foo { bar() { return 1; } }' + ); + expect(result).toContain('let Foo;'); + expect(result).toContain('Foo = class Foo { bar() { return 1; } }'); + }); + }); + + describe('await expressions', () => { + it('supports top-level await with hoisting', () => { + const result = transformForAsyncExecution('await Promise.resolve(42)'); + expect(result).toBe( + '(async () => {\nreturn (await Promise.resolve(42));\n})()' + ); + }); + + it('supports await in variable assignment with hoisting', () => { + const result = transformForAsyncExecution( + 'const result = await fetch("/api");\nresult' + ); + expect(result).toBe( + 'let result;\n(async () => {\nvoid (result = await fetch("/api"));\nreturn (result);\n})()' + ); + }); + + it('supports multiple await expressions with hoisting', () => { + const result = transformForAsyncExecution( + 'const a = await foo();\nconst b = await bar();\na + b' + ); + expect(result).toBe( + 'let a, b;\n(async () => {\nvoid (a = await foo());\nvoid (b = await bar());\nreturn (a + b);\n})()' + ); + }); + + it('supports await followed by expression', () => { + const result = transformForAsyncExecution('await delay(100);\n"done"'); + expect(result).toBe( + '(async () => {\nawait delay(100);\nreturn ("done");\n})()' + ); + }); + + it('supports await expression standalone (with semicolon)', () => { + const result = transformForAsyncExecution('await delay(100);'); + expect(result).toBe('(async () => {\nreturn (await delay(100));\n})()'); + }); + }); + + describe('complex statements', () => { + it('preserves if statement', () => { + const result = transformForAsyncExecution( + 'if (true) { console.log("yes"); }' + ); + expect(result).toBe( + '(async () => {\nif (true) { console.log("yes"); };\n})()' + ); + }); + + it('preserves for loop', () => { + const result = transformForAsyncExecution( + 'for (let i = 0; i < 3; i++) {}' + ); + expect(result).toBe( + '(async () => {\nfor (let i = 0; i < 3; i++) {};\n})()' + ); + }); + + it('handles multiline code with declarations and final expression', () => { + const code = ` +const x = 1; +const y = 2; +x + y + `.trim(); + const result = transformForAsyncExecution(code); + expect(result).toContain('let x, y;'); + expect(result).toContain('void (x = 1)'); + expect(result).toContain('void (y = 2)'); + expect(result).toContain('return (x + y)'); + }); + }); + + describe('error handling', () => { + it('wraps invalid syntax and lets runtime report error', () => { + // Invalid JavaScript syntax + const result = transformForAsyncExecution('const x = {'); + expect(result).toBe('(async () => {\nconst x = {\n})()'); + }); + }); + + describe('real-world LLM code patterns', () => { + it('handles typical LLM-generated fetch pattern with hoisting', () => { + const code = ` +const response = await fetch('https://api.example.com/data'); +const data = await response.json(); +data + `.trim(); + const result = transformForAsyncExecution(code); + expect(result).toContain('let response, data;'); + expect(result).toContain( + "void (response = await fetch('https://api.example.com/data'))" + ); + expect(result).toContain('void (data = await response.json())'); + expect(result).toContain('return (data)'); + }); + + it('handles async/await with error handling', () => { + const code = ` +try { + const result = await doSomething(); + console.log(result); +} catch (e) { + console.error(e); +} + `.trim(); + const result = transformForAsyncExecution(code); + // try/catch is passed through as-is + expect(result).toContain('try {'); + expect(result).toContain('catch (e)'); + }); + }); + + describe('variable persistence across executions (REPL semantics)', () => { + it('transforms code so variables are accessible in outer scope', () => { + // This test verifies the transformation pattern that enables persistence + const result = transformForAsyncExecution('const value = 42;'); + + // Variable should be declared outside the IIFE + expect(result).toContain('let value;'); + + // The IIFE should contain an assignment, not a declaration + expect(result).not.toContain('const value'); + expect(result).toContain('value = 42'); + }); + + it('hoists variables from await expressions for persistence', () => { + const result = transformForAsyncExecution( + 'const result = await Promise.resolve(42);' + ); + + // Variable should be declared outside the IIFE + expect(result).toContain('let result;'); + + // Assignment should be inside the IIFE + expect(result).toContain('result = await Promise.resolve(42)'); + }); + }); + + describe('integration: variables persist in vm.Context', () => { + // These tests verify the transformed code actually works in a real vm context + const vm = require('node:vm'); + + it('variables persist across multiple executions', async () => { + const context = vm.createContext({}); + + // First execution: declare and assign variable + const code1 = transformForAsyncExecution('const x = 42;'); + await vm.runInContext(code1, context); + + // Second execution: access the variable + const code2 = transformForAsyncExecution('x + 1'); + const result = await vm.runInContext(code2, context); + + expect(result).toBe(43); + }); + + it('variables with await persist across executions', async () => { + const context = vm.createContext({ Promise }); + + // First execution: declare variable with await + const code1 = transformForAsyncExecution( + 'const result = await Promise.resolve(100);' + ); + await vm.runInContext(code1, context); + + // Second execution: access the variable + const code2 = transformForAsyncExecution('result * 2'); + const result = await vm.runInContext(code2, context); + + expect(result).toBe(200); + }); + + it('multiple variables persist independently', async () => { + const context = vm.createContext({ Promise }); + + // First execution: declare multiple variables + const code1 = transformForAsyncExecution('const a = 1; const b = 2;'); + await vm.runInContext(code1, context); + + // Second execution: use both variables + const code2 = transformForAsyncExecution('a + b'); + const result = await vm.runInContext(code2, context); + + expect(result).toBe(3); + }); + + it('function declarations persist and can be called', async () => { + const context = vm.createContext({}); + + // First execution: declare function + const code1 = transformForAsyncExecution( + 'function add(a, b) { return a + b; }' + ); + await vm.runInContext(code1, context); + + // Second execution: call the function + const code2 = transformForAsyncExecution('add(10, 20)'); + const result = await vm.runInContext(code2, context); + + expect(result).toBe(30); + }); + + it('destructured variables persist', async () => { + const context = vm.createContext({}); + + // First execution: destructure object + const code1 = transformForAsyncExecution( + 'const obj = { x: 1, y: 2 }; const { x, y } = obj;' + ); + await vm.runInContext(code1, context); + + // Second execution: access destructured variables + const code2 = transformForAsyncExecution('x + y'); + const result = await vm.runInContext(code2, context); + + expect(result).toBe(3); + }); + }); +}); diff --git a/packages/sandbox-container/tests/runtime/thenable.test.ts b/packages/sandbox-container/tests/runtime/thenable.test.ts new file mode 100644 index 00000000..3798b09e --- /dev/null +++ b/packages/sandbox-container/tests/runtime/thenable.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'bun:test'; +import { isThenable } from '../../src/runtime/executors/shared/thenable'; + +describe('isThenable', () => { + it('returns true for Promise', () => { + expect(isThenable(Promise.resolve(42))).toBe(true); + }); + + it('returns true for custom thenable', () => { + expect(isThenable({ then: () => {} })).toBe(true); + }); + + it('returns false for non-thenable', () => { + expect(isThenable(null)).toBe(false); + expect(isThenable(42)).toBe(false); + expect(isThenable({ then: 'not a function' })).toBe(false); + }); +}); diff --git a/tests/e2e/code-interpreter-workflow.test.ts b/tests/e2e/code-interpreter-workflow.test.ts index 887a1c5a..80ae0044 100644 --- a/tests/e2e/code-interpreter-workflow.test.ts +++ b/tests/e2e/code-interpreter-workflow.test.ts @@ -368,6 +368,257 @@ describe('Code Interpreter Workflow (E2E)', () => { ); }, 120000); + // ============================================================================ + // Top-Level Await Support (JavaScript) + // ============================================================================ + + test('should execute top-level await without IIFE wrapper', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Create JavaScript context + const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { + method: 'POST', + headers, + body: JSON.stringify({ language: 'javascript' }) + }); + + const context = await ctxResponse.json(); + + // Execute code with top-level await - no IIFE needed! + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: 'const result = await Promise.resolve(42);\nresult', + options: { context } + }) + }); + + expect(execResponse.status).toBe(200); + const execution = (await execResponse.json()) as ExecutionResult; + + expect(execution.error).toBeUndefined(); + expect(execution.results).toBeDefined(); + expect(execution.results![0].text).toContain('42'); + }, 120000); + + test('should return last expression value from async code', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Create JavaScript context + const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { + method: 'POST', + headers, + body: JSON.stringify({ language: 'javascript' }) + }); + + const context = await ctxResponse.json(); + + // Execute multiple awaits, return last expression + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: ` +const a = await Promise.resolve(10); +const b = await Promise.resolve(20); +a + b +`.trim(), + options: { context } + }) + }); + + expect(execResponse.status).toBe(200); + const execution = (await execResponse.json()) as ExecutionResult; + + expect(execution.error).toBeUndefined(); + expect(execution.results).toBeDefined(); + expect(execution.results![0].text).toContain('30'); + }, 120000); + + test('should handle async errors in top-level await', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Create JavaScript context + const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { + method: 'POST', + headers, + body: JSON.stringify({ language: 'javascript' }) + }); + + const context = await ctxResponse.json(); + + // Execute code that rejects + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: 'await Promise.reject(new Error("async error"))', + options: { context } + }) + }); + + expect(execResponse.status).toBe(200); + const execution = (await execResponse.json()) as ExecutionResult; + + expect(execution.error).toBeDefined(); + expect( + execution.error!.message || execution.logs.stderr.join('') + ).toContain('async error'); + }, 120000); + + test('should support LLM-generated fetch pattern with top-level await', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Create JavaScript context + const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { + method: 'POST', + headers, + body: JSON.stringify({ language: 'javascript' }) + }); + + const context = await ctxResponse.json(); + + // Simulate typical LLM-generated code pattern using setTimeout as async operation + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: ` +const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); +await delay(10); +const data = { status: 'success', value: 123 }; +data +`.trim(), + options: { context } + }) + }); + + expect(execResponse.status).toBe(200); + const execution = (await execResponse.json()) as ExecutionResult; + + expect(execution.error).toBeUndefined(); + expect(execution.results).toBeDefined(); + // Object results come back as JSON, not text + const result = execution.results![0]; + const resultData = result.json ?? result.text; + expect(resultData).toBeDefined(); + expect(JSON.stringify(resultData)).toContain('success'); + expect(JSON.stringify(resultData)).toContain('123'); + }, 120000); + + test('should persist variables defined with await across executions', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Create JavaScript context + const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { + method: 'POST', + headers, + body: JSON.stringify({ language: 'javascript' }) + }); + + const context = await ctxResponse.json(); + + // First execution: define variable with await + const exec1Response = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: 'const result = await Promise.resolve(42);', + options: { context } + }) + }); + + expect(exec1Response.status).toBe(200); + const execution1 = (await exec1Response.json()) as ExecutionResult; + expect(execution1.error).toBeUndefined(); + + // Second execution: access the variable from previous execution + const exec2Response = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: 'result', + options: { context } + }) + }); + + expect(exec2Response.status).toBe(200); + const execution2 = (await exec2Response.json()) as ExecutionResult; + expect(execution2.error).toBeUndefined(); + expect(execution2.results).toBeDefined(); + expect(execution2.results![0].text).toContain('42'); + }, 120000); + + test('should resolve Promise without await keyword', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Create JavaScript context + const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { + method: 'POST', + headers, + body: JSON.stringify({ language: 'javascript' }) + }); + + const context = await ctxResponse.json(); + + // Execute a Promise expression - should resolve automatically + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: 'Promise.resolve(123)', + options: { context } + }) + }); + + expect(execResponse.status).toBe(200); + const execution = (await execResponse.json()) as ExecutionResult; + expect(execution.error).toBeUndefined(); + expect(execution.results).toBeDefined(); + // The Promise should be automatically awaited and return 123 + expect(execution.results![0].text).toContain('123'); + }, 120000); + + test('should support IIFE pattern for backward compatibility', async () => { + currentSandboxId = createSandboxId(); + const headers = createTestHeaders(currentSandboxId); + + // Create JavaScript context + const ctxResponse = await fetch(`${workerUrl}/api/code/context/create`, { + method: 'POST', + headers, + body: JSON.stringify({ language: 'javascript' }) + }); + + const context = await ctxResponse.json(); + + // Execute code with IIFE pattern + const execResponse = await fetch(`${workerUrl}/api/code/execute`, { + method: 'POST', + headers, + body: JSON.stringify({ + code: `(async () => { + const value = await Promise.resolve('hello'); + return value + ' world'; +})()`, + options: { context } + }) + }); + + expect(execResponse.status).toBe(200); + const execution = (await execResponse.json()) as ExecutionResult; + expect(execution.error).toBeUndefined(); + expect(execution.results).toBeDefined(); + expect(execution.results![0].text).toContain('hello world'); + }, 120000); + // ============================================================================ // Streaming Execution // ============================================================================ @@ -598,13 +849,14 @@ console.log('Sum:', sum); contexts.push(context); } - // Set unique state in each context using mutable global + // Set unique state in each context using normal variable declarations + // With variable hoisting, const/let declarations persist across executions for (let i = 0; i < contexts.length; i++) { const execResponse = await fetch(`${workerUrl}/api/code/execute`, { method: 'POST', headers, body: JSON.stringify({ - code: `globalThis.contextValue = ${i}; contextValue;`, + code: `const contextValue = ${i}; contextValue;`, options: { context: contexts[i] } }) }); @@ -668,7 +920,8 @@ console.log('Sum:', sum); } // Execute different code in each context concurrently - // Each context sets its own unique value + // Each context sets its own unique value using normal variable declarations + // With variable hoisting, const/let declarations persist across executions const executionPromises = contexts.map((context, i) => fetch(`${workerUrl}/api/code/execute`, { method: 'POST', @@ -745,7 +998,8 @@ console.log('Sum:', sum); expect(ctxResponse.status).toBe(200); const context = (await ctxResponse.json()) as CodeContext; - // Set up initial state with a counter variable + // Set up initial state with a counter variable using normal declaration + // With variable hoisting, let/var declarations persist across executions const setupResponse = await fetch(`${workerUrl}/api/code/execute`, { method: 'POST', headers,