11 Streaming
trev edited this page 2026-07-14 23:48:20 -04:00

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:

  1. Client opens a bidirectional transport stream.
  2. Client writes one RpcRequest frame containing the protobuf request body.
  3. Client finishes the send side.
  4. Server writes one RpcResponse frame.

Streaming calls:

  1. Client opens a bidirectional transport stream.
  2. Client writes one initial RpcRequest frame with the service, method, metadata, deadline, wire version, and RPC kind.
  3. Client writes zero or more request RpcStreamFrame message frames for client-streaming or bidirectional calls.
  4. Client finishes its request side with QUIC FIN, optionally preceded by a terminal request RpcStreamFrame status.
  5. Server writes zero or more response RpcStreamFrame message frames.
  6. Server ends every response stream with one final RpcStreamFrame status frame.

Stream Termination

Response streams end with a status frame, not just QUIC EOF.

  • OK status 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. Close is 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 return nil.
  • 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 await or explicitly awaiting iterator.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 Flow values close their call in finally, so cancellation or an early collector exit closes the transport stream. Interactive call objects expose idempotent close() methods and must be closed by the caller when not consumed through a generated Flow wrapper.
  • C exposes imperative trevrpc_stream_finish_send, trevrpc_stream_cancel, and trevrpc_stream_close. trevrpc_stream_send_status_with_metadata and trevrpc_call_finish_stream_with_metadata attach terminal status metadata.
  • Node native response streams are async iterators over native frames. Early return() closes the native stream; server handlers call finishStream(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.