From 12518002c5f820b3e6ba9c760c0ed5d4f13f149b Mon Sep 17 00:00:00 2001 From: Jason G <41053218+gianghungtien@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:50:15 +0700 Subject: [PATCH 1/4] [vitest-pool-workers] Treat webSocket* as optional Durable Object handlers The pool wraps each Durable Object class and installs a prototype method for every key in `DURABLE_OBJECT_KEYS` before user code is loaded. `workerd` therefore always sees a handler and always dispatches, and the wrapper threw a TypeError when the wrapped class turned out not to define one. That is wrong for `webSocketMessage()`, `webSocketClose()` and `webSocketError()`, which deployed Workers treat as optional and silently ignore when absent. A hibernatable Durable Object defining only `webSocketMessage()` logged an uncaught TypeError on every close. No-op these three keys instead of throwing. `alarm()` deliberately keeps the old behaviour: `workerd` rejects `setAlarm()` up front on a class with no `alarm()` handler, so a missing one is a genuine error. Verified the production semantics against raw `workerd` (via Miniflare, no pool wrapper): a hibernatable Durable Object with no `webSocket*` handlers accepts, receives and closes a socket without error, while `setAlarm()` on a class with no `alarm()` handler throws "Your Durable Object class must have an alarm() handler in order to call setAlarm()". The `Counter` fixture's empty `webSocketClose()`/`webSocketError()` stubs were a workaround for this and are now removed. --- .changeset/olive-jokes-shave.md | 9 +++++ .../durable-objects/src/env.d.ts | 10 ++++-- .../durable-objects/src/index.ts | 16 +++++++-- .../durable-objects/test/websockets.test.ts | 34 +++++++++++++++++++ .../durable-objects/wrangler.jsonc | 9 ++++- .../src/worker/entrypoints.ts | 13 +++++++ 6 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 .changeset/olive-jokes-shave.md diff --git a/.changeset/olive-jokes-shave.md b/.changeset/olive-jokes-shave.md new file mode 100644 index 0000000000..002d573fd2 --- /dev/null +++ b/.changeset/olive-jokes-shave.md @@ -0,0 +1,9 @@ +--- +"@cloudflare/vitest-pool-workers": patch +--- + +Treat `webSocketMessage()`, `webSocketClose()` and `webSocketError()` as optional Durable Object handlers + +The pool wraps each Durable Object class and installs a prototype method for every default handler before user code is loaded, so `workerd` always sees a handler and always dispatches. When the wrapped class didn't actually define one, the wrapper threw `` exported by does not define a `webSocketClose()` method``, even though deployed Workers silently ignore these events for classes that omit them. A hibernatable Durable Object defining only `webSocketMessage()` would log an uncaught `TypeError` on every close. + +These three handlers now no-op when absent, matching deployed behaviour. `alarm()` is unchanged and still reports a missing handler, since `workerd` rejects `setAlarm()` up front on a class without one. diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/src/env.d.ts b/fixtures/vitest-pool-workers-examples/durable-objects/src/env.d.ts index 1dfacd4978..af801396b8 100644 --- a/fixtures/vitest-pool-workers-examples/durable-objects/src/env.d.ts +++ b/fixtures/vitest-pool-workers-examples/durable-objects/src/env.d.ts @@ -1,13 +1,19 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types ./durable-objects/src/env.d.ts -c ./durable-objects/wrangler.jsonc --no-include-runtime` (hash: dbeecb58f42b6b6ca9742dccb5c058cf) +// Generated by Wrangler by running `wrangler types --config=./durable-objects/wrangler.jsonc --include-runtime=false ./durable-objects/src/env.d.ts` (hash: fc042b993a12203f63112d1f0e0aefae) interface __BaseEnv_Env { COUNTER: DurableObjectNamespace; SQL: DurableObjectNamespace; + OPTIONAL_WS: DurableObjectNamespace< + import("./index").OptionalWebSocketHandlers + >; } declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./index"); - durableNamespaces: "Counter" | "SQLiteDurableObject"; + durableNamespaces: + | "Counter" + | "SQLiteDurableObject" + | "OptionalWebSocketHandlers"; } interface Env extends __BaseEnv_Env {} } diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts b/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts index ce2dd7fbc3..e573ae5e09 100644 --- a/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts +++ b/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts @@ -48,10 +48,22 @@ export class Counter extends DurableObject { this.#webSocketMessages.push(value); ws.send(value); } +} - webSocketClose() {} +// Defines `webSocketMessage()` but deliberately omits `webSocketClose()` and +// `webSocketError()`, which `workerd` treats as optional handlers. +export class OptionalWebSocketHandlers extends DurableObject { + fetch() { + const { 0: client, 1: server } = new WebSocketPair(); + this.ctx.acceptWebSocket(server); + return new Response(null, { status: 101, webSocket: client }); + } - webSocketError() {} + webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { + const value = + typeof message === "string" ? message : new TextDecoder().decode(message); + ws.send(`echo:${value}`); + } } export class SQLiteDurableObject extends DurableObject { diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/test/websockets.test.ts b/fixtures/vitest-pool-workers-examples/durable-objects/test/websockets.test.ts index b72bf3d2b3..43607b0348 100644 --- a/fixtures/vitest-pool-workers-examples/durable-objects/test/websockets.test.ts +++ b/fixtures/vitest-pool-workers-examples/durable-objects/test/websockets.test.ts @@ -49,6 +49,40 @@ function getResponseWebSocket(response: Response) { return socket; } +it("dispatches to a Durable Object that omits optional WebSocket handlers", async ({ + expect, +}) => { + const id = env.OPTIONAL_WS.idFromName( + `optional-websocket-handlers-${crypto.randomUUID()}` + ); + const stub = env.OPTIONAL_WS.get(id); + const response = await stub.fetch("https://example.com/", { + headers: { Upgrade: "websocket" }, + }); + const socket = getResponseWebSocket(response); + const messagesPromise = waitForMessages(socket, 1); + + socket.accept(); + socket.send("hello"); + expect(await messagesPromise).toEqual(["echo:hello"]); + + // `OptionalWebSocketHandlers` defines no `webSocketClose()`. Closing used to + // throw "does not define a `webSocketClose()` method" inside the Durable + // Object; it must be a silent no-op, as it is on deployed Workers. + socket.close(1000, "done"); + await scheduler.wait(1000); + + // The Durable Object survived the close and still serves new connections. + const secondResponse = await stub.fetch("https://example.com/", { + headers: { Upgrade: "websocket" }, + }); + const secondSocket = getResponseWebSocket(secondResponse); + const secondMessages = waitForMessages(secondSocket, 1); + secondSocket.accept(); + secondSocket.send("still-here"); + expect(await secondMessages).toEqual(["echo:still-here"]); +}); + it("preserves hibernatable WebSocket message order", async ({ expect }) => { for (let attempt = 0; attempt < orderingAttempts; attempt++) { const id = env.COUNTER.idFromName( diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/durable-objects/wrangler.jsonc index 53dc8c949e..4243e58f79 100644 --- a/fixtures/vitest-pool-workers-examples/durable-objects/wrangler.jsonc +++ b/fixtures/vitest-pool-workers-examples/durable-objects/wrangler.jsonc @@ -12,13 +12,20 @@ "name": "SQL", "class_name": "SQLiteDurableObject", }, + { + "name": "OPTIONAL_WS", + "class_name": "OptionalWebSocketHandlers", + }, ], }, "migrations": [ { "tag": "v1", "new_classes": ["Counter"], - "new_sqlite_classes": ["SQLiteDurableObject"], + "new_sqlite_classes": [ + "SQLiteDurableObject", + "OptionalWebSocketHandlers", + ], }, ], } diff --git a/packages/vitest-pool-workers/src/worker/entrypoints.ts b/packages/vitest-pool-workers/src/worker/entrypoints.ts index 6fa52e4cc4..4824c44319 100644 --- a/packages/vitest-pool-workers/src/worker/entrypoints.ts +++ b/packages/vitest-pool-workers/src/worker/entrypoints.ts @@ -247,6 +247,17 @@ const DURABLE_OBJECT_KEYS = [ "webSocketClose", "webSocketError", ] as const; +// Handlers `workerd` treats as optional: when a Durable Object doesn't define +// one, the corresponding event is silently dropped. We can't leave them off the +// wrapper's prototype to get that behaviour for free — the prototype is built +// before user code is loaded, so `workerd` always sees a handler and always +// dispatches. The no-op has to be reproduced here instead. +// +// `alarm()` deliberately isn't in this set: `workerd` rejects `setAlarm()` up +// front on a class with no `alarm()` handler, so a missing one is a real error. +const OPTIONAL_DURABLE_OBJECT_KEYS: ReadonlySet< + (typeof DURABLE_OBJECT_KEYS)[number] +> = new Set(["webSocketMessage", "webSocketClose", "webSocketError"] as const); // This type will grab the keys from T and remove "branded" keys type UnbrandedKeys = Exclude; @@ -555,6 +566,8 @@ export function createDurableObjectWrapper( const maybeFn = instance[key]; if (typeof maybeFn === "function") { return (maybeFn as (...a: unknown[]) => void).apply(instance, args); + } else if (OPTIONAL_DURABLE_OBJECT_KEYS.has(key)) { + return; } else { const message = `${className} exported by ${mainPath} does not define a \`${key}()\` method`; throw new TypeError(message); From c1f703113a5edd293b6a31268cef95cd20f4a148 Mon Sep 17 00:00:00 2001 From: Jason G <41053218+gianghungtien@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:38:20 +0700 Subject: [PATCH 2/4] Move the regression test into the pool's own test suite Addresses review feedback: the examples fixture is user-facing, and this is a test of the internal Durable Object wrapper rather than a recipe for users. Reworks it as a seeded `vitestRun()` case in `test/bindings.test.ts` that asserts the child run exits 0 and that its output never mentions "does not define". This is a genuine red-to-green test: without the fix the run reports `OptionalWebSocketHandlers ... does not define a `webSocketClose()` method`. Making it deterministic needed one extra step. The uncaught exception is logged asynchronously, so a test that merely closes the socket can finish before the dispatch happens. The seeded test now polls a `socketCount()` RPC until the runtime has actually processed the close. Reverts the fixture additions, keeping only the removal of `Counter`'s empty `webSocketClose()`/`webSocketError()` stubs, which the fix makes unnecessary. --- .../durable-objects/src/env.d.ts | 10 +- .../durable-objects/src/index.ts | 16 ---- .../durable-objects/test/websockets.test.ts | 34 ------- .../durable-objects/wrangler.jsonc | 9 +- .../vitest-pool-workers/test/bindings.test.ts | 95 +++++++++++++++++++ 5 files changed, 98 insertions(+), 66 deletions(-) diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/src/env.d.ts b/fixtures/vitest-pool-workers-examples/durable-objects/src/env.d.ts index af801396b8..1dfacd4978 100644 --- a/fixtures/vitest-pool-workers-examples/durable-objects/src/env.d.ts +++ b/fixtures/vitest-pool-workers-examples/durable-objects/src/env.d.ts @@ -1,19 +1,13 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types --config=./durable-objects/wrangler.jsonc --include-runtime=false ./durable-objects/src/env.d.ts` (hash: fc042b993a12203f63112d1f0e0aefae) +// Generated by Wrangler by running `wrangler types ./durable-objects/src/env.d.ts -c ./durable-objects/wrangler.jsonc --no-include-runtime` (hash: dbeecb58f42b6b6ca9742dccb5c058cf) interface __BaseEnv_Env { COUNTER: DurableObjectNamespace; SQL: DurableObjectNamespace; - OPTIONAL_WS: DurableObjectNamespace< - import("./index").OptionalWebSocketHandlers - >; } declare namespace Cloudflare { interface GlobalProps { mainModule: typeof import("./index"); - durableNamespaces: - | "Counter" - | "SQLiteDurableObject" - | "OptionalWebSocketHandlers"; + durableNamespaces: "Counter" | "SQLiteDurableObject"; } interface Env extends __BaseEnv_Env {} } diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts b/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts index e573ae5e09..0487ea7fd8 100644 --- a/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts +++ b/fixtures/vitest-pool-workers-examples/durable-objects/src/index.ts @@ -50,22 +50,6 @@ export class Counter extends DurableObject { } } -// Defines `webSocketMessage()` but deliberately omits `webSocketClose()` and -// `webSocketError()`, which `workerd` treats as optional handlers. -export class OptionalWebSocketHandlers extends DurableObject { - fetch() { - const { 0: client, 1: server } = new WebSocketPair(); - this.ctx.acceptWebSocket(server); - return new Response(null, { status: 101, webSocket: client }); - } - - webSocketMessage(ws: WebSocket, message: string | ArrayBuffer) { - const value = - typeof message === "string" ? message : new TextDecoder().decode(message); - ws.send(`echo:${value}`); - } -} - export class SQLiteDurableObject extends DurableObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env); diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/test/websockets.test.ts b/fixtures/vitest-pool-workers-examples/durable-objects/test/websockets.test.ts index 43607b0348..b72bf3d2b3 100644 --- a/fixtures/vitest-pool-workers-examples/durable-objects/test/websockets.test.ts +++ b/fixtures/vitest-pool-workers-examples/durable-objects/test/websockets.test.ts @@ -49,40 +49,6 @@ function getResponseWebSocket(response: Response) { return socket; } -it("dispatches to a Durable Object that omits optional WebSocket handlers", async ({ - expect, -}) => { - const id = env.OPTIONAL_WS.idFromName( - `optional-websocket-handlers-${crypto.randomUUID()}` - ); - const stub = env.OPTIONAL_WS.get(id); - const response = await stub.fetch("https://example.com/", { - headers: { Upgrade: "websocket" }, - }); - const socket = getResponseWebSocket(response); - const messagesPromise = waitForMessages(socket, 1); - - socket.accept(); - socket.send("hello"); - expect(await messagesPromise).toEqual(["echo:hello"]); - - // `OptionalWebSocketHandlers` defines no `webSocketClose()`. Closing used to - // throw "does not define a `webSocketClose()` method" inside the Durable - // Object; it must be a silent no-op, as it is on deployed Workers. - socket.close(1000, "done"); - await scheduler.wait(1000); - - // The Durable Object survived the close and still serves new connections. - const secondResponse = await stub.fetch("https://example.com/", { - headers: { Upgrade: "websocket" }, - }); - const secondSocket = getResponseWebSocket(secondResponse); - const secondMessages = waitForMessages(secondSocket, 1); - secondSocket.accept(); - secondSocket.send("still-here"); - expect(await secondMessages).toEqual(["echo:still-here"]); -}); - it("preserves hibernatable WebSocket message order", async ({ expect }) => { for (let attempt = 0; attempt < orderingAttempts; attempt++) { const id = env.COUNTER.idFromName( diff --git a/fixtures/vitest-pool-workers-examples/durable-objects/wrangler.jsonc b/fixtures/vitest-pool-workers-examples/durable-objects/wrangler.jsonc index 4243e58f79..53dc8c949e 100644 --- a/fixtures/vitest-pool-workers-examples/durable-objects/wrangler.jsonc +++ b/fixtures/vitest-pool-workers-examples/durable-objects/wrangler.jsonc @@ -12,20 +12,13 @@ "name": "SQL", "class_name": "SQLiteDurableObject", }, - { - "name": "OPTIONAL_WS", - "class_name": "OptionalWebSocketHandlers", - }, ], }, "migrations": [ { "tag": "v1", "new_classes": ["Counter"], - "new_sqlite_classes": [ - "SQLiteDurableObject", - "OptionalWebSocketHandlers", - ], + "new_sqlite_classes": ["SQLiteDurableObject"], }, ], } diff --git a/packages/vitest-pool-workers/test/bindings.test.ts b/packages/vitest-pool-workers/test/bindings.test.ts index 7fc5417a9a..0d8bb2f470 100644 --- a/packages/vitest-pool-workers/test/bindings.test.ts +++ b/packages/vitest-pool-workers/test/bindings.test.ts @@ -52,6 +52,101 @@ test("hello_world support", async ({ expect, seed, vitestRun }) => { await expect(result.exitCode).resolves.toBe(0); }); +test("Durable Objects may omit optional WebSocket handlers", async ({ + expect, + seed, + vitestRun, +}) => { + await seed({ + "vitest.config.mts": vitestConfig({ + wrangler: { configPath: "./wrangler.jsonc" }, + }), + "wrangler.jsonc": dedent` + { + "name": "test-worker", + "main": "./index.ts", + "compatibility_date": "2025-12-02", + "compatibility_flags": ["nodejs_compat"], + "durable_objects": { + "bindings": [ + { "name": "OPTIONAL_WS", "class_name": "OptionalWebSocketHandlers" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["OptionalWebSocketHandlers"] } + ] + } + `, + "index.ts": dedent /* javascript */ ` + import { DurableObject } from "cloudflare:workers"; + + // Defines webSocketMessage() but deliberately omits webSocketClose() + // and webSocketError(), which workerd treats as optional handlers. + export class OptionalWebSocketHandlers extends DurableObject { + fetch(request) { + if (request.headers.get("Upgrade") !== "websocket") { + return new Response("ok"); + } + const { 0: client, 1: server } = new WebSocketPair(); + this.ctx.acceptWebSocket(server); + return new Response(null, { status: 101, webSocket: client }); + } + + webSocketMessage(ws, message) { + ws.send("echo:" + message); + } + + socketCount() { + return this.ctx.getWebSockets().length; + } + } + + export default { + async fetch() { return new Response("ok"); }, + }; + `, + "index.test.ts": dedent /* javascript */ ` + import { env } from "cloudflare:workers"; + import { it, expect } from "vitest"; + + it("echoes a message and closes without a webSocketClose() handler", async () => { + const stub = env.OPTIONAL_WS.get(env.OPTIONAL_WS.idFromName("ws")); + const response = await stub.fetch("https://example.com", { + headers: { Upgrade: "websocket" }, + }); + const socket = response.webSocket; + if (!socket) { throw new Error("Expected WebSocket response"); } + + const message = new Promise((resolve) => { + socket.addEventListener("message", (event) => resolve(event.data)); + }); + socket.accept(); + socket.send("hello"); + expect(await message).toBe("echo:hello"); + + // Dispatches webSocketClose() on the Durable Object, which doesn't define it + socket.close(1000, "done"); + + // Wait until the runtime has actually processed the close, so the + // dispatch has definitely happened before the run ends + for (let i = 0; i < 50 && (await stub.socketCount()) > 0; i++) { + await scheduler.wait(100); + } + expect(await stub.socketCount()).toBe(0); + }); + `, + }); + + const result = await vitestRun(); + + await expect(result.exitCode).resolves.toBe(0); + // Dispatching to the absent handlers must be a no-op. Previously the wrapper + // threw " exported by does not define a `webSocketClose()` + // method", surfacing as an uncaught exception from the Durable Object. + expect(result.stderr).not.toMatch("does not define"); + expect(result.stdout).not.toMatch("does not define"); +}); + test("adminSecretsStore seeds and reads secrets", async ({ expect, seed, From 7f6a8889efb748f87c34361d1af821077f2aea95 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Mon, 20 Jul 2026 15:20:29 +0100 Subject: [PATCH 3/4] [vitest-pool-workers] Use waitUntil in WebSocket regression test --- packages/vitest-pool-workers/test/bindings.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/vitest-pool-workers/test/bindings.test.ts b/packages/vitest-pool-workers/test/bindings.test.ts index 0d8bb2f470..bbdf417472 100644 --- a/packages/vitest-pool-workers/test/bindings.test.ts +++ b/packages/vitest-pool-workers/test/bindings.test.ts @@ -107,9 +107,9 @@ test("Durable Objects may omit optional WebSocket handlers", async ({ `, "index.test.ts": dedent /* javascript */ ` import { env } from "cloudflare:workers"; - import { it, expect } from "vitest"; + import { it, vi } from "vitest"; - it("echoes a message and closes without a webSocketClose() handler", async () => { + it("echoes a message and closes without a webSocketClose() handler", async ({ expect }) => { const stub = env.OPTIONAL_WS.get(env.OPTIONAL_WS.idFromName("ws")); const response = await stub.fetch("https://example.com", { headers: { Upgrade: "websocket" }, @@ -129,10 +129,10 @@ test("Durable Objects may omit optional WebSocket handlers", async ({ // Wait until the runtime has actually processed the close, so the // dispatch has definitely happened before the run ends - for (let i = 0; i < 50 && (await stub.socketCount()) > 0; i++) { - await scheduler.wait(100); - } - expect(await stub.socketCount()).toBe(0); + await vi.waitUntil(async () => (await stub.socketCount()) === 0, { + timeout: 5_000, + interval: 100, + }); }); `, }); From 7449959be6d16965fb24a5a1dcf1d807af821548 Mon Sep 17 00:00:00 2001 From: Edmund Hung Date: Mon, 20 Jul 2026 15:26:05 +0100 Subject: [PATCH 4/4] [vitest-pool-workers] Assert socket closure with waitFor --- packages/vitest-pool-workers/test/bindings.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/vitest-pool-workers/test/bindings.test.ts b/packages/vitest-pool-workers/test/bindings.test.ts index bbdf417472..72e2f4345a 100644 --- a/packages/vitest-pool-workers/test/bindings.test.ts +++ b/packages/vitest-pool-workers/test/bindings.test.ts @@ -129,10 +129,12 @@ test("Durable Objects may omit optional WebSocket handlers", async ({ // Wait until the runtime has actually processed the close, so the // dispatch has definitely happened before the run ends - await vi.waitUntil(async () => (await stub.socketCount()) === 0, { - timeout: 5_000, - interval: 100, - }); + await vi.waitFor( + async () => { + expect(await stub.socketCount()).toBe(0); + }, + { timeout: 5_000, interval: 100 } + ); }); `, });