/ concept-collection / commonroom
Sign in
concept-collection / commonroom
commonroom / src / p2p / peer.ts
252 lines · 7.9 KBBlameHistoryRaw
1// A thin WebRTC wrapper, ported from commoncall's peer.ts. One instance per
2// remote participant: it carries the local audio/video tracks plus one small
3// control data channel (hello, mute notices, settings sync). As in the sibling
4// projects we avoid "perfect negotiation" glare handling by ensuring only ONE
5// side (a deterministically chosen initiator) ever creates the offer.
7export type Signal =
8 | {type: 'offer'; sdp: string}
9 | {type: 'answer'; sdp: string}
10 | {type: 'candidate'; candidate: RTCIceCandidateInit}
12/** Caps for the outgoing video encoding; an undefined field CLEARS that cap. */
13export interface VideoSendParams {
14 maxBitrate?: number
15 scaleResolutionDownBy?: number
16 maxFramerate?: number
17 degradationPreference?: 'balanced' | 'maintain-framerate' | 'maintain-resolution'
20export interface PeerHandlers {
21 signal: (signal: Signal) => void
22 /** Connection reached the 'connected' state. */
23 connect: () => void
24 /** Remote media stream became available. */
25 track: (stream: MediaStream) => void
26 /** A string message arrived on the control channel. */
27 data: (data: string) => void
28 close: () => void
31// A media connection can survive a brief network blip: 'disconnected' often
32// recovers on its own, so only tear down if it persists this long.
33const DISCONNECT_GRACE_MS = 5000
35export class Peer {
36 private pc: RTCPeerConnection
37 private channel: RTCDataChannel | null = null
38 /** Control messages sent before the channel opens; flushed on open. */
39 private outbox: string[] = []
40 private handlers: Partial<PeerHandlers> = {}
41 private pendingCandidates: RTCIceCandidateInit[] = []
42 private disconnectTimer: number | null = null
43 private closed = false
45 /** `iceServers` is fixed for the life of the connection: relay credentials
46 * that arrive later apply to the NEXT connection to this peer, not this one
47 * (see the retry in network.ts). */
48 constructor(
49 private initiator: boolean,
50 localStream: MediaStream,
51 iceServers: RTCIceServer[]
52 ) {
53 this.pc = new RTCPeerConnection({iceServers})
55 // Both sides add their tracks up front: the initiator's single offer then
56 // covers all media, and the answerer's tracks ride back in the answer.
57 // (Every participant always has one audio + one video track — real or a
58 // synthetic placeholder — so the m-lines are always symmetric.)
59 for (const track of localStream.getTracks()) {
60 this.pc.addTrack(track, localStream)
61 }
63 this.pc.ontrack = ({streams}) => {
64 if (streams[0]) this.handlers.track?.(streams[0])
65 }
67 this.pc.onicecandidate = ({candidate}) => {
68 if (candidate) {
69 this.handlers.signal?.({type: 'candidate', candidate: candidate.toJSON()})
70 }
71 }
73 this.pc.onconnectionstatechange = () => {
74 const s = this.pc.connectionState
75 if (s === 'connected') {
76 this.clearDisconnectTimer()
77 this.handlers.connect?.()
78 } else if (s === 'failed' || s === 'closed') {
79 this.destroy()
80 } else if (s === 'disconnected') {
81 this.clearDisconnectTimer()
82 this.disconnectTimer = window.setTimeout(() => {
83 if (this.pc.connectionState !== 'connected') this.destroy()
84 }, DISCONNECT_GRACE_MS)
85 }
86 }
88 if (initiator) {
89 this.setupChannel(this.pc.createDataChannel('control'))
90 this.pc.onnegotiationneeded = () => void this.makeOffer()
91 } else {
92 this.pc.ondatachannel = ({channel}) => this.setupChannel(channel)
93 }
94 }
96 setHandlers(handlers: Partial<PeerHandlers>) {
97 Object.assign(this.handlers, handlers)
98 }
100 private clearDisconnectTimer() {
101 if (this.disconnectTimer !== null) {
102 clearTimeout(this.disconnectTimer)
103 this.disconnectTimer = null
104 }
105 }
107 private setupChannel(channel: RTCDataChannel) {
108 this.channel = channel
109 const flush = () => {
110 for (const data of this.outbox.splice(0)) channel.send(data)
111 }
112 if (channel.readyState === 'open') flush()
113 else channel.onopen = flush
114 channel.onclose = () => this.destroy()
115 channel.onmessage = e => {
116 if (typeof e.data === 'string') this.handlers.data?.(e.data)
117 }
118 }
120 private async makeOffer() {
121 if (this.closed) return
122 try {
123 await this.pc.setLocalDescription(await this.pc.createOffer())
124 this.handlers.signal?.({
125 type: 'offer',
126 sdp: this.pc.localDescription!.sdp
127 })
128 } catch {
129 /* ignore */
130 }
131 }
133 async signal(signal: Signal) {
134 if (this.closed) return
135 try {
136 if (signal.type === 'candidate') {
137 if (this.pc.remoteDescription) {
138 await this.pc.addIceCandidate(signal.candidate)
139 } else {
140 this.pendingCandidates.push(signal.candidate)
141 }
142 return
143 }
145 if (signal.type === 'offer') {
146 if (this.initiator) return // initiators never accept remote offers
147 await this.pc.setRemoteDescription({type: 'offer', sdp: signal.sdp})
148 await this.flushCandidates()
149 await this.pc.setLocalDescription(await this.pc.createAnswer())
150 this.handlers.signal?.({
151 type: 'answer',
152 sdp: this.pc.localDescription!.sdp
153 })
154 return
155 }
157 if (signal.type === 'answer') {
158 await this.pc.setRemoteDescription({type: 'answer', sdp: signal.sdp})
159 await this.flushCandidates()
160 }
161 } catch {
162 /* ignore transient signaling errors */
163 }
164 }
166 private async flushCandidates() {
167 const queued = this.pendingCandidates.splice(0)
168 for (const c of queued) {
169 try {
170 await this.pc.addIceCandidate(c)
171 } catch {
172 /* ignore */
173 }
174 }
175 }
177 send(data: string) {
178 if (this.channel?.readyState === 'open') this.channel.send(data)
179 else if (!this.closed) this.outbox.push(data)
180 }
182 /** Swap an outgoing track in place (camera ↔ screen, placeholder → real
183 * device). A same-kind replaceTrack does not trigger renegotiation, so no
184 * signaling is needed and the one-offer design is preserved. */
185 async replaceTrack(
186 kind: 'audio' | 'video',
187 track: MediaStreamTrack
188 ): Promise<boolean> {
189 if (this.closed) return false
190 const sender = this.pc.getSenders().find(s => s.track?.kind === kind)
191 if (!sender) return false
192 try {
193 await sender.replaceTrack(track)
194 return true
195 } catch {
196 return false
197 }
198 }
200 /** Cap (or uncap) the outgoing video encoding. Like replaceTrack,
201 * setParameters applies live with no renegotiation, so it fits the
202 * one-offer design. */
203 async setVideoParameters(opts: VideoSendParams): Promise<boolean> {
204 if (this.closed) return false
205 const sender = this.pc.getSenders().find(s => s.track?.kind === 'video')
206 if (!sender) return false
207 const params = sender.getParameters()
208 const enc = params.encodings[0]
209 if (!enc) return false // no negotiated encoding yet
210 if (opts.maxBitrate === undefined) delete enc.maxBitrate
211 else enc.maxBitrate = opts.maxBitrate
212 if (opts.scaleResolutionDownBy === undefined) {
213 delete enc.scaleResolutionDownBy
214 } else {
215 enc.scaleResolutionDownBy = opts.scaleResolutionDownBy
216 }
217 if (opts.maxFramerate === undefined) delete enc.maxFramerate
218 else enc.maxFramerate = opts.maxFramerate
219 // Not in all TS dom typings, but supported by Chrome/Safari; harmless
220 // where ignored.
221 const p = params as {degradationPreference?: string}
222 if (opts.degradationPreference === undefined) delete p.degradationPreference
223 else p.degradationPreference = opts.degradationPreference
224 try {
225 await sender.setParameters(params)
226 return true
227 } catch {
228 return false
229 }
230 }
232 get isConnected(): boolean {
233 return this.pc.connectionState === 'connected'
234 }
236 destroy() {
237 if (this.closed) return
238 this.closed = true
239 this.clearDisconnectTimer()
240 try {
241 this.channel?.close()
242 } catch {
243 /* ignore */
244 }
245 try {
246 this.pc.close()
247 } catch {
248 /* ignore */
249 }
250 this.handlers.close?.()
251 }
moveopenescclose