@johnhenry/browsermesh-transport 0.0.0 → 0.1.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@johnhenry/browsermesh-transport",
3
- "version": "0.0.0",
3
+ "version": "0.1.1",
4
4
  "description": "WebSocket, WebRTC, WebTransport, and relay adapters for BrowserMesh",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -19,10 +19,14 @@
19
19
  "directory": "packages/browsermesh-transport"
20
20
  },
21
21
  "peerDependencies": {
22
- "@johnhenry/browsermesh-primitives": ">=0.0.0"
22
+ "@johnhenry/browsermesh-primitives": ">=0.0.1 <1.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "node-datachannel": "^0.33.2"
23
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": {
@@ -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 && peer.trust === TRUST_LEVELS.ISOLATED) {
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 && peer.trust === TRUST_LEVELS.VERIFIED && peer.allowedMethods.size > 0) {
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.#sendWs({
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
- /** @type {RTCIceServer[]} */
35
- const DEFAULT_ICE_SERVERS = Object.freeze([
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 STUN defaults. Silently
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
- * Creates a peer connection, waits for the remote DataChannel, and
150
- * returns an SDP answer to send back through signaling.
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
- * @param {{type: string, sdp: string}} offer
153
- * @returns {Promise<{type: 'answer', sdp: string}>}
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
- this.#pc.addIceCandidate(candidate)
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
- * fresh offer with `iceRestart: true`.
448
+ * renegotiation offer on the *existing* connection with `iceRestart: true`.
266
449
  *
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.
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
- * @returns {Promise<{type: 'offer', sdp: string}>}
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
- this.#setState('connecting')
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
- 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
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' || pcState === 'disconnected') {
374
- this.#fireError(new Error(`PeerConnection state: ${pcState}`))
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
- if (this.#state !== 'closed') {
396
- this.#setState('closed')
397
- this.#fireClose()
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({ localPodId, iceServers, onLog, maxReconnectAttempts = 5, reconnectBaseDelayMs = 1000 } = {}) {
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
- * @returns {Promise<{type: 'offer', sdp: string}|null>} null if no connection exists
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
- this.#notifyReconnectOffer(offer, remotePodId)
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
- this.#pc.addIceCandidate(candidate);
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
- this.#stunServers = opts.stunServers || ['stun:stun.l.google.com:19302'];
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