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.
npm i capnweb
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});
}
}
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<UserProfile>;
}
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<PublicApi>("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<AuthedApi> = api.authenticate(apiToken);
// Make a pipelined call to get the user's ID. Again, don't await it.
let userIdPromise: RpcPromise<number> = authedApi.getUserId();
// Make another pipelined call to fetch the user's public profile, based on
// the user ID. Notice how we can use `RpcPromise<T>` 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<number>) => {
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 WebSocket connection:
import { newWebSocketRpcSession } from "capnweb";
// We declare `api` with `using` so that it'll be disposed at the end of the
// scope, which closes the connection. `using` is a fairly new JavaScript
// feature, part of the "explicit resource management" spec. Alternatively,
// we could declare `api` with `let` or `const` and make sure to call
// `api[Symbol.dispose]()` to dispose it and close the connection later.
using api = newWebSocketRpcSession<PublicApi>("wss://example.com/api");
// Usage is exactly the same, except we don't have to await all the promises
// at once.
// Authenticate and get the user ID in one round trip. Note we use `using`
// again so that `authedApi` will be disposed when we're done with it. In
// this case, it won't close the connection (since it's not the main stub),
// but disposing it does release the `AuthedApi` object on the server side.
using authedApi: RpcPromise<AuthedApi> = api.authenticate(apiToken);
let userId: number = await authedApi.getUserId();
// ... continue calling other methods, now or in the future ...
The following types can be passed over RPC (in arguments or return values), and will be passed "by value", meaning the content is serialized, producing a copy at the receiving end:
bigintDateUint8ArrayError and its well-known subclassesBlobReadableStream and WritableStream, with automatic flow control.Headers, Request, and Response from the Fetch API.The following types are not supported as of this writing, but may be added in the future:
* Map and Set
* ArrayBuffer and typed arrays other than Uint8Array
* RegExp
The following are intentionally NOT supported:
* Application-defined classes that do not extend RpcTarget.
* Cyclic values. Messages are serialized strictly as trees (like JSON).
RpcTargetTo export an interface over RPC, you must write a class that extends RpcTarget. Extending RpcTarget tells the RPC system: instances of this class are pass-by-reference. When an instance is passed over RPC, the object should NOT be serialized. Instead, the RPC message will contain a "stub" that points back to the original target object. Invoking this stub calls back over RPC.
When you send someone an RpcTarget reference, they will be able to call any class method over RPC, including getters. They will not, however, be able to access "own" properties. In precise JavaScript terms, they can access prototype properties but not instance properties. This policy is intended to "do the right thing" for typical JavaScript code, where private members are typically stored as instance properties.
WARNING: If you are using TypeScript, note that declaring a method private does not hide it from RPC, because TypeScript annotations are "erased" at runtime, so cannot be enforced. To actually make methods private, you must prefix their names with #, which makes them private for JavaScript (not just TypeScript). Names prefixed with # are never available over RPC.
When a plain function is passed over RPC, it will be treated similarly to an RpcTarget. The function will be replaced by a stub which, when invoked, calls back over RPC to the original function object.
If the function has any own properties, those will be available over RPC. Note that this differs from RpcTarget: With RpcTarget, own properties are not exposed, but with functions, only own properties are exposed. Generally functions don't have properties anyway, making the point moot.
RpcStub<T>When a type T which extends RpcTarget (or is a function) is sent as part of an RPC message (in the arguments to a call, or in the return value), it is replaced with a stub of type RpcStub<T>.
Stubs are implemented using JavaScript Proxys. A stub appears to have every possible method and property name. The stub does not know at runtime which properties actually exist on the server side. If you use a property that doesn't exist, an error will not be produced until you await the results.
TypeScript, however, will know which properties exist from type parameter T. Thus, if you are using TypeScript, you will get full compile-time type checking, auto-complete, etc. Hooray!
To read a property from the remote object (as opposed to calling a method), simply await the property, like let foo = await stub.foo;.
A stub can be passed across RPC again, including over independent connections. If Alice is connected to Bob and Carol, and Alice receives a stub from Bob, Alice can pass the stub in an RPC to Carol, thus allowing Carol to call Bob. (As of this writing, any such calls will be proxied through Alice, but in the future we may support "three-party handoff" such that Carol can make a direct connection to Bob.)
You may construct a stub explicitly without an RPC connection, using new RpcStub(target). This is sometimes useful to be able to perform local calls as if they were remote, or to help manage disposal (see below).
RpcPromise<T>Calling an RPC method returns an RpcPromise rather than a regular Promise. You can use an RpcPromise in all the ways a regular Promise can be used, that is, you can await it, call .then(), pass it to Promise.resolve(), etc. (This is all possible because RpcPromise is a "thenable".)
However, you can do more with RpcPromise. RpcPromise supports Promise Pipelining:
RpcPromise also acts as a stub for the eventual result of the promise. That means, you can access properties and invoke methods on it, without awaiting the promise first.// In a single round trip, authenticate the user, and fetch their notifications.
let user = api.authenticate(cookie);
let notifications = await user.getNotifications();
RpcPromise (or its properties) can be passed as parameters to other RPC calls.// In a single round trip, authenticate the user, and fetch their public profile
// given their ID.
let user = api.authenticate(cookie);
let profile = await api.getUserProfile(user.id);
Whenever an RpcPromise is passed in the parameters to an RPC, or returned as part of the result, the promise will be replaced with its resolution before delivery to the receiving application. So, you can use an RpcPromise<T> anywhere where a T is required!
map() methodEvery RPC promise has a special method .map() which can be used to remotely transform a value, without pulling it back locally. Here's an example:
// Get a list of user IDs.
let idsPromise = api.listUserIds();
// Look up the username for each one.
let names = await idsPromise.map(id => [id, api.getUserName(id)]);
This example calls one API method to get a list of user IDs, then, for each user ID in the list, makes another RPC call to look up the user's name, producing a list of id/name pairs.
All this happens in a single network round trip!
promise.map(func) transfers a representation of func to the server, where it is executed on the promise's result. Specifically:
.map() operation returns a promise for an array of the results.null or undefined, the map function is not executed at all. The result is the same value.$ claude mcp add capnweb \
-- python -m otcore.mcp_server <graph>