@johnhenry/browsermesh-transport 0.0.0 → 0.1.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/package.json +6 -2
- package/src/cross-origin.mjs +25 -2
- package/src/relay.mjs +22 -5
- package/src/webrtc.mjs +509 -41
- package/src/websocket.mjs +17 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@johnhenry/browsermesh-transport",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "WebSocket, WebRTC, WebTransport, and relay adapters for BrowserMesh",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
@@ -21,8 +21,12 @@
|
|
|
21
21
|
"peerDependencies": {
|
|
22
22
|
"@johnhenry/browsermesh-primitives": ">=0.0.0"
|
|
23
23
|
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"node-datachannel": "^0.33.2"
|
|
26
|
+
},
|
|
24
27
|
"scripts": {
|
|
25
|
-
"test": "node --import ./test/_setup-globals.mjs --test test/*.test.mjs"
|
|
28
|
+
"test": "node --import ./test/_setup-globals.mjs --test test/*.test.mjs",
|
|
29
|
+
"test:real-peer": "node --import ./test/_setup-globals.mjs --test --test-concurrency=1 test/real-peer/*.test.mjs"
|
|
26
30
|
},
|
|
27
31
|
"homepage": "https://opensource.johnhenry.me/browsermesh/",
|
|
28
32
|
"engines": {
|
package/src/cross-origin.mjs
CHANGED
|
@@ -344,6 +344,29 @@ export class CrossOriginBridge {
|
|
|
344
344
|
const fromPeerId = data.fromPodId
|
|
345
345
|
const peer = fromPeerId ? this.#peers.get(fromPeerId) : null
|
|
346
346
|
|
|
347
|
+
/*
|
|
348
|
+
* An unknown sender is REFUSED, not exempted.
|
|
349
|
+
*
|
|
350
|
+
* Every check below used to be written `if (peer && ...)`, and `peer` is
|
|
351
|
+
* null whenever `fromPodId` is absent or names a pod that was never
|
|
352
|
+
* registered. So omitting the field skipped origin validation, the
|
|
353
|
+
* ISOLATED block and the method allowlist in one go, and execution
|
|
354
|
+
* carried on into the handler. Identifying yourself as an ISOLATED peer
|
|
355
|
+
* was blocked; identifying yourself as nobody was not, which made the
|
|
356
|
+
* anonymous caller the privileged one.
|
|
357
|
+
*
|
|
358
|
+
* The reply made it worse: `#sendResponse` fell back to
|
|
359
|
+
* `postMessage(msg, '*')` with no peer, so the result went to any origin.
|
|
360
|
+
*
|
|
361
|
+
* Scoped to XO_REQUEST: a RESPONSE legitimately carries no `fromPodId` --
|
|
362
|
+
* it is correlated by `requestId` against a request this side sent -- so
|
|
363
|
+
* refusing peerless messages outright breaks every reply.
|
|
364
|
+
*/
|
|
365
|
+
if (!peer && data.type === XO_REQUEST) {
|
|
366
|
+
this.#log(`Blocked request from unregistered sender: ${fromPeerId ?? '(no fromPodId)'}`)
|
|
367
|
+
return
|
|
368
|
+
}
|
|
369
|
+
|
|
347
370
|
// Origin validation -- reject if the event origin doesn't match the registered origin
|
|
348
371
|
if (peer && event.origin && event.origin !== peer.origin) {
|
|
349
372
|
this.#log(`Origin mismatch for ${fromPeerId}: expected ${peer.origin}, got ${event.origin}`)
|
|
@@ -372,13 +395,13 @@ export class CrossOriginBridge {
|
|
|
372
395
|
const { requestId, method, params, fromPodId } = data
|
|
373
396
|
|
|
374
397
|
// ISOLATED peers cannot invoke anything
|
|
375
|
-
if (peer
|
|
398
|
+
if (peer.trust === TRUST_LEVELS.ISOLATED) {
|
|
376
399
|
this.#log(`Blocked request from isolated peer ${fromPodId}`)
|
|
377
400
|
return
|
|
378
401
|
}
|
|
379
402
|
|
|
380
403
|
// For VERIFIED peers, enforce the method allowlist
|
|
381
|
-
if (peer
|
|
404
|
+
if (peer.trust === TRUST_LEVELS.VERIFIED && peer.allowedMethods.size > 0) {
|
|
382
405
|
if (!peer.allowedMethods.has(method)) {
|
|
383
406
|
this.#sendResponse(source, peer, requestId, null, `Method "${method}" not allowed`)
|
|
384
407
|
return
|
package/src/relay.mjs
CHANGED
|
@@ -347,6 +347,13 @@ export class MeshRelayClient {
|
|
|
347
347
|
const onOpen = () => {
|
|
348
348
|
// Send register immediately on open. Server may or may not ack.
|
|
349
349
|
this.#sendWs({ type: 'register', fingerprint: this.#fingerprint });
|
|
350
|
+
// Re-announce what we advertised before the socket dropped. A relay
|
|
351
|
+
// keeps presence per connection, so a reconnect starts with an empty
|
|
352
|
+
// record for us -- while this client goes on reporting connected:true
|
|
353
|
+
// and the old capabilities, leaving the peer undiscoverable with
|
|
354
|
+
// nothing on either side saying so. Empty on a first connect, so the
|
|
355
|
+
// initial handshake is unchanged.
|
|
356
|
+
if (this._announcedCapabilities.length > 0) this.#sendAnnounceWs();
|
|
350
357
|
if (!settled) { settled = true; resolve(); }
|
|
351
358
|
};
|
|
352
359
|
const onMessage = (ev) => this.#handleWsMessage(ev);
|
|
@@ -456,14 +463,24 @@ export class MeshRelayClient {
|
|
|
456
463
|
if (this.#server) {
|
|
457
464
|
this.#server.broadcastPresence(this.#fingerprint, capabilities);
|
|
458
465
|
} else if (this.#ws) {
|
|
459
|
-
this.#
|
|
460
|
-
type: 'announce',
|
|
461
|
-
fingerprint: this.#fingerprint,
|
|
462
|
-
capabilities: [...capabilities],
|
|
463
|
-
});
|
|
466
|
+
this.#sendAnnounceWs();
|
|
464
467
|
}
|
|
465
468
|
}
|
|
466
469
|
|
|
470
|
+
/**
|
|
471
|
+
* Send the announce message for the capabilities currently on record.
|
|
472
|
+
* Always sends -- announcePresence([]) is a deliberate "I advertise
|
|
473
|
+
* nothing" and must reach the relay. The reconnect path guards on empty
|
|
474
|
+
* itself, so a first connect stays a bare register.
|
|
475
|
+
*/
|
|
476
|
+
#sendAnnounceWs() {
|
|
477
|
+
this.#sendWs({
|
|
478
|
+
type: 'announce',
|
|
479
|
+
fingerprint: this.#fingerprint,
|
|
480
|
+
capabilities: [...this._announcedCapabilities],
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
|
|
467
484
|
/**
|
|
468
485
|
* Query the relay for peers matching criteria.
|
|
469
486
|
*
|
package/src/webrtc.mjs
CHANGED
|
@@ -27,31 +27,89 @@ export function supportsWebRTC() {
|
|
|
27
27
|
return typeof RTCPeerConnection !== 'undefined'
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Extract the session id from an SDP's `o=` (origin) line.
|
|
32
|
+
*
|
|
33
|
+
* RFC 4566 gives the origin line as
|
|
34
|
+
* `o=<username> <sess-id> <sess-version> <nettype> <addrtype> <address>`,
|
|
35
|
+
* and RFC 3264 requires a follow-up offer for an existing session to reuse
|
|
36
|
+
* that `<sess-id>`. So two offers carrying the same session id came from the
|
|
37
|
+
* same peer connection, and an offer whose session id differs from the one
|
|
38
|
+
* already applied is a *new* session -- the peer restarted.
|
|
39
|
+
*
|
|
40
|
+
* Used only as a fallback when the signaling payload lacks the explicit
|
|
41
|
+
* `renegotiation` flag (see `handleOffer`), so that a peer running an older
|
|
42
|
+
* build of this class can still be renegotiated with.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} sdp
|
|
45
|
+
* @returns {string|null} The session id, or null if the SDP has no origin line.
|
|
46
|
+
*/
|
|
47
|
+
export function sdpSessionId(sdp) {
|
|
48
|
+
const m = /^o=\S+ (\S+) /m.exec(sdp || '')
|
|
49
|
+
return m ? m[1] : null
|
|
50
|
+
}
|
|
51
|
+
|
|
30
52
|
// ---------------------------------------------------------------------------
|
|
31
53
|
// ICE defaults
|
|
32
54
|
// ---------------------------------------------------------------------------
|
|
33
55
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
56
|
+
/**
|
|
57
|
+
* Default ICE configuration: none.
|
|
58
|
+
*
|
|
59
|
+
* With no ICE servers a peer gathers host candidates only, which is all two
|
|
60
|
+
* devices on the same LAN need and involves no third party. Contacting a
|
|
61
|
+
* public STUN server discloses the client's public IP, and the fact that it
|
|
62
|
+
* is pairing, to whoever runs it — so that is opt-in, not the default. See
|
|
63
|
+
* PUBLIC_STUN_SERVERS to opt in, or supply your own STUN/TURN.
|
|
64
|
+
*
|
|
65
|
+
* @type {RTCIceServer[]}
|
|
66
|
+
*/
|
|
67
|
+
export const DEFAULT_ICE_SERVERS = Object.freeze([])
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A public STUN server, for callers who need reflexive candidates to traverse
|
|
71
|
+
* NAT and have decided that the disclosure is acceptable.
|
|
72
|
+
*
|
|
73
|
+
* Using this sends a STUN binding request to Google from every peer that
|
|
74
|
+
* gathers candidates, revealing the device's public IP address and the timing
|
|
75
|
+
* of the pairing attempt. Prefer a STUN server you control.
|
|
76
|
+
*
|
|
77
|
+
* @type {RTCIceServer[]}
|
|
78
|
+
*/
|
|
79
|
+
export const PUBLIC_STUN_SERVERS = Object.freeze([
|
|
36
80
|
{ urls: 'stun:stun.l.google.com:19302' },
|
|
37
81
|
])
|
|
38
82
|
|
|
39
83
|
/**
|
|
40
84
|
* Merge user-configured ICE servers (typically TURN, for NAT traversal
|
|
41
|
-
* when direct/STUN connectivity fails) with the
|
|
85
|
+
* when direct/STUN connectivity fails) with the defaults. Silently
|
|
42
86
|
* ignores malformed entries rather than throwing, since this is usually
|
|
43
87
|
* fed by user-editable settings.
|
|
44
88
|
*
|
|
89
|
+
* An explicit empty array means "no ICE servers" and is honoured as given:
|
|
90
|
+
* `mergeIceServers([])` returns `[]`, never the defaults. Omitting the
|
|
91
|
+
* argument (or passing null) is what asks for the defaults.
|
|
92
|
+
*
|
|
45
93
|
* @param {RTCIceServer[]} [userServers] - e.g. [{urls: 'turn:relay.example.com', username, credential}]
|
|
46
94
|
* @param {RTCIceServer[]} [defaults=DEFAULT_ICE_SERVERS]
|
|
47
95
|
* @returns {RTCIceServer[]}
|
|
48
96
|
*/
|
|
49
97
|
export function mergeIceServers(userServers, defaults = DEFAULT_ICE_SERVERS) {
|
|
98
|
+
// An explicit [] is a decision, not an absence. Respect it.
|
|
99
|
+
if (Array.isArray(userServers) && userServers.length === 0) return []
|
|
50
100
|
const valid = (Array.isArray(userServers) ? userServers : [])
|
|
51
101
|
.filter(s => s && typeof s === 'object' && typeof s.urls === 'string' && s.urls.length > 0)
|
|
52
102
|
return [...defaults, ...valid]
|
|
53
103
|
}
|
|
54
104
|
|
|
105
|
+
/**
|
|
106
|
+
* How many remote ICE candidates to hold while waiting for the remote
|
|
107
|
+
* description. A handful of interfaces gather a handful of candidates each;
|
|
108
|
+
* generous for that, and still bounded against a peer that floods candidates
|
|
109
|
+
* and never answers.
|
|
110
|
+
*/
|
|
111
|
+
const MAX_PENDING_REMOTE_CANDIDATES = 64
|
|
112
|
+
|
|
55
113
|
// ---------------------------------------------------------------------------
|
|
56
114
|
// WebRTCPeerConnection
|
|
57
115
|
// ---------------------------------------------------------------------------
|
|
@@ -73,13 +131,23 @@ export class WebRTCPeerConnection {
|
|
|
73
131
|
#dataChannel = null
|
|
74
132
|
#iceServers
|
|
75
133
|
#onLog
|
|
76
|
-
#state = 'new' // new | connecting | connected | closed
|
|
134
|
+
#state = 'new' // new | connecting | connected | failed | closed
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Remote candidates that arrived before the remote description existed.
|
|
138
|
+
* Flushed by #flushPendingCandidates() once it is set.
|
|
139
|
+
* @type {Array<RTCIceCandidate|object>}
|
|
140
|
+
*/
|
|
141
|
+
#pendingRemoteCandidates = []
|
|
142
|
+
#closing = false // reentrancy guard for close(); see close()
|
|
77
143
|
#iceCandidateCbs = []
|
|
78
144
|
#messageCbs = []
|
|
79
145
|
#closeCbs = []
|
|
80
146
|
#errorCbs = []
|
|
81
147
|
#stateChangeCbs = []
|
|
82
148
|
#stats = { bytesSent: 0, bytesReceived: 0, messagesIn: 0, messagesOut: 0 }
|
|
149
|
+
#disconnectedGraceMs
|
|
150
|
+
#disconnectedTimer = null
|
|
83
151
|
|
|
84
152
|
/**
|
|
85
153
|
* @param {object} opts
|
|
@@ -87,14 +155,19 @@ export class WebRTCPeerConnection {
|
|
|
87
155
|
* @param {string} opts.remotePodId - Target pod's identifier
|
|
88
156
|
* @param {RTCIceServer[]} [opts.iceServers]
|
|
89
157
|
* @param {Function} [opts.onLog] - Optional logging callback
|
|
158
|
+
* @param {number} [opts.disconnectedGraceMs=5000] - How long a peer connection
|
|
159
|
+
* may sit in the transient `disconnected` state before it is reported
|
|
160
|
+
* through `onError()`. `failed` is reported immediately and is not
|
|
161
|
+
* subject to this delay.
|
|
90
162
|
*/
|
|
91
|
-
constructor({ localPodId, remotePodId, iceServers, onLog } = {}) {
|
|
163
|
+
constructor({ localPodId, remotePodId, iceServers, onLog, disconnectedGraceMs = 5000 } = {}) {
|
|
92
164
|
if (!localPodId) throw new Error('localPodId is required')
|
|
93
165
|
if (!remotePodId) throw new Error('remotePodId is required')
|
|
94
166
|
this.#localPodId = localPodId
|
|
95
167
|
this.#remotePodId = remotePodId
|
|
96
168
|
this.#iceServers = iceServers || [...DEFAULT_ICE_SERVERS]
|
|
97
169
|
this.#onLog = onLog || null
|
|
170
|
+
this.#disconnectedGraceMs = disconnectedGraceMs
|
|
98
171
|
}
|
|
99
172
|
|
|
100
173
|
// -- Accessors ------------------------------------------------------------
|
|
@@ -145,17 +218,65 @@ export class WebRTCPeerConnection {
|
|
|
145
218
|
}
|
|
146
219
|
|
|
147
220
|
/**
|
|
148
|
-
* Handle an incoming SDP offer (callee side)
|
|
149
|
-
*
|
|
150
|
-
*
|
|
221
|
+
* Handle an incoming SDP offer (callee side) and return the answer to send
|
|
222
|
+
* back through signaling.
|
|
223
|
+
*
|
|
224
|
+
* Two different things arrive here, and telling them apart is the whole
|
|
225
|
+
* point of the `renegotiation` flag:
|
|
151
226
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
227
|
+
* - A **fresh** offer starts a new session. A new RTCPeerConnection is
|
|
228
|
+
* built for it, replacing (and releasing) any connection we already had
|
|
229
|
+
* with this peer -- which is the right response to a peer that restarted.
|
|
230
|
+
*
|
|
231
|
+
* - A **renegotiation** offer -- an ICE restart, or any other mid-session
|
|
232
|
+
* re-offer -- must be applied to the *existing* RTCPeerConnection. It has
|
|
233
|
+
* to be: the answer carries this side's ICE credentials and DTLS
|
|
234
|
+
* fingerprint, and a renegotiation whose answer changes either of those
|
|
235
|
+
* is rejected by the offerer. Building a new connection here is what
|
|
236
|
+
* issue #13 measured, and it killed both peers:
|
|
237
|
+
* `libdatachannel error while adding remote description: Invalid ICE
|
|
238
|
+
* settings from remote SDP`.
|
|
239
|
+
*
|
|
240
|
+
* `offer.renegotiation === true` is the marker on the wire. It is a plain
|
|
241
|
+
* extra field on the same JSON payload the offer already is, so a peer that
|
|
242
|
+
* does not understand it simply ignores it and behaves exactly as it does
|
|
243
|
+
* today. For a sender too old to set it, `#isRenegotiation()` falls back to
|
|
244
|
+
* comparing SDP session ids, which recovers the same answer without any
|
|
245
|
+
* cooperation from the sender.
|
|
246
|
+
*
|
|
247
|
+
* @param {{type: string, sdp: string, renegotiation?: boolean}} offer
|
|
248
|
+
* @returns {Promise<{type: 'answer', sdp: string, renegotiation?: boolean}>}
|
|
249
|
+
* @throws {Error} If the offer is a renegotiation but there is no live
|
|
250
|
+
* connection to renegotiate -- the sender must start a fresh one.
|
|
154
251
|
*/
|
|
155
252
|
async handleOffer(offer) {
|
|
156
253
|
this.#ensureNotClosed()
|
|
157
254
|
if (!offer || !offer.sdp) throw new Error('Invalid offer: missing sdp')
|
|
158
255
|
|
|
256
|
+
if (this.#isRenegotiation(offer)) {
|
|
257
|
+
if (!this.#pc) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
'Cannot answer a renegotiation offer: no existing connection — the peer must send a fresh offer',
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
// Deliberately no #setState() and no new handlers: a renegotiation
|
|
263
|
+
// keeps the DataChannel, the callbacks and the transport state it
|
|
264
|
+
// already has. Data keeps flowing over the old path until the new one
|
|
265
|
+
// is nominated -- that is what makes an ICE restart a repair rather
|
|
266
|
+
// than a reconnection.
|
|
267
|
+
await this.#pc.setRemoteDescription({ type: 'offer', sdp: offer.sdp })
|
|
268
|
+
await this.#flushPendingCandidates()
|
|
269
|
+
const answer = await this.#pc.createAnswer()
|
|
270
|
+
await this.#pc.setLocalDescription(answer)
|
|
271
|
+
this.#log(`Created renegotiation answer for ${this.#remotePodId}`)
|
|
272
|
+
return { type: 'answer', sdp: answer.sdp, renegotiation: true }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// A fresh offer supersedes anything we were holding. Release it rather
|
|
276
|
+
// than overwriting the field: the old RTCPeerConnection is otherwise
|
|
277
|
+
// unreachable and, with a native stack, keeps threads and sockets alive.
|
|
278
|
+
this.#releasePeerConnection()
|
|
279
|
+
|
|
159
280
|
this.#pc = new RTCPeerConnection({ iceServers: this.#iceServers })
|
|
160
281
|
this.#setupIceHandling()
|
|
161
282
|
this.#setupConnectionStateHandling()
|
|
@@ -166,6 +287,7 @@ export class WebRTCPeerConnection {
|
|
|
166
287
|
}
|
|
167
288
|
|
|
168
289
|
await this.#pc.setRemoteDescription({ type: 'offer', sdp: offer.sdp })
|
|
290
|
+
await this.#flushPendingCandidates()
|
|
169
291
|
const answer = await this.#pc.createAnswer()
|
|
170
292
|
await this.#pc.setLocalDescription(answer)
|
|
171
293
|
this.#setState('connecting')
|
|
@@ -182,6 +304,7 @@ export class WebRTCPeerConnection {
|
|
|
182
304
|
if (!this.#pc) throw new Error('No peer connection — call createOffer() first')
|
|
183
305
|
if (!answer || !answer.sdp) throw new Error('Invalid answer: missing sdp')
|
|
184
306
|
await this.#pc.setRemoteDescription({ type: 'answer', sdp: answer.sdp })
|
|
307
|
+
await this.#flushPendingCandidates()
|
|
185
308
|
this.#log(`Applied answer from ${this.#remotePodId}`)
|
|
186
309
|
}
|
|
187
310
|
|
|
@@ -190,11 +313,71 @@ export class WebRTCPeerConnection {
|
|
|
190
313
|
/**
|
|
191
314
|
* Add a remote ICE candidate received through signaling.
|
|
192
315
|
*
|
|
316
|
+
* `RTCPeerConnection.addIceCandidate()` is asynchronous and rejects on a
|
|
317
|
+
* malformed candidate, on a candidate for an m-line that does not exist,
|
|
318
|
+
* and on any candidate that arrives before the remote description is set.
|
|
319
|
+
* All three are ordinary events on a real signaling channel -- candidates
|
|
320
|
+
* race the answer, and the string on the wire came from another machine --
|
|
321
|
+
* and this method used to drop that promise on the floor. An unhandled
|
|
322
|
+
* rejection terminates a Node process by default and raises an uncaught
|
|
323
|
+
* error event in a browser, so one bad candidate from a peer could take the
|
|
324
|
+
* process down. Verified against two real RTCPeerConnections; see
|
|
325
|
+
* `test/real-peer/webrtc.test.mjs`.
|
|
326
|
+
*
|
|
327
|
+
* A rejected candidate is not a connection failure: ICE is designed to try
|
|
328
|
+
* many candidates and keep the ones that work. It is therefore logged and
|
|
329
|
+
* swallowed rather than routed to `onError()`, which WebRTCMeshManager wires
|
|
330
|
+
* to its reconnect backoff -- a peer sending junk candidates should not be
|
|
331
|
+
* able to force ICE restarts.
|
|
332
|
+
*
|
|
193
333
|
* @param {RTCIceCandidate|object} candidate
|
|
334
|
+
* @returns {Promise<boolean>} Resolves true if the candidate was accepted,
|
|
335
|
+
* false if it was rejected. Never rejects.
|
|
336
|
+
* @throws {Error} Synchronously, if there is no peer connection yet.
|
|
194
337
|
*/
|
|
195
338
|
addIceCandidate(candidate) {
|
|
196
339
|
if (!this.#pc) throw new Error('No peer connection')
|
|
197
|
-
|
|
340
|
+
|
|
341
|
+
// "Candidates race the answer" is stated above as an ordinary event, and
|
|
342
|
+
// it was then handled by discarding them. That is not a test artifact: a
|
|
343
|
+
// peer gathers in 1-2ms while an answer crosses a signaling server, so on
|
|
344
|
+
// any real network the candidates arrive FIRST and every one was thrown
|
|
345
|
+
// away. What survives is peer-reflexive discovery from the other side's
|
|
346
|
+
// binding requests -- one inferred pair instead of every signalled one,
|
|
347
|
+
// with no redundancy if it fails.
|
|
348
|
+
//
|
|
349
|
+
// Measured on two real peers: with the answer delayed, 0 of 5 of the
|
|
350
|
+
// remote's candidates were accepted and the selected pair was `prflx`;
|
|
351
|
+
// with no delay, 5 of 5 were accepted and the pair was `host`.
|
|
352
|
+
//
|
|
353
|
+
// So hold them and apply them when the description lands, which is the
|
|
354
|
+
// standard trickle-ICE pattern.
|
|
355
|
+
// Not `!remoteDescription`. The spec says it is null until set, but
|
|
356
|
+
// node-datachannel's polyfill returns a truthy `{ sdp: '' }`, so that
|
|
357
|
+
// test silently never fires there -- measured, after writing it that way
|
|
358
|
+
// first. Checking the sdp works on both.
|
|
359
|
+
const remote = this.#pc.remoteDescription
|
|
360
|
+
if (!remote || !remote.sdp) {
|
|
361
|
+
if (this.#pendingRemoteCandidates.length >= MAX_PENDING_REMOTE_CANDIDATES) {
|
|
362
|
+
// Bounded: a peer that floods candidates before answering must not be
|
|
363
|
+
// able to grow this without limit.
|
|
364
|
+
this.#log(
|
|
365
|
+
`Dropped an early ICE candidate from ${this.#remotePodId}: ` +
|
|
366
|
+
`already holding ${MAX_PENDING_REMOTE_CANDIDATES}`,
|
|
367
|
+
)
|
|
368
|
+
return Promise.resolve(false)
|
|
369
|
+
}
|
|
370
|
+
this.#pendingRemoteCandidates.push(candidate)
|
|
371
|
+
return Promise.resolve(true)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return Promise.resolve(this.#pc.addIceCandidate(candidate)).then(
|
|
375
|
+
() => true,
|
|
376
|
+
(err) => {
|
|
377
|
+
this.#log(`Ignored ICE candidate from ${this.#remotePodId}: ${err?.message || err}`)
|
|
378
|
+
return false
|
|
379
|
+
},
|
|
380
|
+
)
|
|
198
381
|
}
|
|
199
382
|
|
|
200
383
|
/**
|
|
@@ -262,25 +445,72 @@ export class WebRTCPeerConnection {
|
|
|
262
445
|
* Attempt to recover a failed/disconnected connection via ICE restart.
|
|
263
446
|
* Only valid once an underlying RTCPeerConnection exists (i.e. after
|
|
264
447
|
* createOffer() or handleOffer() has run at least once) — generates a
|
|
265
|
-
*
|
|
448
|
+
* renegotiation offer on the *existing* connection with `iceRestart: true`.
|
|
266
449
|
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
450
|
+
* **A healthy connection is left alone.** This used to flip the state to
|
|
451
|
+
* `connecting` as its first act, so calling it on a working connection made
|
|
452
|
+
* `isOpen` false — and `broadcast()` skips a peer that is not open — before
|
|
453
|
+
* anything had gone wrong. It now returns `null` and touches nothing.
|
|
454
|
+
* `{ force: true }` overrides that for a caller who knows something the
|
|
455
|
+
* connection state does not (a changed network interface, say). The state
|
|
456
|
+
* is never moved from here at all: a renegotiation does not interrupt the
|
|
457
|
+
* DataChannel, and the state machine is driven by the channel's own events.
|
|
272
458
|
*
|
|
273
|
-
*
|
|
459
|
+
* It also declines while a negotiation is already in flight
|
|
460
|
+
* (`signalingState !== 'stable'`), so a retry loop cannot stack offers on
|
|
461
|
+
* top of each other.
|
|
462
|
+
*
|
|
463
|
+
* Known limitation: if the answer to a renegotiation offer never comes
|
|
464
|
+
* back, the connection stays in `have-local-offer` and every later
|
|
465
|
+
* `reconnect()` declines. Recovering from that needs a rollback
|
|
466
|
+
* (`setLocalDescription({type: 'rollback'})`), which libdatachannel does
|
|
467
|
+
* not implement, so it is not attempted here. In practice the peer
|
|
468
|
+
* connection reaches `failed` on its own and the connection is torn down;
|
|
469
|
+
* the visible symptom until then is a peer that stops being repaired.
|
|
470
|
+
*
|
|
471
|
+
* ICE restart still requires a full signaling round-trip: the caller must
|
|
472
|
+
* send the returned offer through the same external signaling channel used
|
|
473
|
+
* originally, and route the answer back via handleAnswer() as usual. The
|
|
474
|
+
* offer carries `renegotiation: true` so the receiving peer answers on its
|
|
475
|
+
* existing connection instead of building a new one — without that marker
|
|
476
|
+
* the answer comes back with a different DTLS fingerprint and is rejected.
|
|
477
|
+
* This class doesn't own signaling — see WebRTCMeshManager.onReconnectOffer()
|
|
478
|
+
* for the orchestrated version.
|
|
479
|
+
*
|
|
480
|
+
* Whether the ICE credentials actually change is up to the underlying
|
|
481
|
+
* stack. Browsers mint new ones for `iceRestart: true`; libdatachannel
|
|
482
|
+
* currently ignores the flag and `restartIce()` throws `Not implemented`
|
|
483
|
+
* there. The renegotiation round-trip below is correct either way — on a
|
|
484
|
+
* stack without ICE restart it re-runs connectivity checks over the same
|
|
485
|
+
* credentials, which is strictly better than the connection teardown it
|
|
486
|
+
* replaces.
|
|
487
|
+
*
|
|
488
|
+
* @param {object} [opts]
|
|
489
|
+
* @param {boolean} [opts.force=false] - Renegotiate even if the connection
|
|
490
|
+
* currently looks healthy.
|
|
491
|
+
* @returns {Promise<{type: 'offer', sdp: string, renegotiation: true}|null>}
|
|
492
|
+
* null when there is nothing to repair, or a negotiation is already
|
|
493
|
+
* in flight.
|
|
274
494
|
* @throws {Error} If there's no underlying connection yet, or it's closed
|
|
275
495
|
*/
|
|
276
|
-
async reconnect() {
|
|
496
|
+
async reconnect({ force = false } = {}) {
|
|
277
497
|
this.#ensureNotClosed()
|
|
278
498
|
if (!this.#pc) throw new Error('Cannot reconnect: no underlying connection — call createOffer() first')
|
|
279
|
-
|
|
499
|
+
|
|
500
|
+
const signalingState = this.#pc.signalingState
|
|
501
|
+
if (signalingState !== undefined && signalingState !== 'stable') {
|
|
502
|
+
this.#log(`ICE restart for ${this.#remotePodId} skipped: negotiation already in flight (${signalingState})`)
|
|
503
|
+
return null
|
|
504
|
+
}
|
|
505
|
+
if (!force && this.#isHealthy()) {
|
|
506
|
+
this.#log(`ICE restart for ${this.#remotePodId} skipped: connection is healthy`)
|
|
507
|
+
return null
|
|
508
|
+
}
|
|
509
|
+
|
|
280
510
|
const offer = await this.#pc.createOffer({ iceRestart: true })
|
|
281
511
|
await this.#pc.setLocalDescription(offer)
|
|
282
512
|
this.#log(`ICE restart offer created for ${this.#remotePodId}`)
|
|
283
|
-
return { type: 'offer', sdp: offer.sdp }
|
|
513
|
+
return { type: 'offer', sdp: offer.sdp, renegotiation: true }
|
|
284
514
|
}
|
|
285
515
|
|
|
286
516
|
/**
|
|
@@ -327,16 +557,47 @@ export class WebRTCPeerConnection {
|
|
|
327
557
|
* Close the connection and clean up all resources.
|
|
328
558
|
*/
|
|
329
559
|
close() {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
560
|
+
const alreadyClosed = this.#state === 'closed'
|
|
561
|
+
|
|
562
|
+
// Release the underlying objects unconditionally, even when the state is
|
|
563
|
+
// already 'closed'. It used to return early on that check, which meant a
|
|
564
|
+
// connection closed *by the remote peer* -- where the DataChannel's
|
|
565
|
+
// onclose set the state before anyone released anything -- kept its
|
|
566
|
+
// RTCPeerConnection forever. That is one leaked peer connection per
|
|
567
|
+
// remote disconnect, and with a real WebRTC stack it also keeps the
|
|
568
|
+
// process alive: a Node test that connected two real peers and let one
|
|
569
|
+
// hang up would never exit. Verified against two real
|
|
570
|
+
// RTCPeerConnections; see test/real-peer/webrtc.test.mjs.
|
|
571
|
+
// Releasing the DataChannel fires its onclose, and onclose routes back
|
|
572
|
+
// into close() -- that is how a remote hangup releases our peer
|
|
573
|
+
// connection. Re-entering here before #setState('closed') has run means
|
|
574
|
+
// the guard in onclose still sees a live state, so close() calls itself
|
|
575
|
+
// until the stack runs out. The RangeError is then swallowed by the
|
|
576
|
+
// release try/catch below, so the symptom is not a crash but a *missing*
|
|
577
|
+
// 'closed' transition, appearing only on whichever runs exhaust the stack
|
|
578
|
+
// inside #setState's callback loop. Measured at 2227 frames deep.
|
|
579
|
+
//
|
|
580
|
+
// Browsers dispatch onclose asynchronously, which is why this hides in a
|
|
581
|
+
// real browser and surfaces against a synchronous mock or binding.
|
|
582
|
+
if (this.#closing) return
|
|
583
|
+
this.#closing = true
|
|
584
|
+
try {
|
|
585
|
+
if (this.#dataChannel) {
|
|
586
|
+
try { this.#dataChannel.close() } catch (e) { silentCatch('clawser-mesh-webrtc', 'this', e) }
|
|
587
|
+
this.#dataChannel = null
|
|
588
|
+
}
|
|
589
|
+
if (this.#pc) {
|
|
590
|
+
try { this.#pc.close() } catch (e) { silentCatch('clawser-mesh-webrtc', 'this', e) }
|
|
591
|
+
this.#pc = null
|
|
592
|
+
}
|
|
593
|
+
} finally {
|
|
594
|
+
this.#closing = false
|
|
339
595
|
}
|
|
596
|
+
|
|
597
|
+
// The state transition and the close callbacks fire exactly once, so a
|
|
598
|
+
// second close() -- or a close() following a remote hangup -- is silent.
|
|
599
|
+
if (alreadyClosed) return
|
|
600
|
+
this.#setState('closed')
|
|
340
601
|
this.#fireClose()
|
|
341
602
|
this.#log(`Connection closed with ${this.#remotePodId}`)
|
|
342
603
|
}
|
|
@@ -349,8 +610,86 @@ export class WebRTCPeerConnection {
|
|
|
349
610
|
}
|
|
350
611
|
}
|
|
351
612
|
|
|
613
|
+
/**
|
|
614
|
+
* Is this offer a re-offer on the connection we already have?
|
|
615
|
+
*
|
|
616
|
+
* The explicit flag is authoritative in both directions, so a sender can
|
|
617
|
+
* also assert `renegotiation: false`. Absent the flag, fall back to the
|
|
618
|
+
* SDP session id: RFC 3264 requires a re-offer to reuse the `o=` session id
|
|
619
|
+
* of the session it updates, so an offer matching the description already
|
|
620
|
+
* applied is a re-offer and one that differs is a new session. That
|
|
621
|
+
* fallback is what makes a peer running an older build -- one that cannot
|
|
622
|
+
* set the flag -- renegotiable rather than fatal.
|
|
623
|
+
*/
|
|
624
|
+
#isRenegotiation(offer) {
|
|
625
|
+
if (offer.renegotiation === true) return true
|
|
626
|
+
if (offer.renegotiation === false) return false
|
|
627
|
+
if (!this.#pc) return false
|
|
628
|
+
const applied = this.#pc.remoteDescription?.sdp
|
|
629
|
+
if (!applied) return false
|
|
630
|
+
const incoming = sdpSessionId(offer.sdp)
|
|
631
|
+
return incoming !== null && incoming === sdpSessionId(applied)
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* A connection is healthy when our DataChannel is open *and* the peer
|
|
636
|
+
* connection itself agrees. The second half matters: a peer connection can
|
|
637
|
+
* report `failed` while the DataChannel has not yet noticed, and treating
|
|
638
|
+
* that as healthy would refuse the very repair it needs. Stacks that do not
|
|
639
|
+
* expose `connectionState` fall back to the DataChannel alone.
|
|
640
|
+
*/
|
|
641
|
+
#isHealthy() {
|
|
642
|
+
if (!this.isOpen) return false
|
|
643
|
+
const pcState = this.#pc?.connectionState
|
|
644
|
+
return pcState === undefined || pcState === 'connected'
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Drop the underlying RTCPeerConnection and DataChannel without announcing
|
|
649
|
+
* a close. Handlers are detached first: closing a DataChannel fires its
|
|
650
|
+
* `onclose`, which would otherwise report this connection as closed to
|
|
651
|
+
* every listener -- including WebRTCMeshManager, which would delete it --
|
|
652
|
+
* even though we are only swapping the transport underneath.
|
|
653
|
+
*/
|
|
654
|
+
#releasePeerConnection() {
|
|
655
|
+
const dc = this.#dataChannel
|
|
656
|
+
const pc = this.#pc
|
|
657
|
+
this.#dataChannel = null
|
|
658
|
+
this.#pc = null
|
|
659
|
+
if (dc) {
|
|
660
|
+
dc.onopen = null; dc.onmessage = null; dc.onclose = null; dc.onerror = null
|
|
661
|
+
try { dc.close() } catch (e) { silentCatch('clawser-mesh-webrtc', 'release-dc', e) }
|
|
662
|
+
}
|
|
663
|
+
if (pc) {
|
|
664
|
+
pc.onicecandidate = null; pc.ondatachannel = null; pc.onconnectionstatechange = null
|
|
665
|
+
try { pc.close() } catch (e) { silentCatch('clawser-mesh-webrtc', 'release-pc', e) }
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Apply the candidates held while there was no remote description.
|
|
671
|
+
*
|
|
672
|
+
* Failures are logged and dropped exactly as on the live path -- ICE tries
|
|
673
|
+
* many candidates and keeps the ones that work.
|
|
674
|
+
*/
|
|
675
|
+
async #flushPendingCandidates() {
|
|
676
|
+
if (this.#pendingRemoteCandidates.length === 0) return
|
|
677
|
+
const held = this.#pendingRemoteCandidates
|
|
678
|
+
this.#pendingRemoteCandidates = []
|
|
679
|
+
this.#log(`Applying ${held.length} ICE candidate(s) held for ${this.#remotePodId}`)
|
|
680
|
+
for (const candidate of held) {
|
|
681
|
+
if (!this.#pc) return
|
|
682
|
+
try {
|
|
683
|
+
await this.#pc.addIceCandidate(candidate)
|
|
684
|
+
} catch (err) {
|
|
685
|
+
this.#log(`Ignored held ICE candidate from ${this.#remotePodId}: ${err?.message || err}`)
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
352
690
|
#setState(next) {
|
|
353
691
|
if (this.#state === next) return
|
|
692
|
+
if (next === 'closed') this.#clearDisconnectedGrace()
|
|
354
693
|
this.#state = next
|
|
355
694
|
for (const cb of this.#stateChangeCbs) {
|
|
356
695
|
try { cb(next) } catch (e) { silentCatch('clawser-mesh-webrtc', 'swallow', e) }
|
|
@@ -367,15 +706,123 @@ export class WebRTCPeerConnection {
|
|
|
367
706
|
}
|
|
368
707
|
}
|
|
369
708
|
|
|
709
|
+
/**
|
|
710
|
+
* `failed` and `disconnected` are not the same event and must not be
|
|
711
|
+
* reported the same way.
|
|
712
|
+
*
|
|
713
|
+
* `failed` is terminal: ICE has exhausted its candidate pairs. Report it at
|
|
714
|
+
* once.
|
|
715
|
+
*
|
|
716
|
+
* `disconnected` means connectivity checks are currently failing. It is
|
|
717
|
+
* transient by design and very often recovers on its own within a couple of
|
|
718
|
+
* seconds -- a Wi-Fi roam, a dropped consent check. This used to be
|
|
719
|
+
* reported as an error immediately, and WebRTCMeshManager wires `onError`
|
|
720
|
+
* straight to its reconnect backoff, so an ordinary network blip triggered
|
|
721
|
+
* a renegotiation on a connection that was in the middle of healing. It is
|
|
722
|
+
* now reported only if it is *still* disconnected after
|
|
723
|
+
* `disconnectedGraceMs`; any other state in the meantime cancels it.
|
|
724
|
+
*/
|
|
370
725
|
#setupConnectionStateHandling() {
|
|
371
726
|
this.#pc.onconnectionstatechange = () => {
|
|
372
727
|
const pcState = this.#pc?.connectionState
|
|
373
|
-
if (pcState === 'failed'
|
|
374
|
-
this.#
|
|
728
|
+
if (pcState === 'failed') {
|
|
729
|
+
this.#clearDisconnectedGrace()
|
|
730
|
+
/*
|
|
731
|
+
* `failed` is terminal, and it is the ONLY terminal signal a browser
|
|
732
|
+
* gives for a handshake that dies remotely.
|
|
733
|
+
*
|
|
734
|
+
* Measured against native WebRTC (two RTCPeerConnections, corrupted
|
|
735
|
+
* DTLS fingerprint in the answer):
|
|
736
|
+
*
|
|
737
|
+
* t=51ms connectionState = failed
|
|
738
|
+
* ...20s later, connectionState = failed
|
|
739
|
+
*
|
|
740
|
+
* `closed` never arrives, and the spec agrees: connectionState
|
|
741
|
+
* `closed` means the local object was closed, i.e. someone called
|
|
742
|
+
* close(). So the `closed` branch below cannot fire in a browser for
|
|
743
|
+
* this case -- libdatachannel merely happens to send `closed` as
|
|
744
|
+
* well most of the time, which is why the Node tests mostly pass and
|
|
745
|
+
* flake at ~9%.
|
|
746
|
+
*
|
|
747
|
+
* The state therefore has to move here. It does NOT close: closing
|
|
748
|
+
* on `failed` was tried and broke auto-reconnect, and ICE restart can
|
|
749
|
+
* still recover this connection (the DataChannel reopening sets
|
|
750
|
+
* 'connected' again). What changes is that `state` stops claiming
|
|
751
|
+
* 'connecting' and `isOpen` is false, so a caller polling either one
|
|
752
|
+
* learns the truth instead of waiting forever.
|
|
753
|
+
*
|
|
754
|
+
* State before the error, deliberately: WebRTCMeshManager reconnects
|
|
755
|
+
* from onError, and a handler that inspects state must not see
|
|
756
|
+
* 'connecting' on a connection that has terminally failed.
|
|
757
|
+
*/
|
|
758
|
+
if (this.#state !== 'closed') this.#setState('failed')
|
|
759
|
+
this.#fireError(new Error('PeerConnection state: failed'))
|
|
760
|
+
return
|
|
761
|
+
}
|
|
762
|
+
if (pcState === 'disconnected') {
|
|
763
|
+
this.#startDisconnectedGrace()
|
|
764
|
+
return
|
|
375
765
|
}
|
|
766
|
+
/*
|
|
767
|
+
* `closed` arriving here means the STACK closed the connection, not us:
|
|
768
|
+
* our own close() nulls `#pc` first, so this handler cannot see it.
|
|
769
|
+
*
|
|
770
|
+
* It was falling through to the line below and doing nothing, which
|
|
771
|
+
* left a dead connection looking alive. Measured by corrupting one hex
|
|
772
|
+
* pair of the DTLS fingerprint in the answer -- an answer that cannot
|
|
773
|
+
* verify, which is what a corrupted or hostile one looks like:
|
|
774
|
+
*
|
|
775
|
+
* t= 0ms pc0[ice=checking conn=connecting]
|
|
776
|
+
* t= 52ms pc0[ice=closed conn=closed] <- stack gave up
|
|
777
|
+
* t=12013ms state=connecting error=none <- we never said so
|
|
778
|
+
*
|
|
779
|
+
* Twelve seconds after both peer connections had closed themselves,
|
|
780
|
+
* `state` was still `connecting`, no error had fired, and no close
|
|
781
|
+
* callback had run. A caller waiting on `isOpen` waits forever, and
|
|
782
|
+
* WebRTCMeshManager -- which reconnects on `onError` -- never hears
|
|
783
|
+
* anything to reconnect from.
|
|
784
|
+
*
|
|
785
|
+
* The DataChannel's own close path cannot cover this: the channel never
|
|
786
|
+
* opened, so `dc.onclose` never fires.
|
|
787
|
+
*
|
|
788
|
+
* Reported as an error and then closed, which is the order `dc.onerror`
|
|
789
|
+
* already uses, so a caller listening only for errors still learns the
|
|
790
|
+
* cause before the terminal close.
|
|
791
|
+
*/
|
|
792
|
+
if (pcState === 'closed' && this.#state !== 'closed') {
|
|
793
|
+
this.#clearDisconnectedGrace()
|
|
794
|
+
this.#fireError(new Error('PeerConnection closed before the DataChannel opened'))
|
|
795
|
+
this.close()
|
|
796
|
+
return
|
|
797
|
+
}
|
|
798
|
+
this.#clearDisconnectedGrace()
|
|
376
799
|
}
|
|
377
800
|
}
|
|
378
801
|
|
|
802
|
+
#startDisconnectedGrace() {
|
|
803
|
+
if (this.#disconnectedTimer) return
|
|
804
|
+
this.#log(
|
|
805
|
+
`PeerConnection with ${this.#remotePodId} is disconnected; ` +
|
|
806
|
+
`allowing ${this.#disconnectedGraceMs}ms to recover`,
|
|
807
|
+
)
|
|
808
|
+
this.#disconnectedTimer = setTimeout(() => {
|
|
809
|
+
this.#disconnectedTimer = null
|
|
810
|
+
if (this.#state === 'closed') return
|
|
811
|
+
if (this.#pc?.connectionState !== 'disconnected') return // recovered
|
|
812
|
+
this.#log(`PeerConnection with ${this.#remotePodId} stayed disconnected`)
|
|
813
|
+
this.#fireError(new Error('PeerConnection state: disconnected'))
|
|
814
|
+
}, this.#disconnectedGraceMs)
|
|
815
|
+
// A pending grace period must never be the reason a Node process stays
|
|
816
|
+
// alive; browsers have no unref() and do not need one.
|
|
817
|
+
if (typeof this.#disconnectedTimer?.unref === 'function') this.#disconnectedTimer.unref()
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
#clearDisconnectedGrace() {
|
|
821
|
+
if (!this.#disconnectedTimer) return
|
|
822
|
+
clearTimeout(this.#disconnectedTimer)
|
|
823
|
+
this.#disconnectedTimer = null
|
|
824
|
+
}
|
|
825
|
+
|
|
379
826
|
#setupDataChannel(dc) {
|
|
380
827
|
dc.onopen = () => {
|
|
381
828
|
this.#setState('connected')
|
|
@@ -392,10 +839,9 @@ export class WebRTCPeerConnection {
|
|
|
392
839
|
}
|
|
393
840
|
}
|
|
394
841
|
dc.onclose = () => {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
842
|
+
// The remote hung up. Go through close() rather than just flipping the
|
|
843
|
+
// state, so our own RTCPeerConnection is released too.
|
|
844
|
+
if (this.#state !== 'closed') this.close()
|
|
399
845
|
}
|
|
400
846
|
dc.onerror = (event) => {
|
|
401
847
|
this.#fireError(event?.error || new Error('DataChannel error'))
|
|
@@ -442,6 +888,7 @@ export class WebRTCMeshManager {
|
|
|
442
888
|
#reconnectTimers = new Map() // remotePodId -> timer handle
|
|
443
889
|
#maxReconnectAttempts
|
|
444
890
|
#reconnectBaseDelayMs
|
|
891
|
+
#disconnectedGraceMs
|
|
445
892
|
#lastStats = []
|
|
446
893
|
|
|
447
894
|
/**
|
|
@@ -451,14 +898,21 @@ export class WebRTCMeshManager {
|
|
|
451
898
|
* @param {Function} [opts.onLog]
|
|
452
899
|
* @param {number} [opts.maxReconnectAttempts=5] - Give up auto-reconnecting after this many failures
|
|
453
900
|
* @param {number} [opts.reconnectBaseDelayMs=1000] - Backoff base; doubles each attempt
|
|
901
|
+
* @param {number} [opts.disconnectedGraceMs=5000] - Passed to every connection:
|
|
902
|
+
* how long a peer connection may sit in the transient `disconnected` state
|
|
903
|
+
* before it counts as an error worth reconnecting over.
|
|
454
904
|
*/
|
|
455
|
-
constructor({
|
|
905
|
+
constructor({
|
|
906
|
+
localPodId, iceServers, onLog,
|
|
907
|
+
maxReconnectAttempts = 5, reconnectBaseDelayMs = 1000, disconnectedGraceMs = 5000,
|
|
908
|
+
} = {}) {
|
|
456
909
|
if (!localPodId) throw new Error('localPodId is required')
|
|
457
910
|
this.#localPodId = localPodId
|
|
458
911
|
this.#iceServers = iceServers || [...DEFAULT_ICE_SERVERS]
|
|
459
912
|
this.#onLog = onLog || null
|
|
460
913
|
this.#maxReconnectAttempts = maxReconnectAttempts
|
|
461
914
|
this.#reconnectBaseDelayMs = reconnectBaseDelayMs
|
|
915
|
+
this.#disconnectedGraceMs = disconnectedGraceMs
|
|
462
916
|
}
|
|
463
917
|
|
|
464
918
|
/** Local pod identifier. */
|
|
@@ -500,6 +954,7 @@ export class WebRTCMeshManager {
|
|
|
500
954
|
remotePodId,
|
|
501
955
|
iceServers: this.#iceServers,
|
|
502
956
|
onLog: this.#onLog,
|
|
957
|
+
disconnectedGraceMs: this.#disconnectedGraceMs,
|
|
503
958
|
})
|
|
504
959
|
// Forward messages to manager-level listeners
|
|
505
960
|
conn.onMessage((data) => {
|
|
@@ -524,13 +979,19 @@ export class WebRTCMeshManager {
|
|
|
524
979
|
|
|
525
980
|
/**
|
|
526
981
|
* Manually trigger reconnection for a peer (bypasses backoff).
|
|
982
|
+
*
|
|
527
983
|
* @param {string} remotePodId
|
|
528
|
-
* @
|
|
984
|
+
* @param {object} [opts]
|
|
985
|
+
* @param {boolean} [opts.force=false] - Renegotiate even if the connection
|
|
986
|
+
* looks healthy. Without it a healthy connection is left alone.
|
|
987
|
+
* @returns {Promise<{type: 'offer', sdp: string, renegotiation: true}|null>}
|
|
988
|
+
* null if there is no such connection, or nothing to repair.
|
|
529
989
|
*/
|
|
530
|
-
async reconnectPeer(remotePodId) {
|
|
990
|
+
async reconnectPeer(remotePodId, { force = false } = {}) {
|
|
531
991
|
const conn = this.#connections.get(remotePodId)
|
|
532
992
|
if (!conn) return null
|
|
533
|
-
const offer = await conn.reconnect()
|
|
993
|
+
const offer = await conn.reconnect({ force })
|
|
994
|
+
if (!offer) return null
|
|
534
995
|
this.#notifyReconnectOffer(offer, remotePodId)
|
|
535
996
|
return offer
|
|
536
997
|
}
|
|
@@ -564,7 +1025,14 @@ export class WebRTCMeshManager {
|
|
|
564
1025
|
if (!this.#connections.has(remotePodId)) return // closed/removed meanwhile
|
|
565
1026
|
try {
|
|
566
1027
|
const offer = await conn.reconnect()
|
|
567
|
-
|
|
1028
|
+
if (offer) {
|
|
1029
|
+
this.#notifyReconnectOffer(offer, remotePodId)
|
|
1030
|
+
} else {
|
|
1031
|
+
// Nothing was wrong with the connection after all -- the error that
|
|
1032
|
+
// scheduled this attempt was spurious, or it healed while we waited.
|
|
1033
|
+
// Refund the backoff rather than counting it against the peer.
|
|
1034
|
+
this.#clearReconnectState(remotePodId)
|
|
1035
|
+
}
|
|
568
1036
|
} catch (e) { silentCatch('clawser-mesh-webrtc', 'reconnect-attempt', e) }
|
|
569
1037
|
}, delay)
|
|
570
1038
|
this.#reconnectTimers.set(remotePodId, timer)
|
package/src/websocket.mjs
CHANGED
|
@@ -494,7 +494,15 @@ export class WebRTCTransport {
|
|
|
494
494
|
// Set up signaler listeners for remote ICE candidates
|
|
495
495
|
this.#signaler.onIceCandidate((candidate) => {
|
|
496
496
|
if (this.#pc) {
|
|
497
|
-
|
|
497
|
+
// The promise must not be dropped. addIceCandidate rejects on a
|
|
498
|
+
// malformed candidate and on one that arrives before the remote
|
|
499
|
+
// description -- both ordinary on a real signaling channel -- and an
|
|
500
|
+
// unhandled rejection ends a Node process by default. webrtc.mjs's
|
|
501
|
+
// addIceCandidate docblock records this being fixed there; this path
|
|
502
|
+
// still had it.
|
|
503
|
+
Promise.resolve(this.#pc.addIceCandidate(candidate)).catch((e) => {
|
|
504
|
+
silentCatch('clawser-mesh-websocket', 'ignore-rejected-ice-candidate', e);
|
|
505
|
+
});
|
|
498
506
|
}
|
|
499
507
|
});
|
|
500
508
|
|
|
@@ -884,11 +892,17 @@ export class NATTraversal {
|
|
|
884
892
|
|
|
885
893
|
/**
|
|
886
894
|
* @param {object} [opts]
|
|
887
|
-
* @param {string[]} [opts.stunServers] - STUN server URLs
|
|
895
|
+
* @param {string[]} [opts.stunServers] - STUN server URLs. Defaults to none:
|
|
896
|
+
* host candidates suffice on a LAN, and contacting a public STUN server
|
|
897
|
+
* discloses this device's public IP to a third party.
|
|
888
898
|
* @param {object[]} [opts.turnServers] - TURN server configs with urls, username, credential
|
|
889
899
|
*/
|
|
890
900
|
constructor(opts = {}) {
|
|
891
|
-
|
|
901
|
+
// Default to no STUN. Host candidates alone connect two peers on the same
|
|
902
|
+
// LAN, and a STUN binding request discloses this device's public IP to
|
|
903
|
+
// whoever runs the server. Callers who need reflexive candidates pass
|
|
904
|
+
// stunServers explicitly.
|
|
905
|
+
this.#stunServers = opts.stunServers || [];
|
|
892
906
|
this.#turnServers = opts.turnServers || [];
|
|
893
907
|
}
|
|
894
908
|
|