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..968652b17 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, EventHandlerResponse } from "../types/handler.ts"; export type { Hooks as WebSocketHooks, @@ -10,6 +10,19 @@ 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. + * + * `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?: Partial }; + /** * Define WebSocket hooks. * @@ -26,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,14 +92,14 @@ export function defineWebSocketHandler( const crossws = typeof hooks === "function" ? hooks(event) : hooks; - return Object.assign( - new Response("WebSocket upgrade is required.", { - status: 426, - }), - { - crossws, - }, - ); + // 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(toUpgradeResponse); + } + + return toUpgradeResponse(crossws); }); } @@ -82,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 ae3462eff..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, @@ -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,45 @@ 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(); + }); + + 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 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"); + }); + }); }); diff --git a/test/ws.test.ts b/test/ws.test.ts index d0de25cef..00b413a69 100644 --- a/test/ws.test.ts +++ b/test/ws.test.ts @@ -47,4 +47,29 @@ describe("defineWebSocketHandler", () => { expect((res as Response).status).toBe(426); expect((res as any).crossws).toEqual(hooks); }); + + 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) + const res = wsHandler({} as any); + // 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) + // 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.crossws).not.toBeInstanceOf(Promise); + expect(res.crossws).toEqual(hooks); + }); });