From 68fe5423c6066e47ed6d5c9cf6c473aacfe9aecf Mon Sep 17 00:00:00 2001 From: Mohamed MAACHE Date: Sat, 4 Jul 2026 01:48:50 +0200 Subject: [PATCH 1/7] fix(ws): expose crossws on defineWebSocketHandler return type `defineWebSocketHandler()` attaches `crossws` hooks to the 426 Response at runtime via `Object.assign`, but its return type was a plain `EventHandler` whose response resolved to `unknown`. Consumers (and the crossws adapter pattern of reading `res.crossws`) had to cast with `as any` to reach the field, and `app.fetch`-style callers saw no `crossws` at all. Add a scoped `WebSocketResponse = Response & { crossws?: Partial }` type and typed overloads so the returned handler's response exposes `crossws` without a cast. The http-fallback overload preserves the fallback handler's return type in the union. This is a type-only change; runtime behavior is unchanged. Fixes the type mismatch from #1258 with a scoped augmentation rather than a global response type, per maintainer guidance on #1306. Closes #1258 --- src/index.ts | 1 + src/utils/ws.ts | 22 +++++++++++++++++++++- test/unit/types.test-d.ts | 24 ++++++++++++++++++++++++ test/ws.test.ts | 9 +++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index a7bde03de..db8b62d28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -214,6 +214,7 @@ export { type WebSocketHooks, type WebSocketPeer, type WebSocketMessage, + type WebSocketResponse, defineWebSocketHandler, defineWebSocket, } from "./utils/ws.ts"; diff --git a/src/utils/ws.ts b/src/utils/ws.ts index 39df0f998..dd29d1570 100644 --- a/src/utils/ws.ts +++ b/src/utils/ws.ts @@ -2,7 +2,7 @@ import { defineHandler } from "../handler.ts"; import type { Hooks as WebSocketHooks } from "crossws"; import type { H3Event } from "../event.ts"; -import type { EventHandler } from "../types/handler.ts"; +import type { EventHandler, EventHandlerRequest } from "../types/handler.ts"; export type { Hooks as WebSocketHooks, @@ -10,6 +10,15 @@ export type { Peer as WebSocketPeer, } from "crossws"; +/** + * The `426 Upgrade Required` response returned by `defineWebSocketHandler()` + * for WebSocket upgrade requests, augmented with the `crossws` hooks that + * were attached to it. Adapters (like the crossws `serve()` plugin) read + * `crossws` off this response to wire up the platform-specific WebSocket + * upgrade. + */ +export type WebSocketResponse = Response & { crossws?: Partial }; + /** * Define WebSocket hooks. * @@ -52,6 +61,17 @@ export function defineWebSocket(hooks: Partial): Partial + | ((event: H3Event) => Partial | Promise>), +): EventHandler; +export function defineWebSocketHandler( + hooks: + | Partial + | ((event: H3Event) => Partial | Promise>), + http: Http, +): EventHandler>; export function defineWebSocketHandler( hooks: | Partial diff --git a/test/unit/types.test-d.ts b/test/unit/types.test-d.ts index ae3462eff..5302e02ef 100644 --- a/test/unit/types.test-d.ts +++ b/test/unit/types.test-d.ts @@ -7,6 +7,7 @@ import { readValidatedBody, getValidatedQuery, defineValidatedHandler, + defineWebSocketHandler, } from "../../src/index.ts"; import { defineEventHandler } from "../../src/_deprecated.ts"; import { z } from "zod"; @@ -152,4 +153,27 @@ describe("types", () => { }); }); }); + + describe("defineWebSocketHandler", () => { + it("exposes crossws on the returned response type without a cast", () => { + // https://github.com/h3js/h3/issues/1258 + // Given a WebSocket handler defined via defineWebSocketHandler + const wsHandler = defineWebSocketHandler({ message: () => {} }); + // When the handler is invoked directly (as crossws adapters do) + const res = wsHandler({} as H3Event); + // Then `crossws` must be visible on the returned type, with no `as any` cast + expectTypeOf(res).toHaveProperty("crossws"); + }); + + it("still types the http fallback handler's return value", () => { + // Given a WebSocket handler with an http fallback returning a string + const wsHandler = defineWebSocketHandler({ message: () => {} }, () => "hello"); + const res = wsHandler({} as H3Event); + // Then the returned type is the union of the WebSocket response + // (with `crossws` visible) and the http handler's return type — + // neither branch is widened away. + expectTypeOf(res).toExtend(); + expectTypeOf(res).not.toBeUnknown(); + }); + }); }); diff --git a/test/ws.test.ts b/test/ws.test.ts index d0de25cef..de56c33bf 100644 --- a/test/ws.test.ts +++ b/test/ws.test.ts @@ -47,4 +47,13 @@ describe("defineWebSocketHandler", () => { expect((res as Response).status).toBe(426); expect((res as any).crossws).toEqual(hooks); }); + + it("exposes crossws on the returned response without an `as any` cast (#1258)", () => { + // Given a WebSocket handler defined via defineWebSocketHandler + const wsHandler = defineWebSocketHandler(hooks); + // When the handler is invoked in-process (as crossws adapters do internally) + const res = wsHandler({} as any); + // Then `res.crossws` is readable, typed, and is the exact hooks object + expect(res.crossws).toBe(hooks); + }); }); From 35e571dcf5ae39a26194e034464e48fd6bb58611 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:10:07 +0000 Subject: [PATCH 2/7] chore: apply automated updates --- docs/2.utils/9.more.md | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/docs/2.utils/9.more.md b/docs/2.utils/9.more.md index 8e0b73d25..3741eb0ef 100644 --- a/docs/2.utils/9.more.md +++ b/docs/2.utils/9.more.md @@ -93,37 +93,6 @@ const hooks = defineWebSocket({ ### `defineWebSocketHandler(http?)` -Define WebSocket event handler. - -By default, non-upgrade (plain HTTP) requests receive a `426 Upgrade Required` response. Pass an `http` handler to serve those requests instead, allowing the same route to handle both WebSocket upgrades and regular HTTP requests. WebSocket upgrade requests always go to `hooks`. - -Note: the `http` handler only handles non-upgrade requests. To reject or customize the upgrade handshake itself, use the crossws `upgrade` hook instead. - -**Example:** - -```ts -// WebSocket-only route (non-upgrade requests get `426 Upgrade Required`) -app.get( - "/_ws", - defineWebSocketHandler({ - message: (peer, message) => peer.send(message.text()), - }), -); -``` - -**Example:** - -```ts -// Handle both WebSocket upgrades and plain HTTP on the same route -app.get( - "/_ws", - defineWebSocketHandler( - { message: (peer, message) => peer.send(message.text()) }, - () => "Send a WebSocket upgrade request to connect.", - ), -); -``` - ## Adapters From adc72b6bfd7c8126872f26a27b80deb2e9af748c Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sat, 4 Jul 2026 14:26:50 +0000 Subject: [PATCH 3/7] up --- src/utils/ws.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/utils/ws.ts b/src/utils/ws.ts index dd29d1570..5c2f07a81 100644 --- a/src/utils/ws.ts +++ b/src/utils/ws.ts @@ -3,6 +3,7 @@ import { defineHandler } from "../handler.ts"; import type { Hooks as WebSocketHooks } from "crossws"; import type { H3Event } from "../event.ts"; import type { EventHandler, EventHandlerRequest } from "../types/handler.ts"; +import type { MaybePromise } from "../types/_utils.ts"; export type { Hooks as WebSocketHooks, @@ -16,8 +17,11 @@ export type { * were attached to it. Adapters (like the crossws `serve()` plugin) read * `crossws` off this response to wire up the platform-specific WebSocket * upgrade. + * + * When the handler is defined with an async hooks factory, `crossws` is the + * still-unresolved `Promise` (adapters await it), hence `MaybePromise`. */ -export type WebSocketResponse = Response & { crossws?: Partial }; +export type WebSocketResponse = Response & { crossws?: MaybePromise> }; /** * Define WebSocket hooks. From b93066c9a349cad5cd10e51992c668d7c981cc9d Mon Sep 17 00:00:00 2001 From: Mohamed MAACHE Date: Sun, 5 Jul 2026 14:04:40 +0200 Subject: [PATCH 4/7] fix(ws): await async hooks factory before attaching crossws defineWebSocketHandler() accepted an async hooks(event) factory but never awaited it, so res.crossws ended up as an unresolved Promise instead of the hooks object. Await it before attaching, and keep the sync path untouched. --- src/utils/ws.ts | 24 ++++++++++++++++++++---- test/ws.test.ts | 15 +++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/utils/ws.ts b/src/utils/ws.ts index 5c2f07a81..346d6d096 100644 --- a/src/utils/ws.ts +++ b/src/utils/ws.ts @@ -3,7 +3,6 @@ import { defineHandler } from "../handler.ts"; import type { Hooks as WebSocketHooks } from "crossws"; import type { H3Event } from "../event.ts"; import type { EventHandler, EventHandlerRequest } from "../types/handler.ts"; -import type { MaybePromise } from "../types/_utils.ts"; export type { Hooks as WebSocketHooks, @@ -18,10 +17,11 @@ export type { * `crossws` off this response to wire up the platform-specific WebSocket * upgrade. * - * When the handler is defined with an async hooks factory, `crossws` is the - * still-unresolved `Promise` (adapters await it), hence `MaybePromise`. + * `crossws` is always the resolved hooks object: when the handler is defined + * with an async hooks factory, `defineWebSocketHandler()` awaits it before + * attaching it to the response. */ -export type WebSocketResponse = Response & { crossws?: MaybePromise> }; +export type WebSocketResponse = Response & { crossws?: Partial }; /** * Define WebSocket hooks. @@ -89,6 +89,22 @@ export function defineWebSocketHandler( const crossws = typeof hooks === "function" ? hooks(event) : hooks; + // Async hook factories must be awaited before `crossws` is attached, + // otherwise the response ends up carrying an unresolved Promise instead + // of the hooks object. Sync hooks stay on the sync path (no wrapping). + if (crossws instanceof Promise) { + return crossws.then((resolvedCrossws) => + Object.assign( + new Response("WebSocket upgrade is required.", { + status: 426, + }), + { + crossws: resolvedCrossws, + }, + ), + ); + } + return Object.assign( new Response("WebSocket upgrade is required.", { status: 426, diff --git a/test/ws.test.ts b/test/ws.test.ts index de56c33bf..c0ea6fd50 100644 --- a/test/ws.test.ts +++ b/test/ws.test.ts @@ -56,4 +56,19 @@ describe("defineWebSocketHandler", () => { // Then `res.crossws` is readable, typed, and is the exact hooks object expect(res.crossws).toBe(hooks); }); + + it("awaits an async hooks factory before attaching crossws", async () => { + // Given a WebSocket handler defined with an async hooks factory + const wsHandler = defineWebSocketHandler(async (_event) => { + await Promise.resolve(); + return hooks; + }); + // When the handler is invoked in-process (as crossws adapters do internally) + const res = await (wsHandler({} as any) as unknown as Promise); + expect(res).toBeInstanceOf(Response); + expect(res.status).toBe(426); + // Then `crossws` is the resolved hooks object, not an unresolved Promise + expect((res as any).crossws).not.toBeInstanceOf(Promise); + expect((res as any).crossws).toEqual(hooks); + }); }); From ec06196d99809b99127c28f42bffdae82f8d98b9 Mon Sep 17 00:00:00 2001 From: Mohamed MAACHE Date: Sun, 5 Jul 2026 16:01:49 +0200 Subject: [PATCH 5/7] fix(ws): type async hooks factory response, dedupe upgrade response defineWebSocketHandler only declared a sync WebSocketResponse return type, so an async hooks factory still forced a cast to await the response and read crossws. Split the overloads by hooks shape so a factory (sync or async) returns WebSocketResponse | Promise, while plain object hooks keep the sync-only type. Also extract the duplicated 426 response construction into one toUpgradeResponse helper. --- src/utils/ws.ts | 47 ++++++++++++++++++--------------------- test/unit/types.test-d.ts | 14 ++++++++++++ test/ws.test.ts | 7 +++--- 3 files changed, 40 insertions(+), 28 deletions(-) diff --git a/src/utils/ws.ts b/src/utils/ws.ts index 346d6d096..8fc741a84 100644 --- a/src/utils/ws.ts +++ b/src/utils/ws.ts @@ -2,7 +2,7 @@ import { defineHandler } from "../handler.ts"; import type { Hooks as WebSocketHooks } from "crossws"; import type { H3Event } from "../event.ts"; -import type { EventHandler, EventHandlerRequest } from "../types/handler.ts"; +import type { EventHandler, EventHandlerRequest, EventHandlerResponse } from "../types/handler.ts"; export type { Hooks as WebSocketHooks, @@ -66,16 +66,19 @@ export function defineWebSocket(hooks: Partial): Partial - | ((event: H3Event) => Partial | Promise>), + hooks: Partial, ): EventHandler; +export function defineWebSocketHandler( + hooks: (event: H3Event) => Partial | Promise>, +): EventHandler>; export function defineWebSocketHandler( - hooks: - | Partial - | ((event: H3Event) => Partial | Promise>), + hooks: Partial, http: Http, ): EventHandler>; +export function defineWebSocketHandler( + hooks: (event: H3Event) => Partial | Promise>, + http: Http, +): EventHandler | ReturnType>; export function defineWebSocketHandler( hooks: | Partial @@ -93,26 +96,10 @@ export function defineWebSocketHandler( // otherwise the response ends up carrying an unresolved Promise instead // of the hooks object. Sync hooks stay on the sync path (no wrapping). if (crossws instanceof Promise) { - return crossws.then((resolvedCrossws) => - Object.assign( - new Response("WebSocket upgrade is required.", { - status: 426, - }), - { - crossws: resolvedCrossws, - }, - ), - ); + return crossws.then(toUpgradeResponse); } - return Object.assign( - new Response("WebSocket upgrade is required.", { - status: 426, - }), - { - crossws, - }, - ); + return toUpgradeResponse(crossws); }); } @@ -122,3 +109,13 @@ export function defineWebSocketHandler( function isWebSocketUpgrade(event: H3Event): boolean { return event.req.headers.get("upgrade")?.toLowerCase() === "websocket"; } + +/** + * Build the `426 Upgrade Required` response, with the resolved `crossws` + * hooks attached for adapters to read. + */ +function toUpgradeResponse(crossws: Partial): WebSocketResponse { + return Object.assign(new Response("WebSocket upgrade is required.", { status: 426 }), { + crossws, + }); +} diff --git a/test/unit/types.test-d.ts b/test/unit/types.test-d.ts index 5302e02ef..98576d842 100644 --- a/test/unit/types.test-d.ts +++ b/test/unit/types.test-d.ts @@ -175,5 +175,19 @@ describe("types", () => { expectTypeOf(res).toExtend(); expectTypeOf(res).not.toBeUnknown(); }); + + it("types an async hooks factory's return value without a cast", async () => { + // Given a WebSocket handler defined with an async hooks factory + const wsHandler = defineWebSocketHandler(async (_event) => { + await Promise.resolve(); + return { message: () => {} }; + }); + // When the handler is invoked directly (as crossws adapters do) + const res = wsHandler({} as H3Event); + // Then the return type already reflects that it can be a Promise, + // so it can be awaited and have `crossws` read with no cast. + const awaited = await res; + expectTypeOf(awaited).toHaveProperty("crossws"); + }); }); }); diff --git a/test/ws.test.ts b/test/ws.test.ts index c0ea6fd50..4233bf226 100644 --- a/test/ws.test.ts +++ b/test/ws.test.ts @@ -64,11 +64,12 @@ describe("defineWebSocketHandler", () => { return hooks; }); // When the handler is invoked in-process (as crossws adapters do internally) - const res = await (wsHandler({} as any) as unknown as Promise); + // Then the return type already reflects the Promise branch, no cast needed + const res = await wsHandler({} as any); expect(res).toBeInstanceOf(Response); expect(res.status).toBe(426); // Then `crossws` is the resolved hooks object, not an unresolved Promise - expect((res as any).crossws).not.toBeInstanceOf(Promise); - expect((res as any).crossws).toEqual(hooks); + expect(res.crossws).not.toBeInstanceOf(Promise); + expect(res.crossws).toEqual(hooks); }); }); From e435cfe8b5f81f4c961a9a11f09eb6546b535790 Mon Sep 17 00:00:00 2001 From: Mohamed MAACHE Date: Sun, 5 Jul 2026 16:12:15 +0200 Subject: [PATCH 6/7] test(ws): assert async factory response type, not just the awaited value The async factory test awaited res and checked crossws on the awaited value, but Awaited is a no-op on non-Promise types and crossws is optional on WebSocketResponse, so this passed even with a sync-only overload. Assert on res itself against the union type instead. --- test/unit/types.test-d.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/test/unit/types.test-d.ts b/test/unit/types.test-d.ts index 98576d842..7c10eb2b6 100644 --- a/test/unit/types.test-d.ts +++ b/test/unit/types.test-d.ts @@ -1,4 +1,4 @@ -import type { H3Event } from "../../src/index.ts"; +import type { H3Event, WebSocketResponse } from "../../src/index.ts"; import { describe, it, expectTypeOf } from "vitest"; import { defineHandler, @@ -184,8 +184,12 @@ describe("types", () => { }); // When the handler is invoked directly (as crossws adapters do) const res = wsHandler({} as H3Event); - // Then the return type already reflects that it can be a Promise, - // so it can be awaited and have `crossws` read with no cast. + // Then the return type must itself be the union of the sync response + // and a Promise of it, not just `WebSocketResponse`. Otherwise + // await-ing a sync-typed value would be a no-op and this assertion + // would pass regardless of whether the factory was actually awaited. + expectTypeOf(res).toEqualTypeOf>(); + // And the resolved value still exposes `crossws` with no cast. const awaited = await res; expectTypeOf(awaited).toHaveProperty("crossws"); }); From 3731217d0f0692fa5d50072ff36d868d1e64b685 Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Sun, 5 Jul 2026 17:15:08 +0000 Subject: [PATCH 7/7] up --- docs/2.utils/9.more.md | 31 +++++++++++++++++++++++++++++++ src/utils/ws.ts | 28 ++++++++++++++-------------- test/ws.test.ts | 2 +- 3 files changed, 46 insertions(+), 15 deletions(-) diff --git a/docs/2.utils/9.more.md b/docs/2.utils/9.more.md index 3741eb0ef..8e0b73d25 100644 --- a/docs/2.utils/9.more.md +++ b/docs/2.utils/9.more.md @@ -93,6 +93,37 @@ const hooks = defineWebSocket({ ### `defineWebSocketHandler(http?)` +Define WebSocket event handler. + +By default, non-upgrade (plain HTTP) requests receive a `426 Upgrade Required` response. Pass an `http` handler to serve those requests instead, allowing the same route to handle both WebSocket upgrades and regular HTTP requests. WebSocket upgrade requests always go to `hooks`. + +Note: the `http` handler only handles non-upgrade requests. To reject or customize the upgrade handshake itself, use the crossws `upgrade` hook instead. + +**Example:** + +```ts +// WebSocket-only route (non-upgrade requests get `426 Upgrade Required`) +app.get( + "/_ws", + defineWebSocketHandler({ + message: (peer, message) => peer.send(message.text()), + }), +); +``` + +**Example:** + +```ts +// Handle both WebSocket upgrades and plain HTTP on the same route +app.get( + "/_ws", + defineWebSocketHandler( + { message: (peer, message) => peer.send(message.text()) }, + () => "Send a WebSocket upgrade request to connect.", + ), +); +``` + ## Adapters diff --git a/src/utils/ws.ts b/src/utils/ws.ts index 8fc741a84..968652b17 100644 --- a/src/utils/ws.ts +++ b/src/utils/ws.ts @@ -39,6 +39,20 @@ export function defineWebSocket(hooks: Partial): Partial, +): EventHandler; +export function defineWebSocketHandler( + hooks: (event: H3Event) => Partial | Promise>, +): EventHandler>; +export function defineWebSocketHandler( + hooks: Partial, + http: Http, +): EventHandler>; +export function defineWebSocketHandler( + hooks: (event: H3Event) => Partial | Promise>, + http: Http, +): EventHandler | ReturnType>; /** * Define WebSocket event handler. * @@ -65,20 +79,6 @@ export function defineWebSocket(hooks: Partial): Partial, -): EventHandler; -export function defineWebSocketHandler( - hooks: (event: H3Event) => Partial | Promise>, -): EventHandler>; -export function defineWebSocketHandler( - hooks: Partial, - http: Http, -): EventHandler>; -export function defineWebSocketHandler( - hooks: (event: H3Event) => Partial | Promise>, - http: Http, -): EventHandler | ReturnType>; export function defineWebSocketHandler( hooks: | Partial diff --git a/test/ws.test.ts b/test/ws.test.ts index 4233bf226..00b413a69 100644 --- a/test/ws.test.ts +++ b/test/ws.test.ts @@ -48,7 +48,7 @@ describe("defineWebSocketHandler", () => { expect((res as any).crossws).toEqual(hooks); }); - it("exposes crossws on the returned response without an `as any` cast (#1258)", () => { + it("exposes crossws on the returned response", () => { // Given a WebSocket handler defined via defineWebSocketHandler const wsHandler = defineWebSocketHandler(hooks); // When the handler is invoked in-process (as crossws adapters do internally)