-
-
Notifications
You must be signed in to change notification settings - Fork 197
fix(ai-isolate-cloudflare): port worker from unsafe_eval to worker_loader #523
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
167d052
669a2aa
b2ce85f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
This file was deleted.
| 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' }, | ||
| }); | ||
| } | ||
| }; | ||
| ` | ||
| } | ||
|
|
||
| /** | ||
| * 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]) | ||
|
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 As per coding guidelines, "packages/typescript//src//*.ts: Use Zod for schema validation and tool definition across the library". π€ Prompt for AI Agents |
||
|
|
||
| if (result.status === 'need_tools') { | ||
| return { | ||
|
|
@@ -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: { | ||
|
|
@@ -108,7 +179,6 @@ async function executeCode( | |
| } | ||
| } | ||
|
|
||
| const error = evalError as Error | ||
| return { | ||
| status: 'done', | ||
| success: false, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Catch serialization failures inside the child worker.
wrapCode()can producevalue: unknown, but this path assumes the full result is JSON-serializable. Values likeBigIntor 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