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 <yusuke@kamawada.com>
pull/508/head
Shotaro Nakamura 2024-05-09 21:51:25 +09:00 committed by GitHub
parent 650e20fab5
commit d11c3a565f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 337 additions and 1 deletions

View File

@ -0,0 +1,5 @@
---
'@hono/node-ws': major
---
Inited @hono/node-ws

View File

@ -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

View File

@ -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}'",

View File

@ -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 <https://github.com/nakasyou>
## License
MIT

View File

@ -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"
}
}

View File

@ -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<ServerType>((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)
})
})

View File

@ -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)
},
}
}

View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist"
},
"include": [
"src/**/*.ts"
]
}

View File

@ -0,0 +1,8 @@
/// <reference types="vitest" />
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
globals: true,
},
})

View File

@ -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"