RPC System
ZAP's capability RPC — the call envelope, generated clients and servers, and promise pipelining.
RPC System
An interface in a schema is not just a struct — it is a set of
callable methods. ZAP's RPC system turns those declarations into a typed client
and an abstract server, carried over the wire by a small, fixed call
envelope. It is a capability system: a call can target a specific object, and
results can be used as arguments to further calls before they have even arrived
(promise pipelining).
The call envelope
Every request is a ZAP message tagged as a router frame. Its fixed fields are:
| Field | Type | Meaning |
|---|---|---|
method | UInt32 | The interface method ordinal — the schema's @N. |
promiseID | UInt32 | Identifies a result to pipeline against, or 0. |
target | UInt32 | The capability this call is addressed to (NO_TARGET = 0). |
cap | Data | The capability token, when one is required. |
payload | Data | The request struct's bytes. |
A response carries:
| Field | Type | Meaning |
|---|---|---|
status | UInt32 | 200 ok; 4xx/5xx on error. |
promiseID | UInt32 | Correlates the response to its request. |
body | Data | The response struct's bytes. |
Because the method is addressed by ordinal — never by name — appending a method to an interface never renumbers the existing ones, so a new client and an old server interoperate as long as they share the methods they both know.
Status codes
| Code | Name |
|---|---|
200 | OK |
400 | BadRequest |
401 | Unauthorized |
403 | Forbidden |
404 | NotFound |
500 | Internal |
Generated client and server
From an interface, code generation emits a typed client, an abstract server,
and the method-ordinal table. For this schema:
struct EchoReq
msg Text
struct EchoResp
msg Text
interface Echo
echo (req EchoReq) -> (resp EchoResp)
notify (n EchoReq)the TypeScript backend (zapgen) emits an EchoClient with one
async method per declaration and an abstract EchoServer whose dispatch
decodes the ordinal and routes to the matching handler.
Server
Implement the abstract handlers; dispatch does the envelope decode and ordinal
routing for you.
import { EchoServer } from './gen/echo_zap.js';
class Echo extends EchoServer {
async echo(req: EchoReq): Promise<EchoResp> {
return newEchoResp({ msg: req.msg() });
}
async notify(n: EchoReq): Promise<void> {
console.log('notified:', n.msg());
}
}Client
import { ZapClient } from '@zap-proto/zap/node';
import { EchoClient } from './gen/echo_zap.js';
const conn = await ZapClient.connect('localhost:9000');
const echo = new EchoClient(conn);
const resp = await echo.echo({ msg: 'hello' });
console.log(resp.msg()); // "hello"
conn.close();Driving the envelope directly
Generated clients are the normal path, but the envelope is a public API. Build a request, send the bytes over any transport, and decode the response:
import { buildRequest, parseResponse, NO_TARGET, Status, Message } from '@zap-proto/zap';
const reqBytes = buildRequest({
method: 1, // the interface method ordinal
promiseID: 0,
target: NO_TARGET,
cap: new Uint8Array(0),
payload, // the request struct's bytes
});
// ... send reqBytes, receive responseBytes ...
const res = parseResponse(responseBytes);
if (res.status === Status.OK) {
const result = Message.parse(res.body).root(); // response struct, zero-copy
}Transport framing
The envelope is transport-agnostic; the Node TCP client
(@zap-proto/zap/node) frames it as [len UInt32 LE][payload] with a 10 MB cap,
opens with a nodeID handshake, and correlates responses to requests by
promiseID. The same envelope rides over WebSocket and HTTP transports — see
Transports.
Promise pipelining
ZAP RPC is Level-1: the result of one call can be used as the input of another before the first result has returned. This collapses a dependent two-round-trip exchange into one.
The Target model
There is one canonical pipelining model, byte-identical across the Go,
TypeScript, and Python runtimes. It rides on two envelope fields already shown
above — promiseID and target — and needs no new wire format:
- A call carries a caller-assigned
promiseID— the id its answer resolves to. - A dependent call sets
targetto a prior call'spromiseID. That means: before dispatching me, substitute the resolved body of the call that answered to thatpromiseIDas my payload.
The two calls ship back to back; the server chains them. The dependent never
waits for the first answer to round-trip back to the caller. A non-pipelined call
sets target = NO_TARGET (0), so a pipelining server and a plain dispatcher
are wire-compatible — a non-pipelining peer in any language interoperates by
simply never setting target.
Two pieces implement it, each in one place:
Session(client side) allocatespromiseIDs and stampstargetonto a dependent call —Origin/originbuilds the first call,Pipeline/pipebuilds the dependent.Pipeliner(server side) is a per-connection promise table. It records every OK answer under itspromiseID, substitutes a resolved target's body for a dependent's payload before dispatch, and queues a dependent whose target has not resolved yet until it does. It refuses (400 BadRequest) a dependent whose target answered non-OK or was finished — so it never hangs.Finishbounds the table by dropping an answer once no further call will pipeline on it.
Example
Authenticate, then immediately pipeline a second call on the auth result:
import "github.com/zap-proto/go/rpc"
sess := rpc.NewSession()
srv := rpc.NewPipeliner(DispatchAccount(handler)) // wraps a generated Dispatch<Iface>
// A: authenticate (Target = NoTarget). Its answer resolves to promise p.
p := sess.Next()
aResp, _ := srv.Handle(rpc.BuildRequest(sess.Origin(p, AuthOrdinal, capToken, authReq)))
// B: pipeline on A — the server feeds A's resolved body in as B's payload.
q := sess.Next()
bResp, _ := srv.Handle(rpc.BuildRequest(sess.Pipeline(q, p, GetBalanceOrdinal, capToken, nil)))from zap import Session, Pipeliner, build_request
sess = Session()
srv = Pipeliner(dispatch) # dispatch(envelope) -> response envelope
# A: authenticate (target = NO_TARGET). Its answer resolves to promise p.
p = sess.next()
a_resp = srv.handle(build_request(sess.origin(p, AUTH_ORDINAL, cap_token, auth_req)))
# B: pipeline on A — the server feeds A's resolved body in as B's payload.
q = sess.next()
b_resp = srv.handle(build_request(sess.pipeline(q, p, GET_BALANCE_ORDINAL, cap_token, b"")))import { Session, Pipeliner, buildRequest } from '@zap-proto/zap';
const sess = new Session();
const srv = new Pipeliner(dispatch); // (envelope) => Promise<responseEnvelope>
// A: authenticate (target = NO_TARGET). Its answer resolves to promise p.
const p = sess.next();
const aResp = await srv.handle(buildRequest(sess.origin(p, AUTH_ORDINAL, capToken, authReq)));
// B: pipeline on A — the server feeds A's resolved body in as B's payload.
const q = sess.next();
const bResp = await srv.handle(buildRequest(sess.pipe(q, p, GET_BALANCE_ORDINAL, capToken)));The request/response envelopes (BuildRequest/build_request/buildRequest)
are byte-for-byte identical across these three runtimes, so the same pipelined
exchange round-trips between any of them. The Rust stack (zap-rpc) implements
the richer Cap'n-Proto PromisedAnswer (transform-path) model — a superset that
interoperates at the envelope level for non-pipelined calls. The Node TCP
transport additionally exposes a two-connection pipeline helper
(@zap-proto/zap/node) that ships the dependent leg on a second socket so two
calls are genuinely in flight at the wire level.