# Zuzufy WebSocket protocol

Endpoint: `wss://<host>/ws` (same origin as the app). Authentication uses the session cookie sent
with the upgrade request; anonymous connections are allowed for listening. Cross-site origins are
refused. Messages are JSON objects with a `type`. Max frame 16 KB; ~60 messages / 10 s per connection.

**Requests and replies.** Any client message may include a numeric `ref`. Replies to it carry the
same `ref`; every request with a `ref` also ends with `{ type:'ack', ref, for }`, and failures come
back as `{ type:'error', code, message, ref }`. Messages from one socket are processed in order.

On connect the server sends:

```json
{ "type": "hello", "serverTime": 1791486537629, "user": { "id": 7, "username": "ada" }, "sync": { "ignoreBelow": 1, "gradualAbove": 2, "hardSeekAbove": 5 } }
```

## Clock

| Client → server | Server → client |
|---|---|
| `time.sync { t0, ref }` | `time.sync { t0, serverTime, ref }` (epoch ms; client computes offset = serverTime − (t0+t1)/2 using the lowest-RTT sample) |
| `ping` | `pong { serverTime }` |

## Broadcast

| Client → server | Effect |
|---|---|
| `channel.subscribe { channelId }` | Join the channel room; replies with `broadcast.sync` and `broadcast.listener_count` |
| `channel.unsubscribe { channelId }` | Leave |
| `broadcast.request_sync { channelId, ref }` | Fresh `broadcast.sync` |
| `listen.start { channelId, mode: automated\|live\|ondemand, episodeId?, ref }` | → `listen.started { sessionKey }` (analytics) |
| `listen.heartbeat { sessionKey, listenedMs, positionMs?, durationMs?, p2pBytes?, httpBytes? }` | Every 30 s; deltas are capped server-side |
| `listen.stop { sessionKey, channelId, listenedMs }` | End the session |

Server → client events (room `channel:{id}`):

| Type | Payload |
|---|---|
| `broadcast.sync` | Full state (same shape as `GET /api/broadcast/:id/state`): `state`, `serverTimeMs`, `currentTrack { id, title, url, duration, offset, startedAtMs, endsAtMs, transition, transitionMs }`, `nextTrack`, `nextChangeAtMs`, `scheduleVersion`, `live`, `listeners`, `p2p`, `locked` (URLs are `null` for listeners without access) |
| `broadcast.track_changed` | Same shape, pushed at each track boundary |
| `broadcast.schedule_changed` | `{ scheduleVersion }` (followed by `broadcast.sync`) |
| `broadcast.started` / `broadcast.stopped` | `{ from, to }` state transitions |
| `broadcast.listener_count` | `{ listeners }` |
| `live.started` / `live.ended` | Live override began / ended |
| `shop.pinned` | `{ channelId, product }`: creator pinned a merch product on air (`product` is `null` when unpinned or the store is disconnected). `product` has `{ id, title, price, compareAtPrice, imageUrl, url }`; link listeners via `/go/shop/{channelId}/{id}` |

## Chat

| Client → server | Server → client |
|---|---|
| `chat.send { channelId, body, ref }` | `chat.ack { id, ref }` or `error` (`UNAUTHENTICATED`, `EMAIL_NOT_VERIFIED`, `CHAT_DISABLED`, `CHAT_MUTED`, `CHAT_BANNED`, `SLOW_MODE`, `RATE_LIMITED`, `MESSAGE_TOO_LONG`, …) |

Room events: `chat.message { message: { id, body, createdAt, user { id, username, displayName, avatarUrl, badges } } }`,
`chat.deleted { messageId }`, `chat.settings { settings }`, `chat.user_joined`, `chat.user_left`.
User-room events: `chat.muted`, `chat.banned`. **Render `body` as text, never HTML.**

## Notifications

`user.subscribe` (authenticated) joins `user:{id}` → `notification.created { notification }`,
`media.ready { mediaId, channelId, title }`.

## Live WebRTC signalling (mediasoup)

| Client → server | Reply |
|---|---|
| `live.join { sessionId, role: viewer\|host\|guest, guestToken? }` | `live.joined { peerId, role, canProduce, routerRtpCapabilities, producers[], iceServers }` |
| `live.transport.create { sessionId, direction: send\|recv }` | `live.transport.created { id, iceParameters, iceCandidates, dtlsParameters }` |
| `live.transport.connect { sessionId, transportId, dtlsParameters }` | `live.transport.connected` |
| `live.produce { sessionId, transportId, kind, rtpParameters, source: mic\|camera\|screen }` | `live.produced { producerId }` (hosts/guests only) |
| `live.producer.close` / `live.producer.pause { sessionId, producerId, paused }` | ack |
| `live.consume { sessionId, transportId, producerId, rtpCapabilities }` | `live.consumed { id, producerId, kind, rtpParameters, source, peerId }` (paused) |
| `live.consumer.resume { sessionId, consumerId }` | ack |
| `live.leave { sessionId }` | — |

Room `live:{id}` events: `live.producer_added { producer }`, `live.producer_closed`, `live.consumer_closed`,
`live.guest_joined`, `live.guest_left`, `live.participant_control { action, canProduce }`,
`live.participant_updated`, `live.removed`, `live.host_reconnecting { graceSeconds }`, `live.ended { reason }`.

## Peer-to-peer chunk exchange

| Client → server | Reply |
|---|---|
| `p2p.join { channelId, ref }` (requires a prior `channel.subscribe` with listen access) | `p2p.joined { peerId, peers[], iceServers, chunkSize, maxPeers }` |
| `p2p.signal { channelId, to, data }` (`data` = `{ description }` or `{ candidate }`) | relayed to `to` as `p2p.signal { from, data }`; unknown target → `p2p.gone { peerId }` |
| `p2p.leave { channelId }` | — |

Peer data-channel protocol (`zz-p2p`, between browsers): text `{t:'have', m, r:[[a,b]]}`,
`{t:'got', m, i}`, `{t:'req', id, m, i}`; binary response `[u32 id][u32 status][chunk bytes]`.
Chunks are verified against `GET /api/p2p/manifest/:mediaId`.
