|
| 1 | +# RPC v2 Specification |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +RPC (Remote Procedure Call) allows participants in a LiveKit room to invoke methods on each other |
| 6 | +and receive responses. RPC v1 used inline protobuf packets (`RpcRequest` / `RpcResponse`) with a |
| 7 | +hard 15 KB payload limit. |
| 8 | + |
| 9 | +RPC v2 lifts this limit by transporting request and response payloads over **data streams**, while |
| 10 | +retaining v1 as a fallback for legacy clients. This removes the previously set 15kb request / |
| 11 | +response payload size limitation, making both effectively limitless. |
| 12 | + |
| 13 | +A v2 client should seamlessly communicate with v1 clients by detecting the remote participant's |
| 14 | +client protocol version and falling back to v1 packets if it doesn't support rpc v2. |
| 15 | + |
| 16 | +--- |
| 17 | + |
| 18 | +## Part 1: Client protocol |
| 19 | + |
| 20 | +### What is client protocol? |
| 21 | + |
| 22 | +`clientProtocol` is a new integer that each participant advertises in their `ParticipantInfo` via |
| 23 | +the LiveKit signaling channel. It tells other participants what client-to-client features this SDK |
| 24 | +supports. It is distinct from the existing `protocol` field (which tracks signaling protocol |
| 25 | +version) - `clientProtocol` specifically governs peer-to-peer feature negotiation. |
| 26 | + |
| 27 | +| Value | Constant name | Meaning | |
| 28 | +|-------|---------------|---------| |
| 29 | +| `0` | `CLIENT_PROTOCOL_DEFAULT` | Legacy client. Only supports RPC v1. | |
| 30 | +| `1` | `CLIENT_PROTOCOL_DATA_STREAM_RPC` | Supports RPC v2 (data stream-based payloads). | |
| 31 | + |
| 32 | +### What SDKs need to do |
| 33 | + |
| 34 | +1. **Advertise**: Set your SDK's `clientProtocol` to `1` (`CLIENT_PROTOCOL_DATA_STREAM_RPC`) in the |
| 35 | + `ParticipantInfo` sent during the join handshake. |
| 36 | + |
| 37 | +2. **Read**: When a remote participant joins or updates, store their `clientProtocol` value. This is |
| 38 | + available on the `ParticipantInfo` protobuf. If the field is absent or unrecognized, treat it as |
| 39 | + `0` (`CLIENT_PROTOCOL_DEFAULT`). |
| 40 | + |
| 41 | +3. **Use**: Before sending an RPC request or response, look up the remote participant's |
| 42 | + `clientProtocol` to decide whether to use the v1 (packet) or v2 (data stream) transport. |
| 43 | + |
| 44 | +--- |
| 45 | + |
| 46 | +## Part 2: RPC protocol updates |
| 47 | + |
| 48 | +As a review, here is how RPC v1 works today: |
| 49 | + |
| 50 | +``` |
| 51 | +Caller Handler |
| 52 | + | | |
| 53 | + |--- RpcRequest (DataPacket) ------->| |
| 54 | + | | (looks up handler, invokes it) |
| 55 | + |<-- RpcAck (DataPacket) -----------| |
| 56 | + |<-- RpcResponse (DataPacket) ------| |
| 57 | + | | |
| 58 | +``` |
| 59 | + |
| 60 | +1. The **caller** sends a `DataPacket` containing a `RpcRequest` protobuf: |
| 61 | + - `id`: a UUID identifying this request |
| 62 | + - `method`: the method name |
| 63 | + - `payload`: the request payload string (must be <= 15 KB) |
| 64 | + - `responseTimeoutMs`: the effective timeout in milliseconds |
| 65 | + - `version`: `1` |
| 66 | + - Packet kind: `RELIABLE` |
| 67 | + - Destination: the handler's identity |
| 68 | + |
| 69 | +2. The **handler** receives the `RpcRequest` packet and immediately sends an **ack** - a |
| 70 | + `DataPacket` containing a `RpcAck` protobuf with the `requestId`. This tells the caller that |
| 71 | + the handler is alive and processing. |
| 72 | + |
| 73 | +3. The handler invokes the registered method handler. When it completes, the handler sends a |
| 74 | + `DataPacket` containing a `RpcResponse` protobuf: |
| 75 | + - `requestId`: matches the original request |
| 76 | + - `value`: either `{ case: 'payload', value: responseString }` for success, or |
| 77 | + `{ case: 'error', value: RpcError protobuf }` for failure |
| 78 | + - The response payload is also subject to the 15 KB limit. |
| 79 | + |
| 80 | +4. The **caller** receives the `RpcResponse` and resolves or rejects the pending promise. |
| 81 | + |
| 82 | +### RPC v2 Example |
| 83 | + |
| 84 | +v2 replaces the `RpcRequest` and `RpcResponse` protobuf packets with **text data streams** for |
| 85 | +carrying payloads. The ack mechanism is unchanged. This removes the payload size limit while |
| 86 | +remaining backward-compatible with v1 clients. |
| 87 | + |
| 88 | +``` |
| 89 | +Caller Handler |
| 90 | + | | |
| 91 | + |--- Text data stream (request) --->| |
| 92 | + | topic: "lk.rpc_request" | |
| 93 | + | attrs: request_id, method, | |
| 94 | + | timeout, version=2 | |
| 95 | + | body: <payload> | |
| 96 | + | | (reads stream, looks up handler, invokes it) |
| 97 | + |<-- RpcAck (DataPacket) -----------| |
| 98 | + |<-- Text data stream (response) ---| |
| 99 | + | topic: "lk.rpc_response" | |
| 100 | + | attrs: request_id | |
| 101 | + | body: <response payload> | |
| 102 | + | | |
| 103 | +``` |
| 104 | + |
| 105 | +1. The **caller** opens a text data stream with: |
| 106 | + - **Topic**: `lk.rpc_request` |
| 107 | + - **Destination identities**: `[destinationIdentity]` |
| 108 | + - **Attributes**: |
| 109 | + - `lk.rpc_request_id`: a newly generated UUID |
| 110 | + - `lk.rpc_request_method`: the method name |
| 111 | + - `lk.rpc_request_response_timeout_ms`: the effective timeout in milliseconds, as a string |
| 112 | + - `lk.rpc_request_version`: `"2"` |
| 113 | + - Writes the payload string to the stream, then closes it. |
| 114 | + |
| 115 | +2. The **handler** receives the data stream on topic `lk.rpc_request`. It parses the attributes |
| 116 | + to extract the request ID, method, timeout, and version. It sends an **ack** (same `RpcAck` |
| 117 | + packet as v1), then reads the full stream payload. |
| 118 | + |
| 119 | +3. The handler invokes the registered method handler. On success, it sends the response as a |
| 120 | + text data stream: |
| 121 | + - **Topic**: `lk.rpc_response` |
| 122 | + - **Destination identities**: `[callerIdentity]` |
| 123 | + - **Attributes**: `{ "lk.rpc_request_id": requestId }` |
| 124 | + - Writes the response payload, then closes the stream. |
| 125 | + |
| 126 | +4. The **caller** receives the data stream on topic `lk.rpc_response`. It reads the |
| 127 | + `lk.rpc_request_id` attribute to match it to the pending request, reads the full stream, |
| 128 | + and resolves the pending promise with the payload. |
| 129 | + |
| 130 | +The user-facing API should be identical for v1 and v2. |
| 131 | + |
| 132 | +The protocol version negotiation is invisible to the user. The only visible difference that a user |
| 133 | +should see is that if they send a rpc request or receive a rpc response from a participant |
| 134 | +supporting rpc v2 with a length greater than 15kb, they will NOT receive a |
| 135 | +`REQUEST_PAYLOAD_TOO_LARGE` / `RESPONSE_PAYLOAD_TOO_LARGE` error - it will "just work". With rpc v2, |
| 136 | +these errors are effectively deprecated. |
| 137 | + |
| 138 | +#### Error responses in v2 |
| 139 | + |
| 140 | +**Error responses are always sent as v1 `RpcResponse` packets**, even when both sides are v2. This |
| 141 | +is because error payloads tend to be small (code + message + optional data) and using packets keeps |
| 142 | +the error path simple and uniform. This means: |
| 143 | + |
| 144 | +- Success responses between two v2 clients: **data stream** |
| 145 | +- Error responses between two v2 clients: **packet** (`RpcResponse` with `error` case) |
| 146 | +- All responses to v1 clients: **packet** |
| 147 | + |
| 148 | +#### Data stream topic routing |
| 149 | + |
| 150 | +RPC requests and responses use separate data stream topics: |
| 151 | + |
| 152 | +- **`lk.rpc_request`**: Register a text stream handler for this topic. Incoming streams are RPC |
| 153 | + requests. Route to the handler-side logic, passing the sender identity and the stream attributes. |
| 154 | +- **`lk.rpc_response`**: Register a text stream handler for this topic. Incoming streams are RPC |
| 155 | + responses. Read the `lk.rpc_request_id` attribute to match the response to a pending request, |
| 156 | + then route to the caller-side logic. |
| 157 | + |
| 158 | +### Version negotiation and backward compatibility |
| 159 | + |
| 160 | +The transport used for a given RPC call depends on what both sides support. The caller decides the |
| 161 | +request transport; the handler decides the response transport. |
| 162 | + |
| 163 | +| Caller | Handler | Request transport | Response transport | |
| 164 | +|--------|---------|------------------|--------------------| |
| 165 | +| v2 | v2 | Data stream | Data stream (success) / Packet (error) | |
| 166 | +| v2 | v1 | Packet (`RpcRequest`) | Packet (`RpcResponse`) | |
| 167 | +| v1 | v2 | Packet (`RpcRequest`) | Packet (`RpcResponse`) | |
| 168 | +| v1 | v1 | Packet (`RpcRequest`) | Packet (`RpcResponse`) | |
| 169 | + |
| 170 | +**Data streams are only used when both sides are v2.** Cross-version interactions always fall back |
| 171 | +to v1 packets. This is because: |
| 172 | + |
| 173 | +- The **caller** checks the remote participant's `clientProtocol` before sending. If the remote is |
| 174 | + v1, the caller sends a v1 `RpcRequest` packet. |
| 175 | +- The **handler** checks the caller's `clientProtocol` before responding. If the caller is v1, the |
| 176 | + handler sends a v1 `RpcResponse` packet. (The handler knows the caller is v1 because the request |
| 177 | + arrived as a v1 packet, and it can also check the caller's `clientProtocol`.) |
| 178 | + |
| 179 | +### Timeout and ack behavior |
| 180 | + |
| 181 | +These behaviors are the same for v1 and v2. |
| 182 | + |
| 183 | +## Minimum required test cases |
| 184 | + |
| 185 | +The following tests represent the minimum set that must pass for a conforming implementation. They |
| 186 | +are organized by the version interaction being tested. Since this spec describes implementing a v2 |
| 187 | +SDK, at least one side of every interaction is always v2. Each test describes the full lifecycle |
| 188 | +from both the caller and handler perspectives. |
| 189 | + |
| 190 | +### v2 -> v2 (both sides support data streams) |
| 191 | + |
| 192 | +1. **Caller happy path (short payload)** |
| 193 | + - The caller opens a text data stream on topic `lk.rpc_request` with attributes |
| 194 | + `lk.rpc_request_id`, `lk.rpc_request_method`, `lk.rpc_request_response_timeout_ms`, and |
| 195 | + `lk.rpc_request_version: "2"`. |
| 196 | + - The caller writes the payload string to the stream and closes it. |
| 197 | + - Verify no `RpcRequest` packet is produced. |
| 198 | + - Simulate the handler sending a `RpcAck` packet and a successful response. |
| 199 | + - Verify the caller resolves with the response payload. |
| 200 | + |
| 201 | +2. **Caller happy path (large payload > 15 KB)** |
| 202 | + - The caller opens a text data stream on topic `lk.rpc_request` with the same attributes as |
| 203 | + above, but with a payload exceeding 15 KB (e.g., 20,000 bytes). |
| 204 | + - The caller writes the large payload to the stream and closes it. |
| 205 | + - Verify no `REQUEST_PAYLOAD_TOO_LARGE` error is raised - the data stream path has no size |
| 206 | + limit. |
| 207 | + - Simulate the handler sending a `RpcAck` packet and a successful response. |
| 208 | + - Verify the caller resolves with the response payload. |
| 209 | + |
| 210 | +3. **Handler happy path** |
| 211 | + - The handler receives a text data stream on topic `lk.rpc_request` with valid attributes. |
| 212 | + - The handler sends a `RpcAck` packet with the request ID. |
| 213 | + - The handler reads the full stream payload and invokes the registered method handler with |
| 214 | + `{ requestId, callerIdentity, payload, responseTimeout }`. |
| 215 | + - The method handler returns a response string. |
| 216 | + - The handler sends the response as a text data stream on topic `lk.rpc_response` with |
| 217 | + attribute `lk.rpc_request_id` set to the request ID. |
| 218 | + - Verify no `RpcResponse` packet is produced - successful v2 responses use data streams. |
| 219 | + |
| 220 | +4. **Unhandled error in handler** |
| 221 | + - The handler receives a v2 data stream request. |
| 222 | + - The handler sends a `RpcAck` packet. |
| 223 | + - The registered method handler throws a non-RpcError exception (e.g., a generic `Error`). |
| 224 | + - The handler sends a `RpcResponse` **packet** (not a data stream) with error code |
| 225 | + `APPLICATION_ERROR` (1500). |
| 226 | + - Verify error responses always use packets, even between two v2 clients. |
| 227 | + |
| 228 | +5. **RpcError passthrough in handler** |
| 229 | + - The handler receives a v2 data stream request. |
| 230 | + - The handler sends a `RpcAck` packet. |
| 231 | + - The registered method handler throws a `RpcError` with a custom code (e.g., 101) and |
| 232 | + message. |
| 233 | + - The handler sends a `RpcResponse` packet preserving the original error code and message. |
| 234 | + |
| 235 | +6. **Response timeout** |
| 236 | + - The caller sends a v2 data stream request with a short response timeout (e.g., 50ms). |
| 237 | + - No `RpcAck` or response arrives. |
| 238 | + - After the timeout elapses, the caller rejects with `RESPONSE_TIMEOUT` (code 1502). |
| 239 | + |
| 240 | +7. **Error response** |
| 241 | + - The caller sends a v2 data stream request. |
| 242 | + - Simulate the handler sending a `RpcAck` packet, then a `RpcResponse` packet with an error |
| 243 | + (e.g., code 101, message "Test error message"). |
| 244 | + - Verify the caller rejects with the correct error code and message. |
| 245 | + |
| 246 | +8. **Participant disconnection** |
| 247 | + - The caller sends a v2 data stream request. |
| 248 | + - Before any ack or response arrives, the remote participant disconnects. |
| 249 | + - Verify the caller rejects with `RECIPIENT_DISCONNECTED` (code 1503). |
| 250 | + |
| 251 | +### v2 -> v1 (v2 caller, v1 handler) |
| 252 | + |
| 253 | +10. **Caller happy path (request fallback)** |
| 254 | + - The caller detects the remote's `clientProtocol` is 0. |
| 255 | + - The caller sends a v1 `RpcRequest` packet (not a data stream) with correct `id`, `method`, |
| 256 | + `payload`, `responseTimeoutMs`, and `version: 1`. |
| 257 | + - Verify no data stream is opened. |
| 258 | + - Simulate the handler sending a `RpcAck` packet, then a `RpcResponse` packet with a |
| 259 | + success payload. |
| 260 | + - Verify the caller resolves with the response payload. |
| 261 | + |
| 262 | +11. **Handler happy path (v1 request)** |
| 263 | + - The handler receives a v1 `RpcRequest` packet with `version: 1`. |
| 264 | + - The handler sends a `RpcAck` packet with the request ID. |
| 265 | + - The handler invokes the registered method handler with `{ requestId, callerIdentity, |
| 266 | + payload, responseTimeout }`. |
| 267 | + - The method handler returns a response string. |
| 268 | + - The handler detects the caller's `clientProtocol` is 0 and sends the response as a v1 |
| 269 | + `RpcResponse` packet (not a data stream). |
| 270 | + |
| 271 | +12. **Payload too large** |
| 272 | + - The caller detects the remote's `clientProtocol` is 0. |
| 273 | + - The caller attempts to send a payload exceeding 15 KB. |
| 274 | + - Verify it rejects immediately with `REQUEST_PAYLOAD_TOO_LARGE` (code 1402) without producing |
| 275 | + any packet or data stream. |
| 276 | + |
| 277 | +13. **Response timeout** |
| 278 | + - The caller detects the remote's `clientProtocol` is 0. |
| 279 | + - The caller sends a v1 `RpcRequest` packet with a short response timeout (e.g., 50ms). |
| 280 | + - No `RpcAck` or response arrives. |
| 281 | + - After the timeout elapses, the caller rejects with `RESPONSE_TIMEOUT` (code 1502). |
| 282 | + |
| 283 | +14. **Error response** |
| 284 | + - The caller detects the remote's `clientProtocol` is 0. |
| 285 | + - The caller sends a v1 `RpcRequest` packet. |
| 286 | + - Simulate the handler sending a `RpcAck` packet, then a `RpcResponse` packet with an |
| 287 | + error (e.g., code 101, message "Test error message"). |
| 288 | + - Verify the caller rejects with the correct error code and message. |
| 289 | + |
| 290 | +15. **Participant disconnection** |
| 291 | + - The caller detects the remote's `clientProtocol` is 0. |
| 292 | + - The caller sends a v1 `RpcRequest` packet. |
| 293 | + - Before any ack or response arrives, the remote participant disconnects. |
| 294 | + - Verify the caller rejects with `RECIPIENT_DISCONNECTED` (code 1503). |
| 295 | + |
| 296 | +### v1 -> v2 (v1 caller, v2 handler) |
| 297 | + |
| 298 | +16. **Handler happy path (response fallback)** |
| 299 | + - A v1 caller sends a v1 `RpcRequest` packet with `version: 1`. |
| 300 | + - The v2-capable handler receives it and sends a `RpcAck` packet. |
| 301 | + - The handler invokes the registered method handler, which returns a response string. |
| 302 | + - The handler detects the caller's `clientProtocol` is 0 and sends the response as a v1 |
| 303 | + `RpcResponse` packet (not a data stream), even though it supports v2. |
| 304 | + - Verify no data stream is opened for the response. |
| 305 | + |
| 306 | +17. **Unhandled error in handler (v1 caller)** |
| 307 | + - A v1 caller sends a v1 `RpcRequest` packet. |
| 308 | + - The handler sends a `RpcAck` packet. |
| 309 | + - The registered method handler throws a non-RpcError exception (e.g., a generic `Error`). |
| 310 | + - The handler sends a `RpcResponse` packet with error code `APPLICATION_ERROR` (1500). |
| 311 | + |
| 312 | +18. **RpcError passthrough (v1 caller)** |
| 313 | + - A v1 caller sends a v1 `RpcRequest` packet. |
| 314 | + - The handler sends a `RpcAck` packet. |
| 315 | + - The registered method handler throws a `RpcError` with a custom code (e.g., 101) and |
| 316 | + message. |
| 317 | + - The handler sends a `RpcResponse` packet preserving the original error code and message. |
| 318 | + |
| 319 | +--- |
| 320 | + |
| 321 | +## Benchmarking |
| 322 | + |
| 323 | +Implementing a benchmark is not required, but could be useful for validating correctness and |
| 324 | +performance. The below outlines the test criteria used for `client-sdk-cpp` and `client-sdk-js`. |
| 325 | + |
| 326 | +For an exact reference implementation, see https://github.com/livekit/client-sdk-js/commit/da26fa022197326a8f31db5421f175fad2fe4651. |
| 327 | + |
| 328 | +### Approach |
| 329 | + |
| 330 | +The benchmark connects two participants to the same room in a single process: |
| 331 | + |
| 332 | +1. **Setup**: A "caller" and "receiver" join the same room. |
| 333 | +2. **Echo handler**: The receiver registers an RPC method (`benchmark-echo`) that returns the |
| 334 | + received payload unchanged. |
| 335 | +3. **Payload**: Pre-generate a payload of the desired size. Compute a checksum (e.g., sum of |
| 336 | + character codes) for integrity verification. |
| 337 | +4. **Caller loop**: N concurrent workers each loop for a configured duration, calling the |
| 338 | + echo method and verifying the response matches the original payload (length + checksum). |
| 339 | +5. **Metrics**: Track success/failure counts, latency percentiles (p50, p95, p99), throughput |
| 340 | + (calls/sec), and error breakdown. |
| 341 | + |
| 342 | +### Suggested parameters |
| 343 | + |
| 344 | +| Parameter | Suggested default | Description | |
| 345 | +|-----------|-------------------|-------------| |
| 346 | +| Payload size | 15360 bytes | Size of the RPC payload in bytes | |
| 347 | +| Duration | 30 seconds | How long the benchmark runs | |
| 348 | +| Concurrency | 3 | Number of parallel caller loops | |
| 349 | +| Delay between calls | 10ms | Pause between consecutive calls per thread | |
0 commit comments