8 Protocol and Wire Format
trev edited this page 2026-07-14 23:48:20 -04:00

Protocol and Wire Format

TrevRPC uses a custom protobuf protocol over native QUIC, ordinary HTTP/3, and WebTransport. It is intentionally small: one length-prefixed protobuf control message to start a call, protobuf payload bytes for application messages, and status codes to terminate calls.

Compatibility

TrevRPC is not wire-compatible with Connect, gRPC, or gRPC-Web.

ConnectRPC can speak Connect, gRPC, and gRPC-Web protocols. TrevRPC speaks its own protocol even when carried by ordinary HTTP/3. The similarity is at the developer workflow level: protobuf schema, generated clients, generated server bindings, and typed handlers.

Native QUIC ALPN

The current ALPN value is:

trevrpc/1

Rust exports it as trevrpc::ALPN, a byte string. Go exports it as trevrpc.ALPN, and Kotlin exports it as zip.trev.trevrpc.ALPN; both are strings.

Applications using native QUIC must configure TLS and QUIC with this ALPN value. The runtime does not hide certificate or mTLS setup.

WebTransport Negotiation

WebTransport uses HTTP/3 WebTransport negotiation, not ALPN trevrpc/1. Once a WebTransport session is established, every RPC still uses the same TrevRPC frame encoding described below.

Ordinary HTTP/3 Mapping

Ordinary HTTP/3 uses ALPN h3 and one full-duplex POST /trevrpc request per RPC. Both request and response use Content-Type: application/trevrpc. HTTP/3 DATA payloads contain exactly the TrevRPC frames described below; DATA boundaries are independent of TrevRPC frame boundaries.

Servers send HTTP 200 after HTTP-level admission. RPC failures continue to use RpcResponse or terminal RpcStreamFrame status values. HTTP trailers are not part of the mapping. See HTTP/3 for configuration and pre-RPC HTTP status handling.

Frame Encoding

Each TrevRPC frame is:

4-byte big-endian body length
protobuf-encoded frame body

The frame length is an unsigned 32-bit integer. It is the protobuf body length only and does not include the 4-byte header.

The default maximum frame size is 4 MiB. Oversized frames are rejected and mapped to ResourceExhausted where applicable.

Wire Version

The current wire version is 1.

Every RpcRequest carries the version. Servers reject unsupported versions with FailedPrecondition.

Request Message

RpcRequest contains:

Tag Field Type Purpose
1 service string Fully-qualified protobuf service name, such as example.greeter.Greeter
2 method string Protobuf method name, such as SayHello
3 body bytes Encoded unary request body or initial server-streaming request body
4 metadata map<string, bytes> Request metadata map
5 kind RpcKind RPC kind enum
6 version uint32 TrevRPC wire version
7 timeout_nanos uint64 Relative RPC timeout in nanoseconds, or 0 when unset

RPC kinds:

Value Kind
0 Unary
1 Client streaming
2 Server streaming
3 Bidirectional streaming

Unary Response Message

RpcResponse contains:

Tag Field Type Purpose
1 status uint32 Numeric status code
2 message string Status message
3 body bytes Encoded protobuf response body
4 metadata map<string, bytes> Response metadata map

For non-OK statuses, clients return the status as an error instead of decoding the body.

Streaming Frame Message

RpcStreamFrame contains:

Tag Field Type Purpose
1 kind RpcStreamFrameKind Message frame or status frame
2 status uint32 Numeric status code for status frames
3 message string Status message for status frames
4 body bytes Encoded protobuf message body for message frames
5 metadata map<string, bytes> Metadata map for status frames or future extensions

Frame kinds:

Value Kind
0 Message
1 Status

Streaming responses always end with a status frame. Streaming requests may end directly at transport EOF or with a terminal status frame. OK means graceful request completion; a non-OK request status propagates that status to the server-side request stream. After a request status, the sender must finish its transport send side and must not send another frame; receivers may process the status before transport EOF or drain through EOF to validate the terminal boundary.

