diff --git a/package.json b/package.json index 7f253022..3a5f1d85 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "test:types": "vitest typecheck" }, "dependencies": { + "@types/ws": "^8.5.5", "cookie-es": "^1.0.0", "defu": "^6.1.2", "destr": "^2.0.1", @@ -39,7 +40,8 @@ "radix3": "^1.1.0", "ufo": "^1.3.0", "uncrypto": "^0.1.3", - "unenv": "^1.7.4" + "unenv": "^1.7.4", + "ws": "^8.14.2" }, "devDependencies": { "0x": "^5.6.0", diff --git a/playground/app.ts b/playground/app.ts index ffa15e72..0f57d2b9 100644 --- a/playground/app.ts +++ b/playground/app.ts @@ -1,13 +1,36 @@ -import { createApp, createRouter, eventHandler } from "h3"; - +import { + createApp, + eventHandler, + upgradeWebSocket, + isWebSocketUpgradeRequest, + isWebSocketEvent, + toWebSocketEvent, +} from "h3"; export const app = createApp(); -const router = createRouter(); -app.use(router); - -router.get( +app.use( "/", eventHandler((event) => { - return { path: event.path, message: "Hello World!" }; + if (isWebSocketEvent(event)) { + const wsEvent = toWebSocketEvent(event); + if (wsEvent.type === "connection") { + wsEvent.connection.send("pong"); + } else if (wsEvent.type === "message") { + console.log("got", new TextDecoder().decode(wsEvent.message)); + } + } + if (isWebSocketUpgradeRequest(event)) { + return upgradeWebSocket(event); + } + + return `

Hello World!

`; }), ); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16b0dca4..cb60c944 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@types/ws': + specifier: ^8.5.5 + version: 8.5.5 cookie-es: specifier: ^1.0.0 version: 1.0.0 @@ -32,6 +35,9 @@ importers: unenv: specifier: ^1.7.4 version: 1.7.4 + ws: + specifier: ^8.14.2 + version: 8.14.2 devDependencies: 0x: specifier: ^5.6.0 @@ -74,7 +80,7 @@ importers: version: 1.19.3 listhen: specifier: ^1.4.4 - version: 1.4.4 + version: link:../listhen node-fetch-native: specifier: ^1.4.0 version: 1.4.0 @@ -898,6 +904,7 @@ packages: cpu: [arm64] os: [android] requiresBuild: true + dev: false optional: true /@parcel/watcher-darwin-arm64@2.3.0: @@ -906,6 +913,7 @@ packages: cpu: [arm64] os: [darwin] requiresBuild: true + dev: false optional: true /@parcel/watcher-darwin-x64@2.3.0: @@ -914,6 +922,7 @@ packages: cpu: [x64] os: [darwin] requiresBuild: true + dev: false optional: true /@parcel/watcher-freebsd-x64@2.3.0: @@ -922,6 +931,7 @@ packages: cpu: [x64] os: [freebsd] requiresBuild: true + dev: false optional: true /@parcel/watcher-linux-arm-glibc@2.3.0: @@ -930,6 +940,7 @@ packages: cpu: [arm] os: [linux] requiresBuild: true + dev: false optional: true /@parcel/watcher-linux-arm64-glibc@2.3.0: @@ -938,6 +949,7 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: false optional: true /@parcel/watcher-linux-arm64-musl@2.3.0: @@ -946,6 +958,7 @@ packages: cpu: [arm64] os: [linux] requiresBuild: true + dev: false optional: true /@parcel/watcher-linux-x64-glibc@2.3.0: @@ -954,6 +967,7 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: false optional: true /@parcel/watcher-linux-x64-musl@2.3.0: @@ -962,6 +976,7 @@ packages: cpu: [x64] os: [linux] requiresBuild: true + dev: false optional: true /@parcel/watcher-wasm@2.3.0: @@ -971,6 +986,7 @@ packages: is-glob: 4.0.3 micromatch: 4.0.5 napi-wasm: 1.1.0 + dev: false bundledDependencies: - napi-wasm @@ -980,6 +996,7 @@ packages: cpu: [arm64] os: [win32] requiresBuild: true + dev: false optional: true /@parcel/watcher-win32-ia32@2.3.0: @@ -988,6 +1005,7 @@ packages: cpu: [ia32] os: [win32] requiresBuild: true + dev: false optional: true /@parcel/watcher-win32-x64@2.3.0: @@ -996,6 +1014,7 @@ packages: cpu: [x64] os: [win32] requiresBuild: true + dev: false optional: true /@parcel/watcher@2.3.0: @@ -1019,6 +1038,7 @@ packages: '@parcel/watcher-win32-arm64': 2.3.0 '@parcel/watcher-win32-ia32': 2.3.0 '@parcel/watcher-win32-x64': 2.3.0 + dev: false /@rollup/plugin-alias@5.0.0(rollup@3.28.1): resolution: {integrity: sha512-l9hY5chSCjuFRPsnRm16twWBiSApl2uYFLsepQYwtBuAxNMQ/1dJqADld40P0Jkqm65GRTLy/AC6hnpVebtLsA==} @@ -1190,7 +1210,6 @@ packages: /@types/node@20.5.8: resolution: {integrity: sha512-eajsR9aeljqNhK028VG0Wuw+OaY5LLxYmxeoXynIoE6jannr9/Ucd1LL0hSSoafk5LTYG+FfqsyGt81Q6Zkybw==} - dev: true /@types/normalize-package-data@2.4.1: resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} @@ -1240,6 +1259,12 @@ packages: '@types/superagent': 4.1.18 dev: true + /@types/ws@8.5.5: + resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==} + dependencies: + '@types/node': 20.5.8 + dev: false + /@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0)(eslint@8.48.0)(typescript@5.2.2): resolution: {integrity: sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1554,6 +1579,7 @@ packages: /arch@2.2.0: resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + dev: false /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2140,6 +2166,7 @@ packages: arch: 2.2.0 execa: 5.1.1 is-wsl: 2.2.0 + dev: false /code-point-at@1.1.0: resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==} @@ -2276,6 +2303,7 @@ packages: /cookie-es@1.0.0: resolution: {integrity: sha512-mWYvfOLrfEc996hlKcdABeIiPHUPC6DM2QYZdGGOvhOTbA3tjm2eBwqlJpoFdjC89NI4Qt6h0Pu06Mp+1Pj5OQ==} + dev: false /cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} @@ -2599,6 +2627,7 @@ packages: resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} engines: {node: '>=0.10'} hasBin: true + dev: false /detective@5.2.1: resolution: {integrity: sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==} @@ -3565,6 +3594,7 @@ packages: /get-port-please@3.0.2: resolution: {integrity: sha512-c14cAITf0E+uqdxGALvyYHwOL7UsnWcv3oDtgDAZksiVSGN87xlWVUWGZcmWQU3cICdaOxT+6LdQzUfK2ei1SA==} + dev: false /get-port@7.0.0: resolution: {integrity: sha512-mDHFgApoQd+azgMdwylJrv2DX47ywGq1i5VFJE7fZ0dttNq3iQMfsU4IvEgBHojA3KqEudyu7Vq+oN8kNaNkWw==} @@ -3712,6 +3742,7 @@ packages: ufo: 1.3.0 uncrypto: 0.1.3 unenv: 1.7.4 + dev: false /has-ansi@2.0.0: resolution: {integrity: sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg==} @@ -3854,6 +3885,7 @@ packages: /http-shutdown@1.2.2: resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + dev: false /https-browserify@1.0.0: resolution: {integrity: sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==} @@ -3995,6 +4027,7 @@ packages: /iron-webcrypto@0.8.0: resolution: {integrity: sha512-gScdcWHjTGclCU15CIv2r069NoQrys1UeUFFfaO1hL++ytLHkVw7N5nXJmFf3J2LEDMz1PkrvC0m62JEeu1axQ==} + dev: false /is-arguments@1.1.1: resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} @@ -4395,6 +4428,7 @@ packages: ufo: 1.3.0 untun: 0.1.2 uqr: 0.1.2 + dev: false /local-pkg@0.4.3: resolution: {integrity: sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==} @@ -4586,6 +4620,7 @@ packages: resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} engines: {node: '>=10.0.0'} hasBin: true + dev: false /mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} @@ -4775,6 +4810,7 @@ packages: /napi-wasm@1.1.0: resolution: {integrity: sha512-lHwIAJbmLSjF9VDRm9GoVOy9AGp3aIvkjv+Kvz9h16QR3uSVYH78PNQUnT2U4X53mhlnV2M7wrhibQ3GHicDmg==} + dev: false /natural-compare-lite@1.4.0: resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} @@ -4797,6 +4833,7 @@ packages: /node-addon-api@7.0.0: resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} + dev: false /node-fetch-native@1.4.0: resolution: {integrity: sha512-F5kfEj95kX8tkDhUCYdV8dg3/8Olx/94zB8+ZNthFs6Bz31UpUi8Xh40TN3thLwXgrwXry1pEg9lJ++tLWTcqA==} @@ -4804,6 +4841,7 @@ packages: /node-forge@1.3.1: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} engines: {node: '>= 6.13.0'} + dev: false /node-releases@2.0.13: resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} @@ -5275,6 +5313,7 @@ packages: /radix3@1.1.0: resolution: {integrity: sha512-pNsHDxbGORSvuSScqNJ+3Km6QAVqk8CfsCBIEoDgpqLrkD2f3QM4I7d1ozJJ172OmIcoUcerZaNWqtLkRXTV3A==} + dev: false /randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -6220,6 +6259,7 @@ packages: /uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + dev: false /undeclared-identifiers@1.1.3: resolution: {integrity: sha512-pJOW4nxjlmfwKApE4zvxLScM/njmwj/DiUBv7EabwE4O8kRUy+HIwxQtZLBPll/jx1LJyBcqNfB3/cpv9EZwOw==} @@ -6240,6 +6280,7 @@ packages: mime: 3.0.0 node-fetch-native: 1.4.0 pathe: 1.1.1 + dev: false /universalify@2.0.0: resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} @@ -6263,6 +6304,7 @@ packages: citty: 0.1.3 consola: 3.2.3 pathe: 1.1.1 + dev: false /untyped@1.4.0: resolution: {integrity: sha512-Egkr/s4zcMTEuulcIb7dgURS6QpN7DyqQYdf+jBtiaJvQ+eRsrtWUoX84SbvQWuLkXsOjM+8sJC9u6KoMK/U7Q==} @@ -6296,6 +6338,7 @@ packages: /uqr@0.1.2: resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} + dev: false /uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -6537,6 +6580,19 @@ packages: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} dev: true + /ws@8.14.2: + resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + /xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} diff --git a/src/app.ts b/src/app.ts index b7241c93..2dbc58ec 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,4 +1,5 @@ import { withoutTrailingSlash } from "ufo"; +import { WebSocketServer } from "ws"; import { lazyEventHandler, toEventHandler, @@ -15,6 +16,9 @@ import { sendWebResponse, isWebResponse, sendNoContent, + isWebSocketUpgradeRequest, + isWebSocketUpgradeResponse, + H3WebSocketEvent, } from "./utils"; import type { EventHandler, LazyEventHandler } from "./types"; @@ -112,7 +116,8 @@ export function use( export function createAppEventHandler(stack: Stack, options: AppOptions) { const spacing = options.debug ? 2 : undefined; - return eventHandler(async (event) => { + const ws = new WebSocketServer({ noServer: true }); + const handler: EventHandler = eventHandler(async (event) => { // Keep original incoming url accessable event.node.req.originalUrl = event.node.req.originalUrl || event.node.req.url || "/"; @@ -154,6 +159,13 @@ export function createAppEventHandler(stack: Stack, options: AppOptions) { // 5. Try to handle return value const _body = val === undefined ? undefined : await val; if (_body !== undefined) { + if ( + _body && + isWebSocketUpgradeResponse(_body) && + isWebSocketUpgradeRequest(event) + ) { + return handleWebSocketUpgrade(event, ws, handler); + } const _response = { body: _body }; if (options.onBeforeResponse) { await options.onBeforeResponse(event, _response); @@ -185,6 +197,8 @@ export function createAppEventHandler(stack: Stack, options: AppOptions) { await options.onAfterResponse(event, undefined); } }); + + return handler; } function normalizeLayer(input: InputLayer) { @@ -208,6 +222,61 @@ function normalizeLayer(input: InputLayer) { } as Layer; } +function handleWebSocketUpgrade( + event: H3Event, + ws: WebSocketServer, + handler: EventHandler, +) { + return ws.handleUpgrade( + event.node.req, + event.node.req.socket, + Buffer.alloc(0), + (socket) => { + event.headers.delete("upgrade"); + delete event.node.req.headers.upgrade; + handler( + new H3WebSocketEvent(event.node.req, event.node.res, { + type: "connection", + // @ts-ignore + connection: socket, + }), + ); + + socket.on("message", (message) => { + handler( + new H3WebSocketEvent(event.node.req, event.node.res, { + type: "message", + message, + // @ts-ignore + connection: socket, + }), + ); + }); + + socket.on("close", () => { + handler( + new H3WebSocketEvent(event.node.req, event.node.res, { + type: "close", + // @ts-ignore + connection: socket, + }), + ); + }); + + socket.on("error", (error) => { + handler( + new H3WebSocketEvent(event.node.req, event.node.res, { + type: "error", + error, + // @ts-ignore + connection: socket, + }), + ); + }); + }, + ); +} + function handleHandlerResponse(event: H3Event, val: any, jsonSpace?: number) { // Empty Content if (val === null) { diff --git a/src/event/event.ts b/src/event/event.ts index d9774406..4fe4df92 100644 --- a/src/event/event.ts +++ b/src/event/event.ts @@ -1,7 +1,7 @@ import type { IncomingHttpHeaders } from "node:http"; import type { H3EventContext, HTTPMethod, EventHandlerRequest } from "../types"; import type { NodeIncomingMessage, NodeServerResponse } from "../adapters/node"; -import { sendWebResponse } from "../utils"; +import { sendWebResponse } from "../utils/response"; import { hasProp } from "../utils/internal/object"; export interface NodeEventContext { diff --git a/src/utils/index.ts b/src/utils/index.ts index 4404d426..2c31314a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -10,3 +10,4 @@ export * from "./response"; export * from "./sanitize"; export * from "./session"; export * from "./static"; +export * from "./websocket"; diff --git a/src/utils/websocket.ts b/src/utils/websocket.ts new file mode 100644 index 00000000..72ce7b1a --- /dev/null +++ b/src/utils/websocket.ts @@ -0,0 +1,79 @@ +import { NodeIncomingMessage, NodeServerResponse } from "../adapters"; +import { H3Event } from "../event"; + +export class WebSocketUpgradeResponse { + status: number; + headers: Headers; + constructor(_status = 101, _headers: Headers = new Headers()) { + this.status = _status; + this.headers = _headers; + } +} + +export function isWebSocketUpgradeRequest(event: H3Event): boolean { + return event.headers.get("upgrade") === "websocket"; +} + +export function isWebSocketUpgradeResponse( + response: any, +): response is WebSocketUpgradeResponse { + return response instanceof WebSocketUpgradeResponse; +} + +/** A WebSocket connected to the Party */ +export type Connection = WebSocket & { + /** Connection identifier */ + // id: string; + // We would have been able to use Websocket::url + // but it's not available in the Workers runtime + // (rather, url is `null` when using WebSocketPair) + // It's also set as readonly, so we can't set it ourselves. + // Instead, we'll use the `uri` property. + // uri: string; +}; + +export type WebSocketEvent = + | { + type: "connection"; + connection: Connection; + } + | { + type: "message"; + message: string | ArrayBuffer | Buffer[]; + connection: Connection; + } + | { + type: "error"; + error: Error; + connection: Connection; + } + | { + type: "close"; + connection: Connection; + }; + +export class H3WebSocketEvent extends H3Event { + websocket: WebSocketEvent; + constructor( + req: NodeIncomingMessage, + res: NodeServerResponse, + wsEvent: WebSocketEvent, + ) { + super(req, res); + this.websocket = wsEvent; + } +} + +export function upgradeWebSocket(event: H3Event): WebSocketUpgradeResponse { + return new WebSocketUpgradeResponse(); +} + +export function isWebSocketEvent(event: H3Event): event is H3WebSocketEvent { + // @ts-ignore + return !!event.websocket; +} + +export function toWebSocketEvent(event: H3Event): WebSocketEvent { + // @ts-ignore + return event.websocket; +}