RPC transport
CrateStack ships two generation styles for a.cstack schema. The default is REST — per-model /users, /users/{id}, /$procs/<name> routes, the shape this framework was built around. The alternative is RPC — a single POST /rpc/{op_id} route per callable, a POST /rpc/batch endpoint that takes N frames at a time, and content-negotiated streaming on the same unary route. One binding per schema; the macro emits exactly one binding’s worth of routes and client surface. There is no runtime flip and no schema runs both.
This guide covers what the RPC binding does today and when to pick it. The full design is in ADR 0005.
Pick the binding
Declare the directive at the top of your.cstack file:
Mounting the router
include_server_schema! emits an rpc_router(...) builder when transport rpc is set, same shape as the existing model_router / procedure_router:
POST /rpc/{op_id}— unary for every CRUD verb + every procedurePOST /rpc/batch— sequence ofRpcRequestframes
Op identity
Every callable in atransport rpc schema gets a stable dotted id. The id is the only dispatch key and appears in the URL:
| Schema construct | Op id | Kind |
|---|---|---|
model Widget | model.Widget.list | Unary |
model Widget | model.Widget.get | Unary |
model Widget | model.Widget.create | Unary |
model Widget | model.Widget.update | Unary |
model Widget | model.Widget.delete | Unary |
procedure ping(...) | procedure.ping | Unary |
procedure manyPings(...): PingArgs[] | procedure.manyPings | Sequence |
Unary
Body shape per verb:Batch — POST /rpc/batch
Send N requests in one round-trip, get N responses back in the same order:
- Per-frame errors don’t poison the batch. The envelope returns
200 OKas long as the batch parsed; each frame’s success or failure is on its own response frame. - No transactional mode, no in-batch dependencies. Each frame runs in its own transaction. A batch like
[create A, update B referencing A.id]is not supported — use two roundtrips or a single@procedurethat owns the composite operation. - Per-frame idempotency only. Send
idemon eachRpcRequest. TheIdempotency-KeyHTTP header is rejected on/rpc/batchas ambiguous.
400.
Streaming — Accept: application/cbor-seq
List-return procedures (those declared as ... : T[]) get OpKind::Sequence from the macro and stream over the same POST /rpc/{op_id} route as unary. Switch by content negotiation:
Vec<T> — the route doesn’t change, only the wire shape.
SSE (text/event-stream) is wired in the codec layer and works the same way for clients that need EventSource compatibility.
Consuming streams
The wire side is one paragraph; the interesting question is what a client looks like on the other end of that pipe. CrateStack ships three client paths and you can pick per-app or per-request.The wire shape
application/cbor-seq is a sequence of self-delimiting CBOR top-level items concatenated back-to-back — no envelope, no length prefix, no framing bytes between items. The server emits it from reqwest/axum’s bytes_stream() so the body flushes as items are produced; the response is never fully buffered on the wire. The URL is the same POST /rpc/{op_id} that serves unary; only Accept: application/cbor-seq (the codec’s sequence_accept_header_value()) flips the response shape. Op kind is decided by the schema (OpKind::Sequence for list-return procedures and the model list verb), not by the request.
Path 1 — Rust client via RpcClient::call_streaming
The typed Rust path. The method returns a bounded tokio::sync::mpsc::Receiver so memory stays tight: 16 in-flight items max, with backpressure flowing back through reqwest’s chunk stream when the consumer falls behind.
- Non-2xx responses surface before the channel opens.
call_streamingreturnsErr(RpcClientError)from itsawait, not as the first channel item. The channel exists only after the server has accepted the request and started streaming. - Per-item errors are terminal. Each
Errin the channel is the last item; the pump task exits after sending it. Consumers don’t need an inner loop guard — a singlewhile let Some(item) = rx.recv().awaitcovers happy path, transport mid-stream failure, and clean end-of-stream.
Path 2 — Flutter via callback + frb StreamSink
The reqwest-in-Rust path for Flutter apps. FlutterRuntime::rpc_call_streamed takes a callback that returns bool (false cancels); the natural wrap with flutter_rust_bridge is a StreamSink<FlutterChunkWire> so Dart code consumes a regular Stream. The full Rust shim lives in cratestack-client-flutter/README.md; the gist:
switch over FlutterChunkWire covers every termination path:
Item carries one CBOR-encoded item’s raw bytes — decode it on the Dart side with the cbor package (or anything else that speaks CBOR). End and Error are both terminal: no further variants follow either.
Path 3 — Flutter via dio + CborSeqStreamTransformer
For apps that want HTTP to live in Dart — native NSURLSession/OkHttp visibility, dio interceptors for auth/retry/idempotency, Flutter DevTools network inspection, system proxy and certificate pinning — the generated Dart RPC runtime ships two primitives:
CborSeqDecoderHandle— abstract interface;Future<List<Uint8List>> feed(Uint8List)plusint pendingLen(). The FFI-backedFlutterCborSeqDecoder(fromcratestack-client-flutter) satisfies it; pure-Dart impls work for web or server-side Dart.CborSeqStreamTransformer— a plainStreamTransformer<Uint8List, Uint8List>that wraps any decoder handle. Composes with anything that producesStream<Uint8List>.
FormatException. Cancellation through subscription.cancel() propagates upstream into dio’s request cancellation contract.
Pick one
| Path | Shape on the consumer side | When to pick |
|---|---|---|
Rust RpcClient::call_streaming | Receiver<Result<O, RpcClientError>> | Rust server-to-server, Rust CLIs, anything where the consumer is Rust. Bounded mpsc gives backpressure for free. |
Flutter FlutterRuntime::rpc_call_streamed + frb StreamSink | Stream<FlutterChunkWire> in Dart | Flutter apps that are fine with one HTTP stack (reqwest in Rust); items decode Dart-side. |
dio + CborSeqStreamTransformer + FlutterCborSeqDecoder | Stream<Uint8List> in Dart | Flutter apps that want native HTTP visibility, dio interceptors, or Flutter DevTools network inspection. HTTP lives in Dart; only frame-boundary detection lives in Rust. |
examples/rpc-streaming-client-rust. For the three-crate client split see Client Runtime; for the framing decisions see ADR 0005 §3.3.
Errors — uniform RpcErrorBody shape
Every error on the RPC binding — whether raised inside the dispatcher (decode failure, unknown op id) or inside a handler (auth denied, not found, validation failed) — wire-shapes as:
code field uses gRPC-style lowercase strings: not_found, invalid_argument, permission_denied, failed_precondition, conflict, unauthenticated, internal. Never the REST binding’s SCREAMING_CASE (NOT_FOUND, FORBIDDEN, …).
HTTP status codes match the error category. Clients that catch by status work unchanged from REST; clients that parse the body get a stable string vocabulary.
When to pick RPC
| You want | Pick |
|---|---|
| Cacheable GETs, per-route metrics, REST tooling ecosystem | transport rest |
| Multi-op batching in one round-trip | transport rpc |
| One uniform error vocabulary across every op | transport rpc |
| List/audit/feed streaming with a single content-type flip | Either — REST and RPC both serve application/cbor-seq on list-return shapes |
| Subscriptions / push channels | Neither yet (see below) |
| Server-to-server only, prefer one consistent op-id namespace | transport rpc |
| Public API that benefits from HTTP-native caching at a CDN | transport rest |
What’s not yet built — WebSocket + subscriptions
The HTTP surface of the RPC binding is feature-complete. The remaining direction is a WebSocket binding that would unlock subscriptions —model.<X>.subscribe ops that stream ModelEvent<X> frames over a long-lived channel. The wire-side design is captured in ADR 0005 §3.4; the runtime work is gated on a concrete subscription use case.
Streaming shipped without ceremony because the shape was concrete — list-return procedures, audit feeds, paginated reads, all naturally producing finite sequences with an existing encoder ready to go. Subscriptions don’t have that profile yet: CrateStack’s audit and event-bus consumers today are server-to-server and poll or consume from the audit sink. External clients are the natural fit, but no concrete CrateStack consumer is asking for subscriptions right now. When a concrete use case appears, the WS binding becomes the next cool upgrade. Until then, the gap is deliberate.
Read Next
- ADR 0005: RPC Binding for
transport rpcschemas — the canonical design, including the design decisions made along the way (URL routing, dispatcher delegation, error wire shape) and the deferred items. - Transport architecture — the codec / framing / envelope model that both bindings sit on top of.
- Idempotency, Batches — closely related primitives that work the same way on either binding.