From df244ce9e993b9c6db1a9e8447bf28334ece22b5 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 25 Mar 2026 18:33:08 +0000 Subject: [PATCH 1/5] [vitest-pool-workers] Fix dynamic import() in entrypoint and DO handlers Patch the module runner transport via onModuleRunner hook so that transport.invoke() calls always execute inside the runner DO's I/O context. This fixes dynamic import() inside exports.default.fetch(), SELF.fetch(), and Durable Object handlers. Fixes #12924 Fixes #5387 --- .changeset/fix-dynamic-import-cross-do.md | 9 ++++++ .../dynamic-import/src/greeting.ts | 3 ++ .../dynamic-import/src/index.ts | 20 ++++++++++++ .../dynamic-import/src/tsconfig.json | 4 +++ .../src/worker-configuration.d.ts | 6 ++++ .../test/dynamic-import.test.ts | 29 +++++++++++++++++ .../dynamic-import/test/tsconfig.json | 4 +++ .../dynamic-import/tsconfig.json | 4 +++ .../dynamic-import/vitest.config.ts | 14 ++++++++ .../dynamic-import/wrangler.jsonc | 9 ++++++ .../vitest-pool-workers/src/worker/index.ts | 32 ++++++++++++++----- 11 files changed, 126 insertions(+), 8 deletions(-) create mode 100644 .changeset/fix-dynamic-import-cross-do.md create mode 100644 fixtures/vitest-pool-workers-examples/dynamic-import/src/greeting.ts create mode 100644 fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts create mode 100644 fixtures/vitest-pool-workers-examples/dynamic-import/src/tsconfig.json create mode 100644 fixtures/vitest-pool-workers-examples/dynamic-import/src/worker-configuration.d.ts create mode 100644 fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts create mode 100644 fixtures/vitest-pool-workers-examples/dynamic-import/test/tsconfig.json create mode 100644 fixtures/vitest-pool-workers-examples/dynamic-import/tsconfig.json create mode 100644 fixtures/vitest-pool-workers-examples/dynamic-import/vitest.config.ts create mode 100644 fixtures/vitest-pool-workers-examples/dynamic-import/wrangler.jsonc diff --git a/.changeset/fix-dynamic-import-cross-do.md b/.changeset/fix-dynamic-import-cross-do.md new file mode 100644 index 0000000000..f35a187c6a --- /dev/null +++ b/.changeset/fix-dynamic-import-cross-do.md @@ -0,0 +1,9 @@ +--- +"@cloudflare/vitest-pool-workers": patch +--- + +fix: Support dynamic `import()` inside entrypoint and Durable Object handlers + +Previously, calling `exports.default.fetch()` or `SELF.fetch()` on a worker whose handler used a dynamic `import()` would hang and fail with "Cannot perform I/O on behalf of a different Durable Object". This happened because the module runner's transport — which communicates over a WebSocket owned by the runner Durable Object — was invoked from a different DO context. + +The fix patches the module runner's transport via the `onModuleRunner` hook so that all `invoke()` calls are routed through the runner DO's I/O context, regardless of where the `import()` originates. diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/src/greeting.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/src/greeting.ts new file mode 100644 index 0000000000..df34ef5e60 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/src/greeting.ts @@ -0,0 +1,3 @@ +export function greet(name: string): string { + return `Hello, ${name}!`; +} diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts new file mode 100644 index 0000000000..ca2ef2eb6c --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts @@ -0,0 +1,20 @@ +// Durable Object that uses dynamic import() in fetch handler. +// Regression test for https://github.com/cloudflare/workers-sdk/issues/5387 +export class GreeterDO implements DurableObject { + constructor(readonly state: DurableObjectState) {} + async fetch(request: Request): Promise { + const { greet } = await import("./greeting"); + return new Response(greet("DO")); + } +} + +export default { + async fetch(request: Request, _env: unknown, _ctx: ExecutionContext): Promise { + // Dynamic import inside a fetch handler — this is the pattern that + // triggers the cross-DO I/O violation in vitest-pool-workers 0.13.x + // when called via `exports.default.fetch()` in tests. + // See: https://github.com/cloudflare/workers-sdk/issues/12924 + const { greet } = await import("./greeting"); + return new Response(greet("World")); + }, +} satisfies ExportedHandler; diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/src/tsconfig.json b/fixtures/vitest-pool-workers-examples/dynamic-import/src/tsconfig.json new file mode 100644 index 0000000000..0141323e2f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/src/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd.json", + "include": ["./**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/src/worker-configuration.d.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/src/worker-configuration.d.ts new file mode 100644 index 0000000000..e724eb332c --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/src/worker-configuration.d.ts @@ -0,0 +1,6 @@ +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } +} +interface Env extends Cloudflare.Env {} diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts new file mode 100644 index 0000000000..fae5d98814 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts @@ -0,0 +1,29 @@ +import { env, runDurableObjectAlarm, runInDurableObject } from "cloudflare:test"; +import { exports } from "cloudflare:workers"; +import { it } from "vitest"; + +// Regression test for https://github.com/cloudflare/workers-sdk/issues/12924 +// +// Calling exports.default.fetch() on a worker whose fetch handler uses a +// dynamic import() would hang with "Cannot perform I/O on behalf of a +// different Durable Object". Pre-loading the module (e.g. via a static +// import of the worker) masks the bug by caching the module. +it("exports.default.fetch() with dynamic import()", async ({ expect }) => { + const response = await exports.default.fetch( + new Request("https://example.com/") + ); + expect(response.status).toBe(200); + expect(await response.text()).toBe("Hello, World!"); +}); + +// Regression test for https://github.com/cloudflare/workers-sdk/issues/5387 +// +// Dynamic import() inside a Durable Object fetch handler has the same +// cross-context I/O violation when the module isn't already cached. +it("Durable Object fetch with dynamic import()", async ({ expect }) => { + const id = env.GREETER.idFromName("test"); + const stub = env.GREETER.get(id); + const response = await stub.fetch(new Request("https://example.com/")); + expect(response.status).toBe(200); + expect(await response.text()).toBe("Hello, DO!"); +}); diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/test/tsconfig.json b/fixtures/vitest-pool-workers-examples/dynamic-import/test/tsconfig.json new file mode 100644 index 0000000000..20248fcaf7 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/test/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.workerd-test.json", + "include": ["./**/*.ts", "../src/**/*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/tsconfig.json b/fixtures/vitest-pool-workers-examples/dynamic-import/tsconfig.json new file mode 100644 index 0000000000..90e58bf03e --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.node.json", + "include": ["./*.ts"] +} diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/vitest.config.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/vitest.config.ts new file mode 100644 index 0000000000..b90cb174ce --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/vitest.config.ts @@ -0,0 +1,14 @@ +import { cloudflareTest } from "@cloudflare/vitest-pool-workers"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + wrangler: { + configPath: "./wrangler.jsonc", + }, + }), + ], + + test: {}, +}); diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/dynamic-import/wrangler.jsonc new file mode 100644 index 0000000000..715d079e55 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/wrangler.jsonc @@ -0,0 +1,9 @@ +{ + "name": "dynamic-import", + "main": "src/index.ts", + // don't provide compatibility_date so that vitest will infer the latest one + "durable_objects": { + "bindings": [{ "name": "GREETER", "class_name": "GreeterDO" }], + }, + "migrations": [{ "tag": "v1", "new_classes": ["GreeterDO"] }], +} diff --git a/packages/vitest-pool-workers/src/worker/index.ts b/packages/vitest-pool-workers/src/worker/index.ts index c9163bc1b1..92c876135b 100644 --- a/packages/vitest-pool-workers/src/worker/index.ts +++ b/packages/vitest-pool-workers/src/worker/index.ts @@ -199,14 +199,14 @@ export class __VITEST_POOL_WORKERS_RUNNER_DURABLE_OBJECT__ extends DurableObject try { poolSocket.send(structuredSerializableStringify(response)); } catch (error) { - // If the user tried to perform a dynamic `import()` or `console.log()` - // from inside a `export default { fetch() { ... } }` handler using `SELF` - // or from inside their own Durable Object, Vitest will try to send an - // RPC message from a non-`RunnerObject` I/O context. There's nothing we - // can really do to prevent this: we want to run these things in different - // I/O contexts with the behaviour this causes. We'd still like to send - // the RPC message though, so if we detect this, we try resend the message - // from the runner object. + // If the user called `console.log()` or similar from inside an + // `export default { fetch() {} }` handler (via `SELF`/`exports`) or + // from inside their own Durable Object, Vitest will try to send an + // RPC message from a non-`RunnerObject` I/O context. We'd still like + // to send the message, so if we detect a cross-DO I/O error we + // resend from the runner object. + // (Dynamic `import()` cross-DO errors are handled separately by the + // transport patch in entrypoints.ts — see #12924.) if (isDifferentIOContextError(error)) { const promise = runInRunnerObject(() => { poolSocket.send(structuredSerializableStringify(response)); @@ -231,6 +231,22 @@ export class __VITEST_POOL_WORKERS_RUNNER_DURABLE_OBJECT__ extends DurableObject runTests: (state, traces) => runBaseTests("run", state, traces), collectTests: (state, traces) => runBaseTests("collect", state, traces), setup: setupEnvironment, + // Patch the module runner's transport so that `invoke()` calls always + // execute inside the Runner DO's I/O context. Without this, a dynamic + // `import()` inside an entrypoint handler (which runs in a *different* + // DO context) fails with "Cannot perform I/O on behalf of a different + // Durable Object". See: https://github.com/cloudflare/workers-sdk/issues/12924 + onModuleRunner(moduleRunner: unknown) { + const runner = moduleRunner as { + transport?: { invoke?: (...args: unknown[]) => unknown }; + }; + if (runner.transport?.invoke) { + const originalInvoke = runner.transport.invoke.bind(runner.transport); + runner.transport.invoke = (...args: unknown[]) => { + return runInRunnerObject(() => originalInvoke(...args)); + }; + } + }, }); return new Response(null, { status: 101, webSocket: poolResponseSocket }); From 0005938533099bb60f7e0a5d83c234ddcaf4d330 Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Wed, 25 Mar 2026 18:49:41 +0000 Subject: [PATCH 2/5] format: run oxfmt on dynamic-import fixture --- .../dynamic-import/src/index.ts | 6 +++++- .../dynamic-import/test/dynamic-import.test.ts | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts index ca2ef2eb6c..3ff31625d9 100644 --- a/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts @@ -9,7 +9,11 @@ export class GreeterDO implements DurableObject { } export default { - async fetch(request: Request, _env: unknown, _ctx: ExecutionContext): Promise { + async fetch( + request: Request, + _env: unknown, + _ctx: ExecutionContext + ): Promise { // Dynamic import inside a fetch handler — this is the pattern that // triggers the cross-DO I/O violation in vitest-pool-workers 0.13.x // when called via `exports.default.fetch()` in tests. diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts index fae5d98814..65853716dd 100644 --- a/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts @@ -1,4 +1,8 @@ -import { env, runDurableObjectAlarm, runInDurableObject } from "cloudflare:test"; +import { + env, + runDurableObjectAlarm, + runInDurableObject, +} from "cloudflare:test"; import { exports } from "cloudflare:workers"; import { it } from "vitest"; From e17979cb5550e6211d527d5b8befe813b2ab6af8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Thu, 26 Mar 2026 14:29:54 +0000 Subject: [PATCH 3/5] fix: fix TS errors in dynamic-import fixture --- .../vitest-pool-workers-examples/dynamic-import/src/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts index 3ff31625d9..f50ae2322f 100644 --- a/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts @@ -1,7 +1,8 @@ +import { DurableObject } from "cloudflare:workers"; + // Durable Object that uses dynamic import() in fetch handler. // Regression test for https://github.com/cloudflare/workers-sdk/issues/5387 -export class GreeterDO implements DurableObject { - constructor(readonly state: DurableObjectState) {} +export class GreeterDO extends DurableObject { async fetch(request: Request): Promise { const { greet } = await import("./greeting"); return new Response(greet("DO")); From 6faae10dc1a7a806a92c6f63d0a7ec1288d01864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Somhairle=20MacLe=C3=B2id?= Date: Thu, 26 Mar 2026 14:30:09 +0000 Subject: [PATCH 4/5] fix: fix TS errors in dynamic-import fixture --- .../dynamic-import/src/worker-configuration.d.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/src/worker-configuration.d.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/src/worker-configuration.d.ts index e724eb332c..210b471823 100644 --- a/fixtures/vitest-pool-workers-examples/dynamic-import/src/worker-configuration.d.ts +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/src/worker-configuration.d.ts @@ -2,5 +2,8 @@ declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./index"); } + interface Env { + GREETER: DurableObjectNamespace; + } } interface Env extends Cloudflare.Env {} From 1ab29ab9ed071334a7614028872d2004596aa2df Mon Sep 17 00:00:00 2001 From: Samuel Macleod Date: Thu, 26 Mar 2026 16:33:31 +0000 Subject: [PATCH 5/5] fix review: remove unused imports, fix comment ref, add transport patch warning --- .../dynamic-import/test/dynamic-import.test.ts | 6 +----- packages/vitest-pool-workers/src/worker/index.ts | 7 ++++++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts b/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts index 65853716dd..1c950f9583 100644 --- a/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts @@ -1,8 +1,4 @@ -import { - env, - runDurableObjectAlarm, - runInDurableObject, -} from "cloudflare:test"; +import { env } from "cloudflare:workers"; import { exports } from "cloudflare:workers"; import { it } from "vitest"; diff --git a/packages/vitest-pool-workers/src/worker/index.ts b/packages/vitest-pool-workers/src/worker/index.ts index 92c876135b..191939bfae 100644 --- a/packages/vitest-pool-workers/src/worker/index.ts +++ b/packages/vitest-pool-workers/src/worker/index.ts @@ -206,7 +206,7 @@ export class __VITEST_POOL_WORKERS_RUNNER_DURABLE_OBJECT__ extends DurableObject // to send the message, so if we detect a cross-DO I/O error we // resend from the runner object. // (Dynamic `import()` cross-DO errors are handled separately by the - // transport patch in entrypoints.ts — see #12924.) + // onModuleRunner transport patch below — see #12924.) if (isDifferentIOContextError(error)) { const promise = runInRunnerObject(() => { poolSocket.send(structuredSerializableStringify(response)); @@ -245,6 +245,11 @@ export class __VITEST_POOL_WORKERS_RUNNER_DURABLE_OBJECT__ extends DurableObject runner.transport.invoke = (...args: unknown[]) => { return runInRunnerObject(() => originalInvoke(...args)); }; + } else { + __console.warn( + "[vitest-pool-workers] Could not patch module runner transport. " + + "Dynamic import() inside entrypoint/DO handlers may fail." + ); } }, });