Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .changeset/worker-loader-port.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 12 additions & 12 deletions packages/typescript/ai-isolate-cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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

Expand Down
49 changes: 0 additions & 49 deletions packages/typescript/ai-isolate-cloudflare/dev-server.mjs

This file was deleted.

2 changes: 1 addition & 1 deletion packages/typescript/ai-isolate-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
138 changes: 104 additions & 34 deletions packages/typescript/ai-isolate-cloudflare/src/worker/index.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,150 @@
/**
* 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<Response>
}

interface LoadedWorker {
getEntrypoint: (name?: string) => WorkerLoaderEntrypoint
}

interface WorkerLoader {
load: (options: {
compatibilityDate: string
mainModule: string
modules: Record<string, string>
globalOutbound?: unknown
env?: Record<string, unknown>
}) => 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' },
});
}
Comment on lines +71 to +79
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟑 Minor | ⚑ Quick win

Catch serialization failures inside the child worker.

wrapCode() can produce value: unknown, but this path assumes the full result is JSON-serializable. Values like BigInt or circular objects will throw here, outside the wrapped IIFE, so the parent only sees an opaque fetch/JSON failure and loses the structured sandbox result.

Suggested fix
 export default {
   async fetch() {
     const __result = await ${wrappedCode};
-    return new Response(JSON.stringify(__result), {
-      headers: { 'Content-Type': 'application/json' },
-    });
+    try {
+      return new Response(JSON.stringify(__result), {
+        headers: { 'Content-Type': 'application/json' },
+      });
+    } catch (__error) {
+      return new Response(
+        JSON.stringify({
+          status: 'done',
+          success: false,
+          error: {
+            name: __error?.name ?? 'SerializationError',
+            message: __error?.message ?? String(__error),
+          },
+          logs: __result?.logs ?? [],
+        }),
+        {
+          headers: { 'Content-Type': 'application/json' },
+        },
+      );
+    }
   }
 };
πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai-isolate-cloudflare/src/worker/index.ts` around lines
71 - 79, The child worker currently assumes the wrapped result is
JSON-serializable and lets JSON.stringify throw (losing structured error info);
update wrapAsSandboxModule (and the code path produced by wrapCode) to catch
serialization failures: after awaiting __result, attempt to JSON.stringify
inside a try/catch and, on error, construct a deterministic serializable
fallback (e.g., { ok: false, errorType: 'SerializeError', message: String(err),
valueType: typeof __result }) or attempt safe conversions (e.g., BigInt ->
string) then return that JSON in the Response instead of letting JSON.stringify
propagate; ensure the module exports the same envelope shape for success (e.g.,
{ ok: true, value: ... }) and failure so the parent can reliably parse
structured sandbox results.

};
`
}

/**
* Execute code in a freshly loaded child Worker isolate.
*/
async function executeCode(
request: ExecuteRequest,
env: Env,
): Promise<ExecuteResponse> {
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
// 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()
const timeoutId = setTimeout(() => controller.abort(), timeout)
let timeoutId: ReturnType<typeof setTimeout> | undefined
const TIMEOUT_SENTINEL = '__SANDBOX_TIMEOUT__'
const timeoutPromise = new Promise<never>((_resolve, reject) => {
timeoutId = setTimeout(() => {
controller.abort()
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/', { signal: controller.signal }),
)
const response = await Promise.race([fetchPromise, timeoutPromise])
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (timeoutId) clearTimeout(timeoutId)

const result: {
status: string
success?: boolean
value?: unknown
error?: { name: string; message: string; stack?: string }
logs: Array<string>
toolCalls?: Array<ToolCallRequest>
}

clearTimeout(timeoutId)
} = await response.json()
Comment on lines +140 to +147
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ› οΈ Refactor suggestion | 🟠 Major | ⚑ Quick win

Validate the child-worker payload with Zod at this boundary.

This now crosses a worker boundary, but response.json() is still trusted and cast directly into the expected shape. If the loaded worker returns a partial or malformed payload, lines 149-164 can silently emit an invalid ExecuteResponse instead of a clear protocol error. Please parse this with a Zod schema before branching on status.

As per coding guidelines, "packages/typescript//src//*.ts: Use Zod for schema validation and tool definition across the library".

πŸ€– Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/typescript/ai-isolate-cloudflare/src/worker/index.ts` around lines
140 - 147, The code directly assigns await response.json() to the local result
variable and then branches on its status; instead validate this boundary payload
with Zod before using it. Define a Zod schema that matches the expected
ExecuteResponse shape (fields: status, success, value, error
{name,message,stack?}, logs: string[], toolCalls?: ToolCallRequest[]), call
resultRaw = await response.json() and then parse it with schema.parse or
schema.safeParse, and handle a failed parse by throwing or returning a clear
protocol error; update the code paths that currently reference result (the
status branching and subsequent use of value/logs/error/toolCalls) to use the
validated parsed object instead.


if (result.status === 'need_tools') {
return {
Expand All @@ -96,9 +163,13 @@ async function executeCode(
logs: result.logs,
}
} catch (evalError: unknown) {
clearTimeout(timeoutId)
if (timeoutId) clearTimeout(timeoutId)
const error = evalError as Error

if (controller.signal.aborted) {
// 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: {
Expand All @@ -108,7 +179,6 @@ async function executeCode(
}
}

const error = evalError as Error
return {
status: 'done',
success: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand All @@ -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 () => {
Expand Down
Loading