// Package protocol defines Calls’s client-neutral signaling contract. package protocol import "encoding/json" // PeerID is the server-issued identity of one participation: sixteen // random bytes in hex, bound to a room and a session, shown in rosters // so browsers can address each other. It is not a credential (spec §4.2). type PeerID string // Kind is a participation's authority, re-derived from standing on // every tick (spec §2.4). type Kind int // Reason says why a stream ended. Only "buffer" is followed by a // resume; the rest are terminal (spec §4.5). type Reason string const ( KindGuest Kind = iota KindMember ) func (k Kind) String() string { if k == KindMember { return "member" } return "guest" } const ( ReasonBuffer Reason = "buffer" ReasonReplaced Reason = "replaced" ReasonStanding Reason = "standing" ReasonExpired Reason = "expired" ReasonRemoved Reason = "removed" ReasonEnded Reason = "ended" ) // Upstream types a browser may send. const ( TypeJoin = "join" TypeAdmit = "admit" TypeLeave = "leave" TypeOffer = "offer" TypeAnswer = "answer" TypeCandidate = "candidate" TypeReset = "reset" TypeChat = "chat" // slice 4 TypeShare = "share" // slice 4 // TypeMedia goes both ways: a peer reports its own MediaState // upstream, and the hub fans the change out downstream as // {type, from, name, payload: MediaState} (spec §15.19). TypeMedia = "media" ) // Downstream types the server emits. const ( TypeHello = "hello" TypeRoster = "roster" TypePeer = "peer" TypeGone = "gone" TypeKnock = "knock" TypeKnockGone = "knock-gone" TypeFull = "full" // TypeEnded names the ending in §4.3's table, but nothing sends it: // a meeting that ends closes every stream, and a closing stream // carries its reason in lost, so the browser reads the ending as // lost{reason: "ended"} (§4.5). The name is kept because the spec // uses it. TypeEnded = "ended" TypeLost = "lost" TypePair = "pair" TypeGap = "gap" TypeState = "state" ) // Message is the one envelope, built by the server. Payload is opaque. type Message struct { Type string `json:"type"` From PeerID `json:"from,omitempty"` Name string `json:"name,omitempty"` To PeerID `json:"to,omitempty"` Seq uint64 `json:"seq,omitempty"` Gen uint64 `json:"gen,omitempty"` Payload json.RawMessage `json:"payload,omitempty"` Reason Reason `json:"reason,omitempty"` Roster *Roster `json:"roster,omitempty"` Boot string `json:"boot,omitempty"` Peer PeerID `json:"peer,omitempty"` Waiting bool `json:"waiting,omitempty"` // Report is hello's report token (spec §15.26): the client's authority // to send quality and problem reports, for its participation and ten // minutes after. Sent back in the X-Calls-Report header, never logged. Report string `json:"report,omitempty"` } // Roster is the authoritative snapshot a browser reconciles against // (spec §4.3). Waiting is filled only for a member recipient. type Roster struct { Peers []RosterPeer `json:"peers"` Waiting []RosterPeer `json:"waiting,omitempty"` } type RosterPeer struct { Peer PeerID `json:"peer"` Name string `json:"name"` Kind string `json:"kind"` Gen uint64 `json:"gen,omitempty"` // Media is the peer's own last report of its microphone and camera, // absent until it has made one — which is every client that predates // §15.19, and every peer for the moment between admission and its // first report. Absent means unknown, and a client shows unknown as // neither muted nor camera-off. Never written through: a snapshot // copies the pointer, so a change replaces it. Media *MediaState `json:"media,omitempty"` } // MediaState is what a peer says about its own outgoing media (spec // §15.19): whether its microphone is muted and whether its camera is // off. It is a claim about what the peer is choosing to send, not a // measurement of what arrives — the server never sees media. type MediaState struct { Muted bool `json:"muted"` CameraOff bool `json:"cameraOff"` // AudioBlocked and NoSoundFrom are the receiving half (spec §15.20): // what this peer can say about the sound reaching it, so that the // person talking can be told. Both carry only bad news and are // omitted when there is none, so an ordinary report is the two-key // object §15.19 defined and a server from before §15.20 still takes // it. // // AudioBlocked: the browser refused to play remote sound (an autoplay // policy wanting a click first). AudioBlocked bool `json:"audioBlocked,omitempty"` // NoSoundFrom: the peers whose audio is arriving here as silence — // connected, reporting themselves unmuted, and flat for several // seconds. At most MaxNoSoundFrom, each a peer id, none repeated. NoSoundFrom []PeerID `json:"noSoundFrom,omitempty"` // ScreenStalled (2026-09-28): the peers whose shared screen has stopped // moving here while they say they are sharing — no frame decoded for // several seconds. The sharer reads its own id in it as a request to // recover (a keyframe first). Bad news only, omitted when there is // none, at most MaxScreenStalled, each a peer id, none repeated. ScreenStalled []PeerID `json:"screenStalled,omitempty"` // Hearing (spec §15.24, 2026-10-01): the peers whose audio this client // is actually receiving and playing — the path from them is // established, so they can be heard the moment they speak. Unlike the // members above it is good news, and its presence is itself news: nil // (the key absent) is a client that does not say, an old one, and an // empty list is a client that says it hears nobody yet. So it is // omitted only when nil (omitzero), and an empty list travels as []. // At most MaxHearing, each a peer id, none repeated. Hearing []PeerID `json:"hearing,omitzero"` } // MaxNoSoundFrom bounds MediaState.NoSoundFrom: a room holds at most // sixteen participations (§4.2), so fifteen others is every one there // could be. const MaxNoSoundFrom = 15 // MaxScreenStalled bounds MediaState.ScreenStalled for the same reason. const MaxScreenStalled = 15 // MaxHearing bounds MediaState.Hearing for the same reason. const MaxHearing = 15 // Equal compares two reports field by field; the peer lists are compared // in order, which is fine because the hub only asks whether a report // changed, and a reordered list is a report worth passing on. func (m MediaState) Equal(o MediaState) bool { if m.Muted != o.Muted || m.CameraOff != o.CameraOff || m.AudioBlocked != o.AudioBlocked { return false } if (m.Hearing == nil) != (o.Hearing == nil) { return false } return samePeers(m.NoSoundFrom, o.NoSoundFrom) && samePeers(m.ScreenStalled, o.ScreenStalled) && samePeers(m.Hearing, o.Hearing) } func samePeers(a, b []PeerID) bool { if len(a) != len(b) { return false } for i := range a { if a[i] != b[i] { return false } } return true }