Skip to content

Call systems & serialization

The call system (sometimes CaSy) is the part of the runtime that turns a call to a protocol function into bytes on a transport and back again. This page is the one place that explains its parts; the rest of the docs link here.

Call system

The pluggable part — the call framing (JSON-RPC, a compact binary format, a custom one) and the message serialization — is the call system.

Generating the schema types the runtime needs is covered in Library generation.

The layers

flowchart LR
    subgraph caller["Caller"]
        A["send(text)"] --> F1["format<br/>encodes the arguments"]
        F1 --> R1["framing<br/>wraps them as a request"]
    end
    R1 --> T(["transport<br/>TCP, in-memory, yours"])
    T --> R2
    subgraph provider["Provider"]
        R2["framing<br/>reads the request"] --> F2["format<br/>decodes the arguments"]
        F2 --> H["your handler"]
    end

The reply goes back the same way. Each layer is chosen separately:

Layer What it does Built in Chosen by
Format turns values into bytes packed@1 (the default: fixed-width fields, no tags or names, see packed@1), json@1, msgpack@1 (Rust only, for now) the package's wire.format
Framing wraps a call or a reply around those bytes datagram@3 (the default, for connections, calls by wire number), datagram@2 (calls by position), its lockstep first version datagram, and JSON-RPC 2.0 a protocol's @framing, else the package's wire.framing
Handshake checks both ends agree, before the first call comline@2 (the default), comline@1, none a protocol's @handshake, else the package's handshake
Transport carries the frames TCP and in-memory in Rust, in-memory in TypeScript your code, when you connect

The format, framing and handshake are part of the schema: both ends have to agree on them, so they're declared once, in config.idp (Wire defaults), and generated code uses them without being told. What's built in, and what each one guarantees, is in Official encodings; you can add your own. The transport doesn't change the bytes, so it's plain code you pass in.

JSON-RPC carries JSON, so a protocol on it uses json@1 whatever the package says. Which combinations each language supports is on its reference page.

Handshake

Before the first call, connect and serve run the protocol's handshake: comline@2 by default, which checks compatibility (below). comline@1 checks identity instead, and exchanges one small fixed-size frame each way:

sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: hello: schema fingerprint, format, framing
    S->>C: hello: schema fingerprint, format, framing
    alt all three match
        C->>S: first call
    else any differs
        Note over C,S: both refuse, before any call is made
    end

A mismatch (different schema versions, one end MessagePack and the other JSON, a different framing) is refused at once, rather than surfacing later as a confusing decode error partway through a real call. Capability bits are exchanged too, but a difference there is advisory.

comline@2 checks compatibility instead of identity. It compares each protocol's line, shape hash and wire numbers, so a newer consumer can still call an older provider. It's the default, with its companion framing datagram@3, which addresses calls by wire number: see Handshakes and Datagram framings.

A protocol talking to a peer that can't take part (a legacy service, say) declares @handshake = "none" and says why with @unverified; see Giving up a guarantee. In the runtimes the handshake is a trait (Handshake; an interface in TypeScript), so a custom one can be passed to Client::connect_with / Server::serve_with. Hand-wired code can also skip it at the call site with Client::new_unverified / Server::serve_unverified (Client.unverified / serveUnverified in TypeScript), named so that skipping is as visible there as in a schema.

Connections

With the lockstep datagram framing, a client sends a request and reads the next frame as its reply, one protocol per transport. A connection, which every protocol uses by default, lifts that limit:

sequenceDiagram
    participant C as Client
    participant S as Server
    C->>S: request 1: Chat.send("hi")
    S->>C: request 7: Events.message("hi")
    Note over C: serves the server's call<br/>while waiting on its own
    C->>S: reply to 7: true
    S->>C: reply to 1: 42
  • Either side can call, since datagram@2 marks each frame as a request or a reply.
  • Several calls can be in flight. A reply is matched to its call by request id.
  • Several protocols share it. Each frame carries its protocol's number.
  • A side waiting for a reply keeps serving the peer's requests, so a handler can call back the peer that called it.

The runtime keeps the protocol logic in a core that does no I/O: bytes go in, events come out ("serve this request", "this reply ends call 7"). The Connection around it owns the transport. The handshake is the same as a client's and a server's; a connection checks one hash over every schema in the package, which also fixes the protocols' numbers.

Which protocols run on a connection is the schema's choice: see Calls in both directions.

In code

Language Guides has the concrete API in each language: constructing a format, picking a framing, and what the handshake looks like in code.