mirror of
https://github.com/zap-proto/dev.git
synced 2026-10-30 15:05:18 +00:00
- New content/docs/capabilities.mdx: cap model (issue/attenuate/verify/ verifyChain/revoke), SPEC §2.3/§3/§4 delegation rules, fail-closed scheme dispatch, per-language status (Go/Python/Rust/TS all ship caps), and verified per-runtime examples. Wired into meta.json nav after RPC. - rpc.mdx: replace the thin pipelining blurb with the canonical Target model (promiseID/target, Session + Pipeliner) and a real Go/Python/TS example. All API names verified against go/cap, py/src/zap/cap, spec/src/cap.rs, js/src/cap.ts and the pipeline runtimes.
251 lines
8.4 KiB
Plaintext
251 lines
8.4 KiB
Plaintext
---
|
|
title: RPC System
|
|
description: ZAP's capability RPC — the call envelope, generated clients and servers, and promise pipelining.
|
|
---
|
|
|
|
# RPC System
|
|
|
|
An `interface` in a [schema](/docs/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:
|
|
|
|
```zap
|
|
struct EchoReq
|
|
msg Text
|
|
|
|
struct EchoResp
|
|
msg Text
|
|
|
|
interface Echo
|
|
echo (req EchoReq) -> (resp EchoResp)
|
|
notify (n EchoReq)
|
|
```
|
|
|
|
the TypeScript backend ([`zapgen`](/docs/codegen)) 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.
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
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](/docs/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 `promiseID`s 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:
|
|
|
|
<Tabs items={["Go", "Python", "TypeScript"]}>
|
|
<Tab value="Go">
|
|
```go
|
|
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)))
|
|
```
|
|
</Tab>
|
|
<Tab value="Python">
|
|
```python
|
|
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"")))
|
|
```
|
|
</Tab>
|
|
<Tab value="TypeScript">
|
|
```typescript
|
|
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)));
|
|
```
|
|
</Tab>
|
|
</Tabs>
|
|
|
|
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
|
|
|
|
<Cards>
|
|
<Card title="Schema Language" href="/docs/schema">
|
|
Declare the interfaces RPC is generated from
|
|
</Card>
|
|
<Card title="Code Generation" href="/docs/codegen">
|
|
Generate the typed clients and servers
|
|
</Card>
|
|
<Card title="Transports" href="/docs/transports">
|
|
Carry the envelope over TCP, WebSocket, or HTTP
|
|
</Card>
|
|
</Cards>
|