diff --git a/package.json b/package.json index bd684a2..347abdf 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,7 @@ "@types/jest": "^29.5.3", "@types/node": "^20.10.0", "@types/supertest": "^2.0.12", + "@types/ws": "^8.5.10", "@whatwg-node/fetch": "^0.9.14", "eslint": "^8.55.0", "hono": "^3.11.7", @@ -73,8 +74,10 @@ "np": "^7.7.0", "publint": "^0.1.16", "supertest": "^6.3.3", + "superwstest": "^2.0.3", "ts-jest": "^29.1.1", "tsup": "^7.2.0", - "typescript": "^5.3.2" + "typescript": "^5.3.2", + "ws": "^8.16.0" } } diff --git a/src/listener.ts b/src/listener.ts index 1e02c5c..8a04f85 100644 --- a/src/listener.ts +++ b/src/listener.ts @@ -1,5 +1,7 @@ -import type { IncomingMessage, ServerResponse, OutgoingHttpHeaders } from 'node:http' +import { ServerResponse } from 'node:http' +import type { IncomingMessage, OutgoingHttpHeaders } from 'node:http' import type { Http2ServerRequest, Http2ServerResponse } from 'node:http2' +import type { Socket } from 'node:net' import { newRequest } from './request' import { cacheKey } from './response' import type { FetchCallback } from './types' @@ -56,6 +58,10 @@ const responseViaResponseObject = async ( if (res instanceof Promise) { res = await res.catch(handleFetchError) } + + if (res.status === 101) { + return + } try { if (cacheKey in res) { @@ -103,10 +109,13 @@ const responseViaResponseObject = async ( } } +type RequestListener = ReturnType; + export const getRequestListener = (fetchCallback: FetchCallback) => { return ( incoming: IncomingMessage | Http2ServerRequest, - outgoing: ServerResponse | Http2ServerResponse + outgoing: ServerResponse | Http2ServerResponse, + env?: Record ) => { let res @@ -115,7 +124,11 @@ export const getRequestListener = (fetchCallback: FetchCallback) => { const req = newRequest(incoming) try { - res = fetchCallback(req) as Response | Promise + res = fetchCallback(req, env || { + incoming, + outgoing + }) as Response | Promise + if (cacheKey in res) { // synchronous, cacheable response return responseViaCache(res as Response, outgoing) @@ -131,3 +144,13 @@ export const getRequestListener = (fetchCallback: FetchCallback) => { return responseViaResponseObject(res, outgoing) } } + +export const getUpgradeListener = (requestListener: RequestListener) => { + return (incoming: IncomingMessage, socket: Socket, head: Buffer) => { + const outgoing = new ServerResponse(incoming) + outgoing.assignSocket(socket) + requestListener(incoming, outgoing, { + incoming, outgoing, socket, head + }) + } +} \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index 3ee0d02..7d37797 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,15 +1,19 @@ import { createServer as createServerHTTP } from 'node:http' import type { AddressInfo } from 'node:net' -import { getRequestListener } from './listener' +import { getRequestListener, getUpgradeListener } from './listener' import type { Options, ServerType } from './types' export const createAdaptorServer = (options: Options): ServerType => { const fetchCallback = options.fetch + const upgrade = options.upgrade !== false const requestListener = getRequestListener(fetchCallback) // ts will complain about createServerHTTP and createServerHTTP2 not being callable, which works just fine // eslint-disable-next-line @typescript-eslint/no-explicit-any const createServer: any = options.createServer || createServerHTTP const server: ServerType = createServer(options.serverOptions || {}, requestListener) + if (upgrade) { + server.on('upgrade', getUpgradeListener(requestListener)) + } return server } diff --git a/src/types.ts b/src/types.ts index 94461b0..9c94bcc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,4 @@ + import type { createServer, Server, ServerOptions as HttpServerOptions } from 'node:http' import type { createSecureServer as createSecureHttp2Server, @@ -11,8 +12,9 @@ import type { createServer as createHttpsServer, ServerOptions as HttpsServerOptions, } from 'node:https' +import type { Env } from 'hono' -export type FetchCallback = (request: Request) => Promise | unknown +export type FetchCallback = (request: Request, env: Env) => Promise | unknown export type NextHandlerOption = { fetch: FetchCallback @@ -50,4 +52,5 @@ export type Options = { fetch: FetchCallback port?: number hostname?: string + upgrade?: boolean } & ServerOptions diff --git a/test/server.test.ts b/test/server.test.ts index dcfd4ef..74d3be3 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -1,12 +1,16 @@ import fs from 'node:fs' +import type { IncomingMessage } from 'node:http' import { createServer as createHttp2Server } from 'node:http2' import { createServer as createHTTPSServer } from 'node:https' +import type { Socket } from 'node:net' import { Response as PonyfillResponse } from '@whatwg-node/fetch' import { Hono } from 'hono' import { basicAuth } from 'hono/basic-auth' import { compress } from 'hono/compress' import { poweredBy } from 'hono/powered-by' import request from 'supertest' +import wsRequest from 'superwstest' +import { WebSocketServer } from 'ws' import { createAdaptorServer } from '../src/server' describe('Basic', () => { @@ -469,7 +473,6 @@ describe('HTTP2', () => { }) it('Should return 200 response - GET /', async () => { - // @ts-expect-error: @types/supertest is not updated yet const res = await request(server, { http2: true }).get('/').trustLocalhost() expect(res.status).toBe(200) expect(res.headers['content-type']).toMatch(/text\/plain/) @@ -518,3 +521,38 @@ describe('set child response to c.res', () => { expect(res.headers['content-type']).toMatch(/application\/json/) }) }) + +describe('upgrade websocket', () => { + const app = new Hono<{ + Bindings: { + incoming: IncomingMessage; + socket: Socket; + head: Buffer; + } + }>() + const wsServer = new WebSocketServer({ noServer: true }) + + class UpgradeResponse extends Response { + status = 101 + } + + app.use('/', async (c) => { + wsServer.handleUpgrade(c.env.incoming, c.env.socket, c.env.head, (ws) => { + ws.send('Hello World!') + }) + + return new UpgradeResponse() + }) + + const server = createAdaptorServer(app) + + // 'superwstest' needs the server to be listening + beforeEach((done) => { server.listen(0, 'localhost', done) }) + afterEach((done) => { server.close(done) }) + + it('Should send a WebSocket message - GET /', async () => { + await wsRequest(server) + .ws('/') + .expectText('Hello World!') + }) +}) \ No newline at end of file