From d11c3a565f47f26b6882cef416d86f3f7d9c214c Mon Sep 17 00:00:00 2001 From: Shotaro Nakamura <79000684+nakasyou@users.noreply.github.com> Date: Thu, 9 May 2024 21:51:25 +0900 Subject: [PATCH] feat: Introduce WebSocket helper for Node.js | `@hono/node-ws` (#503) * feat: Introduce WebSocket helper for Node.js | `@hono/node-ws` * feat(node-ws): add ci and build command * chore: update yarn.lock * update `yarn.lock` * fixed the ci config * add `@hono/node-server` to devDependecies * fixed format and specify node version * fix(node-ws/docs): fix example * feat: `upgradeWebSocket` be named function --------- Co-authored-by: Yusuke Wada --- .changeset/neat-onions-hunt.md | 5 ++ .github/workflows/ci-node-ws.yml | 25 +++++++ package.json | 1 + packages/node-ws/README.md | 30 ++++++++ packages/node-ws/package.json | 50 +++++++++++++ packages/node-ws/src/index.test.ts | 39 ++++++++++ packages/node-ws/src/index.ts | 115 +++++++++++++++++++++++++++++ packages/node-ws/tsconfig.json | 10 +++ packages/node-ws/vitest.config.ts | 8 ++ packages/sentry/package.json | 2 +- yarn.lock | 53 +++++++++++++ 11 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 .changeset/neat-onions-hunt.md create mode 100644 .github/workflows/ci-node-ws.yml create mode 100644 packages/node-ws/README.md create mode 100644 packages/node-ws/package.json create mode 100644 packages/node-ws/src/index.test.ts create mode 100644 packages/node-ws/src/index.ts create mode 100644 packages/node-ws/tsconfig.json create mode 100644 packages/node-ws/vitest.config.ts diff --git a/.changeset/neat-onions-hunt.md b/.changeset/neat-onions-hunt.md new file mode 100644 index 00000000..d4116d7c --- /dev/null +++ b/.changeset/neat-onions-hunt.md @@ -0,0 +1,5 @@ +--- +'@hono/node-ws': major +--- + +Inited @hono/node-ws diff --git a/.github/workflows/ci-node-ws.yml b/.github/workflows/ci-node-ws.yml new file mode 100644 index 00000000..4c4c7d97 --- /dev/null +++ b/.github/workflows/ci-node-ws.yml @@ -0,0 +1,25 @@ +name: ci-node-ws +on: + push: + branches: [main] + paths: + - 'packages/node-ws/**' + pull_request: + branches: ['*'] + paths: + - 'packages/node-ws/**' + +jobs: + ci: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./packages/node-ws + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20.x + - run: yarn install --frozen-lockfile + - run: yarn build + - run: yarn test diff --git a/package.json b/package.json index 65f160d6..e59f98c8 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "build:bun-transpiler": "yarn workspace @hono/bun-transpiler build", "build:prometheus": "yarn workspace @hono/prometheus build", "build:oidc-auth": "yarn workspace @hono/oidc-auth build", + "build:node-ws": "yarn workspace @hono/node-ws build", "build": "run-p 'build:*'", "lint": "eslint 'packages/**/*.{ts,tsx}'", "lint:fix": "eslint --fix 'packages/**/*.{ts,tsx}'", diff --git a/packages/node-ws/README.md b/packages/node-ws/README.md new file mode 100644 index 00000000..47fa20b4 --- /dev/null +++ b/packages/node-ws/README.md @@ -0,0 +1,30 @@ +# WebSocket helper for Node.js + +A WebSocket helper for Node.js + +## Usage + +```ts +import { createNodeWebSocket } from '@hono/node-ws' +import { Hono } from 'hono' +import { serve } from '@hono/node-server' + +const app = new Hono() + +const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }) + +app.get('/ws', upgradeWebSocket((c) => ({ + // https://hono.dev/helpers/websocket +}))) + +const server = serve(app) +injectWebSocket(server) +``` + +## Author + +Shotaro Nakamura + +## License + +MIT \ No newline at end of file diff --git a/packages/node-ws/package.json b/packages/node-ws/package.json new file mode 100644 index 00000000..0fcda4b3 --- /dev/null +++ b/packages/node-ws/package.json @@ -0,0 +1,50 @@ +{ + "name": "@hono/node-ws", + "version": "0.1.0", + "description": "WebSocket helper for Node.js", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "test": "vitest --run", + "build": "tsup ./src/index.ts --format esm,cjs --dts", + "publint": "publint", + "release": "yarn build && yarn test && yarn publint && yarn publish" + }, + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "license": "MIT", + "publishConfig": { + "registry": "https://registry.npmjs.org", + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/honojs/middleware.git" + }, + "homepage": "https://github.com/honojs/middleware", + "devDependencies": { + "@hono/node-server": "^1.11.1", + "@types/ws": "^8", + "hono": "^4.2.9", + "tsup": "^8.0.1", + "vitest": "^1.0.4" + }, + "dependencies": { + "ws": "^8.17.0" + }, + "peerDependencies": { + "@hono/node-server": "^1.11.1" + }, + "engines": { + "node": ">=18.14.1" + } +} \ No newline at end of file diff --git a/packages/node-ws/src/index.test.ts b/packages/node-ws/src/index.test.ts new file mode 100644 index 00000000..4de803a9 --- /dev/null +++ b/packages/node-ws/src/index.test.ts @@ -0,0 +1,39 @@ +import { serve } from '@hono/node-server' +import type { ServerType } from '@hono/node-server/dist/types' +import { Hono } from 'hono' +import { WebSocket } from 'ws' +import { createNodeWebSocket } from '.' + +describe('WebSocket helper', () => { + const app = new Hono() + const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app }) + + const mainPromise = new Promise((resolve) => + app.get( + '/', + upgradeWebSocket(() => ({ + onOpen() { + resolve(true) + }, + })) + ) + ) + + it('Should be able to connect', async () => { + const server = await new Promise((resolve) => { + const server = serve( + { + fetch: app.fetch, + port: 3030, + }, + () => { + resolve(server) + } + ) + }) + injectWebSocket(server) + const ws = new WebSocket('ws://localhost:3030/') + + expect(await mainPromise).toBe(true) + }) +}) diff --git a/packages/node-ws/src/index.ts b/packages/node-ws/src/index.ts new file mode 100644 index 00000000..5cf94c28 --- /dev/null +++ b/packages/node-ws/src/index.ts @@ -0,0 +1,115 @@ +import { Buffer } from 'buffer' +import type { Server } from 'node:http' +import type { Http2SecureServer, Http2Server } from 'node:http2' +import type { Hono } from 'hono' +import { createMiddleware } from 'hono/factory' +import type { UpgradeWebSocket, WSContext } from 'hono/ws' +import { WebSocketServer } from 'ws' + +export interface NodeWebSocket { + upgradeWebSocket: UpgradeWebSocket + injectWebSocket(server: Server | Http2Server | Http2SecureServer): void +} +export interface NodeWebSocketInit { + app: Hono + baseUrl?: string | URL +} + +/** + * Extended for telling WebSocket + * @internal + */ +class WSResponse extends Response { + readonly wss: WebSocketServer + constructor(wss: WebSocketServer) { + super() + this.wss = wss + } +} + +/** + * Create WebSockets for Node.js + * @param init Options + * @returns NodeWebSocket + */ +export const createNodeWebSocket = (init: NodeWebSocketInit): NodeWebSocket => { + return { + injectWebSocket(server) { + ;(server as Server).on('upgrade', async (request, socket, head) => { + const url = new URL(request.url ?? '/', init.baseUrl ?? 'http://localhost') + const headers = new Headers() + for (const key in request.headers) { + const value = request.headers[key] + if (!value) { + continue + } + headers.append(key, Array.isArray(value) ? value[0] : value) + } + const res = (await init.app.request(url, { + headers: headers, + })) as Response | WSResponse + if (!(res instanceof WSResponse)) { + socket.destroy() + return + } + res.wss.handleUpgrade(request, socket, head, (ws) => { + res.wss.emit('connection', ws, request) + }) + }) + }, + upgradeWebSocket: (createEvents) => + async function upgradeWebSocket(c, next) { + if (c.req.header('upgrade') !== 'websocket') { + // Not websocket + await next() + return + } + const wss = new WebSocketServer({ noServer: true }) + const events = await createEvents(c) + wss.on('connection', (ws) => { + const ctx: WSContext = { + binaryType: 'arraybuffer', + close(code, reason) { + ws.close(code, reason) + }, + protocol: ws.protocol, + raw: ws, + get readyState() { + return ws.readyState + }, + send(source, opts) { + ws.send(source, { + compress: opts?.compress, + }) + }, + url: new URL(c.req.url), + } + events.onOpen?.(new Event('open'), ctx) + ws.on('message', (data, isBinary) => { + const datas = Array.isArray(data) ? data : [data] + for (const data of datas) { + const buff: Buffer = Buffer.from(data) + events.onMessage?.( + new MessageEvent('message', { + data: isBinary ? buff.buffer : buff.toString('utf-8'), + }), + ctx + ) + } + }) + ws.on('close', () => { + events.onClose?.(new CloseEvent('close'), ctx) + }) + ws.on('error', (error) => { + events.onError?.( + new ErrorEvent('error', { + error: error, + }), + ctx + ) + }) + }) + return new WSResponse(wss) + }, + } +} diff --git a/packages/node-ws/tsconfig.json b/packages/node-ws/tsconfig.json new file mode 100644 index 00000000..6a9d7cc4 --- /dev/null +++ b/packages/node-ws/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./dist" + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/packages/node-ws/vitest.config.ts b/packages/node-ws/vitest.config.ts new file mode 100644 index 00000000..17b54e48 --- /dev/null +++ b/packages/node-ws/vitest.config.ts @@ -0,0 +1,8 @@ +/// +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + globals: true, + }, +}) diff --git a/packages/sentry/package.json b/packages/sentry/package.json index 35edd642..3dbf8589 100644 --- a/packages/sentry/package.json +++ b/packages/sentry/package.json @@ -60,4 +60,4 @@ "tsup": "^8.0.2", "typescript": "^4.7.4" } -} \ No newline at end of file +} diff --git a/yarn.lock b/yarn.lock index 96715cea..e0c5e29f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1937,6 +1937,28 @@ __metadata: languageName: unknown linkType: soft +"@hono/node-server@npm:^1.11.1": + version: 1.11.1 + resolution: "@hono/node-server@npm:1.11.1" + checksum: 87ad10b0e79c5434935cd83172d1923eb04c7ac1853e79b60303cae983876fc3c736e1826b824b34adae7a3e7289c99d764ecefe13b650f81b1f9d5baea3ed4a + languageName: node + linkType: hard + +"@hono/node-ws@workspace:packages/node-ws": + version: 0.0.0-use.local + resolution: "@hono/node-ws@workspace:packages/node-ws" + dependencies: + "@hono/node-server": "npm:^1.11.1" + "@types/ws": "npm:^8" + hono: "npm:^4.2.9" + tsup: "npm:^8.0.1" + vitest: "npm:^1.0.4" + ws: "npm:^8.17.0" + peerDependencies: + "@hono/node-server": ^1.11.1 + languageName: unknown + linkType: soft + "@hono/oauth-providers@workspace:packages/oauth-providers": version: 0.0.0-use.local resolution: "@hono/oauth-providers@workspace:packages/oauth-providers" @@ -4170,6 +4192,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:^8": + version: 8.5.10 + resolution: "@types/ws@npm:8.5.10" + dependencies: + "@types/node": "npm:*" + checksum: e9af279b984c4a04ab53295a40aa95c3e9685f04888df5c6920860d1dd073fcc57c7bd33578a04b285b2c655a0b52258d34bee0a20569dca8defb8393e1e5d29 + languageName: node + linkType: hard + "@types/yargs-parser@npm:*": version: 21.0.3 resolution: "@types/yargs-parser@npm:21.0.3" @@ -9602,6 +9633,13 @@ __metadata: languageName: node linkType: hard +"hono@npm:^4.2.9": + version: 4.3.3 + resolution: "hono@npm:4.3.3" + checksum: 2e02a563ab8461a56a97b59b1c31fd002179999a0323b3a44cbf8b69b92ad35cc8f38ba26a88b64caa71e2c1c39a1454d84473ed0c69f4e9573e7b3b064e0f58 + languageName: node + linkType: hard + "hosted-git-info@npm:^2.1.4": version: 2.8.9 resolution: "hosted-git-info@npm:2.8.9" @@ -19024,6 +19062,21 @@ __metadata: languageName: node linkType: hard +"ws@npm:^8.17.0": + version: 8.17.0 + resolution: "ws@npm:8.17.0" + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + checksum: 55241ec93a66fdfc4bf4f8bc66c8eb038fda2c7a4ee8f6f157f2ca7dc7aa76aea0c0da0bf3adb2af390074a70a0e45456a2eaf80e581e630b75df10a64b0a990 + languageName: node + linkType: hard + "xdg-basedir@npm:^4.0.0": version: 4.0.0 resolution: "xdg-basedir@npm:4.0.0"