Protocol
A protocol — a service in Protobuf, an interface in Cap'n Proto — is a set of functions a caller can invoke.
Functions
- Arguments are
Typeorname: Type, comma-separated:function send(Message)orfunction send(msg: Message). - Return is
-> Type; omit it for a one-way call. ! ErrorNamedeclares that the function can raise a givenerror. List several, comma-separated, for a function that can fail in more than one way:! NotFound, Denied. The name must be anerror— astructorenumafter!is a build error, because only anerrorhas a wire ordinal to travel as.- Every function ends with
;.
Functions take docstrings (including the /// @name: form for
arguments) and @key=value annotations.
Annotations
A @key=value annotation on the protocol itself configures the whole service.
@framingpicks the wire framing the generated code uses. Without it, the protocol takes the package'swire.framingfromconfig.idp(Wire defaults), else"datagram@2", which puts it on a connection."jsonrpc"(json-rpc,jsonrpc-2.0) emits a JSON-RPC 2.0 stack, and"datagram"(datagram@1) the lockstep framing, with a client and a server and one protocol per transport. Both ends must agree — the handshake refuses a mismatch.
Writing @framing is off by default: a package allows it with the setting
annotations.framing.allowed = true, or sets every protocol's framing at
once with wire.framing.
The name is shorthand for a dictionary, which can say more about the
framing: @framing = { name = "jsonrpc", method_names = qualified }.
method_names is JSON-RPC's only setting: bare (the default) or
qualified; see JSON-RPC on a connection.
- @provider says which side implements the protocol: Server (the
default), Client or Multiple. See
Calls in both directions.
- @consumer is the other side, which follows from @provider. You don't
need to write it, but if you do, it has to agree.
- @handshake picks what runs on a connection before the first call:
"comline@1" (the default) checks that both ends share the schema, format
and framing; "none" sends and checks nothing. Without it, the protocol takes
the package's handshake = "..." from config.idp, else comline@1.
- @unverified says why a protocol gives up a guarantee Comline would
otherwise give its peers. It's required where one is given up, as with
@handshake = "none"; see Giving up a guarantee.
Calls in both directions
By default the side that dialled calls and the side that accepted answers. A protocol the client implements turns that round: the server calls it, to push a message or to ask the client something mid-request.
@provider = Client: the client implements it and the server calls it.@provider = Multiple: both sides implement it and either can call.- The framing has to tell requests from replies, since both ends now send
both. The default,
datagram@2, does, and so does JSON-RPC. The lockstepdatagramcan't, so aClientorMultipleprotocol on it is a build error (validation::reverse-calls-need-framing). @consumerthat disagrees with@provideris a build error (validation::provider-consumer-mismatch).
Every protocol on datagram@2 — all of them, unless they pick another
framing — runs on a connection instead of a client and a server:
- One connection carries all of them. Each side provides the protocols it
implements and calls the others. The generated package
servetakes one implementation per protocol and serves them all. - Calls can nest. A handler receives the peer that called it, so it can call back before it answers. While a side waits for a reply, it keeps serving the peer's requests.
- Each protocol has a number on the wire: its position among the package's protocols, in schema order.
- A connection keeps typed data of its own (a session, a user id) that every handler on it can reach.
How this looks in code: Rust, TypeScript.
JSON-RPC on a connection
JSON-RPC carries calls both ways too: a message with a method is a request,
any other is a reply.
- Once one JSON-RPC protocol is on a connection, they all are.
Eventsputs the package on a connection, soChat, the server's API, shares it. A JSON-RPC message looks the same with or without one. - A call names its method, not a protocol number. With
barenames a call issend. Withqualifiedit'snamespace.Protocol/function(chat.Events/message). Each side looks a method up among the protocols it provides. - Bare names have to be unique on each side. Two JSON-RPC protocols one
side provides, both with a
send, can't be told apart, andcomline buildrefuses them. Qualify either. - A one-way call is a JSON-RPC notification: it has no
id, and nothing is sent back.
Giving up a guarantee
A protocol fronting a peer that can't take part in Comline's handshake (a legacy service with no schema, say) can run none:
Nothing then checks that both ends agree on the schema or the encoding, so the protocol gives up schema agreement and encoding agreement. Core works that out; the schema only says that something is unverified and why:
- The marker is required. Giving a guarantee up without
@unverifiedis a build error, and the reason can't be empty. A marker on a protocol that gives nothing up is a warning. - It's off by default, like
@framing: a package allows it withsettings = { annotations = { unverified = { allowed = true } } }. - Core freezes the set with the reason. Build output and generated code
name it ("
Billingis unverified: schema agreement isn't established (handshakenone)"), each guarantee under its own diagnostic code (unverified::schema-agreement, …) so an organisation can raise or quiet them. - Giving one up is a major change; restoring one is minor.
- Every consumer accepts it, under the dependency in its own
config.idp, for exactly the set given up:
dependencies = {
billing = {
path = "../billing"
accept_unverified = { Billing = ["schema_agreement", "encoding_agreement"] }
}
}
Nobody writes this by hand: comline add shows what a new dependency gives
up and asks before writing it, and comline accept billing (or
billing::Billing) does the same for one already declared. In the editor,
the dependency's entry in config.idp shows the error, with a quick fix
that writes the line. Until it's accepted, the build fails, printing the
entry. The list is a snapshot: if a
later version of the dependency gives up more, the build stops again until
comline accept is run again.
How a call travels between caller and implementation is the runtime's job, over a pluggable call system.