/ concept-collection / commoncall
Sign in
concept-collection / commoncall
commoncall / src / p2p / peer.ts
194 lines · 5.6 KBBlameHistoryRaw
1// A thin WebRTC wrapper, distilled from commonview's peer.ts. Instead of a
2// data-only connection it carries the local audio/video tracks plus one small
3// control data channel (hang-up, mute notices). As in commonview we avoid
4// "perfect negotiation" glare handling by ensuring only ONE side (a
5// 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}
12export interface PeerHandlers {
13 signal: (signal: Signal) => void
14 /** Connection reached the 'connected' state. */
15 connect: () => void
16 /** Remote media stream became available. */
17 track: (stream: MediaStream) => void
18 /** A string message arrived on the control channel. */
19 data: (data: string) => void
20 close: () => void
23export const ICE_SERVERS: RTCIceServer[] = [
24 {urls: 'stun:stun.l.google.com:19302'},
25 {urls: 'stun:stun1.l.google.com:19302'},
26 {urls: 'stun:stun.cloudflare.com:3478'},
27 // Free TURN relay (openrelayproject) — needed when direct/STUN pairing
28 // fails (symmetric NAT, hairpinning, host-candidate blocking).
29 {
30 urls: [
31 'turn:openrelay.metered.ca:80',
32 'turn:openrelay.metered.ca:443',
33 'turns:openrelay.metered.ca:443'
34 ],
35 username: 'openrelayproject',
36 credential: 'openrelayproject'
37 }
40// A media call can survive a brief network blip: 'disconnected' often recovers
41// on its own, so only tear down if it persists this long.
42const DISCONNECT_GRACE_MS = 5000
44export class Peer {
45 private pc: RTCPeerConnection
46 private channel: RTCDataChannel | null = null
47 private handlers: Partial<PeerHandlers> = {}
48 private pendingCandidates: RTCIceCandidateInit[] = []
49 private disconnectTimer: number | null = null
50 private closed = false
52 constructor(private initiator: boolean, localStream: MediaStream) {
53 this.pc = new RTCPeerConnection({iceServers: ICE_SERVERS})
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 for (const track of localStream.getTracks()) {
58 this.pc.addTrack(track, localStream)
59 }
61 this.pc.ontrack = ({streams}) => {
62 if (streams[0]) this.handlers.track?.(streams[0])
63 }
65 this.pc.onicecandidate = ({candidate}) => {
66 if (candidate) {
67 this.handlers.signal?.({type: 'candidate', candidate: candidate.toJSON()})
68 }
69 }
71 this.pc.onconnectionstatechange = () => {
72 const s = this.pc.connectionState
73 if (s === 'connected') {
74 this.clearDisconnectTimer()
75 this.handlers.connect?.()
76 } else if (s === 'failed' || s === 'closed') {
77 this.destroy()
78 } else if (s === 'disconnected') {
79 this.clearDisconnectTimer()
80 this.disconnectTimer = window.setTimeout(() => {
81 if (this.pc.connectionState !== 'connected') this.destroy()
82 }, DISCONNECT_GRACE_MS)
83 }
84 }
86 if (initiator) {
87 this.setupChannel(this.pc.createDataChannel('control'))
88 this.pc.onnegotiationneeded = () => void this.makeOffer()
89 } else {
90 this.pc.ondatachannel = ({channel}) => this.setupChannel(channel)
91 }
92 }
94 setHandlers(handlers: Partial<PeerHandlers>) {
95 Object.assign(this.handlers, handlers)
96 }
98 private clearDisconnectTimer() {
99 if (this.disconnectTimer !== null) {
100 clearTimeout(this.disconnectTimer)
101 this.disconnectTimer = null
102 }
103 }
105 private setupChannel(channel: RTCDataChannel) {
106 this.channel = channel
107 channel.onclose = () => this.destroy()
108 channel.onmessage = e => {
109 if (typeof e.data === 'string') this.handlers.data?.(e.data)
110 }
111 }
113 private async makeOffer() {
114 if (this.closed) return
115 try {
116 await this.pc.setLocalDescription(await this.pc.createOffer())
117 this.handlers.signal?.({
118 type: 'offer',
119 sdp: this.pc.localDescription!.sdp
120 })
121 } catch {
122 /* ignore */
123 }
124 }
126 async signal(signal: Signal) {
127 if (this.closed) return
128 try {
129 if (signal.type === 'candidate') {
130 if (this.pc.remoteDescription) {
131 await this.pc.addIceCandidate(signal.candidate)
132 } else {
133 this.pendingCandidates.push(signal.candidate)
134 }
135 return
136 }
138 if (signal.type === 'offer') {
139 if (this.initiator) return // initiators never accept remote offers
140 await this.pc.setRemoteDescription({type: 'offer', sdp: signal.sdp})
141 await this.flushCandidates()
142 await this.pc.setLocalDescription(await this.pc.createAnswer())
143 this.handlers.signal?.({
144 type: 'answer',
145 sdp: this.pc.localDescription!.sdp
146 })
147 return
148 }
150 if (signal.type === 'answer') {
151 await this.pc.setRemoteDescription({type: 'answer', sdp: signal.sdp})
152 await this.flushCandidates()
153 }
154 } catch {
155 /* ignore transient signaling errors */
156 }
157 }
159 private async flushCandidates() {
160 const queued = this.pendingCandidates.splice(0)
161 for (const c of queued) {
162 try {
163 await this.pc.addIceCandidate(c)
164 } catch {
165 /* ignore */
166 }
167 }
168 }
170 send(data: string) {
171 if (this.channel?.readyState === 'open') this.channel.send(data)
172 }
174 get isConnected(): boolean {
175 return this.pc.connectionState === 'connected'
176 }
178 destroy() {
179 if (this.closed) return
180 this.closed = true
181 this.clearDisconnectTimer()
182 try {
183 this.channel?.close()
184 } catch {
185 /* ignore */
186 }
187 try {
188 this.pc.close()
189 } catch {
190 /* ignore */
191 }
192 this.handlers.close?.()
193 }
moveopenescclose