@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/LICENSE +21 -0
- package/README.md +40 -0
- package/package.json +31 -0
- package/src/channel-relay.mjs +225 -0
- package/src/cross-origin.mjs +543 -0
- package/src/gateway.mjs +627 -0
- package/src/index.mjs +12 -0
- package/src/relay.mjs +653 -0
- package/src/silent-catch.mjs +55 -0
- package/src/streams.mjs +627 -0
- package/src/transport.mjs +357 -0
- package/src/webrtc.mjs +773 -0
- package/src/websocket.mjs +1082 -0
- package/src/webtransport.mjs +216 -0
- package/src/wisp-client.mjs +747 -0
- package/src/wisp.mjs +348 -0
- package/src/wsh-bridge.mjs +242 -0
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
|
+
}
|