RPC transport
A.cstack schema’s TransportStyle has two variants — REST and RPC. The default is REST — per-model /users, /users/{id}, /$procs/<name> routes, the shape this framework was built around. This guide covers the second style, 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.
There used to be a third variant. Protobuf/gRPC support was removed in
0.8.5 (ADR 0017);
transport grpc and the @pb attribute no longer parse at all. A schema
still declaring it gets a compile error pointing here: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:
The op id appearing in the URL (not the body) is deliberate — it lets nginx, CDNs, and HTTP tracing tools route and instrument per-op without parsing payloads.
On
transport rpc the canonical signed request is the actual rpc request — not the REST shape. It is method POST, path /rpc/<op_id> (the concrete URL, e.g. /rpc/model.Widget.update, /rpc/procedure.ping), no query, and the raw rpc frame bytes as the body. Because the frame body carries the id / patch / args, signing it binds them — model.Widget.get for two different ids is two different signed requests. The server feeds exactly this into signature verification (request_context) and the cratestack_route tracing field, so it matches the rpc client byte-for-byte; the REST /$procs/<name> and /<plural>[/<id>] paths never appear on the RPC binding, for url, dispatch, signing, or logs. (On the REST binding the canonical stays the REST method / path / query / body.)
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 negotiate 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.
Genuine incremental delivery is opt-in: only procedures explicitly marked with the @stream directive produce items as they’re generated (via an async_stream generator internally). A plain T[]-returning procedure without @stream still negotiates application/cbor-seq correctly, but the response is fully buffered server-side first, then sent — it’s a wire-shape change, not a latency win, unless the procedure opts in with @stream:
text/event-stream) is not implemented anywhere in the codebase today — it exists only as a forward-looking note in the RPC transport design doc, not as shipped behavior.
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 four 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), not by the request — the model list verb is always Unary today, per the op id table above.
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.
Path 4 — TypeScript via runtime.stream(...) + RpcStreamLink chain
The browser/Node path, entirely in TypeScript — no Rust FFI in the loop at all. cratestack generate-typescript emits CratestackRpcRuntime.stream() for every RPC schema (both the default and swr output presets), backed by fetch()’s native streaming body reader and the same boundary-scan logic as the other three paths, reimplemented in TypeScript rather than shared through FFI.
stream() negotiates Accept: application/cbor-seq, <configured codec> on every call. When the server picks the configured codec (the common case for a small/finite result), the body is a single encoded array, decoded and yielded in one go. When the server picks application/cbor-seq for a genuinely-incremental @stream procedure, a CborSeqBoundaryScanner reads the response body’s ReadableStream chunk by chunk and yields each self-delimiting CBOR item as soon as its bytes are complete — never after buffering the whole response. A tag-48900 item ends the iteration by throwing CratestackRpcStreamError instead of yielding one more item; any other transport failure (a dropped connection, a truncated final item) throws CratestackRpcTransportError instead.
Unlike call()/batch(), stream() doesn’t reuse the RpcLink chain — a Response-shaped link contract can’t work for streaming (a link wanting to retry would need to clone an already-streaming body, defeating the point). Streaming links are shaped as async generators instead, via a separate streamLinks option:
AsyncIterable<RpcStreamFrame> its next hands it and yields its own frames onward — { kind: "output", output } for a decoded item, { kind: "error", error } for the mid-stream sentinel — so a link author checks frame.kind rather than catching an exception. links/streamLinks are two separate chains on the same CratestackRpcRuntime; passing neither is a true no-op on both. See the TypeScript client generation guide’s “Composable links” section for the @cratestack/* package family that ships ready-made links (batching, logging) for the links chain — as of this writing that family doesn’t yet ship a published streamLinks link, so a custom one (or the generated reference createLoggerStreamLink) is the starting point today.
Pick one
For a worked end-to-end Rust example see
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, resource_exhausted, unavailable, 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.
resource_exhausted (REST TOO_MANY_REQUESTS, HTTP 429) and unavailable (REST UNAVAILABLE, HTTP 503) arrived in 0.11.0 alongside the additive CratestackError::TooManyRequests variant (#846). This matters most for /rpc/batch: that response is always HTTP 200 and the per-frame status is synthesized from the code, so before the arm existed a throttled frame surfaced as a synthetic 500. @cratestack/link-batch’s errorStatus now maps resource_exhausted to 429.
The two tower middleware layers participate in this vocabulary too: every response they emit themselves is now the codec-negotiated envelope — RpcErrorBody on /rpc/* paths, CratestackErrorResponse elsewhere — rather than a bare text/plain string. See rate limiting.
Client middleware — the RpcLink chain
Before today, the generated TypeScript RPC client had exactly two extension points: a single fetch override and a single headers value. That’s fine for one concern, but layering independent ones — logging, retry, auth-refresh — meant one consumer’s override clobbering another’s; there was no way to compose them.
CratestackRpcClientOptions now takes a links?: RpcLink[] array instead, modeled on tRPC’s Links and Dio’s interceptor chain: each link wraps the next, terminating in the real network call. An empty or omitted links array is a true no-op — byte-identical to not having the option at all, so existing generated clients are unaffected until you opt in.
The types live in a new generated src/links.ts, re-exported from the client’s index.ts:
nextre-runs everything below it in the chain — the real fetch and any links declared after it — never “just” the terminal fetch. That’s what lets a retry link compose with an auth-refresh link declared earlier: callingnextfrom the retry link re-invokes the auth-refresh link’s ownnextchain on each attempt, not a shortcut straight to the network.stream()calls bypass the chain entirely. A link that wants to inspect a response body would need to clone/replay a streamed body, which defeats the point of streaming — socall()andbatch()go throughlinks,stream()doesn’t. If you need logging or auth-refresh on streaming calls today, wrap the call site itself rather than relying on a link.
transport rpc) only. The REST binding (transport rest) doesn’t have a link chain yet — that’s a future ticket, not an oversight.
Automatic call coalescing with @cratestack/api
@cratestack/api is a new, hand-written (not generated) npm package, published standalone with provenance, inspired by batshit. It ships createBatchLink() — a batshit/tRPC-httpBatchLink/Apollo-BatchHttpLink-style automatic batch scheduler, implemented as an RpcLink so it composes with createLoggerLink() or any other link instead of being a fetch override that would clobber them. It transparently collapses multiple unary RPC calls issued within the same tick into one POST /rpc/batch request — the same batch envelope described above, just assembled for you instead of hand-built.
This is unrelated to the server-side ORM batch primitives in Batches (
batch_get/batch_create/batch_update/batch_delete/batch_upsert). Both are called “batch” and both end up as one round trip, but they solve different problems at different layers: createBatchLink is client-side RPC-call coalescing — turning N small HTTP requests the caller issued into one /rpc/batch request, transparently, with no change to caller code. The ORM batch primitives are a server-side API a handler calls deliberately, taking an array of rows and processing them with per-item success/failure. Don’t conflate the two — a call through createBatchLink still dispatches to whatever the schema’s routes do per op; it doesn’t imply the handler on the other end is using batch_create internally.
When to pick RPC
Schemas can’t switch styles without migrating clients, so pick deliberately. If you’re unsure, REST is the back-compat default.
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. Note that
Batchescovers the server-side ORM primitives, a different concept from the client-sidecreateBatchLinkcovered above. - TypeScript client generation — where the generated
CratestackRpcRuntimeand itslinksoption are constructed day-to-day.