/ concept-collection / hitandrun-commonview
Sign in
concept-collection / hitandrun-commonview
hitandrun-commonview / src / p2p / peer.ts
211 lines · 6.2 KBBlameHistoryRaw
1// A thin WebRTC wrapper, distilled from trystero's peer.ts. We only need a
2// reliable ordered data channel plus offer/answer/ICE signaling. To keep things
3// simple we avoid "perfect negotiation" glare handling by ensuring only ONE
4// side (a deterministically chosen initiator) ever creates the offer.
5//
6// Unlike commonview's original, the channel carries two kinds of frames:
7// strings (JSON control messages) and ArrayBuffers (chunks of large binary
8// payloads, e.g. sample sets). The wrapper keeps them apart and applies
9// backpressure when streaming binary data.
11export type Signal =
12 | {type: 'offer'; sdp: string}
13 | {type: 'answer'; sdp: string}
14 | {type: 'candidate'; candidate: RTCIceCandidateInit}
16export interface PeerHandlers {
17 signal: (signal: Signal) => void
18 connect: () => void
19 data: (data: string) => void
20 binary: (data: ArrayBuffer) => void
21 close: () => void
24export const ICE_SERVERS: RTCIceServer[] = [
25 {urls: 'stun:stun.l.google.com:19302'},
26 {urls: 'stun:stun1.l.google.com:19302'},
27 {urls: 'stun:stun.cloudflare.com:3478'},
28 // Free TURN relay (openrelayproject) — needed when direct/STUN pairing
29 // fails (symmetric NAT, hairpinning, host-candidate blocking).
30 {
31 urls: [
32 'turn:openrelay.metered.ca:80',
33 'turn:openrelay.metered.ca:443',
34 'turns:openrelay.metered.ca:443'
35 ],
36 username: 'openrelayproject',
37 credential: 'openrelayproject'
38 }
41// Keep binary frames well under the ~256 KB cross-browser SCTP message limit.
42export const BINARY_CHUNK_BYTES = 64 * 1024
44// While streaming a large payload, pause whenever this much is queued in the
45// channel and resume once it drains below the low-water mark.
46const HIGH_WATER = 1 << 20 // 1 MiB
47const LOW_WATER = 1 << 18 // 256 KiB
49export class Peer {
50 private pc: RTCPeerConnection
51 private channel: RTCDataChannel | null = null
52 private handlers: Partial<PeerHandlers> = {}
53 private pendingCandidates: RTCIceCandidateInit[] = []
54 private closed = false
56 constructor(private initiator: boolean) {
57 this.pc = new RTCPeerConnection({iceServers: ICE_SERVERS})
59 this.pc.onicecandidate = ({candidate}) => {
60 if (candidate) {
61 this.handlers.signal?.({type: 'candidate', candidate: candidate.toJSON()})
62 }
63 }
65 this.pc.onconnectionstatechange = () => {
66 const s = this.pc.connectionState
67 if (s === 'failed' || s === 'closed' || s === 'disconnected') {
68 this.destroy()
69 }
70 }
72 if (initiator) {
73 this.setupChannel(this.pc.createDataChannel('data'))
74 this.pc.onnegotiationneeded = () => void this.makeOffer()
75 } else {
76 this.pc.ondatachannel = ({channel}) => this.setupChannel(channel)
77 }
78 }
80 setHandlers(handlers: Partial<PeerHandlers>) {
81 Object.assign(this.handlers, handlers)
82 }
84 private setupChannel(channel: RTCDataChannel) {
85 this.channel = channel
86 channel.binaryType = 'arraybuffer'
87 channel.bufferedAmountLowThreshold = LOW_WATER
88 channel.onopen = () => this.handlers.connect?.()
89 channel.onclose = () => this.destroy()
90 channel.onmessage = e => {
91 if (typeof e.data === 'string') this.handlers.data?.(e.data)
92 else this.handlers.binary?.(e.data as ArrayBuffer)
93 }
94 }
96 private async makeOffer() {
97 if (this.closed) return
98 try {
99 await this.pc.setLocalDescription(await this.pc.createOffer())
100 this.handlers.signal?.({
101 type: 'offer',
102 sdp: this.pc.localDescription!.sdp
103 })
104 } catch {
105 /* ignore */
106 }
107 }
109 async signal(signal: Signal) {
110 if (this.closed) return
111 try {
112 if (signal.type === 'candidate') {
113 if (this.pc.remoteDescription) {
114 await this.pc.addIceCandidate(signal.candidate)
115 } else {
116 this.pendingCandidates.push(signal.candidate)
117 }
118 return
119 }
121 if (signal.type === 'offer') {
122 if (this.initiator) return // initiators never accept remote offers
123 await this.pc.setRemoteDescription({type: 'offer', sdp: signal.sdp})
124 await this.flushCandidates()
125 await this.pc.setLocalDescription(await this.pc.createAnswer())
126 this.handlers.signal?.({
127 type: 'answer',
128 sdp: this.pc.localDescription!.sdp
129 })
130 return
131 }
133 if (signal.type === 'answer') {
134 await this.pc.setRemoteDescription({type: 'answer', sdp: signal.sdp})
135 await this.flushCandidates()
136 }
137 } catch {
138 /* ignore transient signaling errors */
139 }
140 }
142 private async flushCandidates() {
143 const queued = this.pendingCandidates.splice(0)
144 for (const c of queued) {
145 try {
146 await this.pc.addIceCandidate(c)
147 } catch {
148 /* ignore */
149 }
150 }
151 }
153 send(data: string) {
154 if (this.channel?.readyState === 'open') this.channel.send(data)
155 }
157 /** Stream a large binary payload as sequential chunks, respecting channel
158 * backpressure. Resolves when everything is handed to the channel; resolves
159 * false if the channel closed part-way. */
160 async sendBinary(payload: ArrayBuffer): Promise<boolean> {
161 for (let off = 0; off < payload.byteLength; off += BINARY_CHUNK_BYTES) {
162 const ch = this.channel
163 if (!ch || ch.readyState !== 'open') return false
164 if (ch.bufferedAmount > HIGH_WATER) {
165 const ok = await this.drain(ch)
166 if (!ok) return false
167 }
168 try {
169 ch.send(payload.slice(off, off + BINARY_CHUNK_BYTES))
170 } catch {
171 return false
172 }
173 }
174 return true
175 }
177 private drain(ch: RTCDataChannel): Promise<boolean> {
178 return new Promise(resolve => {
179 const done = (ok: boolean) => {
180 ch.removeEventListener('bufferedamountlow', onLow)
181 ch.removeEventListener('close', onClose)
182 resolve(ok)
183 }
184 const onLow = () => done(true)
185 const onClose = () => done(false)
186 ch.addEventListener('bufferedamountlow', onLow)
187 ch.addEventListener('close', onClose)
188 if (ch.readyState !== 'open') done(false)
189 })
190 }
192 get isConnected(): boolean {
193 return this.channel?.readyState === 'open'
194 }
196 destroy() {
197 if (this.closed) return
198 this.closed = true
199 try {
200 this.channel?.close()
201 } catch {
202 /* ignore */
203 }
204 try {
205 this.pc.close()
206 } catch {
207 /* ignore */
208 }
209 this.handlers.close?.()
210 }
moveopenescclose