PacketRelayDocs

Message reference#

PacketRelay parses exactly one message per connection: the hello. Everything after it is forwarded as opaque text, so the wire vocabulary is small and closed — five hellos a client may send, twelve responses the relay may return, and nothing else. This page enumerates every envelope field by field, gives the JSON shape of each, and states the framing rules that decide which WebSocket frames the relay looks at in the first place.

Both tables are in source order — the order the variants are declared in src/protocol.rs — not alphabetical order. Source order groups the bridge, broadcast and room families together, which alphabetical order would scatter.

Client hellos#

Five hello variants, discriminated by a type tag (protocol.rs:77, protocol.rs:80). Every field on every hello is required: no field is an Option, so a missing field fails deserialisation and the connection is dropped without an error frame — connection.rs:491-546.

type Field Type Required Meaning
desktop_hello session_id String Yes Bridge session to create or reuse as the desktop peer — protocol.rs:81-82
mobile_hello session_id String Yes Bridge session to join as the mobile peer. The session must already exist — protocol.rs:84-85, connection.rs:84-96
host_hello session_id String Yes Broadcast session to create or reuse as the host — protocol.rs:87-88
spectator_hello session_id String Yes Broadcast session to join as a spectator. Creates the session if absent — protocol.rs:90-91, connection.rs:99-107
room_hello session_id String Yes Room to create or join. A capability secret, redacted in logs — protocol.rs:95, connection.rs:247-248
room_hello member_id String Yes Slot claimed in the room, trust-on-first-use pinned to pubkeyprotocol.rs:96
room_hello pubkey String Yes base64 (standard alphabet, padded) of the ECDSA P-256 SPKI DER identity key — protocol.rs:101
room_hello nonce u64 Yes Freshness value in unix-epoch milliseconds, range-checked against the relay clock — protocol.rs:104
room_hello sig String Yes base64 of the 64-byte IEEE-P1363 `r

Both session_id and member_id are validated after parsing: non-empty, at most --max-id-bytes, charset [A-Za-z0-9._-]connection.rs:41-50, connection.rs:472-478. A bad session_id yields {"type":"error","message":"invalid session ID"}; a bad member_id yields "invalid member ID"connection.rs:54-65.

Hello envelopes#

The four unauthenticated hellos carry one field each. Possession of the session_id plus self-selection of a role is the whole admission boundary for them — nothing in these four envelopes is verified against anything.

{"type":"desktop_hello","session_id":"demo-session"}
{"type":"mobile_hello","session_id":"demo-session"}
{"type":"host_hello","session_id":"demo-session"}
{"type":"spectator_hello","session_id":"demo-session"}

The room_hello below uses the field values of the golden signing vector at room_auth.rs:804-812, so it lines up with the payload documented on Room authentication. The pubkey there is the literal test string pk, not a real SPKI DER key, and sig is shown elided because the vector pins the payload rather than a signature.

{
  "type": "room_hello",
  "session_id": "sid",
  "member_id": "m1",
  "pubkey": "pk",
  "nonce": 42,
  "sig": "<base64 P1363 r||s>"
}

Unknown fields are ignored#

No hello variant carries #[serde(deny_unknown_fields)]protocol.rs:76-80. Extra keys in a hello are silently dropped during deserialisation, so a client that sends a client_version or user_id alongside session_id is admitted exactly as though it had not, and receives no indication that the field went nowhere.

Warning

A misspelled field name is indistinguishable from an ignored one. room_hello is the exception that fails loudly: because all five of its fields are required, misspelling pubkey makes the whole envelope undeserialisable and the connection is dropped with no error frame.

Relay responses#

Twelve response variants, also tagged by type (protocol.rs:114-115). Every one is emitted as a WebSocket text frame; the relay never sends a binary frame.

type Field Type Meaning
session_ready session_id String Bridge or broadcast admission accepted; echoes the id the client sent — protocol.rs:116-117
peer_connected The other bridge peer is present. Sent to the joiner and to the incumbent — protocol.rs:119-120, connection.rs:283-289
peer_disconnected The other bridge peer's socket closed — protocol.rs:122-123, connection.rs:422-428
host_connected A broadcast host is present. Sent to spectators — protocol.rs:125-126
host_disconnected The broadcast host's socket closed — protocol.rs:128-129, connection.rs:430-439
viewer_count count usize Current spectator count, sent to the host and every spectator — protocol.rs:131-132, session.rs:347-360
room_ready session_id String Room admission accepted after signature verification — protocol.rs:134-135
member_joined member_id String A member entered the room, or a roster-snapshot entry for a joiner — protocol.rs:137-138, connection.rs:299-329
member_left member_id String A member's socket closed — protocol.rs:140-141
member_count count usize Current room membership, including the recipient — protocol.rs:143-144, session.rs:291-297
member_frame member_id String Relay-authenticated sender identity. Authoritative — protocol.rs:146-151
member_frame payload String The sender's raw text frame, unparsed and unmodified — protocol.rs:151
error message String Human-readable refusal. The handler returns immediately after sending — protocol.rs:153-154
Important

Consumers must treat member_frame.member_id as the sender identity. Any user_id claimed inside payload is self-asserted — the relay forwards the payload verbatim and never inspects it, so a member can write whatever it likes in there.

Response envelopes#

session_ready and room_ready are quoted from the shared fixtures in testdata/, which the wire-conformance tests assert against the serialised enum — protocol.rs:172-191. The member_joined, member_left and member_count samples use the exact values pinned at protocol.rs:193-213.

{"type":"session_ready","session_id":"test-sid"}
{"type":"peer_connected"}
{"type":"peer_disconnected"}
{"type":"host_connected"}
{"type":"host_disconnected"}
{"type":"viewer_count","count":3}
{"type":"room_ready","session_id":"test-sid"}
{"type":"member_joined","member_id":"m1"}
{"type":"member_left","member_id":"m1"}
{"type":"member_count","count":3}
{"type":"member_frame","member_id":"m1","payload":"{\"kind\":\"chat\"}"}
{"type":"error","message":"invalid session ID"}

A field-less variant serialises to an object carrying only type — that is what #[serde(tag = "type")] does to a unit variant, and it is why peer_connected is a well-formed object rather than a bare string.

Frame handling is narrower than WebSocket#

The relay reads six WebSocket message kinds and acts on two of them. The table is exhaustive: any frame not listed does not exist in tokio-tungstenite 0.26's message enum.

Frame Before the hello In the forwarding loop Charged to the budget
Text Parsed as the hello — connection.rs:502-524 Rate-charged, then dispatched by role — connection.rs:346 Yes, text.len() once per frame
Binary Refused; the connection closes — connection.rs:530-533 Silently discarded — connection.rs:405 No
Ping Not a hello; the read continues Echoed as Pongconnection.rs:397-399 No
Pong Not a hello; the read continues Silently discarded — connection.rs:405 No
Frame (raw) Not a hello; the read continues Silently discarded — connection.rs:405 No
Close Treated as close-before-hello; drop, no error frame — connection.rs:491-546 Breaks the loop and runs cleanup — connection.rs:400-403 No

Three consequences follow, and all three surprise clients.

The first frame must be text. A client that opens with a binary hello — a MessagePack or CBOR encoding, say — is closed without an error frame, because the refusal happens before any parsing that could produce a message to send.

Discarded frames cost nothing. Binary, Pong and raw Frame are dropped before the budget is touched, so a peer can flood the relay with binary frames and never trip "message rate exceeded". The connection-admission limiter and the tungstenite max_frame_size ceiling are the only things standing in the way — see Limits & capacity.

The relay never initiates a keepalive. It echoes Ping as Pong and sends no Ping of its own — connection.rs:397-399. There is also no post-hello idle timeout (connection.rs:333-409), so an idle connection is held open indefinitely by the relay and torn down only by the client or the network path.

Keepalive recognition is exact, not heuristic#

The relay recognises one application-level keepalive envelope and drops it rather than forwarding it. is_relay_keepalive_ping requires all four conditions — connection.rs:480-488:

  1. The frame is shorter than 64 bytes.
  2. It parses as JSON.
  3. The parsed value is an object with exactly one key.
  4. That key is "type" and its value is the string "ping".
{"type":"ping"}

Anything else is an ordinary application frame and is forwarded. A keepalive with a second key — {"type":"ping","t":1756512000000} — fails condition 3 and is relayed to the peer as normal traffic. This is deliberate: the test at connection.rs:584 pins the exact envelope so that widening the rule is a visible change rather than an accident.

Note

Keepalive frames are charged to the message budget even though they are dropped. The charge happens at connection.rs:346, before the keepalive check at connection.rs:361, so a chatty client's pings count against --max-messages-per-window and --max-bytes-per-window.

The relay never sends a Close frame#

Every refusal path — invalid session id, invalid member id, session kind mismatch, capacity, a failed room signature, a breached message budget — sends an error text frame and then returns from the handler, dropping the sink. No WebSocket Close frame with a status code is ever sent.

A client therefore cannot distinguish refusal reasons from the close code, because there is no close code. The error frame is the only signal, and on the paths that precede it — hello timeout, close before hello, unparseable hello JSON, connection-rate breach — there is no signal at all: the socket simply goes away. Troubleshooting maps the silent failures to their causes.

  • Room authentication — the canonical signing payload, nonce window and TOFU pinning behind room_hello.
  • Core concepts — sessions, roles and session kinds, and which hello selects which.
  • Bridge sessions — the message order a desktop and mobile pair actually observe.
  • Broadcast sessions — host fan-out, viewer counts, and the ordering quirk spectators see.
  • Limits & capacity — what the message budget charges, and what it does not.
  • Troubleshooting — the silent drops, and how to tell them apart.