You can implement a custom RPC transport across any bidirectional stream.
The interface
A transport is two required methods, plus abort() if there is something useful to do with a
fatal error:
// Interface for an RPC transport, which is a simple bidirectional message stream.
export interface RpcTransport {
// Sends a message to the other end.
send(message: string): Promise<void>;
// Receives a message sent by the other end.
//
// If and when the transport becomes disconnected, this will reject. The thrown
// error will be propagated to all outstanding calls and future calls on any
// stubs associated with the session. If there are no outstanding calls (and
// none are made in the future), then the error does not propagate anywhere --
// this is considered a "clean" shutdown.
receive(): Promise<string>;
// Indicates that the RPC system has suffered an error that prevents the session
// from continuing. The transport should ideally try to send any queued messages
// if it can, and then close the connection. (It's not strictly necessary to
// deliver queued messages, but the last message sent before abort() is called is
// often an "abort" message, which communicates the error to the peer, so if that
// is dropped, the peer may have less information about what happened.)
abort?(reason: any): void;
}Starting a session
Hand the transport to RpcSession, along with whatever you want the other end to be able to
call:
// Create the transport.
let transport: RpcTransport = new MyTransport();
// Create the main interface we will expose to the other end.
let localMain: RpcTarget = new MyMainInterface();
// Start the session.
let session = new RpcSession<RemoteMainInterface>(transport, localMain);
// Get a stub for the other end's main interface.
let stub: RemoteMainInterface = session.getRemoteMain();
// Now we can call methods on the stub.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 client/server scenarios, the server exposes a main interface and the client does not.
Encoding levels
By default, send() accepts a string and receive() returns a string, with Cap’n Web handling the
encoding all the way to and from strings. Transports that want more control over serialization can
declare an encodingLevel property:
encodingLevel |
What the transport receives | Use when |
|---|---|---|
"string" (default) |
Fully-serialized JSON strings. | HTTP batch and WebSocket use this. |
"jsonCompatible" |
JavaScript value trees that are JSON-compatible. | You serialize to CBOR, MessagePack, etc. |
"jsonCompatibleWithBytes" |
Same, but byte arrays stay as Uint8Array. |
Your format has native binary; avoids base64 overhead. |
"structuredClonable" |
Structured-clonable values, native types passed through. | MessagePort and similar. |
Details:
"string": full JSON round-trip. The transport deals in strings only; Cap’n Web handles all encoding and decoding."jsonCompatible": the transport works with JavaScript value trees, but they must be JSON-compatible. Cap’n Web still encodes special types, but skips the finalJSON.stringify. The transport is responsible for serialization."jsonCompatibleWithBytes": like"jsonCompatible", except byte arrays are left asUint8Arrayinstead of base64-encoded, avoiding the ~33% base64 size overhead and the encode/decode CPU cost. Handy with CBOR or MessagePack."structuredClonable": messages are structured-clonable values. Cap’n Web passes through native structured-clone types where possible, while still handling RPC-specific values such as stubs.
Framing is your job
The protocol operates on a stream of discrete messages; it does not define how they are framed. If your underlying stream is byte-oriented (a TCP socket, a serial line), you must add framing using length prefixes or newline delimiting, as the built-in HTTP transport does.
A worked example
A minimal transport over a pair of async queues:
class QueueTransport implements RpcTransport {
#outgoing: (msg: string) => void;
#incoming: string[] = [];
#waiters: ((msg: string) => void)[] = [];
constructor(outgoing: (msg: string) => void) {
this.#outgoing = outgoing;
}
// Call this when the underlying stream delivers a message.
deliver(message: string) {
let waiter = this.#waiters.shift();
if (waiter) waiter(message);
else this.#incoming.push(message);
}
async send(message: string) {
this.#outgoing(message);
}
receive(): Promise<string> {
let queued = this.#incoming.shift();
if (queued !== undefined) return Promise.resolve(queued);
return new Promise((resolve) => this.#waiters.push(resolve));
}
abort(reason: any) {
console.error('transport aborted', reason);
}
}:::caution
Apply payload size limits at the transport layer. Cap’n Web enforces a maximum incoming message size
before JSON.parse, but that check runs only after receive() has returned a complete message
string, so transport-level limits are the first line of defence against buffering very large
frames. See Security considerations.
:::