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 pubkey — protocol.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.
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 |
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 Pong — connection.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:
- The frame is shorter than 64 bytes.
- It parses as JSON.
- The parsed value is an object with exactly one key.
- 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.
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.
Related#
- 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.