WebSocket protocol
Every frame a client may send, every frame the room sends, the sequence numbers and the close codes.
The live auction runs over one WebSocket per client at /ws. Frames are JSON text, one object per frame. This page is the contract: the shapes come from packages/shared/src/wire/, and the behaviour from the room host in packages/server.
Verified against the code, not measured
Shapes and limits are read from the shared schemas and the server host. This is a description of the protocol, not a claim about load or latency.
WebSocket protocol: join, resume, live frames, bids and reconnect
Scroll the figure sideways, or open it full size.
1. Upgrade. The socket lives at /ws. A request with no Origin, or one that is not on the allowed list, is refused before any frame. After the upgrade the client has 5 seconds to send a join, or the host closes with 4001. A frame sent before the join gets not_joined.
packages/shared/src/wire/ (frames and size caps), packages/server/src/host/gateway.ts, transport/handshake.ts, transport/connections.ts, room/resync.ts, room/room.ts and the client side in packages/web/src/net/connection.ts. Auctioneer controls (hammer, pause, extend) are not WebSocket frames; they use the HTTP control route. This is a map of code paths, not a measurement.Text version of this diagram
Lanes: client, gateway, room.
- The client upgrades at /ws. No join within 5 seconds closes with 4001.
- The client sends join with a token, the auction id and optionally lastSeq. The gateway verifies the token for that auction and attaches the connection to the room.
- The room replays missed audit frames if 200 events or fewer were missed, then sends a state snapshot.
- Live frames carry seq and ts: price, audit_event, hammer, settlement_update. confirm_request goes to the held bid's owner only.
- The client may send bid and confirm_response. The room answers with bid_accepted or bid_rejected carrying the same mid.
- The host pings every 20 seconds and closes after two missed pongs. A connection 256 frames behind, or undrained for 5 seconds, closes with 4008.
- The client reconnects with a back-off of 0.5, 1, 2, 4, then 8 seconds, plus or minus 20 percent, and joins with its last seq.
The envelope
Every frame is a strict object: { "v": 1, "t": "<type>", ... }. A frame with an unknown t, an extra field, bad JSON, a non-object, or a version other than 1 is rejected.
Prop
Type
Frames a client may send
After the join a client may send only bid and confirm_response. Auctioneer controls such as hammer, pause and extend are HTTP requests, not frames.
Must be the first frame. A frame before it gets an error with not_joined.
{ "v": 1, "t": "join", "token": "<session token>", "auctionId": "<ULID>", "lastSeq": 41 }Prop
Type
Frames the room sends
t | Sent to | Carries |
|---|---|---|
bid_accepted | the sender | mid, bidId, amount |
bid_rejected | the sender | mid, reason (an error code), retryable, optional retryAfterMs |
price | everyone | price, leaderId, leaderPaddle, endTs, minNext, extensions, optional paused and remainingMs |
confirm_request | that bidder only | confirmId, amount, optional transcript, expiresAt |
audit_event | everyone, redacted | one audit row; the auctioneer sees full rows, others public rows |
hammer | everyone | winnerId (or null), price, ranking, optional stopped, registrationClosed |
Sequence numbers
The room numbers every event it logs. A client that keeps the highest seq it has applied can always resume.
- One bid, one seq.
bid_accepted,priceandaudit_eventfor the same bid share a seq. A client must not count them as three events. - No gaps. An event a viewer may not see still consumes a seq, so the room sends that viewer a filler (a repeat of the current price, or a snapshot when there is no price yet). A hidden confirm hold, a voice drop and a control record all work this way.
- Resume. A join with
lastSeqgets the missed audit frames when the gap is 200 events or fewer, then astatesnapshot. A client that is current, ahead, or more than 200 behind gets the snapshot only. - The web client ignores a seq it has already applied, and a seq more than one ahead is a gap: it keeps what arrives, asks for a fresh snapshot by joining again with its
lastSeq, and accepts that snapshot at any seq.
Who may do what
| Operation | auctioneer | bidder | screen |
|---|---|---|---|
| Join the socket | yes | yes | yes |
| Bid as themselves | no | yes | no |
| Bid for a bidder (manual) | yes | no | no |
| Answer a confirm | no | yes | no |
| Control the clock, hammer, rotate invites | yes (HTTP) | no | no |
Three limits keep one client from hurting the room: connections per token (3, the oldest is closed with 4011), connections per room (500) and a bid rate limit per bidder. Every frame, joined or not, also takes a token from the connection's frame bucket.
Errors and close codes
An error frame carries one of the shared codes. Some also close the socket.
| Close code | Reason | When |
|---|---|---|
| 4001 | join_timeout | no join within 5 seconds |
| 1008 | bad_message | a malformed join, or a bad frame after the join |
| 4401 | unauthorized | the token fails for this auction, expired or was revoked while connected |
| 4009 | room_full | the room is at its connection cap |
| 4010 | host_fenced | another host owns the room now |
| 4011 | superseded | a newer connection on the same token pushed this one out |
| 4008 | slow_consumer | 256 frames queued, or a write undrained for 5 seconds |
| 1001 | heartbeat_lost | two missed pongs (the host pings every 20 seconds) |
| 1011 | internal | an unexpected failure |
Each code is marked retryable in packages/shared/src/errors.ts. The retryable ones are rate_limited, room_full, unconfirmed, auction_paused and store_unavailable. The web client treats unauthorized, forbidden and invite_used as the end of the session, and waits for the bidder before retrying after room_full, auction_full, room_cap or rate_limited. After a socket closes for any other reason it reconnects with a back-off of 0.5, 1, 2, 4 and then 8 seconds, plus or minus 20 percent.
A bidder's frames are redacted by role. Ceilings, holds and you blocks appear only where they name the viewer. Everyone except the auctioneer sees public audit rows only: accepted bids, no transcripts and no rejection reasons. A held bid's amount and transcript never reach the auctioneer either.
The host answers an upgrade only on /ws, refuses an upgrade with no Origin or one that is not on the allowed list with HTTP 403, and can refuse with 429 when too many upgrades arrive. A plain GET on /ws is answered with 426. The voice audio socket is a separate path, /audio, and uses the auctioneer's token.
- packages/shared/src/wire/envelope.tsthe envelope, caps, decodeFrame
- packages/shared/src/wire/client.tsjoin, bid, confirm_response
- packages/shared/src/wire/server.tsevery server frame and the snapshot
- packages/shared/src/errors.tserror codes, close codes, retryable
- packages/server/src/host/gateway.tsjoin verification, frame loop
- packages/server/src/host/node-ws.ts/ws path, Origin check
- packages/server/src/transport/handshake.tsjoin deadline, ping
- packages/server/src/transport/connections.tsbounded queue, slow consumer
- packages/server/src/room/resync.tsreplay window, connection caps
- packages/server/src/auth/authorize.tsrole matrix and redaction
- packages/web/src/net/connection.tsreconnect and back-off
Last updated on