Let’s build the smallest useful Cap’n Web service, then add types.
A client
Open a session and call a method on it:
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);There is no client generation and no interface registration. api is a stub: a Proxy that
appears to have every possible method. Calling one sends an RPC.
A server
The other half, implementing the hello the client just called:
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.)
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 });
},
};Extending RpcTarget is what makes the object available over RPC. Callers
can invoke its prototype methods and getters, but not its instance properties.
See Server runtimes for Node.js, Deno, Bun, and Hono equivalents.
Adding types
You don’t have to declare your interface separately; the client could just use
import("./server").ApiServer as the type. But a shared types file is often cleaner:
// shared/api.ts
interface PublicApi {
// Authenticate the API token, and return 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;
};On the server, 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, the stub is fully typed: you get compile-time checking and autocomplete, even though nothing was generated:
import { newWebSocketRpcSession } from 'capnweb';
using api = newWebSocketRpcSession<PublicApi>('wss://example.com/api');
using authed = api.authenticate(apiToken);
let userId: number = await authed.getUserId();:::caution TypeScript types are erased at runtime. A malicious client can send values of types you did not expect. See Security considerations and Runtime validation. :::
Documenting your API
There is no OpenAPI document to generate, because there is no schema to generate it from, and no separate artifact that can drift out of date with the implementation.
The shared types file is the API description, so document it there:
// shared/api.ts
export interface AuthedApi {
/**
* The user IDs of everyone this user is friends with.
*
* Returns at most 5000 entries; there is currently no pagination.
*/
getFriendIds(): number[];
}That gives you three things at once: hover documentation in every editor for anyone importing the file, a single file you can hand someone as “the API”, and, if you want a browsable site, ordinary TypeScript documentation output from a tool like TypeDoc.
The catch is the one you already know from OpenAPI: comments are not enforced. If you want the boundary actually checked, that is runtime validation.
Which transport?
| You want | Use |
|---|---|
| A burst of calls, then done | HTTP batch |
| A long-lived session, server-initiated calls | WebSocket |
| Talk to a Web Worker or iframe | MessagePort |
| Something else entirely | Custom transport |
Next steps
- Pipelining tour: do all of the above in one round trip.
- What can be passed: the type system on the wire.
- Disposal: the one piece of bookkeeping Cap’n Web asks of you.