4a0be26Initial commit: P2P counter app (nostr discovery + WebRTC mesh)Jeremy Magland 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.
6export type Signal =
7 | {type: 'offer'; sdp: string}
8 | {type: 'answer'; sdp: string}
9 | {type: 'candidate'; candidate: RTCIceCandidateInit}
11export interface PeerHandlers {
12 signal: (signal: Signal) => void
13 connect: () => void
14 data: (data: string) => void
15 close: () => void
16}
18const ICE_SERVERS: RTCIceServer[] = [
19 {urls: 'stun:stun.l.google.com:19302'},
20 {urls: 'stun:stun1.l.google.com:19302'},
21 {urls: 'stun:stun.cloudflare.com:3478'}
22]
24export class Peer {
25 private pc: RTCPeerConnection
26 private channel: RTCDataChannel | null = null
27 private handlers: Partial<PeerHandlers> = {}
28 private pendingCandidates: RTCIceCandidateInit[] = []
29 private closed = false
31 constructor(private initiator: boolean) {
32 this.pc = new RTCPeerConnection({iceServers: ICE_SERVERS})
34 this.pc.onicecandidate = ({candidate}) => {
35 if (candidate) {
36 this.handlers.signal?.({type: 'candidate', candidate: candidate.toJSON()})
37 }
38 }
40 this.pc.onconnectionstatechange = () => {
41 const s = this.pc.connectionState
42 if (s === 'failed' || s === 'closed' || s === 'disconnected') {
43 this.destroy()
44 }
45 }
47 if (initiator) {
48 this.setupChannel(this.pc.createDataChannel('data'))
49 this.pc.onnegotiationneeded = () => void this.makeOffer()
50 } else {
51 this.pc.ondatachannel = ({channel}) => this.setupChannel(channel)
52 }
53 }
55 setHandlers(handlers: Partial<PeerHandlers>) {
56 Object.assign(this.handlers, handlers)
57 }
59 private setupChannel(channel: RTCDataChannel) {
60 this.channel = channel
61 channel.onopen = () => this.handlers.connect?.()
62 channel.onclose = () => this.destroy()
63 channel.onmessage = e => this.handlers.data?.(e.data as string)
64 }
66 private async makeOffer() {
67 if (this.closed) return
68 try {
69 await this.pc.setLocalDescription(await this.pc.createOffer())
70 this.handlers.signal?.({
71 type: 'offer',
72 sdp: this.pc.localDescription!.sdp
73 })
74 } catch {
75 /* ignore */
76 }
77 }
79 async signal(signal: Signal) {
80 if (this.closed) return
81 try {
82 if (signal.type === 'candidate') {
83 if (this.pc.remoteDescription) {
84 await this.pc.addIceCandidate(signal.candidate)
85 } else {
86 this.pendingCandidates.push(signal.candidate)
87 }
88 return
89 }
91 if (signal.type === 'offer') {
92 if (this.initiator) return // initiators never accept remote offers
93 await this.pc.setRemoteDescription({type: 'offer', sdp: signal.sdp})
94 await this.flushCandidates()
95 await this.pc.setLocalDescription(await this.pc.createAnswer())
96 this.handlers.signal?.({
97 type: 'answer',
98 sdp: this.pc.localDescription!.sdp
99 })
100 return
101 }
103 if (signal.type === 'answer') {
104 await this.pc.setRemoteDescription({type: 'answer', sdp: signal.sdp})
105 await this.flushCandidates()
106 }
107 } catch {
108 /* ignore transient signaling errors */
109 }
110 }
112 private async flushCandidates() {
113 const queued = this.pendingCandidates.splice(0)
114 for (const c of queued) {
115 try {
116 await this.pc.addIceCandidate(c)
117 } catch {
118 /* ignore */
119 }
120 }
121 }
123 send(data: string) {
124 if (this.channel?.readyState === 'open') this.channel.send(data)
125 }
127 get isConnected(): boolean {
128 return this.channel?.readyState === 'open'
129 }
131 destroy() {
132 if (this.closed) return
133 this.closed = true
134 try {
135 this.channel?.close()
136 } catch {
137 /* ignore */
138 }
139 try {
140 this.pc.close()
141 } catch {
142 /* ignore */
143 }
144 this.handlers.close?.()
145 }
146}