Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ export {
type WebSocketHooks,
type WebSocketPeer,
type WebSocketMessage,
type WebSocketResponse,
defineWebSocketHandler,
defineWebSocket,
} from "./utils/ws.ts";
Expand Down
55 changes: 46 additions & 9 deletions src/utils/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,27 @@ 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,
Message as WebSocketMessage,
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<WebSocketHooks> };
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Comment thread
pi0 marked this conversation as resolved.
/**
* Define WebSocket hooks.
*
Expand All @@ -26,6 +39,20 @@ export function defineWebSocket(hooks: Partial<WebSocketHooks>): Partial<WebSock
return hooks;
}

export function defineWebSocketHandler(
hooks: Partial<WebSocketHooks>,
): EventHandler<EventHandlerRequest, WebSocketResponse>;
export function defineWebSocketHandler(
hooks: (event: H3Event) => Partial<WebSocketHooks> | Promise<Partial<WebSocketHooks>>,
): EventHandler<EventHandlerRequest, EventHandlerResponse<WebSocketResponse>>;
export function defineWebSocketHandler<Http extends EventHandler>(
hooks: Partial<WebSocketHooks>,
http: Http,
): EventHandler<EventHandlerRequest, WebSocketResponse | ReturnType<Http>>;
export function defineWebSocketHandler<Http extends EventHandler>(
hooks: (event: H3Event) => Partial<WebSocketHooks> | Promise<Partial<WebSocketHooks>>,
http: Http,
): EventHandler<EventHandlerRequest, EventHandlerResponse<WebSocketResponse> | ReturnType<Http>>;
/**
* Define WebSocket event handler.
*
Expand Down Expand Up @@ -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);
});
}

Expand All @@ -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<WebSocketHooks>): WebSocketResponse {
return Object.assign(new Response("WebSocket upgrade is required.", { status: 426 }), {
crossws,
});
}
44 changes: 43 additions & 1 deletion test/unit/types.test-d.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -7,6 +7,7 @@ import {
readValidatedBody,
getValidatedQuery,
defineValidatedHandler,
defineWebSocketHandler,
} from "../../src/index.ts";
import { defineEventHandler } from "../../src/_deprecated.ts";
import { z } from "zod";
Expand Down Expand Up @@ -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<string | (Response & { crossws?: unknown })>();
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<WebSocketResponse | Promise<WebSocketResponse>>();
// And the resolved value still exposes `crossws` with no cast.
const awaited = await res;
expectTypeOf(awaited).toHaveProperty("crossws");
});
});
});
25 changes: 25 additions & 0 deletions test/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading