fix(ai-isolate-cloudflare): port worker from unsafe_eval to worker_loader#523
fix(ai-isolate-cloudflare): port worker from unsafe_eval to worker_loader#523Sriketk wants to merge 3 commits intoTanStack:mainfrom
Conversation
…ader
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 TanStack#522.
📝 WalkthroughWalkthroughThis PR ports the Cloudflare worker in ChangesWorker Loader Migration
Sequence DiagramsequenceDiagram
actor Client
participant MainWorker as Main Worker (ai-isolate)
participant LoaderBinding as LOADER Binding
participant ChildWorker as Child Worker (Sandbox)
Client->>MainWorker: POST /execute (code + context)
MainWorker->>MainWorker: wrapAsSandboxModule(code) -> module source
MainWorker->>LoaderBinding: load({ mainModule, modules, compatibilityDate })
LoaderBinding->>ChildWorker: initialize isolate with module
MainWorker->>ChildWorker: getEntrypoint().fetch(Request, { signal })
ChildWorker->>ChildWorker: run wrapped IIFE, produce JSON result
ChildWorker-->>MainWorker: Response with result JSON
MainWorker->>MainWorker: parse result, handle need_tools/done/errors/timeouts
MainWorker-->>Client: JSON { status, value/error, toolCalls?, continuationId? }
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 👉 Get your free trial and get 200 agent minutes per Slack user (a $50 value). Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Review rate limit: 7/8 reviews remaining, refill in 7 minutes and 30 seconds.Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts (1)
191-209: ⚡ Quick winPlease add one success-path
LOADERtest here as well.Right now this file only locks down the missing-binding branch. The new
LOADER.load(...).getEntrypoint().fetch(...)flow is the real regression surface in this PR, and a small mocked happy-path test would catch contract drift inmainModule,modules, entrypoint resolution, and JSON handling.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts` around lines 191 - 209, Add a happy-path test that supplies a mocked LOADER binding to the worker invocation and asserts the full load→getEntrypoint→fetch flow: create an env object with LOADER having a load() method that returns an object whose getEntrypoint() returns an object with an async fetch(request) that returns a Response (e.g., JSON { status: 'ok', result: ... }), call worker.fetch(request, env, mockExecutionContext) using the same request shape as the existing test, then assert response.status === 200 and the parsed JSON matches the expected success payload (e.g., json.status === 'ok' and contains the returned result); this will exercise the LOADER.load, getEntrypoint, and entrypoint.fetch contract used in the worker.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/typescript/ai-isolate-cloudflare/src/worker/index.ts`:
- Around line 111-131: The Promise.race approach returns a timeout error but
leaves entrypoint.fetch running; replace the race with a cancellation-aware
flow: create an AbortController before calling
env.LOADER.load()/loaded.getEntrypoint() and pass controller.signal into
entrypoint.fetch if the loader supports AbortSignal, set timeoutId to call
controller.abort() (and reject with TIMEOUT_SENTINEL) on timeout, and clear the
timeout when fetch resolves; if the loader/entrypoint does not support
AbortSignal, instead call the loader/entrypoint termination/dispose API (if
available) when the timeout fires or else update docs to state this is a
response-time limit only—adjust handling around timeoutPromise,
TIMEOUT_SENTINEL, timeoutId, entrypoint.fetch, env.LOADER.load, and
loaded.getEntrypoint accordingly.
---
Nitpick comments:
In `@packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts`:
- Around line 191-209: Add a happy-path test that supplies a mocked LOADER
binding to the worker invocation and asserts the full load→getEntrypoint→fetch
flow: create an env object with LOADER having a load() method that returns an
object whose getEntrypoint() returns an object with an async fetch(request) that
returns a Response (e.g., JSON { status: 'ok', result: ... }), call
worker.fetch(request, env, mockExecutionContext) using the same request shape as
the existing test, then assert response.status === 200 and the parsed JSON
matches the expected success payload (e.g., json.status === 'ok' and contains
the returned result); this will exercise the LOADER.load, getEntrypoint, and
entrypoint.fetch contract used in the worker.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: ee905ac4-2230-4b85-8d36-afc9224989c3
📒 Files selected for processing (10)
.changeset/worker-loader-port.mdpackages/typescript/ai-isolate-cloudflare/README.mdpackages/typescript/ai-isolate-cloudflare/dev-server.mjspackages/typescript/ai-isolate-cloudflare/package.jsonpackages/typescript/ai-isolate-cloudflare/src/worker/index.tspackages/typescript/ai-isolate-cloudflare/src/worker/wrap-code.tspackages/typescript/ai-isolate-cloudflare/tests/escape-attempts.test.tspackages/typescript/ai-isolate-cloudflare/tests/isolate-driver.test.tspackages/typescript/ai-isolate-cloudflare/tests/worker.test.tspackages/typescript/ai-isolate-cloudflare/wrangler.toml
💤 Files with no reviewable changes (1)
- packages/typescript/ai-isolate-cloudflare/dev-server.mjs
…-path tests Address CodeRabbit review on TanStack#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.
There was a problem hiding this comment.
🧹 Nitpick comments (2)
packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts (2)
223-228: ⚡ Quick winAssertions inside
load()mock may be silently swallowed by the outer worker's error handling.
load()is a synchronous function called inside the outer worker's implementation. If anyexpect(...)inside it throws (e.g., thetoBeNull()onglobalOutbound), the worker'stry/catchwill intercept thatAssertionErrorand return a generic error response. The test then fails at a later downstream assertion (such asexpect(json.status).toBe('done')) rather than at the actual failing line, making failures hard to diagnose.Capture the options outside the mock and assert after
worker.fetch()resolves:♻️ Proposed refactor
+ let loadCalled = false + let capturedOptions: Parameters<NonNullable<typeof env['LOADER']>['load']>[0] | null = null let receivedSignal: AbortSignal | null = null const env = { LOADER: { load: (options: { ... }) => { 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) => { ... }, }), } }, }, } // ... worker.fetch() call ... expect(loadCalled).toBe(true) + expect(capturedOptions!.mainModule).toBe('main.js') + expect(capturedOptions!.modules).toHaveProperty('main.js') + expect(capturedOptions!.modules['main.js']).toContain('export default') + expect(capturedOptions!.globalOutbound).toBeNull()🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts` around lines 223 - 228, The assertions currently inside the synchronous load() mock can be swallowed by the worker's try/catch; instead capture the options object and loadCalled flag from inside the mock (e.g., set loadCalled = true and savedOptions = options) and remove all expect(...) calls from inside load(); then after awaiting worker.fetch(...) resolve, assert on savedOptions (expect(savedOptions.mainModule).toBe('main.js'), expect(savedOptions.modules).toHaveProperty('main.js'), expect(savedOptions.modules['main.js']).toContain('export default'), expect(savedOptions.globalOutbound).toBeNull()) and on loadCalled and json.status to surface assertion failures at their true origin.
232-232: ⚡ Quick win
expect(receivedSignal).not.toBeNull()is trivially true; the timeout test doesn't definitively exercise the AbortSignal path.Per the WHATWG Fetch spec and Cloudflare Workers docs,
Request.signalis always a non-nullAbortSignal— a never-aborted one is synthesised if no signal is provided inRequestInit. So line 257 passes regardless of whether the outer worker constructs and threads anAbortControllersignal.The timeout test's mock comment says "Never resolves on its own; relies on AbortSignal," but because the outer worker also has a
Promise.racesentinel (per the PR description), the test passes via that sentinel even if theAbortControlleris never properly connected. Neither test therefore proves that the signal is the outer worker's own timedAbortControllersignal.Fix: drop the weak
not.toBeNull()assertion; instead, capturereceivedSignalin the timeout test and assertreceivedSignal?.aborted === trueafterworker.fetch()returns. This confirms the outer worker actually aborts the signal — not just that a signal exists.♻️ Proposed refactor for the timeout test
it('returns TimeoutError when entrypoint.fetch exceeds timeout', async () => { + let receivedSignal: AbortSignal | null = null const env = { LOADER: { load: () => ({ getEntrypoint: () => ({ fetch: (req: Request) => new Promise<Response>((_resolve, reject) => { + receivedSignal = req.signal req.signal.addEventListener('abort', () => { reject(new Error('aborted')) }) // Never resolves on its own; relies on AbortSignal. }), }), }), }, } // ... const response = await worker.fetch(request, env, mockExecutionContext) + // Confirms the outer worker aborts its own AbortController signal on timeout, + // not merely that Promise.race fired. + expect(receivedSignal?.aborted).toBe(true) 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') })Also applies to: 257-257, 303-336
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts` at line 232, The current timeout test only asserts expect(receivedSignal).not.toBeNull(), which is ineffective because Request.signal is always non-null; modify the timeout test to capture the receivedSignal from the mocked fetch handler (the same variable currently set by receivedSignal = req.signal) and after calling worker.fetch(...) assert that receivedSignal?.aborted === true to prove the outer worker's AbortController actually aborted the signal; remove the weak not.toBeNull() assertion and add the aborted check in the timeout test that exercises the timeout-path used by the worker code (ensure the assertion runs after the worker.fetch promise resolves).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts`:
- Around line 223-228: The assertions currently inside the synchronous load()
mock can be swallowed by the worker's try/catch; instead capture the options
object and loadCalled flag from inside the mock (e.g., set loadCalled = true and
savedOptions = options) and remove all expect(...) calls from inside load();
then after awaiting worker.fetch(...) resolve, assert on savedOptions
(expect(savedOptions.mainModule).toBe('main.js'),
expect(savedOptions.modules).toHaveProperty('main.js'),
expect(savedOptions.modules['main.js']).toContain('export default'),
expect(savedOptions.globalOutbound).toBeNull()) and on loadCalled and
json.status to surface assertion failures at their true origin.
- Line 232: The current timeout test only asserts
expect(receivedSignal).not.toBeNull(), which is ineffective because
Request.signal is always non-null; modify the timeout test to capture the
receivedSignal from the mocked fetch handler (the same variable currently set by
receivedSignal = req.signal) and after calling worker.fetch(...) assert that
receivedSignal?.aborted === true to prove the outer worker's AbortController
actually aborted the signal; remove the weak not.toBeNull() assertion and add
the aborted check in the timeout test that exercises the timeout-path used by
the worker code (ensure the assertion runs after the worker.fetch promise
resolves).
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 5e527ef4-c182-4ea4-a978-f32123c8d0a7
📒 Files selected for processing (2)
packages/typescript/ai-isolate-cloudflare/src/worker/index.tspackages/typescript/ai-isolate-cloudflare/tests/worker.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/typescript/ai-isolate-cloudflare/src/worker/index.ts
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.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/typescript/ai-isolate-cloudflare/src/worker/index.ts`:
- Around line 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.
- Around line 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.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 4f4ebfd5-c892-4c94-8625-c266567b8e89
📒 Files selected for processing (2)
packages/typescript/ai-isolate-cloudflare/src/worker/index.tspackages/typescript/ai-isolate-cloudflare/tests/worker.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/typescript/ai-isolate-cloudflare/tests/worker.test.ts
| 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' }, | ||
| }); | ||
| } |
There was a problem hiding this comment.
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.
| 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() |
There was a problem hiding this comment.
🛠️ 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.
🎯 Changes
Port
@tanstack/ai-isolate-cloudflareworker from theunsafe_evalbinding toworker_loader(Dynamic Workers).Closes #522.
Why
unsafe_evalis workerd-internal and gated by Cloudflare for all customer accounts — there is no public entitlement and no path to enable it. The current driver is unusable in production and broken inwrangler devon Wrangler 4.x:wrangler deploybinding UNSAFE_EVAL has an unknown type eval [code: 10021]wrangler devremote, runtime returnsUnsafeEvalNotAvailablewrangler dev --local(3.114)Fatal: setsocketopt TCP_NODELAYcrashCloudflare's supported replacement is the
worker_loader(Dynamic Workers) binding, GA-beta'd 2026-03-24.What changed
src/worker/index.ts: swapenv.UNSAFE_EVAL.eval(wrappedCode)forenv.LOADER.load({modules}).getEntrypoint().fetch(...). Wraps the existing IIFE-returning string in an ES module that exposes afetchhandler returning the structured result as JSON.wrangler.toml:[[unsafe.bindings]]→[[worker_loaders]] binding = "LOADER". Compat date bumped to2026-05-01.UnsafeEvalNotAvailable→WorkerLoaderNotAvailable.dev-server.mjsremoved:wrangler devbindsworker_loadernatively, so the custom Miniflare bootstrap is no longer needed.dev:workerscript now runswrangler dev.The HTTP tool-callback protocol, driver code, and public API are unchanged. ~120 LOC change.
Validation
pnpm test:lib— 36/36 passpnpm test:types— cleanpnpm test:eslint— cleanbun patchof the published 0.1.8 with the same swap, deployed atcobalt-sandbox.<account>.workers.dev, e2e calling tool-callback round-trip against a Drizzle-backed driver).Plan / cost
worker_loaderis Workers Paid plan only. The CF API rejects deploys and--remoteon Free withcode: 10195— "In order to use Dynamic Workers, you must switch to a paid plan." Localwrangler devaccepts the binding on the Free plan, so inner-loop iteration stays free.This is a tier requirement Cloudflare enforces, not a regression in this PR —
unsafe_evalwould have required Paid in any account that ever managed to enable it. The README now states the plan requirement explicitly.Breaking
Yes — minor bump. Consumers must update
wrangler.toml:✅ Checklist
pnpm run test:pr(package-scoped tests pass).🚀 Release Impact
Summary by CodeRabbit
Breaking Changes
Documentation
Chores