Unknown RpcKind and RpcStreamFrameKind values are protocol errors. Servers reject unsupported request kinds with InvalidArgument; stream readers reject unknown stream-frame kinds with InvalidArgument or Internal depending on whether the invalid frame came from a peer request stream or response stream.

Metadata

Metadata is a map from lowercase ASCII string keys to arbitrary byte values.

Limits:

Limit Value
Entries 64
Key length 128 bytes
Value length 8 KiB
Total size 64 KiB

Key rules:

  • Keys are normalized to lowercase by call option helpers.
  • Keys must use lowercase ASCII letters, digits, ., _, or -.
  • Empty keys are invalid.
  • The trevrpc- prefix is reserved for protocol use.

Metadata is validated by clients before sending and by servers before route lookup.

Metadata map ordering is not semantically meaningful. Implementations must not depend on map entry order.

Status Codes

TrevRPC uses canonical status codes with numeric values matching gRPC:

Code Value
Ok 0
Cancelled 1
Unknown 2
InvalidArgument 3
DeadlineExceeded 4
NotFound 5
AlreadyExists 6
PermissionDenied 7
ResourceExhausted 8
FailedPrecondition 9
Aborted 10
OutOfRange 11
Unimplemented 12
Internal 13
Unavailable 14
DataLoss 15
Unauthenticated 16

Unknown numeric status codes map to Unknown.

Timeouts

Clients can set per-call timeouts. The runtime sends the relative timeout in RpcRequest.timeout_nanos and also enforces the local timeout.

timeout_nanos = 0 means no wire timeout. Non-zero values are relative durations measured from the server receiving the request. Servers derive request context deadlines from that duration. Locally exceeded deadlines map to DeadlineExceeded.

Implementations must reject timeout values that cannot be represented by their local deadline type instead of wrapping or truncating them.

Golden Vectors

Shared golden vectors live in testdata/wire-golden-vectors.txt. C, Go, JavaScript, Kotlin, and Rust runtime tests consume this fixture to lock protobuf body bytes and length-prefixed frame bytes for representative requests, responses, metadata, timeouts, message frames, and status frames.

The fixture format is intentionally simple:

name.body = hex-encoded protobuf body
name.frame = hex-encoded 4-byte big-endian length prefix followed by the same protobuf body

Compatibility Policy

Wire compatibility is defined by protobuf field numbers, protobuf wire types, enum numeric values, frame length encoding, and the semantics documented here.

Compatible changes may add optional fields with new tag numbers or add new status codes that old peers safely map to Unknown. Incompatible changes include reusing tags, changing existing field types, changing existing enum values, changing frame length encoding, changing timeout_nanos semantics, or requiring peers to understand a new field to preserve existing behavior.

Incompatible changes require a new wire version. Rolling upgrades should introduce additive changes first, deploy readers that tolerate the new fields, and only then deploy writers that depend on them.

Transport Stream Usage

Native QUIC transports use one bidirectional QUIC stream per RPC. WebTransport transports use one bidirectional WebTransport stream per RPC. Ordinary HTTP/3 uses one full-duplex request stream per RPC. Transport-level unidirectional streams are not used for RPC data, although HTTP/3 requires control and QPACK streams.

Rust Quinn configuration selects trevrpc::quinn::TransportMode::Native to disable unused inbound unidirectional streams, or TransportMode::WebTransport for HTTP/3 and WebTransport endpoints. The Go and C server configuration helpers likewise allow HTTP/3 control streams only when an HTTP/3 transport is enabled.

Kotlin's Netty transport maps native QUIC, ordinary HTTP/3, and server-side WebTransport streams to the same core wire frames. NettyRpcServer uses one UDP listener and dispatches by negotiated ALPN and, for h3, by the accepted HTTP/3 request or WebTransport session.