Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ Examples

- batch-pipelining: Node server + client. Shows batching and pipelining to execute a dependent sequence of RPC calls in a single HTTP round trip, with timing vs sequential.
- worker-react: Cloudflare Worker backend + React frontend. Shows the same pattern from a browser app, served by the Worker.
- hono: Cap'n Web servers on Cloudflare Workers / Deno / Node.js with Hono.

Notes

Expand Down
36 changes: 36 additions & 0 deletions examples/hono/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package-lock.json
bun.lock

# prod
dist/

# dev
.yarn/
!.yarn/releases
.vscode/*
!.vscode/launch.json
!.vscode/*.code-snippets
.idea/workspace.xml
.idea/usage.statistics.xml
.idea/shelf

# deps
node_modules/
.wrangler

# env
.env
.env.production
.dev.vars

# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

# misc
.DS_Store
31 changes: 31 additions & 0 deletions examples/hono/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
Cap'n Web servers on Cloudflare Workers / Deno / Node.js with Hono.

Cloudflare Workers:

```console
npm run dev:workers
```

Deno:

```console
npm run dev:deno
```

Node.js:

```console
npm run dev:node
```

Client:

```console
npm run client
```

Client with batch:

```console
npm run client:batch
```
6 changes: 6 additions & 0 deletions examples/hono/client/client-batch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { newHttpBatchRpcSession, RpcStub } from '../../../dist/index.js'
import type { PublicApi } from '../src/my-api-server'

const stub: RpcStub<PublicApi> = newHttpBatchRpcSession<PublicApi>('http://localhost:8787/api')

console.log(await stub.hello("Cap'n Web"))
6 changes: 6 additions & 0 deletions examples/hono/client/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { newWebSocketRpcSession, RpcStub } from '../../../dist/index.js'
import type { PublicApi } from '../src/my-api-server'

using stub: RpcStub<PublicApi> = newWebSocketRpcSession<PublicApi>("ws://localhost:8787/api");

console.log(await stub.hello('Cap\'n Web'))
21 changes: 21 additions & 0 deletions examples/hono/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "my-capnweb-app",
"type": "module",
"scripts": {
"dev:workers": "wrangler dev",
"dev:deno": "deno serve --sloppy-imports --port 8787 --watch src/deno.ts",
"dev:node": "tsx --watch src/node.ts",
"client": "tsx client/client.ts",
"client:batch": "tsx client/client-batch.ts",
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
},
"dependencies": {
"@hono/node-server": "^1.19.4",
"@hono/node-ws": "^1.2.0",
"hono": "^4.9.9"
},
"devDependencies": {
"tsx": "^4.20.6",
"wrangler": "^4.4.0"
}
}
23 changes: 23 additions & 0 deletions examples/hono/src/deno.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Hono } from 'hono'
import { upgradeWebSocket } from 'hono/deno'
import { newHttpBatchRpcResponse, newWebSocketRpcSession } from '../../../dist/index.js'
import { MyApiServer } from './my-api-server.ts'

const app = new Hono()

app.get(
'/api',
upgradeWebSocket((_c) => {
return {
onOpen(_event, ws) {
newWebSocketRpcSession(ws.raw!, new MyApiServer())
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine this doesn't work on Bun, since it doesn't use the standard WebSocket API under the hood. Is that right?

I wonder if it makes sense to write a custom transport specifically for Hono, that uses Hono's wrapper and so works with all runtimes Hono supports?

Although I see Hono's upgradeWebSocket is actually different for each runtime, so perhaps this code can't really be totally runtime-independent anyway?

What do you think?

Copy link
Member Author

@yusukebe yusukebe Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I deeply dived. Bun uses their original ServerWebSocket. This object does not have addEventListener, so the following error is thrown with the code like deno.ts:

Code:

import { Hono } from 'hono'
import { upgradeWebSocket, websocket } from 'hono/bun'
import { MyApiServer } from './my-api-server'
import { newWebSocketRpcSession } from 'capnweb'

const app = new Hono()

app.get(
  '/api',
  upgradeWebSocket((_c) => {
    return {
      onOpen(_event, ws) {
        newWebSocketRpcSession(ws.raw!, new MyApiServer())
      }
    }
  })
)

export default {
  fetch: app.fetch,
  port: 8787,
  websocket
}

Error:

CleanShot 2025-10-01 at 16 29 20@2x

But Bun's ServerWebSocket can handle the methods, such as message or error. So, I think Cap'n Web can work on Bun with a wrapper or something.

My idea to connect Cap'n Web and Hono is to create the adapter(or should we say it's a transport?) like this:

// ...
import { newHonoRpcResponse } from 'capnweb' // or @hono/capnweb-server

const app = new Hono()

app.all('/api', (c) => {
  return newHonoRpcResponse(c, new MyApiServer(), {
    upgradeWebSocket
  })
})

export default app

With this API, you can run it on any runtime supported by Hono, using upgradeWebSocket. It is the same interface between them. We can also make it support Bun by wrapping the ServerWebSocket inside.

This is PoC, but it works:

https://gist.github.com/yusukebe/ec1747487bc22f06eb14e348abb7370c#file-capnweb-server-hono-ts

For example, the Bun code:

import { Hono } from 'hono'
import { upgradeWebSocket, websocket } from 'hono/bun'
import { MyApiServer } from './my-api-server'
import { newHonoRpcResponse } from './capnweb-server-hono.ts'

const app = new Hono()

app.all('/api', (c) => {
  return newHonoRpcResponse(c, new MyApiServer(), {
    upgradeWebSocket
  })
})

export default {
  fetch: app.fetch,
  port: 8787,
  websocket
}

The above case is to support Bun on the Hono side.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the long delay here, I got slammed with too many things in the last month (the benchmark saga, Connect, some vacation time, ...)

But I'd like to get back to this!

So picking this conversation back up:

OK, it looks like you've written newHonoRpcResponse() which handles all the cross-runtime stuff. That's nice and would be nice to ship somewhere.

But also I think Cap'n Web needs to support Bun directly, independent of Hono. I wonder if there's a way we can ship this compat layer that isn't specific to Hono, but such that Hono can leverage it? But I see that Hono seems to have some of its own code already in hono/ws which adapts various WebSocket runtime APIs, and you are building on top of that. So maybe it doesn't actually make sense to support Bun and Hono-Bun at the same time after all?

Anyway, I would like to ship newHonoRpcResponse() somewhere. I suppose it should be part of Cap'n Web, but of course we don't want Cap'n Web itself to have a dependency on Hono. Is there a way to ship this code with Cap'n Web as an optional add-on that doesn't force the dependency, or do we need to make it a separate package? I'm not that experienced with JavaScript tooling...

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there are plenty of hono-* packages, so hono-capnweb can totally be a thing (or even officially as a @hono/ package

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kentonv

But also I think Cap'n Web needs to support Bun directly, independent of Hono. I wonder if there's a way we can ship this compat layer that isn't specific to Hono, but that Hono can still leverage?

I think it's possible to make Cap'n Web support Bun without Hono. The difference between Bun's API and others is only the interface. I'll take a look at it deeply.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I do want to directly support Bun's API, that's #61... I'm just wondering if it makes any sense for the Hono adapter to then reuse that code, or if the Hono adapter should be totally separate. It looks like Hono already adapts WebSockets to use a more common interface across runtimes so it might make sense for the Hono adapter to just be totally independent of the Bun adapter.

there are plenty of hono-* packages, so hono-capnweb can totally be a thing (or even officially as a @hono/ package

Yeah I suppose. Should the code be in a separate repo or should we make a "packages" directory in this repo?

Copy link
Member Author

@yusukebe yusukebe Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, there is a ready #61! The approch by @grampelberg is great. With it, we can make Cap'n Web support Bun independent of Hono. Let's discuss it on #61.

We, Hono side, will develop "Cap'n Web Adapter" as @hono/capnweb in our repo (honojs/middleware), not in this repo.

Regarding this PR, I'll revise after creating @hono/capnweb.

}
}
})
)

app.post('/api', (c) => {
return newHttpBatchRpcResponse(c.req.raw, new MyApiServer())
})

export default app
11 changes: 11 additions & 0 deletions examples/hono/src/my-api-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { RpcTarget } from '../../../dist'

export interface PublicApi {
hello(name: string): string
}

export class MyApiServer extends RpcTarget implements PublicApi {
hello(name: string) {
return `Hello, ${name}!`
}
}
31 changes: 31 additions & 0 deletions examples/hono/src/node.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Hono } from 'hono'
import { createNodeWebSocket } from '@hono/node-ws'
import { serve } from '@hono/node-server'
import { newHttpBatchRpcResponse, newWebSocketRpcSession } from '../../../dist/index.js'
import { MyApiServer } from './my-api-server'

const app = new Hono()

const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })

app.get(
'/api',
upgradeWebSocket((_c) => {
return {
onOpen(_event, ws) {
newWebSocketRpcSession(ws.raw!, new MyApiServer())
}
}
})
)

app.post('/api', (c) => {
return newHttpBatchRpcResponse(c.req.raw, new MyApiServer())
})

const server = serve({
port: 8787,
fetch: app.fetch
})

injectWebSocket(server)
11 changes: 11 additions & 0 deletions examples/hono/src/workers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Hono } from 'hono'
import { newWorkersRpcResponse } from '../../../dist/index.js'
import { MyApiServer } from './my-api-server'

const app = new Hono()

app.all('/api', (c) => {
return newWorkersRpcResponse(c.req.raw, new MyApiServer())
})

export default app
14 changes: 14 additions & 0 deletions examples/hono/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"lib": [
"ESNext"
],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
},
}
Loading