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..f50ae2322f --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/src/index.ts @@ -0,0 +1,25 @@ +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 extends DurableObject { + 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..210b471823 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/src/worker-configuration.d.ts @@ -0,0 +1,9 @@ +declare namespace Cloudflare { + interface GlobalProps { + mainModule: typeof import("./index"); + } + interface Env { + GREETER: DurableObjectNamespace; + } +} +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..1c950f9583 --- /dev/null +++ b/fixtures/vitest-pool-workers-examples/dynamic-import/test/dynamic-import.test.ts @@ -0,0 +1,29 @@ +import { env } from "cloudflare:workers"; +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..191939bfae 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 + // onModuleRunner transport patch below — see #12924.) if (isDifferentIOContextError(error)) { const promise = runInRunnerObject(() => { poolSocket.send(structuredSerializableStringify(response)); @@ -231,6 +231,27 @@ 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)); + }; + } else { + __console.warn( + "[vitest-pool-workers] Could not patch module runner transport. " + + "Dynamic import() inside entrypoint/DO handlers may fail." + ); + } + }, }); return new Response(null, { status: 101, webSocket: poolResponseSocket });