From 167d0523421134596ce44fbd1c3df3e452d4e09e Mon Sep 17 00:00:00 2001 From: Sriket Komali Date: Sat, 2 May 2026 20:15:25 -0400 Subject: [PATCH 1/3] fix(ai-isolate-cloudflare): port worker from unsafe_eval to worker_loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare gates the `unsafe_eval` binding for all customer prod accounts (no public entitlement); the previous driver was unusable in production and broken in `wrangler dev` on current Wrangler 4.x. Swap `env.UNSAFE_EVAL.eval(code)` for the supported `worker_loader` (Dynamic Workers) binding — load the wrapped code as an ES module into a fresh child Worker isolate via `env.LOADER.load({...}).getEntrypoint() .fetch(...)` and read the structured result back as JSON. The HTTP tool-callback protocol, driver, and public API are unchanged. ~120 LOC change in worker; tests + wrangler.toml + README updated. Workers Paid plan is required for any edge usage (deploy or `wrangler dev --remote`); local `wrangler dev` works on the Free plan. The custom Miniflare `dev-server.mjs` is removed since `wrangler dev` now binds `worker_loader` natively. Closes #522. --- .changeset/worker-loader-port.md | 24 ++++ .../ai-isolate-cloudflare/README.md | 24 ++-- .../ai-isolate-cloudflare/dev-server.mjs | 49 ------- .../ai-isolate-cloudflare/package.json | 2 +- .../ai-isolate-cloudflare/src/worker/index.ts | 131 +++++++++++++----- .../src/worker/wrap-code.ts | 2 +- .../tests/escape-attempts.test.ts | 6 +- .../tests/isolate-driver.test.ts | 8 +- .../tests/worker.test.ts | 25 +++- .../ai-isolate-cloudflare/wrangler.toml | 24 ++-- 10 files changed, 173 insertions(+), 122 deletions(-) create mode 100644 .changeset/worker-loader-port.md delete mode 100644 packages/typescript/ai-isolate-cloudflare/dev-server.mjs diff --git a/.changeset/worker-loader-port.md b/.changeset/worker-loader-port.md new file mode 100644 index 000000000..6f7339a5f --- /dev/null +++ b/.changeset/worker-loader-port.md @@ -0,0 +1,24 @@ +--- +'@tanstack/ai-isolate-cloudflare': minor +--- + +Port the Cloudflare worker driver from `unsafe_eval` to `worker_loader` (Dynamic Workers). + +Cloudflare gates the `unsafe_eval` binding for all customer prod accounts; the previous driver was unusable in production and broken in `wrangler dev` on current Wrangler 4.x. The supported replacement is the `worker_loader` binding (GA-beta'd 2026-03-24). + +**Breaking:** the worker now requires the `LOADER` binding instead of `UNSAFE_EVAL`. Update your `wrangler.toml`: + +```toml +# before +[[unsafe.bindings]] +name = "UNSAFE_EVAL" +type = "unsafe_eval" + +# after +[[worker_loaders]] +binding = "LOADER" +``` + +The HTTP tool-callback protocol and public driver API are unchanged. Workers Paid plan is required for any edge usage (deploy or `wrangler dev --remote`); local `wrangler dev` works on the Free plan. + +Closes #522. diff --git a/packages/typescript/ai-isolate-cloudflare/README.md b/packages/typescript/ai-isolate-cloudflare/README.md index aeb922a7a..7fbb8da3f 100644 --- a/packages/typescript/ai-isolate-cloudflare/README.md +++ b/packages/typescript/ai-isolate-cloudflare/README.md @@ -12,9 +12,9 @@ pnpm add @tanstack/ai-isolate-cloudflare ## Environment Guidance -- **Local development:** supported with the package's Miniflare dev server (`pnpm dev:worker`) -- **Remote dev:** supported with `wrangler dev --remote` -- **Production:** supported on Cloudflare accounts with the `unsafe_eval` binding enabled. Before rollout, put the Worker behind authentication (e.g. Cloudflare Access or the `authorization` driver option), rate limiting, and CORS restrictions — running LLM-authored code is a high-trust operation. +- **Local development:** supported with `wrangler dev` (the `worker_loader` binding works in local workerd on the Workers Free plan). +- **Remote dev:** supported with `wrangler dev --remote` on a Workers Paid plan. +- **Production:** supported on Cloudflare accounts on the Workers Paid plan ($5/mo). The Free plan rejects `worker_loader` deploys at the API level. Before rollout, put the Worker behind authentication (e.g. Cloudflare Access or the `authorization` driver option), rate limiting, and CORS restrictions — running LLM-authored code is a high-trust operation. If you want a self-contained host without Cloudflare infrastructure, prefer `@tanstack/ai-isolate-node` or `@tanstack/ai-isolate-quickjs`. @@ -60,31 +60,31 @@ const result = await chat({ ## Worker Setup -### Option 1: Local Miniflare server +### Option 1: Local dev with `wrangler dev` From this package directory: ```bash -pnpm dev:worker +wrangler dev ``` -This starts a local Worker endpoint (default `http://localhost:8787`) with the `UNSAFE_EVAL` binding configured in `wrangler.toml`. +This starts a local Worker endpoint (default `http://localhost:8787`) with the `worker_loader` binding from `wrangler.toml`. Local workerd accepts the binding on the Workers Free plan, so no upgrade is needed for inner-loop iteration. -### Option 3: Production deployment +### Option 2: Wrangler remote dev ```bash -wrangler deploy +wrangler dev --remote ``` -The same `wrangler.toml` `[[unsafe.bindings]]` configuration applies in production. Deploying requires that your Cloudflare account has `unsafe_eval` enabled; without it, the Worker returns an `UnsafeEvalNotAvailable` error. Because this Worker executes LLM-generated code, only deploy it behind authentication, rate limiting, and an allow-listed origin. +Runs through Cloudflare's network for validation against the hosted runtime. Requires a Workers Paid plan because `worker_loader` is gated to Paid for any edge usage. -### Option 2: Wrangler remote dev +### Option 3: Production deployment ```bash -wrangler dev --remote +wrangler deploy ``` -This runs through Cloudflare's network and can be useful when validating behavior against the hosted runtime. +Requires a Workers Paid plan. The Free plan rejects deploys with `worker_loader` (`code: 10195` — "In order to use Dynamic Workers, you must switch to a paid plan."). Without the binding, the Worker returns a `WorkerLoaderNotAvailable` error. Because this Worker executes LLM-generated code, only deploy it behind authentication, rate limiting, and an allow-listed origin. ## API diff --git a/packages/typescript/ai-isolate-cloudflare/dev-server.mjs b/packages/typescript/ai-isolate-cloudflare/dev-server.mjs deleted file mode 100644 index eec0a4463..000000000 --- a/packages/typescript/ai-isolate-cloudflare/dev-server.mjs +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Local dev server using miniflare directly. - * - * wrangler dev does NOT translate [[unsafe.bindings]] type = "unsafe_eval" - * into miniflare's unsafeEvalBinding option, so the binding is always - * undefined in local dev. This script bundles the Worker with esbuild - * and runs it via miniflare with unsafeEvalBinding configured correctly. - * - * Usage: node dev-server.mjs [--port 8787] - */ - -import { Miniflare } from 'miniflare' -import { build } from 'esbuild' -import { resolve, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' - -const __dirname = dirname(fileURLToPath(import.meta.url)) -const ENTRY = resolve(__dirname, 'src/worker/index.ts') -const PORT = Number(process.env.PORT) || 8787 - -const result = await build({ - entryPoints: [ENTRY], - bundle: true, - format: 'esm', - target: 'esnext', - write: false, -}) - -const mf = new Miniflare({ - modules: [ - { - type: 'ESModule', - path: 'worker.js', - contents: result.outputFiles[0].text, - }, - ], - unsafeEvalBinding: 'UNSAFE_EVAL', - compatibilityDate: '2024-12-01', - compatibilityFlags: ['nodejs_compat'], - port: PORT, -}) - -const url = await mf.ready -console.log(`Worker ready on ${url}`) - -process.on('SIGINT', async () => { - await mf.dispose() - process.exit(0) -}) diff --git a/packages/typescript/ai-isolate-cloudflare/package.json b/packages/typescript/ai-isolate-cloudflare/package.json index 55b619181..90cbc57d0 100644 --- a/packages/typescript/ai-isolate-cloudflare/package.json +++ b/packages/typescript/ai-isolate-cloudflare/package.json @@ -35,7 +35,7 @@ "scripts": { "build": "vite build", "clean": "premove ./build ./dist", - "dev:worker": "node dev-server.mjs", + "dev:worker": "wrangler dev", "deploy:worker": "wrangler deploy", "lint:fix": "eslint ./src --fix", "test:build": "publint --strict", diff --git a/packages/typescript/ai-isolate-cloudflare/src/worker/index.ts b/packages/typescript/ai-isolate-cloudflare/src/worker/index.ts index 2e6d4ff79..ffe543cf6 100644 --- a/packages/typescript/ai-isolate-cloudflare/src/worker/index.ts +++ b/packages/typescript/ai-isolate-cloudflare/src/worker/index.ts @@ -1,41 +1,88 @@ /** * Cloudflare Worker for Code Mode execution * - * This Worker executes JavaScript code in a V8 isolate on Cloudflare's edge network. - * Tool calls are handled via a request/response loop with the driver. + * Executes JavaScript code in a fresh V8 isolate on Cloudflare's edge network + * using the `worker_loader` (Dynamic Workers) binding. Tool calls round-trip + * to the driver via the same request/response protocol as before. * * Flow: * 1. Receive code + tool schemas - * 2. Execute code, collecting any tool calls - * 3. If tool calls are needed, return them to the driver - * 4. Driver executes tools locally, sends results back - * 5. Re-execute with tool results injected - * 6. Return final result + * 2. Wrap user code in an ES module exporting a `fetch` handler that returns + * the IIFE result as JSON + * 3. Load the module into a child Worker via `env.LOADER.load(...)` and + * invoke its entrypoint + * 4. If tool calls are needed, return them to the driver + * 5. Driver executes tools locally, sends results back + * 6. Re-execute with tool results injected + * 7. Return final result + * + * `worker_loader` replaces the previous `unsafe_eval` binding, which is gated + * by Cloudflare for all customer accounts and unusable in production. See + * https://developers.cloudflare.com/dynamic-workers/ for the supported API. */ import { wrapCode } from './wrap-code' import type { ExecuteRequest, ExecuteResponse, ToolCallRequest } from '../types' /** - * UnsafeEval binding type. + * Compatibility date for the loaded child Worker. Pinned at this layer so + * sandbox semantics don't drift with the parent Worker's compat date. + */ +const SANDBOX_COMPAT_DATE = '2026-05-01' + +/** + * Worker Loader binding type. * - * Provides dynamic-code execution against the Worker's V8 isolate. Available - * locally (via wrangler dev) and in production deployments where the - * `unsafe_eval` binding has been enabled on the Cloudflare account. + * Provides dynamic-code execution by loading a module into a fresh V8 + * isolate. Configure in wrangler.toml under `[[worker_loaders]]`. Requires a + * Workers Paid plan; see https://developers.cloudflare.com/dynamic-workers/. */ -interface UnsafeEval { - eval: (code: string) => unknown +interface WorkerLoaderEntrypoint { + fetch: (request: Request) => Promise +} + +interface LoadedWorker { + getEntrypoint: (name?: string) => WorkerLoaderEntrypoint +} + +interface WorkerLoader { + load: (options: { + compatibilityDate: string + mainModule: string + modules: Record + globalOutbound?: unknown + env?: Record + }) => LoadedWorker } interface Env { /** - * UnsafeEval binding. Configured in wrangler.toml as an unsafe binding. + * worker_loader (Dynamic Workers) binding. Configured in wrangler.toml + * under `[[worker_loaders]] binding = "LOADER"`. */ - UNSAFE_EVAL?: UnsafeEval + LOADER?: WorkerLoader } /** - * Execute code in the Worker's V8 isolate + * Wrap the existing IIFE-returning string in an ES module that exposes a + * `fetch` handler. The child Worker's entrypoint runs the IIFE on each + * invocation and returns the structured result as JSON. + */ +function wrapAsSandboxModule(wrappedCode: string): string { + return ` +export default { + async fetch() { + const __result = await ${wrappedCode}; + return new Response(JSON.stringify(__result), { + headers: { 'Content-Type': 'application/json' }, + }); + } +}; +` +} + +/** + * Execute code in a freshly loaded child Worker isolate. */ async function executeCode( request: ExecuteRequest, @@ -43,41 +90,55 @@ async function executeCode( ): Promise { const { code, tools, toolResults, timeout = 30000 } = request - // Check if UNSAFE_EVAL binding is available - if (!env.UNSAFE_EVAL) { + if (!env.LOADER) { return { status: 'error', error: { - name: 'UnsafeEvalNotAvailable', + name: 'WorkerLoaderNotAvailable', message: - 'UNSAFE_EVAL binding is not available. ' + - 'This Worker requires the unsafe_eval binding. ' + - 'Declare it in wrangler.toml under [[unsafe.bindings]] ' + - '(works for local development and production where the ' + - 'account has unsafe_eval enabled).', + 'LOADER binding is not available. ' + + 'This Worker requires the worker_loader (Dynamic Workers) binding. ' + + 'Declare it in wrangler.toml under [[worker_loaders]] with ' + + 'binding = "LOADER" (Workers Paid plan required).', }, } } try { const wrappedCode = wrapCode(code, tools, toolResults) + const moduleSource = wrapAsSandboxModule(wrappedCode) - // Execute with timeout - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), timeout) + let timeoutId: ReturnType | undefined + const TIMEOUT_SENTINEL = '__SANDBOX_TIMEOUT__' + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(TIMEOUT_SENTINEL)) + }, timeout) + }) try { - // Execute the wrapped code through the UNSAFE_EVAL binding. - const result = (await env.UNSAFE_EVAL.eval(wrappedCode)) as { + const loaded = env.LOADER.load({ + compatibilityDate: SANDBOX_COMPAT_DATE, + mainModule: 'main.js', + modules: { 'main.js': moduleSource }, + globalOutbound: null, + env: {}, + }) + const entrypoint = loaded.getEntrypoint() + const fetchPromise = entrypoint.fetch( + new Request('https://sandbox.invalid/'), + ) + const response = await Promise.race([fetchPromise, timeoutPromise]) + if (timeoutId) clearTimeout(timeoutId) + + const result: { status: string success?: boolean value?: unknown error?: { name: string; message: string; stack?: string } logs: Array toolCalls?: Array - } - - clearTimeout(timeoutId) + } = await response.json() if (result.status === 'need_tools') { return { @@ -96,9 +157,10 @@ async function executeCode( logs: result.logs, } } catch (evalError: unknown) { - clearTimeout(timeoutId) + if (timeoutId) clearTimeout(timeoutId) + const error = evalError as Error - if (controller.signal.aborted) { + if (error.message === TIMEOUT_SENTINEL) { return { status: 'error', error: { @@ -108,7 +170,6 @@ async function executeCode( } } - const error = evalError as Error return { status: 'done', success: false, diff --git a/packages/typescript/ai-isolate-cloudflare/src/worker/wrap-code.ts b/packages/typescript/ai-isolate-cloudflare/src/worker/wrap-code.ts index 72aafb6c8..3a711058e 100644 --- a/packages/typescript/ai-isolate-cloudflare/src/worker/wrap-code.ts +++ b/packages/typescript/ai-isolate-cloudflare/src/worker/wrap-code.ts @@ -1,6 +1,6 @@ /** * Code wrapping utilities for the Cloudflare Worker. - * Extracted for testability without UNSAFE_EVAL. + * Extracted for testability without a live worker_loader binding. */ import type { ToolResultPayload, ToolSchema } from '../types' diff --git a/packages/typescript/ai-isolate-cloudflare/tests/escape-attempts.test.ts b/packages/typescript/ai-isolate-cloudflare/tests/escape-attempts.test.ts index f68ab8109..7cfcfcabe 100644 --- a/packages/typescript/ai-isolate-cloudflare/tests/escape-attempts.test.ts +++ b/packages/typescript/ai-isolate-cloudflare/tests/escape-attempts.test.ts @@ -4,9 +4,9 @@ import type { ToolResultPayload, ToolSchema } from '../src/types' /** * The CF Worker delegates actual sandboxing to Workers' V8 isolate via the - * UNSAFE_EVAL binding, so we can't perform a real escape attempt in Node. What - * we verify here instead is structural — the wrapper must not let user inputs - * break out of their intended quoting/scoping. + * worker_loader binding, so we can't perform a real escape attempt in Node. + * What we verify here instead is structural — the wrapper must not let user + * inputs break out of their intended quoting/scoping. */ const benignTool: ToolSchema = { diff --git a/packages/typescript/ai-isolate-cloudflare/tests/isolate-driver.test.ts b/packages/typescript/ai-isolate-cloudflare/tests/isolate-driver.test.ts index 42e2dc2d5..848f0244d 100644 --- a/packages/typescript/ai-isolate-cloudflare/tests/isolate-driver.test.ts +++ b/packages/typescript/ai-isolate-cloudflare/tests/isolate-driver.test.ts @@ -424,8 +424,8 @@ describe('createCloudflareIsolateDriver', () => { ({ status: 'error', error: { - name: 'UnsafeEvalNotAvailable', - message: 'UNSAFE_EVAL binding is not available', + name: 'WorkerLoaderNotAvailable', + message: 'LOADER binding is not available', }, }) as ExecuteResponse, }) @@ -436,8 +436,8 @@ describe('createCloudflareIsolateDriver', () => { const result = await context.execute('return 1') expect(result.success).toBe(false) - expect(result.error?.name).toBe('UnsafeEvalNotAvailable') - expect(result.error?.message).toContain('UNSAFE_EVAL') + expect(result.error?.name).toBe('WorkerLoaderNotAvailable') + expect(result.error?.message).toContain('LOADER') }) it('returns error when Worker returns status: done with success: false', async () => { diff --git a/packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts b/packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts index 27534e1b9..99e18ed26 100644 --- a/packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts +++ b/packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts @@ -6,7 +6,21 @@ import workerModule from '../src/worker/index' const worker = workerModule as { fetch: ( request: Request, - env: { UNSAFE_EVAL?: { eval: (code: string) => unknown } }, + env: { + LOADER?: { + load: (options: { + compatibilityDate: string + mainModule: string + modules: Record + globalOutbound?: unknown + env?: Record + }) => { + getEntrypoint: (name?: string) => { + fetch: (request: Request) => Promise + } + } + } + }, ctx: ExecutionContext, ) => Promise } @@ -174,7 +188,7 @@ describe('Worker fetch handler', () => { expect(json).toHaveProperty('error', 'Code is required') }) - it('returns 200 with UnsafeEvalNotAvailable when env has no UNSAFE_EVAL', async () => { + it('returns 200 with WorkerLoaderNotAvailable when env has no LOADER', async () => { const request = new Request('https://worker.test/', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -188,11 +202,10 @@ describe('Worker fetch handler', () => { expect(response.status).toBe(200) const json = await response.json() expect(json.status).toBe('error') - expect(json.error.name).toBe('UnsafeEvalNotAvailable') - expect(json.error.message).toContain('UNSAFE_EVAL') + expect(json.error.name).toBe('WorkerLoaderNotAvailable') + expect(json.error.message).toContain('LOADER') + expect(json.error.message).toContain('worker_loaders') expect(json.error.message).toContain('wrangler.toml') - // No longer steers users to Workers for Platforms - expect(json.error.message).not.toContain('Workers for Platforms') }) it('returns 500 with RequestError when body is invalid JSON', async () => { diff --git a/packages/typescript/ai-isolate-cloudflare/wrangler.toml b/packages/typescript/ai-isolate-cloudflare/wrangler.toml index 39dab3696..87752bad8 100644 --- a/packages/typescript/ai-isolate-cloudflare/wrangler.toml +++ b/packages/typescript/ai-isolate-cloudflare/wrangler.toml @@ -1,22 +1,24 @@ #:schema node_modules/wrangler/config-schema.json # Cloudflare Worker configuration for Code Mode execution -# Run locally: pnpm dev:worker (or wrangler dev) -# Deploy: pnpm deploy:worker (or wrangler deploy) +# Run locally: wrangler dev (Dynamic Workers binding works in local workerd +# on the Free plan; deploy/--remote require Paid) +# Deploy: wrangler deploy name = "tanstack-ai-code-mode" main = "src/worker/index.ts" -compatibility_date = "2024-12-01" +compatibility_date = "2026-05-01" compatibility_flags = ["nodejs_compat"] -# UnsafeEval binding - enables dynamic code execution inside the Worker's V8 isolate. -# Works in both local dev (wrangler dev) and production deployments where the -# Cloudflare account has the unsafe_eval binding enabled. Because this lets the -# Worker evaluate arbitrary JavaScript, protect the Worker's public endpoint -# with authentication and rate limiting before deploying. -[[unsafe.bindings]] -name = "UNSAFE_EVAL" -type = "unsafe_eval" +# worker_loader (Dynamic Workers) binding — loads LLM-generated code into a +# fresh V8 isolate via env.LOADER.load(...). Replaces the previous +# `unsafe_eval` binding, which Cloudflare gates for all customer prod +# deploys. Workers Paid plan required to deploy or run --remote; local +# wrangler dev works on the Free plan. Because this Worker evaluates +# arbitrary JavaScript, protect the public endpoint with authentication and +# rate limiting before deploying. +[[worker_loaders]] +binding = "LOADER" # Local development settings [dev] From 669a2aac3d6bd160bc0481b977d2c1ea44113f99 Mon Sep 17 00:00:00 2001 From: Sriket Komali Date: Sat, 2 May 2026 20:29:50 -0400 Subject: [PATCH 2/3] fix(ai-isolate-cloudflare): cancel in-flight fetch on timeout + happy-path tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address CodeRabbit review on #523: 1. Promise.race timeout left `entrypoint.fetch` running, leaking the loaded child Worker isolate. Add an AbortController whose signal flows into the Request passed to entrypoint.fetch — the timeout now actually cancels the in-flight request. Promise.race remains as a belt-and-suspenders guard. 2. Add three integration tests against a mocked LOADER binding: - happy path: full load → getEntrypoint → fetch chain, asserts the load() arguments (mainModule, modules, globalOutbound) and that the Request carries an AbortSignal - need_tools: forwards toolCalls + continuationId from sandbox - TimeoutError: AbortSignal-driven cancellation triggers the right error shape Tests: 39/39 pass. --- .../ai-isolate-cloudflare/src/worker/index.ts | 8 +- .../tests/worker.test.ts | 127 ++++++++++++++++++ 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/packages/typescript/ai-isolate-cloudflare/src/worker/index.ts b/packages/typescript/ai-isolate-cloudflare/src/worker/index.ts index ffe543cf6..fa795923f 100644 --- a/packages/typescript/ai-isolate-cloudflare/src/worker/index.ts +++ b/packages/typescript/ai-isolate-cloudflare/src/worker/index.ts @@ -108,10 +108,16 @@ async function executeCode( const wrappedCode = wrapCode(code, tools, toolResults) const moduleSource = wrapAsSandboxModule(wrappedCode) + // AbortController propagates into the loaded Worker via Request.signal so + // a timeout actually cancels the in-flight fetch instead of leaking the + // child isolate. The Promise.race remains as a belt-and-suspenders guard + // for runtimes that ignore the signal. + const controller = new AbortController() let timeoutId: ReturnType | undefined const TIMEOUT_SENTINEL = '__SANDBOX_TIMEOUT__' const timeoutPromise = new Promise((_resolve, reject) => { timeoutId = setTimeout(() => { + controller.abort() reject(new Error(TIMEOUT_SENTINEL)) }, timeout) }) @@ -126,7 +132,7 @@ async function executeCode( }) const entrypoint = loaded.getEntrypoint() const fetchPromise = entrypoint.fetch( - new Request('https://sandbox.invalid/'), + new Request('https://sandbox.invalid/', { signal: controller.signal }), ) const response = await Promise.race([fetchPromise, timeoutPromise]) if (timeoutId) clearTimeout(timeoutId) diff --git a/packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts b/packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts index 99e18ed26..1974eaf29 100644 --- a/packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts +++ b/packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts @@ -208,6 +208,133 @@ describe('Worker fetch handler', () => { expect(json.error.message).toContain('wrangler.toml') }) + it('exercises the LOADER.load → getEntrypoint → fetch chain on the happy path', async () => { + let loadCalled = false + let receivedSignal: AbortSignal | null = null + const env = { + LOADER: { + load: (options: { + compatibilityDate: string + mainModule: string + modules: Record + globalOutbound?: unknown + env?: Record + }) => { + loadCalled = true + // Sanity-check the load() arguments the worker passes. + expect(options.mainModule).toBe('main.js') + expect(options.modules).toHaveProperty('main.js') + expect(options.modules['main.js']).toContain('export default') + expect(options.globalOutbound).toBeNull() + return { + getEntrypoint: () => ({ + fetch: async (req: Request) => { + receivedSignal = req.signal + return new Response( + JSON.stringify({ + status: 'done', + success: true, + value: 42, + logs: ['hello from sandbox'], + }), + { headers: { 'Content-Type': 'application/json' } }, + ) + }, + }), + } + }, + }, + } + + const request = new Request('https://worker.test/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: 'return 42', tools: [], timeout: 5000 }), + }) + const response = await worker.fetch(request, env, mockExecutionContext) + + expect(loadCalled).toBe(true) + expect(receivedSignal).not.toBeNull() + expect(response.status).toBe(200) + const json = await response.json() + expect(json.status).toBe('done') + expect(json.success).toBe(true) + expect(json.value).toBe(42) + expect(json.logs).toEqual(['hello from sandbox']) + }) + + it('forwards need_tools status from the loaded Worker back to the driver', async () => { + const env = { + LOADER: { + load: () => ({ + getEntrypoint: () => ({ + fetch: async () => + new Response( + JSON.stringify({ + status: 'need_tools', + toolCalls: [{ id: 'tc_0', name: 'fetchData', args: {} }], + logs: [], + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + }), + }), + }, + } + + const request = new Request('https://worker.test/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code: 'return await fetchData({})', + tools: [{ name: 'fetchData', description: 'd', inputSchema: {} }], + }), + }) + const response = await worker.fetch(request, env, mockExecutionContext) + + expect(response.status).toBe(200) + const json = await response.json() + expect(json.status).toBe('need_tools') + expect(json.toolCalls).toHaveLength(1) + expect(json.toolCalls[0].name).toBe('fetchData') + expect(typeof json.continuationId).toBe('string') + }) + + it('returns TimeoutError when entrypoint.fetch exceeds timeout', async () => { + const env = { + LOADER: { + load: () => ({ + getEntrypoint: () => ({ + fetch: (req: Request) => + new Promise((_resolve, reject) => { + req.signal.addEventListener('abort', () => { + reject(new Error('aborted')) + }) + // Never resolves on its own; relies on AbortSignal. + }), + }), + }), + }, + } + + const request = new Request('https://worker.test/', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + code: 'while(true){}', + tools: [], + timeout: 50, + }), + }) + const response = await worker.fetch(request, env, mockExecutionContext) + + expect(response.status).toBe(200) + const json = await response.json() + expect(json.status).toBe('error') + expect(json.error.name).toBe('TimeoutError') + expect(json.error.message).toContain('50ms') + }) + it('returns 500 with RequestError when body is invalid JSON', async () => { const request = new Request('https://worker.test/', { method: 'POST', From b2ce85fe432f898afdae4e1f473701aa81d8631c Mon Sep 17 00:00:00 2001 From: Sriket Komali Date: Sat, 2 May 2026 20:52:40 -0400 Subject: [PATCH 3/3] fix(ai-isolate-cloudflare): tighten timeout test + happy-path assertions Address CodeRabbit second-pass review: 1. happy-path test: hoist load() argument assertions out of the synchronous mock. Inside load() they get swallowed by the worker's outer try/catch and surface as a generic 500. Capture options into a local + assert after worker.fetch() resolves. 2. timeout test: `expect(receivedSignal).not.toBeNull()` is trivially true per the Fetch spec (Request.signal is always present). Drop it from the happy-path test and instead assert `signal.aborted === true` in the timeout test, which actually proves the outer worker's AbortController fired. 3. worker fix: when the AbortController fires first, fetchPromise rejects before timeoutPromise. Detect the timeout via either TIMEOUT_SENTINEL or `controller.signal.aborted` so the right error surfaces regardless of which branch of the race wins. Tests: 39/39 pass. --- .../ai-isolate-cloudflare/src/worker/index.ts | 5 +- .../tests/worker.test.ts | 64 +++++++++++-------- 2 files changed, 42 insertions(+), 27 deletions(-) diff --git a/packages/typescript/ai-isolate-cloudflare/src/worker/index.ts b/packages/typescript/ai-isolate-cloudflare/src/worker/index.ts index fa795923f..073b89252 100644 --- a/packages/typescript/ai-isolate-cloudflare/src/worker/index.ts +++ b/packages/typescript/ai-isolate-cloudflare/src/worker/index.ts @@ -166,7 +166,10 @@ async function executeCode( if (timeoutId) clearTimeout(timeoutId) const error = evalError as Error - if (error.message === TIMEOUT_SENTINEL) { + // Either branch of the Promise.race may win on timeout: timeoutPromise + // rejects with TIMEOUT_SENTINEL, while the AbortController.abort() call + // can race-reject the in-flight fetch first. Treat both as a timeout. + if (error.message === TIMEOUT_SENTINEL || controller.signal.aborted) { return { status: 'error', error: { diff --git a/packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts b/packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts index 1974eaf29..1b692ef63 100644 --- a/packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts +++ b/packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts @@ -209,37 +209,37 @@ describe('Worker fetch handler', () => { }) it('exercises the LOADER.load → getEntrypoint → fetch chain on the happy path', async () => { + // Capture mock state for post-fetch assertions. Asserting inside the + // synchronous `load()` mock would be swallowed by the outer worker's + // try/catch and surface as a generic 500, masking the real failure. let loadCalled = false - let receivedSignal: AbortSignal | null = null + type LoadOptions = { + compatibilityDate: string + mainModule: string + modules: Record + globalOutbound?: unknown + env?: Record + } + let capturedOptions: LoadOptions | null = null const env = { LOADER: { - load: (options: { - compatibilityDate: string - mainModule: string - modules: Record - globalOutbound?: unknown - env?: Record - }) => { + load: (options: LoadOptions) => { loadCalled = true - // Sanity-check the load() arguments the worker passes. - expect(options.mainModule).toBe('main.js') - expect(options.modules).toHaveProperty('main.js') - expect(options.modules['main.js']).toContain('export default') - expect(options.globalOutbound).toBeNull() + capturedOptions = options return { getEntrypoint: () => ({ - fetch: async (req: Request) => { - receivedSignal = req.signal - return new Response( - JSON.stringify({ - status: 'done', - success: true, - value: 42, - logs: ['hello from sandbox'], - }), - { headers: { 'Content-Type': 'application/json' } }, - ) - }, + fetch: (_req: Request) => + Promise.resolve( + new Response( + JSON.stringify({ + status: 'done', + success: true, + value: 42, + logs: ['hello from sandbox'], + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + ), }), } }, @@ -254,7 +254,11 @@ describe('Worker fetch handler', () => { const response = await worker.fetch(request, env, mockExecutionContext) expect(loadCalled).toBe(true) - expect(receivedSignal).not.toBeNull() + expect(capturedOptions).not.toBeNull() + expect(capturedOptions!.mainModule).toBe('main.js') + expect(capturedOptions!.modules).toHaveProperty('main.js') + expect(capturedOptions!.modules['main.js']).toContain('export default') + expect(capturedOptions!.globalOutbound).toBeNull() expect(response.status).toBe(200) const json = await response.json() expect(json.status).toBe('done') @@ -301,12 +305,18 @@ describe('Worker fetch handler', () => { }) it('returns TimeoutError when entrypoint.fetch exceeds timeout', async () => { + // Capture the AbortSignal seen by the loaded Worker so we can assert that + // the outer worker's AbortController actually fires `abort` on timeout. + // (Request.signal is always non-null per spec, so `not.toBeNull()` would + // be trivially true and prove nothing.) + let receivedSignal: AbortSignal | null = null const env = { LOADER: { load: () => ({ getEntrypoint: () => ({ fetch: (req: Request) => new Promise((_resolve, reject) => { + receivedSignal = req.signal req.signal.addEventListener('abort', () => { reject(new Error('aborted')) }) @@ -327,6 +337,8 @@ describe('Worker fetch handler', () => { }), }) const response = await worker.fetch(request, env, mockExecutionContext) + expect(receivedSignal).not.toBeNull() + expect(receivedSignal!.aborted).toBe(true) expect(response.status).toBe(200) const json = await response.json()