std::rpc reference
Transports, the generated service surface, errors, and connection state.
Concepts and usage: the services guide. Most apps
touch only the generated client, RpcError, and ConnectionState;
everything else here is the machinery those sit on.
The generated surface ([service])
For [service(FooClient)] struct Foo with [rpc] methods and [expose]
signal fields, the macro generates:
// client side
FooClient::connect(url: str, codec: Codec): Result<FooClient<SocketTransport>, RpcError>
client.some_rpc(args…): Result<T, RpcError> // per [rpc] method; implicitly awaited
client.some_handle(args…): RemoteSource<T> // per [rpc] method RETURNING a source (SignalCell or MemoCell); sync, unleased
client.some_keyed(args…): KeyedSource<K, V> // per [rpc] method returning a KeyedCell; same, keyed
client.some_signal: RemoteSource<T> // per [expose] field; a typed mirror (below)
client.some_map: KeyedSource<K, V> // per [expose(keyed)] field; a patched mirror (below)
client.transport: SocketTransport // connection state lives here
// server side
foo.dispatcher(): Dispatcher // the method table
dispatcher.into_protocol(codec: Codec): RpcProtocol // what Service::new takes
A handle stub is idempotent per origin: a second call with the same
arguments on the same client answers the mirror the first one minted (one
call, one Subscribe), until that mirror’s last lease closes. The table
behind it is generated beside the client — one MirrorTable per handle
method — so a stub is safe inside a cold select (and_then, switch)
that runs on every read.
connect accepts a relative url ("/") in the browser; it dials the same
host over WebSocket, waits for the server’s announcement, and verifies the
contract hash: a drifted server fails the connect with
RpcError::Contract. The hash covers the surface’s RESOLVED types, so a
respelling is not a drift: HashMap<str, i32> and a renaming import of it hash
alike. It also covers each of your Wire types’ SHAPE — a struct’s fields in
declaration order (name and type, recursively), an enum’s variants in order —
because a codec reads the type itself: a field added, removed, renamed or
reordered, or a variant moved, is a different contract under the same name,
and a server redeployed with one refuses its old clients at connect instead of
being mis-decoded by them. (std’s own types stand for themselves: they change
only with the toolchain both sides are built by.)
Mirrors: RemoteSource<T>
struct RemoteSource<T> { … }
impl RemoteSource<type T> with Source<Option<T>> {
fun get(self): Option<T> // passive: the cache, `None` before the first update
[must_use]
fun on_settle(self, subscriber: Subscriber): Subscription // counted: attach, then lease
[must_use]
fun attach_observer(self, observer: |Option<T>| void, immediately: bool): Subscription
// counted; eager = lease first, one immediate call
}
// Through `Flow` (every source is one): `on_change` (counted, lazy), `sub`
// (counted, eager), `effect` (counted, eager, owner-scoped), `derive`, …
impl RemoteSource<type T> {
fun status(self): SignalCell<Status> // passive: what the mirror was last told
fun or(self, initial: T): Derive<RemoteSource<T>, Option<T>, T> // a pipe: `initial` until the first update
[must_use]
fun sub(self, observer: (|T| void) context tracking): Subscription // counted, manual: present values; dispose to release
}
// A mirror is a transient source (std::reactive::transient): Status mapped arm for arm.
impl RemoteSource<type T> with TransientSource<T, RpcError> {
fun state(self): MemoCell<TransientState<T, RpcError>> // passive, like `status`: leases nothing
fun states(self): dyn Pipe<TransientState<T, RpcError>> // leases while bound; the whole state
fun latest(self): dyn Pipe<Option<T>> // leases while bound; keeps the stale value
fun is_pending(self): dyn Pipe<bool> // leases while bound
}
[derive(PartialEq, Debug)]
enum Status {
Waiting, // nothing has arrived — including "nothing has been asked"
Ready, // the cache holds a value
Absent, // a handle's call answered `None`: no such source
Failed(RpcError), // a handle's call failed, and this is what it said
}
Absent and Failed are a handle mirror’s arms (the stub of an [rpc]
method that returns a source): they say what the minting call was told, and a
field mirror never reads either. Both are answers about now — the mirror
keeps whatever it last held, and the next 0→1 lease asks again — and both are
cleared by the rebind that a successful ask produces.
A mirror is a Source<Option<T>> (tracker A52), so on_change, effect,
effect_on_change and every generic S: Source<…> consumer — selector
among them — take one. The trait argument is Option<T> because that is what
a mirror holds, so a RemoteSource<List<Note>> is not a Source<List<Note>>
and each takes mirror.or([]). sub has one spelling per view of the
value: the inherent one hands the observer a present T, the trait’s hands it
the Option<T>, and the observer’s own parameter type picks between them.
A mirror holds Option<T> — None until the first Update lands — and
subscribes by demand: every subscribing leaf — a sub, an effect, a
.cell(), a binding or an each — takes a counted lease on the channel, on
the mirror itself or on any node over it (mirror.map(..), mirror.or(..)). The 0→1 lease sends Subscribe (the server answers with
the current value at once); the 1→0 release sends Unsubscribe, deferred
to the end of the ambient turn so a same-turn re-subscribe (a view
re-rendering in place) sends nothing. A second watcher on an open channel
sends no frame. On reconnect a watched mirror (count > 0) re-subscribes on
its fresh channel; an unwatched one does not.
map (the blanket every source has) and or are cold nodes: building one
opens nothing, anywhere — map is where the Option is confronted once, and
or is map for the same-type fallback. The owner is asked at the
subscribing leaf, where the lease is taken: effect and
effect_on_change on the mirror or a node over it hand the lease to the
ambient owner and are a compile error (context coverage) where none is — a
network subscription must have a releaser; a .cell() of it takes one lease
and ties it to the ambient owner (inside a view, the view’s); a binding or an
each leases under the view that places it. sub and on_change are the
manual form for code with no owner: you hold the Subscription and
dispose it, which releases the lease.
get and status open nothing. A status observer alone never sees
Waiting → Ready: status reports, it does not ask; until something
that renders the value subscribes, the mirror stays Waiting, and that is
correct — the channel was never opened.
A handle mirror takes that one step further: it is minted with no
channel at all, and its first lease is what issues the call. So a handle
nothing watches has not merely opened no channel — it has not asked, and
costs nothing on either side. What the call answered is then status():
Absent for a None reply, Failed(error) for a call that failed, and
the next 0→1 lease asks again in both cases.
A .cell() of or/map is a local derivative: writing it writes nothing
back (the server owns the source) and the next update overwrites it. An
empty-list initial needs no annotation — the [] takes its element type from
the mirror (let notes = client.notes.or([]); is a source of List<Note>).
Keyed mirrors: KeyedSource<K, T>
The mirror an [expose(keyed)] / [expose(keyed = K)] field produces — and
what an [rpc] method returning a KeyedCell<K, T> hands back, minted per
call and unleased, exactly like a plain handle. On a minted one a per-key
lease is a first demand too, and the channel is withdrawn when the last demand
on it goes rather than the first.
Where a RemoteSource<T> receives the whole value on every change, this one
receives a Patch of Delta ops and applies them in order — and it can lease
one key.
struct KeyedSource<K, T> { … }
impl KeyedSource<type K: Wire + Hashable, type T: Wire + Keyed<K>> with Source<Option<List<T>>> {
fun get(self): Option<List<T>> // passive: what this client subscribed to
[must_use]
fun on_settle(self, subscriber: Subscriber): Subscription // counted: attach, then lease
[must_use]
fun attach_observer(self, observer: |Option<List<T>>| void, immediately: bool): Subscription
}
// Through `Flow`: `on_change` (counted, lazy), `sub` (counted, eager), `effect`
// (counted, eager, owner-scoped), `derive`, …
impl KeyedSource<type K: Wire + Hashable, type T: Wire + Keyed<K>> {
fun status(self): SignalCell<Status> // passive: `Waiting` until the first patch
fun fault(self): Option<str> // passive: the first protocol fault, sticky
fun or(self, initial: List<T>): Derive<KeyedSource<K, T>, Option<List<T>>, List<T>> // a pipe: the whole collection
[must_use]
fun sub(self, observer: |List<T>| void): Subscription // counted, manual: the whole collection
fun of(self, key: K): SignalCell<Option<T>> // counted per KEY, owner-scoped
[must_use]
fun sub_key(self, key: K, observer: |Option<T>| void): Subscription // counted per KEY, manual
fun rebind(self, channel: i32) // reconnect: re-subscribe every demand held
}
A keyed mirror is a Source<Option<List<T>>> (tracker A55), on the same
counted lease and with the same reading as RemoteSource’s: the trait argument
is the Option because a mirror that has been told nothing is not an empty
collection, so a KeyedSource<K, T> is not a Source<List<T>> and
each takes mirror.or([]). sub has one spelling per view of the
value — the inherent one hands the observer a present List<T>, the trait’s
hands it the Option<List<T>>, and the observer’s own parameter type picks
between them.
The counted lease is RemoteSource’s, applied per demand rather than
per channel: a per-key 0→1 sends Subscribe(channel, Some(key)) and the
server forwards that key’s changes and nothing else; the 1→0 releases that
key alone. Whole-collection demand subsumes per-key demand — while the
whole collection is held, a key asks for nothing, and when the whole lease
is released the keys still held take the wire back — so holding both never
doubles a delivery.
The mirror holds exactly what this client asked for, which is why a
per-key subscriber’s get() is a one-element list rather than the
collection. fault() is Some(reason) if a patch ever named a key the
mirror does not hold; that op is refused rather than applied.
Hand-wired exposures use ReactiveServer::expose_keyed(source, key_of)
(for a Source<List<T>>) or expose_keyed_map(source, key_of) (for a
Source<HashMap<K, V>>), with ReactiveClient::attached_keyed_source /
keyed_source on the other end. key_of is a value parameter rather than
a Keyed<K> bound alone because K appears nowhere else in the
signature, and vilan infers a type parameter from a call’s types, not from
its bounds.
The attribute generates whichever of the two the field’s collection calls for,
and the key type comes from wherever it is written (tracker A51): a HashMap<K, V>
element names it and takes the bare [expose(keyed)], and every other
collection names it in the attribute — [expose(keyed = str)] items: SignalCell<List<Task>>. Naming it in both places is redundant rather than
wrong, but the two spellings must AGREE: an argument that disagrees with the
HashMap’s own key is refused at the attribute (tracker A56), because the
expansion reads K from the annotation before any type resolves and cannot
pick between them. The generated wiring is the hand-written call, frame
for frame, and the two spellings are one contract: same Patch frames, same
KeyedSource<K, T>, same contract hash.
Keyed cells: KeyedCell<K, T>
The SERVER-side twin of KeyedSource, and the cheap way to hold a keyed
collection (tracker A54). Exposing a SignalCell<List<T>> keyed makes the
server DIFF two snapshots on every change — it re-keys both and compares every
retained element — so a keyed channel costs O(N) per change per subscribed
connection, however small the change is. That is irreducible while the source
is List-valued, because only the mutation knows what changed. A KeyedCell
is the mutation saying so: each write appends the Delta<K, T> it is, and a
subscriber’s forward sends the ops since it last looked.
struct KeyedCell<K, T> { … }
impl KeyedCell<type K: Hashable, type T: Keyed<K>> {
fun new(initial: List<T>): KeyedCell<K, T>
fun insert(self, value: T) // append, or replace what is held under its key
fun remove(self, key: K) // a key it does not hold is a no-op
fun update(self, key: K, mutate: sync |&mut T| void) // in place, and the `Update` op it is
fun set(self, value: List<T>) // the wholesale write: one `Reset`
fun locate(self, key: K): Option<(usize, T)> // the element and where it sits, by lookup
}
impl KeyedCell<type K: Hashable, type T: Keyed<K>> with Source<List<T>> {
fun get(self): List<T>
[must_use]
fun on_settle(self, subscriber: Subscriber): Subscription
}
It is a Source<List<T>> with no Option in it — a cell always holds a
collection, where a mirror may not have been told one yet — so
each(cell, …) takes it directly and the or([]) a KeyedSource needs
has nothing to say here.
[expose] items: KeyedCell<str, Task> is a keyed channel. The cell names both
its key and its element in its own type, so keyed is redundant on it (writing
[expose(keyed)] is the same thing) and there is nothing for keyed = K to
add. The wire does not change: the same Reset seed and the same
element-grained Patch per change, the same KeyedSource<K, T> on the client,
and the same contract hash as [expose(keyed = str)] items: SignalCell<List<Task>> — a service may swap one field for the other without
moving its contract. What changes is the cost: measured per change on one
connection, 0.0078 ms at 1,000 rows and 0.0103 at 10,000 where the diffing
exposure is 0.315 and 3.814 (children CPU, getrusage).
The hand-wired form is ReactiveServer::expose_keyed_cell(cell), which takes
no key_of — KeyedCell<K, T> names K in its own type, so Keyed is a real
bound there. It is a distinct name rather than an overload of expose_keyed
because a cell is a Source<List<T>> and would otherwise satisfy that
bound and take the diffing path silently. expose_keyed over a cell is still
correct, just O(N).
T: PartialEq is a bound of the diffing exposure and not of the cell: a diff
can only learn that an element changed by comparing it, and a cell whose writes
are the ops never compares anything.
The op log itself is not the cell’s own any more: it is DeltaLog<O> in
std::reactive (see Reactive),
generic in the op rather than in the collection, and the cell records the
POSITIONAL SeqOp every sequence cell records. So KeyedCell is a sequence
cell plus a position index by key hash plus the wire translation, and it is a
DeltaSource<List<T>, SeqOp<T>> as well as a Source<List<T>> — which is what
lets a consumer that knows nothing about keys read what changed. cursor,
drop_cursor and since are unchanged in name and in what they answer; the
cursor type is DeltaCursor (it was KeyedCursor, which no longer exists).
Errors
[derive(Wire, Json, Debug, PartialEq)]
enum RpcError {
Transport(str), // couldn't reach / lost the server ("not connected", "connection lost")
Decode(str), // reply didn't parse
Remote(str), // the handler failed
Contract(str), // connect-time shape mismatch (old client vs new server)
Unauthorized, // 401/403 at the handshake: not with this credential
Unavailable, // 503 at the handshake: not now — the app's own refusal
}
Infrastructure failures only: an application “not found” belongs in the
rpc’s own return type (Option<Task>), not here.
The POST {mount}rpc leg’s statuses
A vilan client reads the ENVELOPE and has never read the status. The status is
the contract for everything that is not a vilan client — curl, a fetch in a
page, a proxy, a load balancer, a monitoring probe — and the two never
disagree:
| outcome | status |
|---|---|
Success(..), including an application Err arm | 200 |
Failure(Decode(..)) | 400 |
Failure(Unauthorized) | 401 |
Failure(Remote("unknown method: …")) | 404 |
Failure(Contract(..)) | 409 |
Failure(Remote(..)) — a handler failed | 500 |
a Service::factory service on this leg | 501, as an envelope |
Failure(Unavailable) | 503 |
a non-POST on {mount}rpc | 405, Allow: POST |
An application error is a 200: Result<Token, str>’s Err arm is a value
that crossed successfully, and a server answering 401 for “wrong password”
would be claiming the CALL was unauthorized — a different fact, and the one
authorize reports. The cost is worth stating: a dashboard reading statuses
alone cannot see application errors, because they are not errors of the
transport.
The leg requires Content-Type: application/json or
application/octet-stream, and answers 400 otherwise. That one check is the
CSRF posture: a cross-site HTML form can produce only text/plain,
application/x-www-form-urlencoded and multipart/form-data, so it is
structurally unable to reach any [service] — whether or not the service’s
author thought about CSRF. HttpTransport sets the header; a hand-written
caller must too. std does no CORS beyond this: on_request plus
Response::builder().set_header is the whole mechanism, and an allowed-origin
list is a decision std has no information for — the
services guide carries the recipe.
Every envelope carries one of those two media types whatever its status, and
that is what HttpTransport reads to tell a reply from a stranger’s document:
an answer that is not an envelope is Err(RpcError::Transport(..)) naming the
status, not a Decode blaming the codec. A status cannot serve — the leg’s 404
for an unknown method and an app’s 404 for an unclaimed path are the same
number.
A Decode on the SERVER’s side of a call is what the generated route answers
for a request it cannot read: an argument count the method does not declare
(expects 2 argument(s), got 3 — extra arguments are a disagreement, not
something to drop), or an argument of the wrong type. A vilan client cannot
provoke either, since both sides are generated from one surface and the
contract hash refuses a client that disagrees; an arbitrary HTTP caller can,
which is what the check is for.
Connection state
enum ConnectionState { Connected, Reconnecting, Closed }
impl SocketTransport {
fun connection_state(self): SignalCell<ConnectionState>
fun on_reconnect(self, hook: async || void)
}
The reconnect lifecycle (automatic): on drop → Reconnecting, in-flight
calls reject with Transport("connection lost"), new calls fail fast with
Transport("not connected"); dial with backoff (250 ms doubling, 4 s cap,
10 attempts); on success → contract re-check, mirrors re-attach and resync,
Connected. Nothing is ever silently retried; retry is the app’s decision.
Closed is terminal, and three things reach it: the attempt budget runs
out; the re-dialled server’s contract has drifted (it redeployed a
different surface, so typed mirrors would decode against a shape they were
not built for); or the re-attach itself is refused — the server answers,
but will not hand back channel ids. The last two close the socket rather
than staying Connected, because a mirror that cannot be rebound is pointed
at the previous connection’s dead channels: it would never update again, and
the socket would say nothing was wrong. Bind Closed and offer the restart;
that decision is the app’s.
On every path that reaches Closed the client disposes itself: its
update routes are emptied and its transport forgets it. The state is what
decides, not the reason for it — a spent budget releases exactly as a drifted
contract does. Closed is terminal, so nothing on that socket can ever reach
those mirrors again and nothing keeps holding them: the mirrors read their
last value forever, exactly as they did before, and the graph behind them is
released instead of wedged open. What does not dispose is a connection that
is merely slow — a redial in progress, or a server unreachable when the
re-attach runs — because that socket is Reconnecting, not Closed: it keeps
trying, and its mirrors resync when it succeeds.
on_reconnect is where that decision goes. Hooks run after each successful
re-dial, awaited in order, and the generated client registers its own mirror
re-attach when it connects — so a hook you register runs after the mirrors
have resynced, which connection_state cannot tell you: the state flips to
Connected one beat earlier, because the re-attach’s own rpc call needs a
usable transport first. Bind the signal for a banner; use the hook for
anything that needs current mirrors.
client.transport.on_reconnect(|| title.repush());
Keep a hook short — it runs inside the reconnect loop’s own extent, so a long round-trip inside one holds the reconnect open behind it.
Transports
trait Transport {
fun call(self, request: Frame): Task<Result<Frame, str>>;
}
| Transport | Wire | Use |
|---|---|---|
SocketTransport | WebSocket (reconnecting) | what connect gives you, the production client transport |
HttpTransport | one POST per call | stateless calls, no mirrors, no handles |
HeaderedHttpTransport | one POST per call, with headers | the same, carrying a header credential |
LocalTransport | in-process | tests: client and service in one process; handles need a stamped connection |
A [service] an HTTP client can hold gets a constructor for this leg:
impl FooClient<HttpTransport> {
fun over_http(mount: str, codec: Codec): FooClient<HttpTransport>
}
impl FooClient<HeaderedHttpTransport> {
fun over_http_with(mount: str, codec: Codec, headers: List<(str, str)>): FooClient<HeaderedHttpTransport>
}
It takes the same mount string connect takes ("/", "/auth/") — the
route is {mount}rpc — and it is SYNC and makes no call: over HTTP there
is no connection, so there is no round trip to amortize, and an HTTP client is
unversioned unless you call verify() yourself. There are no mirrors, no
handles and no reverse direction, so it is generated only for a service that
declares none: a service with an [expose]d field or a handle-returning
method, or one declaring client = H, has no over_http and the call is
refused by name.
over_http_with is the same constructor for a client that has to CARRY
something: its headers are sent on every POST the client makes, after the
leg’s own media type. That is how a page that keeps its credential in storage
rather than in a cookie reaches a service gated by authorize_request — a
cookie rides a same-origin POST on its own, a header token has to be written:
let client = AuthClient::over_http_with("/auth/", json_codec(), [("Authorization", i"Bearer {token}")]);
It builds a HeaderedHttpTransport { url, headers } rather than widening
HttpTransport, so every HttpTransport { url } written today still means
what it meant. The media type is the transport’s to set; a Content-Type does
not belong among the headers.
[service(FooClient, http)] says the same thing at the DECLARATION: the
http marker generates nothing and moves no contract hash, and it refuses
each of those three shapes at the field, the method or the attribute that
declared it — in the attribute’s own words, rather than as a missing
over_http at a call site far away. It is opt-in, because which transports
reach a service is a property of its mount and its methods; a service that
wants both legs simply does not write it.
A transport FAILING is Err(reason), which call maps to
RpcError::Transport(..): an unreachable host, a DNS failure, a connection
dropped while the body was still arriving. HttpTransport answers that for
every rejection of the host fetch, so a call to a server that is not there is
an arm of your match and not the end of the process. It does not retry —
dialling again is SocketTransport’s job.
Below SocketTransport sits SocketDuplex (the reconnect-surviving socket:
pending-call registry, inbound dispatch, on_reconnect hooks) and the
DuplexTransport machinery (duplex_pair, bridge, connect_split for
the SSE/split fallback). App code doesn’t construct these; the generated
connect does.
Disposing a reactive session (ReactiveServer/ReactiveClient) also clears
its end’s inbound handler — DuplexEnd::clear_on_frame, the teardown half of
on_frame. Without it the wire kept the closure that captured the session, so
a closed connection stayed reachable from its transport; the server half runs
per disconnect, through drop_session.
fun connect_socket(url: str): Result<SocketDuplex, str> // dial + announcement (backoff); offers `vilan-rpc`
fun connect_socket_with(url: str, protocols: List<str>): Result<SocketDuplex, str>
fun dial_socket(url: str, protocols: List<str>): Result<SocketDuplex, DialFailure>
enum DialFailure { Unreachable(str), Refused(str) } // Refused carries "401"/"403"/"503"
impl SocketDuplex {
fun transport(self): SocketTransport
}
dial_socket is the typed dial the generated Client::connect reaches
through: Refused is a server that upgraded this client and then declined it
in one frame, which is the only way a refusal can be told from an unreachable
server — no host WebSocket exposes a failed handshake’s HTTP status. A refusal
ends the retry budget at the first attempt. connect_socket and
connect_socket_with are this with the failure flattened to its sentence.
connect_socket offers vilan-rpc (tracker A52), because the refusal frame is
only sent to a client that named the protocol — a bare connect that offered
nothing could not tell a 401 from an outage and paid the whole backoff to learn
nothing. connect_socket_with offers exactly the list it is given, which is the
seam for a peer that speaks something else.
Server plumbing (std::rpc::server, process layer)
impl Service {
fun new(protocol: RpcProtocol): Service // mounted at "/"
fun at(own self, prefix: str): Service // mount elsewhere, e.g. "/admin/"
fun on_connect(own self, handler: |i32, DuplexEnd| void): Service
fun on_disconnect(own self, handler: |i32| void): Service
// the handshake gate and its limits
fun authorize(own self, check: async |Handshake| Result<Session, Reject>): Service
fun authorize_timeout(own self, millis: i32): Service // 429 if the hook does not answer — std's limit, never the app's 503
fun authorize_request(own self, check: async |Request| Result<Session, Reject>): Service // gates each POST {mount}rpc (A120 S4)
fun max_connections(own self, limit: i32): Service // upgraded sockets on this mount only
fun handshake_rate(own self, attempts: i32, window_millis: f64): Service
fun handshake_timeout(own self, millis: i32): Service // bounds the greeting, not idleness
fun trust_forwarded_for(own self, trusted: bool): Service // key on X-Forwarded-For
}
impl ServerBuilder {
fun with_service(own self, service: Service): ServerBuilder // repeatable
}
A service is WebSocket upgrade + per-connection session registration
(mirror attach/detach) + rpc dispatch. Each handler runs in a turn
(AtEnd). ServerBuilder::with_service installs those routes and the
handshake on a Server::builder() chain,
answering before on_request, so a page and a service sit on one
builder instead of one replacing the other. It is repeatable — a second
service goes on its own mount (Service::new(protocol).at("/admin/")),
picked by longest mount and independent of call order.
Service::new(protocol) wires the runtime session registry as the
connection lifecycle — what Client::connect’s generated __attach
answers from; on_connect/on_disconnect replace it for apps holding
their own per-connection state (connection-scoped auth, an app-written
attach). The {mount}rpc route is the server side of std::rpc’s
HttpTransport (HttpTransport { url = "http://host:port/rpc" }), and
on_request answers every path no service claims — the app shell,
usually; serving the build’s own artifacts is
ServerBuilder::serve_build’s job, on the same builder. Details:
Services & RPC and the
process reference.
One matching rule is worth knowing: a service claims a path segment —
its route exactly, or its route followed by ? — so /rpc does not
shadow an application’s /rpcs or /rpc-docs.
Envelope & codec layer
Frame is the codec-agnostic unit (std::wire); encode_request /
open_request / encode_reply read and write the rpc envelope
({"method": …, "args": […]} on the json codec). Codec comes from
json_codec() (std::json) or binary_codec() (std::binary); both ends
must use the same one. You only meet this layer when implementing a custom
transport or protocol bridge.