From 50e46d730af069185e36e0949fd0196f98d9d602 Mon Sep 17 00:00:00 2001 From: Aron Carroll Date: Thu, 26 Mar 2026 14:03:08 +0000 Subject: [PATCH 1/3] Add Bun transport Bun's server-side WebSocket uses callback-based handlers registered on `Bun.serve()` rather than the standard `addEventListener` interface that capnweb's WebSocket transport relies on. This means passing a Bun `ServerWebSocket` to `newWebSocketRpcSession()` errors due to the missing interface. This commit adds a new `BunWebSocketTransport` that implements the `RpcTransport` interface directly, using the same resolver pattern as the existing WebSocket and `MessagePort` transports. It exposes `dispatchMessage`, `dispatchClose`, and `dispatchError` methods that Bun's handler callbacks invoke to feed messages into the transport. Two convenience functions sit on top of the transport. `newBunWebSocketRpcSession()` returns the stub and transport for users who need custom handler logic. `newBunWebSocketRpcHandler()` returns a complete WebSocket handler object that can be passed directly to Bun.serve(), wiring everything up automatically via `ws.data` so users don't have to touch the transport at all. --- __tests__/bun.test.ts | 318 ++++++++++++++++++++++++++++++++++++++++++ src/bun.ts | 142 +++++++++++++++++++ src/index.ts | 21 ++- vitest.config.ts | 2 +- 4 files changed, 481 insertions(+), 2 deletions(-) create mode 100644 __tests__/bun.test.ts create mode 100644 src/bun.ts diff --git a/__tests__/bun.test.ts b/__tests__/bun.test.ts new file mode 100644 index 0000000..806bd00 --- /dev/null +++ b/__tests__/bun.test.ts @@ -0,0 +1,318 @@ +// Copyright (c) 2025 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit + +import { expect, it, describe, vi } from "vitest" +import { BunWebSocketTransport, newBunWebSocketRpcSession, newBunWebSocketRpcHandler, + RpcSession, RpcTarget, RpcStub } from "../src/index.js" +import { Counter, TestTarget } from "./test-util.js"; + +// Mock BunServerWebSocket — plain object matching the minimal interface. +function createMockWs() { + return { + send: vi.fn(), + close: vi.fn(), + readyState: 1, + data: undefined as any, + }; +} + +// Creates a loopback pair: two BunWebSocketTransports that forward messages to each other. +// This simulates a client ↔ server connection without needing real sockets. +function createLoopbackPair() { + let wsA = createMockWs(); + let wsB = createMockWs(); + let transportA = new BunWebSocketTransport(wsA); + let transportB = new BunWebSocketTransport(wsB); + + // Wire A's sends to B's receives and vice versa. + wsA.send = vi.fn((msg: string) => { transportB.dispatchMessage(msg); }); + wsB.send = vi.fn((msg: string) => { transportA.dispatchMessage(msg); }); + + return { transportA, transportB, wsA, wsB }; +} + +describe("BunWebSocketTransport", () => { + it("send() forwards to ws.send()", async () => { + let ws = createMockWs(); + let transport = new BunWebSocketTransport(ws); + await transport.send("hello"); + expect(ws.send).toHaveBeenCalledWith("hello"); + }); + + it("receive() resolves on dispatchMessage", async () => { + let ws = createMockWs(); + let transport = new BunWebSocketTransport(ws); + let promise = transport.receive(); + transport.dispatchMessage("hi"); + expect(await promise).toBe("hi"); + }); + + it("dispatchMessage queues when no pending receive", async () => { + let ws = createMockWs(); + let transport = new BunWebSocketTransport(ws); + transport.dispatchMessage("queued"); + expect(await transport.receive()).toBe("queued"); + }); + + it("multiple messages queue in order", async () => { + let ws = createMockWs(); + let transport = new BunWebSocketTransport(ws); + transport.dispatchMessage("first"); + transport.dispatchMessage("second"); + transport.dispatchMessage("third"); + expect(await transport.receive()).toBe("first"); + expect(await transport.receive()).toBe("second"); + expect(await transport.receive()).toBe("third"); + }); + + it("receive() rejects on dispatchClose", async () => { + let ws = createMockWs(); + let transport = new BunWebSocketTransport(ws); + let promise = transport.receive(); + transport.dispatchClose(1000, "normal"); + await expect(promise).rejects.toThrow("Peer closed WebSocket: 1000 normal"); + }); + + it("receive() rejects on dispatchError", async () => { + let ws = createMockWs(); + let transport = new BunWebSocketTransport(ws); + let promise = transport.receive(); + transport.dispatchError(new Error("boom")); + await expect(promise).rejects.toThrow("WebSocket connection failed."); + }); + + it("messages after close are ignored", async () => { + let ws = createMockWs(); + let transport = new BunWebSocketTransport(ws); + transport.dispatchClose(1000, "bye"); + transport.dispatchMessage("should be ignored"); + await expect(transport.receive()).rejects.toThrow(); + }); + + it("abort() calls ws.close(3000, message)", () => { + let ws = createMockWs(); + let transport = new BunWebSocketTransport(ws); + transport.abort(new Error("test abort")); + expect(ws.close).toHaveBeenCalledWith(3000, "test abort"); + }); + + it("abort() with non-Error reason stringifies", () => { + let ws = createMockWs(); + let transport = new BunWebSocketTransport(ws); + transport.abort("string reason"); + expect(ws.close).toHaveBeenCalledWith(3000, "string reason"); + }); + + it("receive() after abort rejects", async () => { + let ws = createMockWs(); + let transport = new BunWebSocketTransport(ws); + transport.abort(new Error("aborted")); + await expect(transport.receive()).rejects.toThrow("aborted"); + }); + + it("non-string messages are converted", async () => { + let ws = createMockWs(); + let transport = new BunWebSocketTransport(ws); + let promise = transport.receive(); + // Simulate a Buffer (Node Buffer is a Uint8Array subclass with toString) + let buf = Buffer.from("buffered"); + transport.dispatchMessage(buf); + expect(await promise).toBe("buffered"); + }); +}); + +describe("newBunWebSocketRpcSession", () => { + it("returns { stub, transport }", () => { + let ws = createMockWs(); + let result = newBunWebSocketRpcSession(ws, new TestTarget()); + expect(result).toHaveProperty("stub"); + expect(result).toHaveProperty("transport"); + expect(result.transport).toBeInstanceOf(BunWebSocketTransport); + }); + + it("RPC round-trip via loopback", async () => { + let { transportA, transportB } = createLoopbackPair(); + let serverSession = new RpcSession(transportB, new TestTarget()); + let clientSession = new RpcSession(transportA); + let stub: any = clientSession.getRemoteMain(); + + expect(await stub.square(5)).toBe(25); + expect(await stub.square(3)).toBe(9); + }); + + it("bi-directional RPC (server calls client callback)", async () => { + let { transportA, transportB } = createLoopbackPair(); + + let serverSession = new RpcSession(transportB, new TestTarget()); + let clientSession = new RpcSession(transportA); + let stub: any = clientSession.getRemoteMain(); + + // callFunction takes a callable stub and invokes it with the given argument. + let double = new RpcStub((x: number) => x * 2); + let result = await stub.callFunction(double, 7); + expect(await result.result).toBe(14); + }); + + it("error propagation", async () => { + let { transportA, transportB } = createLoopbackPair(); + let serverSession = new RpcSession(transportB, new TestTarget()); + let clientSession = new RpcSession(transportA); + let stub: any = clientSession.getRemoteMain(); + + await expect(stub.throwError()).rejects.toThrow("test error"); + }); + + it("pending calls reject on close", async () => { + let ws = createMockWs(); + let transport = new BunWebSocketTransport(ws); + // Don't wire up the other end — messages go nowhere. + let clientSession = new RpcSession(transport); + let stub: any = clientSession.getRemoteMain(); + + let promise = stub.square(5); + transport.dispatchClose(1000, "gone"); + await expect(promise).rejects.toThrow(); + }); +}); + +describe("newBunWebSocketRpcHandler", () => { + it("returns object with open/message/close/error", () => { + let handler = newBunWebSocketRpcHandler(() => new TestTarget()); + expect(handler).toHaveProperty("open"); + expect(handler).toHaveProperty("message"); + expect(handler).toHaveProperty("close"); + expect(handler).toHaveProperty("error"); + expect(typeof handler.open).toBe("function"); + expect(typeof handler.message).toBe("function"); + expect(typeof handler.close).toBe("function"); + expect(typeof handler.error).toBe("function"); + }); + + it("open() calls createMain and sets ws.data", () => { + let createMain = vi.fn(() => new TestTarget()); + let handler = newBunWebSocketRpcHandler(createMain); + let ws = createMockWs(); + + handler.open(ws); + + expect(createMain).toHaveBeenCalledOnce(); + expect(ws.data).toBeDefined(); + expect(ws.data.__capnwebTransport).toBeInstanceOf(BunWebSocketTransport); + }); + + it("message() dispatches to transport", async () => { + let handler = newBunWebSocketRpcHandler(() => new TestTarget()); + let ws = createMockWs(); + handler.open(ws); + + let transport: BunWebSocketTransport = ws.data.__capnwebTransport; + let promise = transport.receive(); + handler.message(ws, "test-message"); + expect(await promise).toBe("test-message"); + }); + + it("close() dispatches to transport", async () => { + let handler = newBunWebSocketRpcHandler(() => new TestTarget()); + let ws = createMockWs(); + handler.open(ws); + + let transport: BunWebSocketTransport = ws.data.__capnwebTransport; + let promise = transport.receive(); + handler.close(ws, 1000, "bye"); + await expect(promise).rejects.toThrow("Peer closed WebSocket: 1000 bye"); + }); + + it("error() dispatches to transport", async () => { + let handler = newBunWebSocketRpcHandler(() => new TestTarget()); + let ws = createMockWs(); + handler.open(ws); + + let transport: BunWebSocketTransport = ws.data.__capnwebTransport; + let promise = transport.receive(); + handler.error(ws, new Error("ws error")); + await expect(promise).rejects.toThrow("WebSocket connection failed."); + }); + + it("RPC round-trip through handler", async () => { + let handler = newBunWebSocketRpcHandler(() => new TestTarget()); + + // Create a mock "server" ws and a client transport that talks to it. + let serverWs = createMockWs(); + let clientWs = createMockWs(); + let clientTransport = new BunWebSocketTransport(clientWs); + + // Wire: client sends → server receives (via handler.message), server sends → client receives. + clientWs.send = vi.fn((msg: string) => { handler.message(serverWs, msg); }); + serverWs.send = vi.fn((msg: string) => { clientTransport.dispatchMessage(msg); }); + + handler.open(serverWs); + + let clientSession = new RpcSession(clientTransport); + let stub: any = clientSession.getRemoteMain(); + + expect(await stub.square(7)).toBe(49); + }); +}); + +describe("Bun transport: full RPC integration", () => { + it("square(i)", async () => { + let { transportA, transportB } = createLoopbackPair(); + let serverSession = new RpcSession(transportB, new TestTarget()); + let clientSession = new RpcSession(transportA); + let stub: any = clientSession.getRemoteMain(); + + expect(await stub.square(4)).toBe(16); + }); + + it("makeCounter + incrementCounter", async () => { + let { transportA, transportB } = createLoopbackPair(); + let serverSession = new RpcSession(transportB, new TestTarget()); + let clientSession = new RpcSession(transportA); + let stub: any = clientSession.getRemoteMain(); + + let counter = stub.makeCounter(10); + expect(await stub.incrementCounter(counter, 5)).toBe(15); + expect(await stub.incrementCounter(counter)).toBe(16); + }); + + it("throwError", async () => { + let { transportA, transportB } = createLoopbackPair(); + let serverSession = new RpcSession(transportB, new TestTarget()); + let clientSession = new RpcSession(transportA); + let stub: any = clientSession.getRemoteMain(); + + await expect(stub.throwError()).rejects.toThrow("test error"); + }); + + it("callSquare(self, i)", async () => { + let { transportA, transportB } = createLoopbackPair(); + let serverSession = new RpcSession(transportB, new TestTarget()); + let clientSession = new RpcSession(transportA); + let stub: any = clientSession.getRemoteMain(); + + let result = await stub.callSquare(stub, 6); + expect(await result.result).toBe(36); + }); + + it("generateFibonacci(10)", async () => { + let { transportA, transportB } = createLoopbackPair(); + let serverSession = new RpcSession(transportB, new TestTarget()); + let clientSession = new RpcSession(transportA); + let stub: any = clientSession.getRemoteMain(); + + let fib = await stub.generateFibonacci(10); + expect(fib).toEqual([0, 1, 1, 2, 3, 5, 8, 13, 21, 34]); + }); + + it("returnNull / returnUndefined / returnNumber", async () => { + let { transportA, transportB } = createLoopbackPair(); + let serverSession = new RpcSession(transportB, new TestTarget()); + let clientSession = new RpcSession(transportA); + let stub: any = clientSession.getRemoteMain(); + + expect(await stub.returnNull()).toBe(null); + expect(await stub.returnUndefined()).toBe(undefined); + expect(await stub.returnNumber(42)).toBe(42); + }); +}); diff --git a/src/bun.ts b/src/bun.ts new file mode 100644 index 0000000..c4372e2 --- /dev/null +++ b/src/bun.ts @@ -0,0 +1,142 @@ +// Copyright (c) 2025 Cloudflare, Inc. +// Licensed under the MIT license found in the LICENSE.txt file or at: +// https://opensource.org/license/mit + +import { RpcStub } from "./core.js"; +import { RpcTransport, RpcSession, RpcSessionOptions } from "./rpc.js"; + +// Minimal interface matching Bun's ServerWebSocket. Avoids a hard dependency on @types/bun. +export interface BunServerWebSocket { + send(data: string | ArrayBuffer | Uint8Array): number; + close(code?: number, reason?: string): void; + readonly readyState: number; + data: any; +} + +/** + * Start an RPC session over a Bun ServerWebSocket. + * + * Returns both the stub and the transport. The transport must be wired to Bun's + * `WebSocketHandler` callbacks (`message`, `close`, `error`) by calling its + * `dispatchMessage`, `dispatchClose`, and `dispatchError` methods. + * + * For a zero-wiring alternative, see `newBunWebSocketRpcHandler`. + */ +export function newBunWebSocketRpcSession( + ws: BunServerWebSocket, localMain?: any, + options?: RpcSessionOptions): { stub: RpcStub, transport: BunWebSocketTransport } { + let transport = new BunWebSocketTransport(ws); + let rpc = new RpcSession(transport, localMain, options); + return { stub: rpc.getRemoteMain(), transport }; +} + +/** + * Create a Bun `WebSocketHandler` object that manages RPC sessions automatically. + * + * The returned object can be passed directly as the `websocket` option to `Bun.serve()`. + * A fresh `localMain` is created for each connection via the `createMain` callback. + * The transport is stored on `ws.data.__capnwebTransport`. + * + * @param createMain Called once per connection to create the main RPC interface for that client. + * @param options Optional RPC session options applied to every connection. + */ +export function newBunWebSocketRpcHandler(createMain: () => any, options?: RpcSessionOptions) { + return { + open(ws: BunServerWebSocket) { + let transport = new BunWebSocketTransport(ws); + let rpc = new RpcSession(transport, createMain(), options); + ws.data = { __capnwebTransport: transport, __capnwebStub: rpc.getRemoteMain() }; + }, + message(ws: BunServerWebSocket, message: string | Buffer) { + ws.data.__capnwebTransport.dispatchMessage(message); + }, + close(ws: BunServerWebSocket, code: number, reason: string) { + ws.data.__capnwebTransport.dispatchClose(code, reason); + }, + error(ws: BunServerWebSocket, error: Error) { + ws.data.__capnwebTransport.dispatchError(error); + }, + }; +} + +export class BunWebSocketTransport implements RpcTransport { + constructor (ws: BunServerWebSocket) { + this.#ws = ws; + } + + #ws: BunServerWebSocket; + #receiveResolver?: (message: string) => void; + #receiveRejecter?: (err: any) => void; + #receiveQueue: string[] = []; + #error?: any; + + async send(message: string): Promise { + this.#ws.send(message); + } + + async receive(): Promise { + if (this.#receiveQueue.length > 0) { + return this.#receiveQueue.shift()!; + } else if (this.#error) { + throw this.#error; + } else { + return new Promise((resolve, reject) => { + this.#receiveResolver = resolve; + this.#receiveRejecter = reject; + }); + } + } + + abort?(reason: any): void { + let message: string; + if (reason instanceof Error) { + message = reason.message; + } else { + message = `${reason}`; + } + this.#ws.close(3000, message); + + if (!this.#error) { + this.#error = reason; + // No need to call receiveRejecter(); RPC implementation will stop listening anyway. + } + } + + // --- Dispatch methods: call these from Bun's WebSocketHandler callbacks --- + + dispatchMessage(data: string | Buffer): void { + if (this.#error) { + // Ignore further messages. + return; + } + + let strData = typeof data === "string" ? data : data.toString("utf-8"); + + if (this.#receiveResolver) { + this.#receiveResolver(strData); + this.#receiveResolver = undefined; + this.#receiveRejecter = undefined; + } else { + this.#receiveQueue.push(strData); + } + } + + dispatchClose(code: number, reason: string): void { + this.#receivedError(new Error(`Peer closed WebSocket: ${code} ${reason}`)); + } + + dispatchError(error: Error): void { + this.#receivedError(new Error(`WebSocket connection failed.`)); + } + + #receivedError(reason: any) { + if (!this.#error) { + this.#error = reason; + if (this.#receiveRejecter) { + this.#receiveRejecter(reason); + this.#receiveResolver = undefined; + this.#receiveRejecter = undefined; + } + } + } +} diff --git a/src/index.ts b/src/index.ts index 10e4b7a..2921356 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,9 @@ import { newWebSocketRpcSession as newWebSocketRpcSessionImpl, import { newHttpBatchRpcSession as newHttpBatchRpcSessionImpl, newHttpBatchRpcResponse, nodeHttpBatchRpcResponse } from "./batch.js"; import { newMessagePortRpcSession as newMessagePortRpcSessionImpl } from "./messageport.js"; +import { newBunWebSocketRpcSession as newBunWebSocketRpcSessionImpl, + newBunWebSocketRpcHandler, + BunWebSocketTransport } from "./bun.js"; import { forceInitMap } from "./map.js"; import { forceInitStreams } from "./streams.js"; @@ -19,8 +22,9 @@ forceInitStreams(); // Re-export public API types. export { serialize, deserialize, newWorkersWebSocketRpcResponse, newHttpBatchRpcResponse, - nodeHttpBatchRpcResponse }; + nodeHttpBatchRpcResponse, newBunWebSocketRpcHandler, BunWebSocketTransport }; export type { RpcTransport, RpcSessionOptions, RpcCompatible }; +export type { BunServerWebSocket } from "./bun.js"; // Hack the type system to make RpcStub's types work nicely! /** @@ -123,6 +127,21 @@ export let newHttpBatchRpcSession:> (urlOrRequest: string | Request, options?: RpcSessionOptions) => RpcStub = newHttpBatchRpcSessionImpl; +/** + * Start an RPC session over a Bun ServerWebSocket. + * + * Returns both the RPC stub and the transport. The transport exposes `dispatchMessage`, + * `dispatchClose`, and `dispatchError` methods that must be wired to Bun's `WebSocketHandler` + * callbacks. For a zero-wiring alternative, use `newBunWebSocketRpcHandler` instead. + * + * @param ws The Bun ServerWebSocket from the `open` callback. + * @param localMain The main RPC interface to expose to the peer. + */ +export let newBunWebSocketRpcSession: = Empty> + (ws: import("./bun.js").BunServerWebSocket, localMain?: any, + options?: RpcSessionOptions) => { stub: RpcStub, transport: BunWebSocketTransport } = + newBunWebSocketRpcSessionImpl; + /** * Initiate an RPC session over a MessagePort, which is particularly useful for communicating * between an iframe and its parent frame in a browser context. Each side should call this function diff --git a/vitest.config.ts b/vitest.config.ts index 1e43ca4..0b09c5b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ name: 'node', // We throw flow-control test under Node only because it's testing straightforward // JavaScript -- no need to run it on every runtime. - include: ['__tests__/index.test.ts', '__tests__/flow-control.test.ts'], + include: ['__tests__/index.test.ts', '__tests__/flow-control.test.ts', '__tests__/bun.test.ts'], environment: 'node', }, }, From f848d29e45aa352d073a98bd5dd4bb51edbb7962 Mon Sep 17 00:00:00 2001 From: Aron Carroll Date: Thu, 26 Mar 2026 14:33:49 +0000 Subject: [PATCH 2/3] Document Bun ServerWebSocket usage Document the new Bun WebSocket support added in the previous commit. Add an HTTP server on Bun section showing both the zero-wiring newBunWebSocketRpcHandler API and the lower-level newBunWebSocketRpcSession escape hatch. Add Bun to the introductory runtime list, note in the other runtimes section that Bun's ServerWebSocket requires the dedicated API, and update the custom transports section to list all four built-in transports. --- README.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3f436fc..a43d064 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Cap'n Web is a spiritual sibling to [Cap'n Proto](https://capnproto.org) (and is * That said, it integrates nicely with TypeScript. * Also unlike Cap'n Proto, Cap'n Web's underlying serialization is human-readable. In fact, it's just JSON, with a little pre-/post-processing. * It works over HTTP, WebSocket, and postMessage() out-of-the-box, with the ability to extend it to other transports easily. -* It works in all major browsers, Cloudflare Workers, Node.js, and other modern JavaScript runtimes. +* It works in all major browsers, Cloudflare Workers, Node.js, Bun, Deno, and other modern JavaScript runtimes. The whole thing compresses (minify+gzip) to under 10kB with no dependencies. Cap'n Web is more expressive than almost every other RPC system, because it implements an object-capability RPC model. That means it: @@ -630,6 +630,71 @@ Deno.serve(async (req) => { }); ``` +### HTTP server on Bun + +Bun's server-side WebSocket API uses [callback-based handlers](https://bun.sh/docs/runtime/http/websockets) instead of the standard `addEventListener` interface. Cap'n Web provides `newBunWebSocketRpcHandler()` which returns a handler object you can pass directly to `Bun.serve()`: + +```ts +import { RpcTarget, newBunWebSocketRpcHandler, newHttpBatchRpcResponse } from "capnweb"; + +class MyApiImpl extends RpcTarget implements MyApi { + // ... define API, same as above ... +} + +// Create a WebSocket handler that manages RPC sessions automatically. +// The callback is invoked once per connection to create a fresh API instance. +let rpcHandler = newBunWebSocketRpcHandler(() => new MyApiImpl()); + +Bun.serve({ + async fetch(req, server) { + let url = new URL(req.url); + if (url.pathname === "/api") { + // Upgrade WebSocket requests. + if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { + if (server.upgrade(req)) return; + return new Response("WebSocket upgrade failed", { status: 500 }); + } + + // Handle HTTP batch requests. + let response = await newHttpBatchRpcResponse(req, new MyApiImpl()); + response.headers.set("Access-Control-Allow-Origin", "*"); + return response; + } + + return new Response("Not Found", { status: 404 }); + }, + + // Pass the handler directly — no manual wiring needed. + websocket: rpcHandler, +}); +``` + +If you need to attach custom data to connections or add your own logic to the WebSocket handlers, use `newBunWebSocketRpcSession()` instead, which gives you direct access to the transport: + +```ts +import { newBunWebSocketRpcSession, newHttpBatchRpcResponse, RpcTarget } from "capnweb"; + +class MyApiImpl extends RpcTarget implements MyApi { + // ... define API, same as above ... +} + +Bun.serve({ + fetch(req, server) { + let userId = authenticate(req); + server.upgrade(req, { data: { userId } }); + }, + websocket: { + open(ws) { + let { stub, transport } = newBunWebSocketRpcSession(ws, new MyApiImpl()); + ws.data.transport = transport; + }, + message(ws, msg) { ws.data.transport.dispatchMessage(msg); }, + close(ws, code, reason) { ws.data.transport.dispatchClose(code, reason); }, + error(ws, err) { ws.data.transport.dispatchError(err); }, + }, +}); +``` + ### HTTP server on other runtimes Every runtime does HTTP handling and WebSockets a little differently, although most modern runtimes use the standard `Request` and `Response` types from the Fetch API, as well as the standard `WebSocket` API. You should be able to use these two functions (exported by `capnweb`) to implement both HTTP batch and WebSocket handling on all platforms: @@ -654,6 +719,8 @@ function newWebSocketRpcSession( : Disposable; ``` +Note: Bun's `ServerWebSocket` does not implement the standard `WebSocket` `addEventListener` interface. If you are using Bun, use the dedicated `newBunWebSocketRpcHandler()` or `newBunWebSocketRpcSession()` described above. + ### HTTP server using Hono If your app is built on [Hono](https://hono.dev/) (on any runtime it supports), check out [`@hono/capnweb`](https://github.com/honojs/middleware/tree/main/packages/capnweb). @@ -735,4 +802,6 @@ let stub: RemoteMainInterface = session.getRemoteMain(); // Now we can call methods on the stub. ``` +Cap'n Web's built-in transports (WebSocket, MessagePort, HTTP batch, and Bun ServerWebSocket) are all implemented on top of this interface. + Note that sessions are entirely symmetric: neither side is defined as the "client" nor the "server". Each side can optionally expose a "main interface" to the other. In typical scenarios with a logical client and server, the server exposes a main interface but the client does not. From 60f740e2e19f8ccaa21b40e855b9a1d4ae58c040 Mon Sep 17 00:00:00 2001 From: Aron Date: Thu, 26 Mar 2026 15:00:20 +0000 Subject: [PATCH 3/3] Add changeset --- .changeset/bun-support.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/bun-support.md diff --git a/.changeset/bun-support.md b/.changeset/bun-support.md new file mode 100644 index 0000000..ecc2f49 --- /dev/null +++ b/.changeset/bun-support.md @@ -0,0 +1,5 @@ +--- +"capnweb": patch +--- + +Add support for [Bun](https://bun.sh/) WebSocket servers via `newBunWebSocketRpcHandler()`.