cloudflare/capnweb
TypeScript
Captured source
source ↗cloudflare/capnweb
Description: JavaScript/TypeScript-native, low-boilerplate, object-capability RPC system
Language: TypeScript
License: MIT
Stars: 3834
Forks: 133
Open issues: 28
Created: 2025-06-08T02:51:20Z
Pushed: 2026-06-09T02:01:11Z
Default branch: main
Fork: no
Archived: no
README:
Cap'n Web: A JavaScript-native RPC system
Cap'n Web is a spiritual sibling to Cap'n Proto (and is created by the same author), but designed to play nice in the web stack. That means:
- Like Cap'n Proto, it is an object-capability protocol. ("Cap'n" is short for "capabilities and".) We'll get into this more below, but it's incredibly powerful.
- Unlike Cap'n Proto, Cap'n Web has no schemas. In fact, it has almost no boilerplate whatsoever. This means it works more like the JavaScript-native RPC system in Cloudflare Workers.
- 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, 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:
- Supports bidirectional calling. The client can call the server, and the server can also call the client.
- Supports passing functions by reference: If you pass a function over RPC, the recipient receives a "stub". When they call the stub, they actually make an RPC back to you, invoking the function where it was created. This is how bidirectional calling happens: the client passes a callback to the server, and then the server can call it later.
- Similarly, supports passing objects by reference: If a class extends the special marker type
RpcTarget, then instances of that class are passed by reference, with method calls calling back to the location where the object was created. - Supports promise pipelining. When you start an RPC, you get back a promise. Instead of awaiting it, you can immediately use the promise in dependent RPCs, thus performing a chain of calls in a single network round trip.
- Supports capability-based security patterns.
Installation
npm i capnweb
Example
A client looks like this:
import { newWebSocketRpcSession } from "capnweb";
// One-line setup.
let api = newWebSocketRpcSession("wss://example.com/api");
// Call a method on the server!
let result = await api.hello("World");
console.log(result);Here's the server:
import { RpcTarget, newWorkersRpcResponse } from "capnweb";
// This is the server implementation.
class MyApiServer extends RpcTarget {
hello(name) {
return `Hello, ${name}!`
}
}
// Standard Cloudflare Workers HTTP handler.
//
// (Node and other runtimes are supported too; see below.)
export default {
fetch(request, env, ctx) {
// Parse URL for routing.
let url = new URL(request.url);
// Serve API at `/api`.
if (url.pathname === "/api") {
return newWorkersRpcResponse(request, new MyApiServer());
}
// You could serve other endpoints here...
return new Response("Not found", {status: 404});
}
}More complicated example
Here's an example that:
- Uses TypeScript
- Sends multiple calls, where the second call depends on the result of the first, in one round trip.
We declare our interface in a shared types file:
interface PublicApi {
// Authenticate the API token, and returned the authenticated API.
authenticate(apiToken: string): AuthedApi;
// Get a given user's public profile info. (Doesn't require authentication.)
getUserProfile(userId: string): Promise;
}
interface AuthedApi {
getUserId(): number;
// Get the user IDs of all the user's friends.
getFriendIds(): number[];
}
type UserProfile = {
name: string;
photoUrl: string;
}(Note: you don't _have to_ declare your interface separately. The client could just use import("./server").ApiServer as the type.)
On the server, we implement the interface as an RpcTarget:
import { newWorkersRpcResponse, RpcTarget } from "capnweb";
class ApiServer extends RpcTarget implements PublicApi {
// ... implement PublicApi ...
}
export default {
async fetch(req, env, ctx) {
// ... same as previous example ...
}
}On the client, we can use it in a batch request:
import { newHttpBatchRpcSession } from "capnweb";
let api = newHttpBatchRpcSession("https://example.com/api");
// Call authenticate(), but don't await it. We can use the returned promise
// to make "pipelined" calls without waiting.
let authedApi: RpcPromise = api.authenticate(apiToken);
// Make a pipelined call to get the user's ID. Again, don't await it.
let userIdPromise: RpcPromise = authedApi.getUserId();
// Make another pipelined call to fetch the user's public profile, based on
// the user ID. Notice how we can use `RpcPromise` in the parameters of a
// call anywhere where T is expected. The promise will be replaced with its
// resolution before delivering the call.
let profilePromise = api.getUserProfile(userIdPromise);
// Make another call to get the user's friends.
let friendsPromise = authedApi.getFriendIds();
// That only returns an array of user IDs, but we want all the profile info
// too, so use the magic .map() function to get them, too! Still one round
// trip.
let friendProfilesPromise = friendsPromise.map((id: RpcPromise) => {
return { id, profile: api.getUserProfile(id) };
});
// Now await the promises. The batch is sent at this point. It's important
// to simultaneously await all promises for which you actually want the
// result. If you don't actually await a promise before the batch is sent,
// the system detects this and doesn't actually ask the server to send the
// return value back!
let [profile, friendProfiles] =
await Promise.all([profilePromise, friendProfilesPromise]);
console.log(`Hello, ${profile.name}!`);
// Note that at this point, the `api` and `authedApi` stubs no longer work,
// because the batch is done. You must start a new batch.Alternatively, for a long-running interactive application, we can set up a persistent…
Excerpt shown — open the source for the full document.
Notability
notability 6.0/10High-starred new repo by major org.