Skip to content

Commit 2eeb9c3

Browse files
authored
feat: introduce Cap'n Web Adapter (#1555)
* feat: introduce Cap'n Web Adapter * implemented * simplify the test * fix the test path * tweak * fix the node ws version * skip type check * lint * remove examples * use ts-ignore * add more tests * add changeset
1 parent 8ca98cb commit 2eeb9c3

File tree

14 files changed

+517
-3
lines changed

14 files changed

+517
-3
lines changed

.changeset/chilly-cups-roll.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hono/capnweb': minor
3+
---
4+
5+
initial release

deno.jsonc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
// "packages/auth-js",
77
// "packages/bun-compress",
88
// "packages/bun-transpiler",
9+
"packages/capnweb",
910
// "packages/casbin",
1011
"packages/class-validator",
1112
"packages/clerk-auth",

packages/capnweb/README.md

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Cap'n Web Adapter for Hono
2+
3+
[![codecov](https://codecov.io/github/honojs/middleware/graph/badge.svg?flag=capnweb)](https://codecov.io/github/honojs/middleware)
4+
5+
Cap'n Web Adapter Middleware for [Hono](https://hono.dev). Enables RPC over WebSocket and HTTP with [Cap'n Web](https://github.com/cloudflare/capnweb).
6+
7+
## Installation
8+
9+
```bash
10+
npm install @hono/capnweb capnweb hono
11+
```
12+
13+
## Usage
14+
15+
### Define your RPC API
16+
17+
```ts
18+
import { RpcTarget } from 'capnweb'
19+
20+
export interface PublicApi {
21+
hello(name: string): string
22+
}
23+
24+
export class MyApiServer extends RpcTarget implements PublicApi {
25+
hello(name: string) {
26+
return `Hello, ${name}!`
27+
}
28+
}
29+
```
30+
31+
### Cloudflare Workers
32+
33+
```ts
34+
import { Hono } from 'hono'
35+
import { upgradeWebSocket } from 'hono/cloudflare-workers'
36+
import { newRpcResponse } from '@hono/capnweb'
37+
import { MyApiServer } from './my-api-server'
38+
39+
const app = new Hono()
40+
41+
app.all('/api', (c) => {
42+
return newRpcResponse(c, new MyApiServer(), {
43+
upgradeWebSocket,
44+
})
45+
})
46+
47+
export default app
48+
```
49+
50+
### Node.js
51+
52+
```ts
53+
import { serve } from '@hono/node-server'
54+
import { createNodeWebSocket } from '@hono/node-ws'
55+
import { Hono } from 'hono'
56+
import { newRpcResponse } from '@hono/capnweb'
57+
import { MyApiServer } from './my-api-server'
58+
59+
const app = new Hono()
60+
61+
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
62+
63+
app.all('/api', (c) => {
64+
return newRpcResponse(c, new MyApiServer(), {
65+
upgradeWebSocket,
66+
})
67+
})
68+
69+
const server = serve({
70+
port: 8787,
71+
fetch: app.fetch,
72+
})
73+
74+
injectWebSocket(server)
75+
```
76+
77+
### Deno
78+
79+
```ts
80+
import { Hono } from 'hono'
81+
import { upgradeWebSocket } from 'hono/deno'
82+
import { newRpcResponse } from '@hono/capnweb'
83+
import { MyApiServer } from './my-api-server.ts'
84+
85+
const app = new Hono()
86+
87+
app.all('/api', (c) => {
88+
return newRpcResponse(c, new MyApiServer(), {
89+
upgradeWebSocket,
90+
})
91+
})
92+
93+
export default app
94+
```
95+
96+
### Bun
97+
98+
```ts
99+
import { Hono } from 'hono'
100+
import { upgradeWebSocket, websocket } from 'hono/bun'
101+
import { newRpcResponse } from '@hono/capnweb'
102+
import { MyApiServer } from './my-api-server'
103+
104+
const app = new Hono()
105+
106+
app.all('/api', (c) => {
107+
return newRpcResponse(c, new MyApiServer(), {
108+
upgradeWebSocket,
109+
})
110+
})
111+
112+
export default {
113+
fetch: app.fetch,
114+
port: 8787,
115+
websocket,
116+
}
117+
```
118+
119+
## Client
120+
121+
### WebSocket RPC
122+
123+
```ts
124+
import { newWebSocketRpcSession } from 'capnweb'
125+
import type { PublicApi } from './my-api-server'
126+
127+
using stub = newWebSocketRpcSession<PublicApi>('ws://localhost:8787/api')
128+
129+
console.log(await stub.hello("Cap'n Web"))
130+
```
131+
132+
### HTTP Batch RPC
133+
134+
```ts
135+
import { newHttpBatchRpcSession } from 'capnweb'
136+
import type { PublicApi } from './my-api-server'
137+
138+
const stub = newHttpBatchRpcSession<PublicApi>('http://localhost:8787/api')
139+
140+
console.log(await stub.hello("Cap'n Web"))
141+
```
142+
143+
## Author
144+
145+
Yusuke Wada <https://github.com/yusukebe>
146+
147+
## License
148+
149+
MIT

packages/capnweb/deno.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "@hono/capnweb",
3+
"version": "0.0.0",
4+
"license": "MIT",
5+
"exports": {
6+
".": "./src/index.ts"
7+
},
8+
"imports": {
9+
"hono": "jsr:@hono/hono@^4.8.3"
10+
},
11+
"publish": {
12+
"include": ["deno.json", "README.md", "src/**/*.ts"],
13+
"exclude": ["src/**/*.test.ts"]
14+
}
15+
}

packages/capnweb/package.json

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
{
2+
"name": "@hono/capnweb",
3+
"version": "0.0.0",
4+
"description": "Cap'n Web Adapter for Hono",
5+
"type": "module",
6+
"module": "dist/index.js",
7+
"types": "dist/index.d.ts",
8+
"files": [
9+
"dist"
10+
],
11+
"scripts": {
12+
"build": "tsdown",
13+
"format": "prettier --check . --ignore-path ../../.gitignore",
14+
"lint": "eslint",
15+
"publint": "attw --pack && publint",
16+
"typecheck": "tsc -b tsconfig.json",
17+
"test": "vitest",
18+
"version:jsr": "yarn version:set $npm_package_version"
19+
},
20+
"exports": {
21+
".": {
22+
"import": {
23+
"types": "./dist/index.d.ts",
24+
"default": "./dist/index.js"
25+
},
26+
"require": {
27+
"types": "./dist/index.d.cts",
28+
"default": "./dist/index.cjs"
29+
}
30+
}
31+
},
32+
"license": "MIT",
33+
"publishConfig": {
34+
"registry": "https://registry.npmjs.org",
35+
"access": "public"
36+
},
37+
"repository": {
38+
"type": "git",
39+
"url": "git+https://github.com/honojs/middleware.git",
40+
"directory": "packages/capnweb"
41+
},
42+
"homepage": "https://github.com/honojs/middleware",
43+
"peerDependencies": {
44+
"capnweb": ">=0.2.0",
45+
"hono": ">=4.0.0"
46+
},
47+
"devDependencies": {
48+
"@hono/node-server": "^1.19.2",
49+
"@hono/node-ws": "workspace:^",
50+
"@types/ws": "^8.5.0",
51+
"capnweb": "^0.2.0",
52+
"hono": "^4.10.1",
53+
"publint": "^0.3.14",
54+
"tsdown": "^0.15.9",
55+
"typescript": "^5.8.2",
56+
"vitest": "^3.2.4",
57+
"ws": "^8.17.0"
58+
}
59+
}

packages/capnweb/src/index.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { serve } from '@hono/node-server'
2+
import type { ServerType } from '@hono/node-server'
3+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
4+
// @ts-ignore @hono/node-ws may not be typed
5+
import { createNodeWebSocket } from '@hono/node-ws'
6+
import { RpcTarget, newWebSocketRpcSession } from 'capnweb'
7+
import { Hono } from 'hono'
8+
import { WebSocket } from 'ws'
9+
import { newRpcResponse } from './index'
10+
11+
class MyApiServer extends RpcTarget {
12+
hello(name: string) {
13+
return `Hello, ${name}!`
14+
}
15+
16+
throwError() {
17+
throw new Error('Test error')
18+
}
19+
}
20+
21+
describe("Cap'n Web middleware - Node.js", () => {
22+
let app: Hono
23+
let server: ServerType
24+
let port: number
25+
let upgradeWebSocket: ReturnType<typeof createNodeWebSocket>['upgradeWebSocket']
26+
27+
beforeEach(async () => {
28+
app = new Hono()
29+
30+
const { injectWebSocket, upgradeWebSocket: upgrade } = createNodeWebSocket({ app })
31+
upgradeWebSocket = upgrade
32+
33+
app.all('/api', (c) => {
34+
return newRpcResponse(c, new MyApiServer(), { upgradeWebSocket })
35+
})
36+
37+
app.all('/no-upgrade', (c) => {
38+
return newRpcResponse(c, new MyApiServer())
39+
})
40+
41+
server = await new Promise<ServerType>((resolve) => {
42+
const srv = serve({ fetch: app.fetch, port: 0 }, () => {
43+
resolve(srv)
44+
})
45+
})
46+
47+
injectWebSocket(server)
48+
const address = server.address()
49+
port = typeof address === 'object' && address ? address.port : 0
50+
})
51+
52+
afterEach(() => {
53+
server?.close()
54+
})
55+
56+
it('can accept WebSocket RPC connections in Node.js', async () => {
57+
const ws = new WebSocket(`ws://localhost:${port}/api`)
58+
59+
await new Promise<void>((resolve, reject) => {
60+
ws.on('open', resolve)
61+
ws.on('error', reject)
62+
})
63+
64+
const cap = newWebSocketRpcSession<MyApiServer>(ws as any)
65+
expect(await cap.hello('Node.js')).toBe('Hello, Node.js!')
66+
ws.close()
67+
})
68+
69+
it('should return 400 when WebSocket upgrade requested without upgradeWebSocket option', async () => {
70+
const response = await fetch(`http://localhost:${port}/no-upgrade`, {
71+
method: 'POST',
72+
headers: {
73+
'Content-Type': 'application/json',
74+
},
75+
body: JSON.stringify({ test: 'data' }),
76+
})
77+
expect(response.status).not.toBe(400)
78+
})
79+
80+
it('should return 400 when WebSocket upgrade is requested without upgradeWebSocket', async () => {
81+
const ws = new WebSocket(`ws://localhost:${port}/no-upgrade`)
82+
83+
await new Promise<void>((resolve, reject) => {
84+
ws.on('error', () => {
85+
// WebSocket connection will fail with 400, which is expected
86+
ws.close()
87+
resolve()
88+
})
89+
90+
ws.on('open', () => {
91+
// Should not reach here
92+
ws.close()
93+
reject(new Error('WebSocket connection should not succeed'))
94+
})
95+
})
96+
})
97+
98+
it('should handle WebSocket close event', async () => {
99+
const ws = new WebSocket(`ws://localhost:${port}/api`)
100+
101+
await new Promise<void>((resolve, reject) => {
102+
let opened = false
103+
104+
ws.on('open', () => {
105+
opened = true
106+
// Close the connection immediately
107+
ws.close()
108+
})
109+
110+
ws.on('close', (code) => {
111+
// Close event should be triggered
112+
expect(opened).toBe(true)
113+
expect(code).toBeDefined()
114+
resolve()
115+
})
116+
117+
ws.on('error', (error) => {
118+
reject(error)
119+
})
120+
})
121+
})
122+
123+
it('should handle RPC method errors', async () => {
124+
const ws = new WebSocket(`ws://localhost:${port}/api`)
125+
126+
await new Promise<void>((resolve, reject) => {
127+
ws.on('open', async () => {
128+
const cap = newWebSocketRpcSession<MyApiServer>(ws as any)
129+
try {
130+
await cap.throwError()
131+
reject(new Error('Should have thrown an error'))
132+
} catch (error) {
133+
// Error from RPC method should be caught
134+
expect(error).toBeDefined()
135+
expect((error as Error).message).toContain('Test error')
136+
ws.close()
137+
resolve()
138+
}
139+
})
140+
141+
ws.on('error', (error) => {
142+
reject(error)
143+
})
144+
})
145+
})
146+
})

0 commit comments

Comments
 (0)