ZAP Protocol

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:

FieldTypeMeaning
methodUInt32The interface method ordinal — the schema's @N.
promiseIDUInt32Identifies a result to pipeline against, or 0.
targetUInt32The capability this call is addressed to (NO_TARGET = 0).
capDataThe capability token, when one is required.
payloadDataThe request struct's bytes.

A response carries:

FieldTypeMeaning
statusUInt32200 ok; 4xx/5xx on error.
promiseIDUInt32Correlates the response to its request.
bodyDataThe 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

CodeName
200OK
400BadRequest
401Unauthorized
403Forbidden
404NotFound
500Internal

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 target to a prior call's promiseID. That means: before dispatching me, substitute the resolved body of the call that answered to that promiseID as 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) allocates promiseIDs and stamps target onto a dependent call — Origin/origin builds the first call, Pipeline/pipe builds the dependent.
  • Pipeliner (server side) is a per-connection promise table. It records every OK answer under its promiseID, 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. Finish bounds 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.

Next Steps

On this page