Streaming
TrevRPC supports all four protobuf RPC shapes in C, Go, Kotlin, Rust, and generated JavaScript clients. Node native server helpers are generated for JavaScript services when using trevrpc-js/node.
RPC Shapes
| Shape | Protobuf | Rust handler | Go handler | Kotlin handler | JavaScript client |
|---|---|---|---|---|---|
| Unary | rpc M(Req) returns (Res) |
Req -> Result<Res, Status> |
Req -> (Res, error) |
suspend Req -> Res |
request -> Promise<response> |
| Server streaming | rpc M(Req) returns (stream Res) |
Req -> Result<BoxMessageStream<Res>, Status> |
Req -> (MessageStream[Res], error) |
suspend Req -> Flow<Res> |
request -> Promise<AsyncIterable<response>> |
| Client streaming | rpc M(stream Req) returns (Res) |
BoxMessageStream<Req> -> Result<Res, Status> |
MessageStream[Req] -> (Res, error) |
suspend Flow<Req> -> Res |
options -> Promise<ClientStreamingCall> |
| Bidirectional streaming | rpc M(stream Req) returns (stream Res) |
BoxMessageStream<Req> -> Result<BoxMessageStream<Res>, Status> |
MessageStream[Req] -> (MessageStream[Res], error) |
suspend Flow<Req> -> Flow<Res> |
options -> Promise<BidirectionalCall> |
Transport Model
Every RPC uses one bidirectional transport stream. Native QUIC uses a bidirectional QUIC stream. WebTransport uses a bidirectional WebTransport stream.
Unary calls:
- Client opens a bidirectional transport stream.
- Client writes one
RpcRequestframe containing the protobuf request body. - Client finishes the send side.
- Server writes one
RpcResponseframe.
Streaming calls:
- Client opens a bidirectional transport stream.
- Client writes one initial
RpcRequestframe with the service, method, metadata, deadline, wire version, and RPC kind. - Client writes zero or more request
RpcStreamFramemessage frames for client-streaming or bidirectional calls. - Client finishes its request side with QUIC FIN, optionally preceded by a terminal request
RpcStreamFramestatus. - Server writes zero or more response
RpcStreamFramemessage frames. - Server ends every response stream with one final
RpcStreamFramestatus frame.
Stream Termination
Response streams end with a status frame, not just QUIC EOF.
OKstatus means the stream completed successfully.- Non-OK status becomes a stream error on the client.
- EOF before a final status is treated as an internal protocol error.
- Client-streaming calls expect exactly one response message before the final status.
Request streams accept either QUIC EOF or a terminal status. An OK request status is equivalent to graceful request EOF; a non-OK request status ends the request flow with that error. The sender must still finish its QUIC send side and must not write another frame. Receivers may process the status immediately or drain through FIN to validate that no trailing data follows. Native Kotlin sends its OK request status and QUIC FIN atomically in one non-empty QUIC frame so completion remains deterministic when the upload has exhausted stream flow-control capacity.
Reading through the final status is the graceful path. If a terminal status arrives while the client is still uploading a streaming request, the runtime cancels or aborts the local upload side. A terminal non-OK status is reported as the stream error. A terminal OK status reports any non-cancelled local upload or close error when the language API can surface it.
Early client close does not drain the remaining response. It releases local resources and asks the transport to stop the receive side and abort the send side where the transport exposes those operations.
- Go response streams implement
Close() error.Closeis idempotent on runtime-created response streams. The first call cancels the call context, closes the underlying frame stream, and may return a non-cancelled local upload or transport close error. Later calls returnnil. - Rust response streams have no close method. Dropping the
BoxMessageStream<T>is cancellation: native QUIC, HTTP/3, and WebTransport response streams stop the receive side and abort any pending request upload task. Drop cannot report errors, so local writer errors are only observable while polling the stream. - JavaScript response streams are async iterators. Breaking out of
for awaitor explicitly awaitingiterator.return()cancels the reader, aborts the writer, and releases locks.return()is idempotent; the first call may reject with a non-cancelled local upload error, and later calls resolve to{ done: true, value: undefined }. - Kotlin generated response
Flowvalues close their call infinally, so cancellation or an early collector exit closes the transport stream. Interactive call objects expose idempotentclose()methods and must be closed by the caller when not consumed through a generatedFlowwrapper. - C exposes imperative
trevrpc_stream_finish_send,trevrpc_stream_cancel, andtrevrpc_stream_close.trevrpc_stream_send_status_with_metadataandtrevrpc_call_finish_stream_with_metadataattach terminal status metadata. - Node native response streams are async iterators over native frames. Early
return()closes the native stream; server handlers callfinishStream(status, message, metadata)to send terminal status metadata.
Rust Streams
Rust uses an async pull stream:
#[trevrpc::async_trait]
pub trait MessageStream<T>: Send {
async fn next(&mut self) -> Option<trevrpc::Result<T>>;
}
Consume a stream:
while let Some(reply) = replies.next().await {
println!("{}", reply?.message);
}
Create simple streams:
let requests = trevrpc::stream::from_iter([
HelloRequest { name: "first".into() },
HelloRequest { name: "second".into() },
]);
Implement a streaming response by forwarding from a request stream:
struct EchoReplies {
requests: trevrpc::BoxMessageStream<HelloRequest>,
}
#[trevrpc::async_trait]
impl trevrpc::MessageStream<HelloReply> for EchoReplies {
async fn next(&mut self) -> Option<trevrpc::Result<HelloReply>> {
self.requests.next().await.map(|request| {
request.map(|request| HelloReply {
message: format!("stream hello, {}", request.name),
})
})
}
}
Go Streams
Go uses a blocking pull stream and provides an iterator adapter for complete stream consumption:
type MessageStream[T any] interface {
Recv() (T, error)
Close() error
}
Consume a stream:
for reply, err := range trevrpc.Messages(replies) {
if err != nil {
return err
}
log.Printf("%s", reply.Message)
}
Messages closes the stream after completion, error, or an early loop exit.
Use Recv and Close directly when send and receive operations must be
interleaved or a close error must be observed.
Create simple streams:
requests := trevrpc.FromSlice(
&greeter.HelloRequest{Name: "first"},
&greeter.HelloRequest{Name: "second"},
)
Implement a bidirectional response stream:
type echoReplies struct {
requests trevrpc.MessageStream[*greeter.HelloRequest]
}
func (s *echoReplies) Recv() (*greeter.HelloReply, error) {
request, err := s.requests.Recv()
if err != nil {
return nil, err
}
return &greeter.HelloReply{Message: "stream hello, " + request.Name}, nil
}
func (s *echoReplies) Close() error {
return s.requests.Close()
}
JavaScript Streams
Generated JavaScript and TypeScript clients use async iterables for streaming responses:
const replies = await client.lotsOfReplies({ name: "TrevRPC" });
for await (const reply of replies) {
console.log(reply.message);
}
Client-streaming and bidirectional-streaming methods return sendable call objects:
const call = await client.lotsOfGreetings();
await call.send({ name: "first" });
await call.send({ name: "second" });
const summary = await call.closeAndRecv();
Kotlin Streams
Generated Kotlin convenience methods use Flow for request and response streams:
client.lotsOfReplies(request).collect { reply ->
println(reply.message)
}
val summary = client.lotsOfGreetings(flowOf(first, second))
client.bidiHello(flowOf(first, second)).collect { reply ->
println(reply.message)
}
Use interactive call objects when send and receive operations must be interleaved:
val call = client.bidiHello()
try {
call.send(first)
val firstReply = call.receive()
call.send(second)
call.closeSend()
while (call.receive() != null) {
// Process remaining replies.
}
} finally {
call.close()
}
ClientStreamingCall similarly provides send, closeSend, receive, and close. Its receive result is a ResponseEnvelope, which preserves response metadata.
Limits and Timeouts
Rust, Go, and Kotlin servers and generated clients enforce stream limits by default. JavaScript generated clients also enforce response stream limits.
| Limit | Default |
|---|---|
| Max response messages | 4096 |
| Max cumulative response stream body | 16 MiB |
| Server max stream messages | 4096 |
| Server max cumulative stream body | 16 MiB |
| Stream idle timeout | 30 seconds |
Use client call options and server options to tighten or relax these limits.
Backpressure
TrevRPC streams are pull-based at the runtime API. The underlying QUIC or WebTransport stream still provides transport-level flow control. Kotlin additionally uses bounded coroutine channels between transport callbacks and application flows. For application code, avoid buffering unbounded numbers of protobuf messages before returning a stream.