@johnhenry/browsermesh-transport 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/webrtc.mjs ADDED
@@ -0,0 +1,773 @@
1
+ /**
2
+ // STATUS: INTEGRATED — wired into ClawserPod lifecycle, proven via E2E testing
3
+ * clawser-mesh-webrtc.js -- WebRTC mesh transport.
4
+ *
5
+ * Provides WebRTC DataChannel-based P2P connections for the BrowserMesh.
6
+ * Includes signaling helpers, connection management, and a transport
7
+ * adapter that integrates with MeshTransportNegotiator.
8
+ *
9
+ * No browser-only imports at module level.
10
+ *
11
+ * Run tests:
12
+ * node --import ./web/test/_setup-globals.mjs --test web/test/clawser-mesh-webrtc.test.mjs
13
+ */
14
+
15
+ import { MeshTransport } from './transport.mjs'
16
+ import { silentCatch } from './silent-catch.mjs'
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Feature detection
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /**
23
+ * Returns true when the current environment supports WebRTC.
24
+ * @returns {boolean}
25
+ */
26
+ export function supportsWebRTC() {
27
+ return typeof RTCPeerConnection !== 'undefined'
28
+ }
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // ICE defaults
32
+ // ---------------------------------------------------------------------------
33
+
34
+ /** @type {RTCIceServer[]} */
35
+ const DEFAULT_ICE_SERVERS = Object.freeze([
36
+ { urls: 'stun:stun.l.google.com:19302' },
37
+ ])
38
+
39
+ /**
40
+ * Merge user-configured ICE servers (typically TURN, for NAT traversal
41
+ * when direct/STUN connectivity fails) with the STUN defaults. Silently
42
+ * ignores malformed entries rather than throwing, since this is usually
43
+ * fed by user-editable settings.
44
+ *
45
+ * @param {RTCIceServer[]} [userServers] - e.g. [{urls: 'turn:relay.example.com', username, credential}]
46
+ * @param {RTCIceServer[]} [defaults=DEFAULT_ICE_SERVERS]
47
+ * @returns {RTCIceServer[]}
48
+ */
49
+ export function mergeIceServers(userServers, defaults = DEFAULT_ICE_SERVERS) {
50
+ const valid = (Array.isArray(userServers) ? userServers : [])
51
+ .filter(s => s && typeof s === 'object' && typeof s.urls === 'string' && s.urls.length > 0)
52
+ return [...defaults, ...valid]
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // WebRTCPeerConnection
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Manages a single WebRTC peer connection with a DataChannel.
61
+ *
62
+ * Lifecycle:
63
+ * 1. Caller side: createOffer() -> send offer via signaling -> handleAnswer()
64
+ * 2. Callee side: handleOffer(offer) -> send answer via signaling
65
+ * 3. Both sides: exchange ICE candidates via onIceCandidate / addIceCandidate
66
+ * 4. DataChannel opens -> state becomes 'connected'
67
+ * 5. close() tears down everything
68
+ */
69
+ export class WebRTCPeerConnection {
70
+ #localPodId
71
+ #remotePodId
72
+ #pc = null
73
+ #dataChannel = null
74
+ #iceServers
75
+ #onLog
76
+ #state = 'new' // new | connecting | connected | closed
77
+ #iceCandidateCbs = []
78
+ #messageCbs = []
79
+ #closeCbs = []
80
+ #errorCbs = []
81
+ #stateChangeCbs = []
82
+ #stats = { bytesSent: 0, bytesReceived: 0, messagesIn: 0, messagesOut: 0 }
83
+
84
+ /**
85
+ * @param {object} opts
86
+ * @param {string} opts.localPodId - This pod's identifier
87
+ * @param {string} opts.remotePodId - Target pod's identifier
88
+ * @param {RTCIceServer[]} [opts.iceServers]
89
+ * @param {Function} [opts.onLog] - Optional logging callback
90
+ */
91
+ constructor({ localPodId, remotePodId, iceServers, onLog } = {}) {
92
+ if (!localPodId) throw new Error('localPodId is required')
93
+ if (!remotePodId) throw new Error('remotePodId is required')
94
+ this.#localPodId = localPodId
95
+ this.#remotePodId = remotePodId
96
+ this.#iceServers = iceServers || [...DEFAULT_ICE_SERVERS]
97
+ this.#onLog = onLog || null
98
+ }
99
+
100
+ // -- Accessors ------------------------------------------------------------
101
+
102
+ /** Local pod identifier. */
103
+ get localPodId() { return this.#localPodId }
104
+
105
+ /** Remote pod identifier. */
106
+ get remotePodId() { return this.#remotePodId }
107
+
108
+ /** Current connection state. */
109
+ get state() { return this.#state }
110
+
111
+ /** Byte-level stats (copy). */
112
+ get stats() { return { ...this.#stats } }
113
+
114
+ /** True when the DataChannel is open and usable. */
115
+ get isOpen() {
116
+ return this.#state === 'connected' &&
117
+ this.#dataChannel?.readyState === 'open'
118
+ }
119
+
120
+ // -- Offer / Answer -------------------------------------------------------
121
+
122
+ /**
123
+ * Create an SDP offer (caller side).
124
+ * Sets up the RTCPeerConnection, creates a DataChannel, and returns
125
+ * the offer to be sent through signaling.
126
+ *
127
+ * @returns {Promise<{type: 'offer', sdp: string}>}
128
+ */
129
+ async createOffer() {
130
+ this.#ensureNotClosed()
131
+ this.#pc = new RTCPeerConnection({ iceServers: this.#iceServers })
132
+ this.#setupIceHandling()
133
+ this.#setupConnectionStateHandling()
134
+
135
+ this.#dataChannel = this.#pc.createDataChannel('mesh', {
136
+ ordered: true,
137
+ })
138
+ this.#setupDataChannel(this.#dataChannel)
139
+
140
+ const offer = await this.#pc.createOffer()
141
+ await this.#pc.setLocalDescription(offer)
142
+ this.#setState('connecting')
143
+ this.#log(`Created offer for ${this.#remotePodId}`)
144
+ return { type: 'offer', sdp: offer.sdp }
145
+ }
146
+
147
+ /**
148
+ * Handle an incoming SDP offer (callee side).
149
+ * Creates a peer connection, waits for the remote DataChannel, and
150
+ * returns an SDP answer to send back through signaling.
151
+ *
152
+ * @param {{type: string, sdp: string}} offer
153
+ * @returns {Promise<{type: 'answer', sdp: string}>}
154
+ */
155
+ async handleOffer(offer) {
156
+ this.#ensureNotClosed()
157
+ if (!offer || !offer.sdp) throw new Error('Invalid offer: missing sdp')
158
+
159
+ this.#pc = new RTCPeerConnection({ iceServers: this.#iceServers })
160
+ this.#setupIceHandling()
161
+ this.#setupConnectionStateHandling()
162
+
163
+ this.#pc.ondatachannel = (event) => {
164
+ this.#dataChannel = event.channel
165
+ this.#setupDataChannel(this.#dataChannel)
166
+ }
167
+
168
+ await this.#pc.setRemoteDescription({ type: 'offer', sdp: offer.sdp })
169
+ const answer = await this.#pc.createAnswer()
170
+ await this.#pc.setLocalDescription(answer)
171
+ this.#setState('connecting')
172
+ this.#log(`Created answer for ${this.#remotePodId}`)
173
+ return { type: 'answer', sdp: answer.sdp }
174
+ }
175
+
176
+ /**
177
+ * Apply the remote SDP answer (caller side, after receiving answer).
178
+ *
179
+ * @param {{type: string, sdp: string}} answer
180
+ */
181
+ async handleAnswer(answer) {
182
+ if (!this.#pc) throw new Error('No peer connection — call createOffer() first')
183
+ if (!answer || !answer.sdp) throw new Error('Invalid answer: missing sdp')
184
+ await this.#pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp })
185
+ this.#log(`Applied answer from ${this.#remotePodId}`)
186
+ }
187
+
188
+ // -- ICE ------------------------------------------------------------------
189
+
190
+ /**
191
+ * Add a remote ICE candidate received through signaling.
192
+ *
193
+ * @param {RTCIceCandidate|object} candidate
194
+ */
195
+ addIceCandidate(candidate) {
196
+ if (!this.#pc) throw new Error('No peer connection')
197
+ this.#pc.addIceCandidate(candidate)
198
+ }
199
+
200
+ /**
201
+ * Register callback for locally-gathered ICE candidates.
202
+ * These must be sent to the remote peer through signaling.
203
+ *
204
+ * @param {Function} cb - Called with (candidate: RTCIceCandidate)
205
+ */
206
+ onIceCandidate(cb) {
207
+ this.#iceCandidateCbs.push(cb)
208
+ }
209
+
210
+ // -- Messaging ------------------------------------------------------------
211
+
212
+ /**
213
+ * Register a callback for incoming DataChannel messages.
214
+ * JSON strings are automatically parsed.
215
+ *
216
+ * @param {Function} cb
217
+ */
218
+ onMessage(cb) { this.#messageCbs.push(cb) }
219
+
220
+ /**
221
+ * Register a callback for connection close.
222
+ *
223
+ * @param {Function} cb
224
+ */
225
+ onClose(cb) { this.#closeCbs.push(cb) }
226
+
227
+ /**
228
+ * Register a callback for connection errors.
229
+ *
230
+ * @param {Function} cb
231
+ */
232
+ onError(cb) { this.#errorCbs.push(cb) }
233
+
234
+ /**
235
+ * Register a callback for every connection state transition
236
+ * (new/connecting/connected/closed). Used by WebRTCMeshManager's
237
+ * reconnect-backoff logic to detect recovery, and by the mesh health
238
+ * dashboard to track connectivity.
239
+ *
240
+ * @param {Function} cb - Called with (state: string)
241
+ */
242
+ onStateChange(cb) { this.#stateChangeCbs.push(cb) }
243
+
244
+ /**
245
+ * Send data over the DataChannel.
246
+ * Objects are JSON-serialized automatically.
247
+ *
248
+ * @param {string|object} data
249
+ */
250
+ send(data) {
251
+ if (!this.#dataChannel) throw new Error('No data channel')
252
+ if (this.#dataChannel.readyState !== 'open') {
253
+ throw new Error('Data channel not open')
254
+ }
255
+ const str = typeof data === 'string' ? data : JSON.stringify(data)
256
+ this.#dataChannel.send(str)
257
+ this.#stats.bytesSent += str.length
258
+ this.#stats.messagesOut += 1
259
+ }
260
+
261
+ /**
262
+ * Attempt to recover a failed/disconnected connection via ICE restart.
263
+ * Only valid once an underlying RTCPeerConnection exists (i.e. after
264
+ * createOffer() or handleOffer() has run at least once) — generates a
265
+ * fresh offer with `iceRestart: true`.
266
+ *
267
+ * ICE restart still requires a full signaling round-trip: the caller
268
+ * must send the returned offer through the same external signaling
269
+ * channel used originally, and route the answer back via
270
+ * handleAnswer() as usual. This class doesn't own signaling — see
271
+ * WebRTCMeshManager.onReconnectOffer() for the orchestrated version.
272
+ *
273
+ * @returns {Promise<{type: 'offer', sdp: string}>}
274
+ * @throws {Error} If there's no underlying connection yet, or it's closed
275
+ */
276
+ async reconnect() {
277
+ this.#ensureNotClosed()
278
+ if (!this.#pc) throw new Error('Cannot reconnect: no underlying connection — call createOffer() first')
279
+ this.#setState('connecting')
280
+ const offer = await this.#pc.createOffer({ iceRestart: true })
281
+ await this.#pc.setLocalDescription(offer)
282
+ this.#log(`ICE restart offer created for ${this.#remotePodId}`)
283
+ return { type: 'offer', sdp: offer.sdp }
284
+ }
285
+
286
+ /**
287
+ * Query real-time connection health via `RTCPeerConnection.getStats()`.
288
+ * Data channels don't expose a standard `packetsLost` counter the way
289
+ * RTP media tracks do (there's no media here), so `packetLossRatio` is
290
+ * an approximation derived from the nominated candidate pair's STUN
291
+ * connectivity-check retransmission ratio — a reasonable proxy for
292
+ * path quality, not an exact application-level loss count.
293
+ *
294
+ * @returns {Promise<{remotePodId: string, state: string, bytesSent: number,
295
+ * bytesReceived: number, messagesSent: number, messagesReceived: number,
296
+ * roundTripTime: number|null, packetLossRatio: number}>}
297
+ * @throws {Error} If there's no underlying connection yet.
298
+ */
299
+ async getConnectionStats() {
300
+ if (!this.#pc) throw new Error('Cannot get stats: no peer connection — call createOffer() first')
301
+ const report = await this.#pc.getStats()
302
+ let bytesSent = 0, bytesReceived = 0, messagesSent = 0, messagesReceived = 0
303
+ let roundTripTime = null, requestsSent = 0, responsesReceived = 0
304
+ for (const stat of report.values()) {
305
+ if (stat.type === 'data-channel') {
306
+ bytesSent += stat.bytesSent || 0
307
+ bytesReceived += stat.bytesReceived || 0
308
+ messagesSent += stat.messagesSent || 0
309
+ messagesReceived += stat.messagesReceived || 0
310
+ } else if (stat.type === 'candidate-pair' && stat.nominated) {
311
+ if (typeof stat.currentRoundTripTime === 'number') roundTripTime = stat.currentRoundTripTime
312
+ requestsSent += stat.requestsSent || 0
313
+ responsesReceived += stat.responsesReceived || 0
314
+ }
315
+ }
316
+ const packetLossRatio = requestsSent > 0 ? Math.max(0, 1 - responsesReceived / requestsSent) : 0
317
+ return {
318
+ remotePodId: this.#remotePodId,
319
+ state: this.#state,
320
+ bytesSent, bytesReceived, messagesSent, messagesReceived,
321
+ roundTripTime,
322
+ packetLossRatio,
323
+ }
324
+ }
325
+
326
+ /**
327
+ * Close the connection and clean up all resources.
328
+ */
329
+ close() {
330
+ if (this.#state === 'closed') return
331
+ this.#setState('closed')
332
+ if (this.#dataChannel) {
333
+ try { this.#dataChannel.close() } catch (e) { silentCatch('clawser-mesh-webrtc', 'this', e) }
334
+ this.#dataChannel = null
335
+ }
336
+ if (this.#pc) {
337
+ try { this.#pc.close() } catch (e) { silentCatch('clawser-mesh-webrtc', 'this', e) }
338
+ this.#pc = null
339
+ }
340
+ this.#fireClose()
341
+ this.#log(`Connection closed with ${this.#remotePodId}`)
342
+ }
343
+
344
+ // -- Internal helpers -----------------------------------------------------
345
+
346
+ #ensureNotClosed() {
347
+ if (this.#state === 'closed') {
348
+ throw new Error('Connection is closed')
349
+ }
350
+ }
351
+
352
+ #setState(next) {
353
+ if (this.#state === next) return
354
+ this.#state = next
355
+ for (const cb of this.#stateChangeCbs) {
356
+ try { cb(next) } catch (e) { silentCatch('clawser-mesh-webrtc', 'swallow', e) }
357
+ }
358
+ }
359
+
360
+ #setupIceHandling() {
361
+ this.#pc.onicecandidate = (event) => {
362
+ if (event.candidate) {
363
+ for (const cb of this.#iceCandidateCbs) {
364
+ try { cb(event.candidate) } catch (e) { silentCatch('clawser-mesh-webrtc', 'swallow', e) }
365
+ }
366
+ }
367
+ }
368
+ }
369
+
370
+ #setupConnectionStateHandling() {
371
+ this.#pc.onconnectionstatechange = () => {
372
+ const pcState = this.#pc?.connectionState
373
+ if (pcState === 'failed' || pcState === 'disconnected') {
374
+ this.#fireError(new Error(`PeerConnection state: ${pcState}`))
375
+ }
376
+ }
377
+ }
378
+
379
+ #setupDataChannel(dc) {
380
+ dc.onopen = () => {
381
+ this.#setState('connected')
382
+ this.#log(`DataChannel open with ${this.#remotePodId}`)
383
+ }
384
+ dc.onmessage = (event) => {
385
+ const rawLen = event.data?.length || 0
386
+ this.#stats.bytesReceived += rawLen
387
+ this.#stats.messagesIn += 1
388
+ let parsed = event.data
389
+ try { parsed = JSON.parse(event.data) } catch { /* keep as string */ }
390
+ for (const cb of this.#messageCbs) {
391
+ try { cb(parsed) } catch (e) { silentCatch('clawser-mesh-webrtc', 'swallow', e) }
392
+ }
393
+ }
394
+ dc.onclose = () => {
395
+ if (this.#state !== 'closed') {
396
+ this.#setState('closed')
397
+ this.#fireClose()
398
+ }
399
+ }
400
+ dc.onerror = (event) => {
401
+ this.#fireError(event?.error || new Error('DataChannel error'))
402
+ if (this.#state !== 'closed') {
403
+ this.#setState('closed')
404
+ this.#fireClose()
405
+ }
406
+ }
407
+ }
408
+
409
+ #fireClose() {
410
+ for (const cb of this.#closeCbs) {
411
+ try { cb() } catch (e) { silentCatch('clawser-mesh-webrtc', 'swallow', e) }
412
+ }
413
+ }
414
+
415
+ #fireError(err) {
416
+ for (const cb of this.#errorCbs) {
417
+ try { cb(err) } catch (e) { silentCatch('clawser-mesh-webrtc', 'swallow', e) }
418
+ }
419
+ }
420
+
421
+ #log(msg) {
422
+ if (this.#onLog) this.#onLog(msg)
423
+ }
424
+ }
425
+
426
+ // ---------------------------------------------------------------------------
427
+ // WebRTCMeshManager
428
+ // ---------------------------------------------------------------------------
429
+
430
+ /**
431
+ * Manages multiple WebRTC peer connections indexed by remotePodId.
432
+ * Thin orchestration layer — signaling is left to the caller.
433
+ */
434
+ export class WebRTCMeshManager {
435
+ #localPodId
436
+ #iceServers
437
+ #connections = new Map() // remotePodId -> WebRTCPeerConnection
438
+ #onLog
439
+ #messageCbs = []
440
+ #reconnectOfferCbs = []
441
+ #reconnectAttempts = new Map() // remotePodId -> count
442
+ #reconnectTimers = new Map() // remotePodId -> timer handle
443
+ #maxReconnectAttempts
444
+ #reconnectBaseDelayMs
445
+ #lastStats = []
446
+
447
+ /**
448
+ * @param {object} opts
449
+ * @param {string} opts.localPodId
450
+ * @param {RTCIceServer[]} [opts.iceServers]
451
+ * @param {Function} [opts.onLog]
452
+ * @param {number} [opts.maxReconnectAttempts=5] - Give up auto-reconnecting after this many failures
453
+ * @param {number} [opts.reconnectBaseDelayMs=1000] - Backoff base; doubles each attempt
454
+ */
455
+ constructor({ localPodId, iceServers, onLog, maxReconnectAttempts = 5, reconnectBaseDelayMs = 1000 } = {}) {
456
+ if (!localPodId) throw new Error('localPodId is required')
457
+ this.#localPodId = localPodId
458
+ this.#iceServers = iceServers || [...DEFAULT_ICE_SERVERS]
459
+ this.#onLog = onLog || null
460
+ this.#maxReconnectAttempts = maxReconnectAttempts
461
+ this.#reconnectBaseDelayMs = reconnectBaseDelayMs
462
+ }
463
+
464
+ /** Local pod identifier. */
465
+ get localPodId() { return this.#localPodId }
466
+
467
+ /** Number of tracked connections. */
468
+ get connectionCount() { return this.#connections.size }
469
+
470
+ /**
471
+ * Register a global message listener that fires for all connections.
472
+ *
473
+ * @param {Function} cb - Called with (data, remotePodId)
474
+ */
475
+ onMessage(cb) { this.#messageCbs.push(cb) }
476
+
477
+ /**
478
+ * Register a callback fired with a fresh ICE-restart offer whenever the
479
+ * manager auto-retries a failed connection. The caller must forward
480
+ * this offer through the same external signaling channel used for the
481
+ * original connection.
482
+ *
483
+ * @param {Function} cb - Called with (offer: {type, sdp}, remotePodId: string)
484
+ */
485
+ onReconnectOffer(cb) { this.#reconnectOfferCbs.push(cb) }
486
+
487
+ /**
488
+ * Create or return an existing WebRTCPeerConnection for a remote pod.
489
+ * Returns the same instance on duplicate calls with the same remotePodId.
490
+ *
491
+ * @param {string} remotePodId
492
+ * @returns {Promise<WebRTCPeerConnection>}
493
+ */
494
+ async connectToPeer(remotePodId) {
495
+ if (this.#connections.has(remotePodId)) {
496
+ return this.#connections.get(remotePodId)
497
+ }
498
+ const conn = new WebRTCPeerConnection({
499
+ localPodId: this.#localPodId,
500
+ remotePodId,
501
+ iceServers: this.#iceServers,
502
+ onLog: this.#onLog,
503
+ })
504
+ // Forward messages to manager-level listeners
505
+ conn.onMessage((data) => {
506
+ for (const cb of this.#messageCbs) {
507
+ try { cb(data, remotePodId) } catch (e) { silentCatch('clawser-mesh-webrtc', 'swallow', e) }
508
+ }
509
+ })
510
+ // Auto-remove on close
511
+ conn.onClose(() => {
512
+ this.#connections.delete(remotePodId)
513
+ this.#clearReconnectState(remotePodId)
514
+ })
515
+ // Reset backoff once the connection actually recovers
516
+ conn.onStateChange((state) => {
517
+ if (state === 'connected') this.#clearReconnectState(remotePodId)
518
+ })
519
+ // Auto-retry with exponential backoff on failure/disconnect
520
+ conn.onError(() => this.#scheduleReconnect(remotePodId, conn))
521
+ this.#connections.set(remotePodId, conn)
522
+ return conn
523
+ }
524
+
525
+ /**
526
+ * Manually trigger reconnection for a peer (bypasses backoff).
527
+ * @param {string} remotePodId
528
+ * @returns {Promise<{type: 'offer', sdp: string}|null>} null if no connection exists
529
+ */
530
+ async reconnectPeer(remotePodId) {
531
+ const conn = this.#connections.get(remotePodId)
532
+ if (!conn) return null
533
+ const offer = await conn.reconnect()
534
+ this.#notifyReconnectOffer(offer, remotePodId)
535
+ return offer
536
+ }
537
+
538
+ #clearReconnectState(remotePodId) {
539
+ this.#reconnectAttempts.delete(remotePodId)
540
+ const timer = this.#reconnectTimers.get(remotePodId)
541
+ if (timer) {
542
+ clearTimeout(timer)
543
+ this.#reconnectTimers.delete(remotePodId)
544
+ }
545
+ }
546
+
547
+ #notifyReconnectOffer(offer, remotePodId) {
548
+ for (const cb of this.#reconnectOfferCbs) {
549
+ try { cb(offer, remotePodId) } catch (e) { silentCatch('clawser-mesh-webrtc', 'swallow', e) }
550
+ }
551
+ }
552
+
553
+ #scheduleReconnect(remotePodId, conn) {
554
+ if (this.#reconnectTimers.has(remotePodId)) return // already scheduled
555
+ const attempts = this.#reconnectAttempts.get(remotePodId) || 0
556
+ if (attempts >= this.#maxReconnectAttempts) {
557
+ if (this.#onLog) this.#onLog(`Giving up reconnecting to ${remotePodId} after ${attempts} attempts`)
558
+ return
559
+ }
560
+ const delay = this.#reconnectBaseDelayMs * (2 ** attempts)
561
+ this.#reconnectAttempts.set(remotePodId, attempts + 1)
562
+ const timer = setTimeout(async () => {
563
+ this.#reconnectTimers.delete(remotePodId)
564
+ if (!this.#connections.has(remotePodId)) return // closed/removed meanwhile
565
+ try {
566
+ const offer = await conn.reconnect()
567
+ this.#notifyReconnectOffer(offer, remotePodId)
568
+ } catch (e) { silentCatch('clawser-mesh-webrtc', 'reconnect-attempt', e) }
569
+ }, delay)
570
+ this.#reconnectTimers.set(remotePodId, timer)
571
+ }
572
+
573
+ /**
574
+ * Get an existing connection by remotePodId.
575
+ *
576
+ * @param {string} remotePodId
577
+ * @returns {WebRTCPeerConnection|null}
578
+ */
579
+ getConnection(remotePodId) {
580
+ return this.#connections.get(remotePodId) || null
581
+ }
582
+
583
+ /**
584
+ * Check whether a connection to remotePodId exists.
585
+ *
586
+ * @param {string} remotePodId
587
+ * @returns {boolean}
588
+ */
589
+ hasConnection(remotePodId) {
590
+ return this.#connections.has(remotePodId)
591
+ }
592
+
593
+ /**
594
+ * List all tracked connections with their current state.
595
+ *
596
+ * @returns {Array<{remotePodId: string, state: string}>}
597
+ */
598
+ listConnections() {
599
+ return [...this.#connections.entries()].map(([remotePodId, conn]) => ({
600
+ remotePodId,
601
+ state: conn.state,
602
+ }))
603
+ }
604
+
605
+ /**
606
+ * Query `getConnectionStats()` on every tracked connection. A single
607
+ * connection's stats query failing (e.g. mid-teardown) doesn't abort
608
+ * the rest — its entry carries `error` instead. Result is cached on
609
+ * `lastStats` for synchronous readers (e.g. MeshInspector.snapshot(),
610
+ * which can't await this method).
611
+ *
612
+ * @returns {Promise<Array<object>>}
613
+ */
614
+ async getAllConnectionStats() {
615
+ const results = []
616
+ for (const [remotePodId, conn] of this.#connections.entries()) {
617
+ try {
618
+ results.push(await conn.getConnectionStats())
619
+ } catch (err) {
620
+ results.push({ remotePodId, state: conn.state, error: err?.message || String(err) })
621
+ }
622
+ }
623
+ this.#lastStats = results
624
+ return results
625
+ }
626
+
627
+ /**
628
+ * The result of the most recent `getAllConnectionStats()` call, read
629
+ * synchronously. Empty until the first call.
630
+ * @returns {Array<object>}
631
+ */
632
+ get lastStats() { return this.#lastStats }
633
+
634
+ /**
635
+ * Broadcast data to all connected peers.
636
+ *
637
+ * @param {string|object} data
638
+ * @returns {number} Number of peers the message was sent to
639
+ */
640
+ broadcast(data) {
641
+ let sent = 0
642
+ for (const conn of this.#connections.values()) {
643
+ if (conn.isOpen) {
644
+ try {
645
+ conn.send(data)
646
+ sent++
647
+ } catch { /* skip failed sends */ }
648
+ }
649
+ }
650
+ return sent
651
+ }
652
+
653
+ /**
654
+ * Close a specific peer connection.
655
+ *
656
+ * @param {string} remotePodId
657
+ * @returns {boolean} True if a connection was found and closed
658
+ */
659
+ closePeer(remotePodId) {
660
+ const conn = this.#connections.get(remotePodId)
661
+ if (!conn) return false
662
+ conn.close()
663
+ this.#connections.delete(remotePodId)
664
+ return true
665
+ }
666
+
667
+ /**
668
+ * Close all peer connections and clear internal state.
669
+ */
670
+ closeAll() {
671
+ for (const conn of this.#connections.values()) {
672
+ try { conn.close() } catch (e) { silentCatch('clawser-mesh-webrtc', 'conn.close', e) }
673
+ }
674
+ this.#connections.clear()
675
+ }
676
+ }
677
+
678
+ // ---------------------------------------------------------------------------
679
+ // WebRTCTransportAdapter
680
+ // ---------------------------------------------------------------------------
681
+
682
+ /**
683
+ * Wraps a WebRTCPeerConnection as a MeshTransport for use with
684
+ * MeshTransportNegotiator. The connection negotiation (offer/answer/ICE)
685
+ * happens externally; this adapter handles the send/close lifecycle.
686
+ */
687
+ export class WebRTCTransportAdapter extends MeshTransport {
688
+ #connection
689
+
690
+ /**
691
+ * @param {WebRTCPeerConnection} connection
692
+ */
693
+ constructor(connection) {
694
+ super('webrtc')
695
+ if (!connection) throw new Error('connection is required')
696
+ this.#connection = connection
697
+
698
+ // Forward messages from the underlying connection
699
+ this.#connection.onMessage((data) => {
700
+ this._fire('message', data)
701
+ })
702
+ this.#connection.onClose(() => {
703
+ if (this.state !== 'closed') {
704
+ this._setState('closed')
705
+ }
706
+ })
707
+ this.#connection.onError((err) => {
708
+ this._fire('error', err)
709
+ })
710
+ }
711
+
712
+ /**
713
+ * Mark transport as connected.
714
+ * The actual WebRTC negotiation (offer/answer) happens outside this adapter.
715
+ */
716
+ async connect() {
717
+ this._setState('connecting')
718
+ this._setState('connected')
719
+ }
720
+
721
+ /**
722
+ * Send data through the underlying WebRTC DataChannel.
723
+ *
724
+ * @param {string|object} data
725
+ */
726
+ send(data) {
727
+ this.#connection.send(data)
728
+ }
729
+
730
+ /**
731
+ * Close the underlying WebRTC connection.
732
+ */
733
+ close() {
734
+ this.#connection.close()
735
+ super.close()
736
+ }
737
+
738
+ /** The underlying WebRTCPeerConnection. */
739
+ get peerConnection() { return this.#connection }
740
+ }
741
+
742
+ // ---------------------------------------------------------------------------
743
+ // WebRTCAdapterFactory
744
+ // ---------------------------------------------------------------------------
745
+
746
+ /**
747
+ * Factory for creating WebRTC transports.
748
+ * Plugs into MeshTransportNegotiator.registerAdapter().
749
+ */
750
+ export class WebRTCAdapterFactory {
751
+ /**
752
+ * Returns true for transport type 'webrtc'.
753
+ *
754
+ * @param {string} type
755
+ * @returns {boolean}
756
+ */
757
+ canCreate(type) { return type === 'webrtc' }
758
+
759
+ /**
760
+ * Create a WebRTCTransportAdapter wrapping an existing connection.
761
+ *
762
+ * @param {string} remotePodId
763
+ * @param {object} opts
764
+ * @param {WebRTCPeerConnection} opts.connection - Pre-negotiated connection
765
+ * @returns {WebRTCTransportAdapter}
766
+ */
767
+ create(remotePodId, opts) {
768
+ if (!opts || !opts.connection) {
769
+ throw new Error('WebRTCAdapterFactory requires opts.connection')
770
+ }
771
+ return new WebRTCTransportAdapter(opts.connection)
772
+ }
773
+ }