Your first session#
A bridge is PacketRelay's one-to-one session kind: one desktop client, one mobile client, and every text frame forwarded verbatim between them. Opening one takes a running relay, two WebSocket connections and two JSON hellos. This page walks that from a cold start through to a frame arriving unchanged on the other side, and then names the three ways a first attempt usually fails.
Before you start#
- A relay you can reach.
cargo run --locked -- --port 8080from a checkout is enough — see Run the relay. - Two WebSocket clients, in two terminals. The examples use
websocat, which is not part of this repository; any client that can send a text frame works. - A session id you have chosen. The charset rule is narrow — see What a session id may contain.
- The right scheme.
ws://against a relay on your own machine,wss://through any TLS-terminating edge. The relay terminates no TLS itself. - Speed on the hello. There is a five-second deadline from connect to first application message, and missing it is silent.
Steps#
Start the relay.
cargo run --locked -- --port 8080It binds
0.0.0.0:8080—main.rs:317— unlessPORTis set in the environment, in which case that value overrides--port(main.rs:313-316).Open the desktop connection. Any path will do.
websocat ws://127.0.0.1:8080/Route selection is by hello, not by path.
/,/anythingand even/v1/product-routeall classify as the Legacy route and upgrade into the same handler —main.rs:485, pinned atmain.rs:608. Only/health,/healthz,/readyand/readyzare reserved. Which of the three session protocols you are speaking is decided by the first application message —connection.rs:502-524.Send the desktop hello. It must be the first frame, and it must be a text frame.
{"type":"desktop_hello","session_id":"first-session"}This creates a session of kind Bridge under that id, or reuses an existing bridge with it —
connection.rs:70-83,session.rs:118-128.The hello structs carry no
deny_unknown_fields—protocol.rs:76-80— so extra fields are accepted and silently ignored. A client that sends aversionorclientfield alongsidesession_idis admitted exactly as if it had not, which means a protocol negotiation added to the hello would be discarded without a word rather than rejected.Read
session_readyon the desktop socket.{"type":"session_ready","session_id":"first-session"}Sent at
connection.rs:261-266. The shape is pinned by a fixture rather than by a hand-typed literal:testdata/session_ready.jsonholds exactly{"type":"session_ready","session_id":"test-sid"}, andprotocol.rs:172-191asserts the serialiser reproduces it byte for byte.Open the mobile connection in the second terminal, to the same relay and any path.
Send the mobile hello with the same session id.
{"type":"mobile_hello","session_id":"first-session"}A mobile hello does not create. The session has to exist already, or the relay answers
"session not found"and closes —connection.rs:84-96.Read the join events. The mobile socket receives
session_readyand thenpeer_connected; the desktop socket receivespeer_connectedunprompted. The envelope carries no fields at all —connection.rs:283-289,protocol.rs:119-120.{"type":"peer_connected"}Send a frame from either side. Type anything into one socket and watch it appear on the other.
{"hello":"world"}Every text frame is forwarded verbatim to the current peer —
connection.rs:357-366. The relay does not parse it, validate it, wrap it or attribute it; a bridge frame arrives exactly as it was sent, and non-JSON text is forwarded just as happily. Attribution is a Room feature, not a bridge one.Size is capped below the relay's own logic.
--max-message-bytesdefaults to 65,536 and sets bothmax_message_sizeandmax_frame_sizeon the WebSocket, and 64 KiB is a hard ceiling the validator refuses to raise —main.rs:39,main.rs:234,main.rs:417-422. An oversized frame is therefore a tungstenite-level failure rather than a relay error envelope; the precise wire behaviour tungstenite 0.26 produces at that boundary has not been verified here.Know the one envelope that does not cross. Exactly
{"type":"ping"}is recognised as a relay keepalive and dropped, never forwarded —connection.rs:361,connection.rs:480-488. Recognition is deliberately narrow: under 64 bytes, valid JSON, an object, exactly one key, and that key"type"with the value"ping".{"type":"ping","seq":1}has two keys and crosses to the peer like any other frame.
What a session id may contain#
Both hellos validate the id before anything else happens —
connection.rs:41-50, connection.rs:472-478. Three rules, all of which must
hold:
- non-empty;
- at most
--max-id-bytesbytes, default64, configurable in the range 8–256; - every byte drawn from
[A-Za-z0-9._-]— ASCII letters, digits, dot, underscore and hyphen.
Anything else earns one frame and then a close:
{"type":"error","message":"invalid session ID"}
Spaces, newlines, ; and emoji are all rejected, and the member_id_tests
module at connection.rs:584 pins each of those cases. The same validator and
the same limit apply to a room's member_id, which fails with
"invalid member ID" instead.
On a bridge the session id is the entire admission boundary.
desktop_hello and mobile_hello carry nothing but the id —
protocol.rs:81-85 — so anyone who knows it can claim either role, and a
second desktop silently replaces the first. Choose something unguessable, and
read Security model for what that does and does not buy you.
One consequence worth knowing before you pick an id: a bridge session id is
written to the logs in full. Only Role::Member ids are redacted to their first
six characters plus a length, because a room id is a capability secret —
connection.rs:549-559.
Verify#
You have a working bridge when all four of these hold.
| Check | Expected |
|---|---|
| Desktop hello | {"type":"session_ready","session_id":"first-session"} returns on the desktop socket |
| Mobile hello | session_ready, then {"type":"peer_connected"}, on the mobile socket |
| Desktop socket, unprompted | {"type":"peer_connected"} arrives the moment the mobile joins |
| Round trip | A text frame sent on either socket appears byte-for-byte on the other |
Now close one of the two sockets. The survivor receives:
{"type":"peer_disconnected"}
Sent at connection.rs:422-428. Cleanup is ownership-aware — unregister
removes a slot only when the stored channel is the one closing
(session.rs:383-451) — so a desktop reconnect that replaced an earlier
connection never produces a spurious disconnect on the peer when that older
connection finally tears down (session.rs:378-382, test
session.rs:617-647). When both slots are empty the session is deleted
outright (session.rs:412-414), and a later mobile_hello for that id gets
"session not found".
The first half of this exchange is pinned end-to-end:
main.rs:670 stands up a real listener, connects over ws://, sends
desktop_hello and asserts session_ready comes back. Bridge forwarding
has no end-to-end listener test — that gap is acknowledged in-repo at
BACKLOG.md:101-111, so steps 7 to 9 above are read from the code, not from
a test.
If it does not work#
Three stumbles account for most first attempts, and two of the three are completely silent.
A binary first frame is refused outright#
The hello has to be a text frame. A binary first frame is refused and the
connection closed — connection.rs:530-533. Nothing comes back: no error
envelope, and no WebSocket Close frame carrying a status code either, because
every pre-hello failure path drops the sink rather than closing it politely.
This is a client-library problem more often than a hand-typing problem. A client that sends a byte buffer rather than a string produces a binary frame, and if that is the first frame the connection is gone before the relay has looked at the JSON inside it. Send a string.
The asymmetry persists after the hello, too: in the forwarding loop only
Message::Text is handled, and Binary, Pong and raw Frame are silently
discarded — connection.rs:405. A bridge that appears to work but drops half
its traffic is usually sending binary.
"session not found" means the desktop is not there yet#
{"type":"error","message":"session not found"}
A mobile hello requires the session to exist already — connection.rs:84-96.
There are three ways to arrive here:
- Ordering. The desktop has not sent its hello yet. The mobile cannot create the session, so connecting the two clients in the other order never works.
- The bridge was reaped.
--session-ttldefaults to 300 s, and a bridge with no mobile peer is reaped onceage > ttleven though the desktop socket is still open —session.rs:459-461.ageis measured from creation and is never refreshed by activity —session.rs:137-147. A desktop that has been waiting six minutes is still connected to a session that no longer exists. The ambiguity is flagged in-repo atBACKLOG.md:196-204. - Both peers left. The session is deleted the instant both slots empty —
session.rs:412-414. Reconnecting means re-sending the desktop hello first.
A fourth outcome is worth recognising because the message changes. If the id
exists but belongs to a broadcast or a room, the existence check passes — it
tests key presence only, not kind (session.rs:479-481) — and the hello then
fails one step later at registration:
{"type":"error","message":"session kind mismatch"}
That comes from session.rs:165. One session id is one session kind, for the
lifetime of the session.
The five-second hello deadline is silent#
--hello-timeout defaults to 5 s and covers two things at once: the HTTP
request head, and the first application hello after the upgrade —
main.rs:389, rate_limit.rs:170-172. Miss it and the relay logs the drop and
closes with no error frame at all — connection.rs:491-546. The same silent
drop covers a WebSocket error before the hello, a close before the hello, and a
hello that is not parseable JSON.
Interactive clients are the usual victim: you open websocat, glance back at
the documentation, type the hello, and the socket has already gone. Have the
JSON on the clipboard before you connect, or drive the socket from a script
rather than by hand.
Once you are past the hello there is no idle timeout at all
(connection.rs:333-409) and the relay never initiates a Ping
(connection.rs:397-399), so the relay itself will not close a quiet
connection. An intermediary might; see
Run the relay.
Other envelopes a bridge client may see#
| Envelope | Cause |
|---|---|
{"type":"error","message":"invalid session ID"} |
Empty id, longer than --max-id-bytes, or a byte outside [A-Za-z0-9._-] — connection.rs:41-50. |
{"type":"error","message":"mobile already connected"} |
A bridge holds one mobile slot and rejects the second — session.rs:176-181. A desktop reconnect is not rejected; it silently replaces the incumbent — session.rs:169-175. |
{"type":"error","message":"server at capacity"} |
The global session ceiling, default 256, was hit under the creation write lock — session.rs:130-132. |
{"type":"error","message":"message rate exceeded"} |
The per-connection budget, default 3,000 messages per 60 s, was exceeded — rate_limit.rs:220-225. The frame is sent and the connection then closes. Keepalive pings are charged even though they are dropped — connection.rs:346. |
| Nothing at all; the TCP stream simply closes | The connection-rate limiter rejected the connection at accept, before a byte was read — main.rs:365-369. |
Next#
- Bridge sessions — the full one-to-one lifecycle, reconnects and replacement included.
- Broadcast sessions — one host, many spectators, and the ordering quirk on join.
- Authenticated rooms — signed hellos, pinned identities and attributed frames.
- Message reference — every hello and every response, field by field.
- Troubleshooting — symptoms across all three session kinds.