wireweave 0.3.16 → 0.3.18

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/voice.js +303 -25
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wireweave",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "nostr + webrtc voice + data SDK. networking layer for 247420 projects.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/voice.js CHANGED
@@ -20,9 +20,11 @@ export const setIceServers = (list) => { if (Array.isArray(list) && list.length)
20
20
  export const getIceServers = () => ICE_SERVERS.slice();
21
21
 
22
22
  const PRESENCE_EXPIRY = 300000;
23
- const HEARTBEAT = 30000;
23
+ const HEARTBEAT = 5000; // tight cadence: heartbeat carries election scores + reflexive addr
24
24
  const STALL_CHECK = 5000;
25
25
  const DISCONNECT_GRACE = 8000;
26
+ const HUB_HYSTERESIS_MS = 8000; // minimum hold-time before re-election can replace incumbent
27
+ const HUB_REL_ADVANTAGE = 0.25; // challenger must beat incumbent by ≥25% to take over
26
28
 
27
29
  // Speaker-activity / queue / anti-overtalk tunables
28
30
  const SPEAKER_ACTIVE_RMS = 0.045; // RMS threshold above which a stream counts as "speaking"
@@ -384,15 +386,48 @@ export class VoiceSession extends EventTarget {
384
386
 
385
387
  async _publishPresence(action) {
386
388
  if (!this.auth.isLoggedIn() || !this.roomId) return;
387
- const rttScores = action === 'heartbeat' ? this._rttScores() : {};
389
+ const isHb = action === 'heartbeat';
390
+ const rttScores = isHb ? this._rttScores() : {};
391
+ const capScores = isHb ? this._capScores() : {};
392
+ const reflexive = isHb ? this._reflexiveAddrs() : [];
393
+ const uplinkKbps = isHb ? this._estimateUplinkKbps() : 0;
388
394
  const signed = await this.auth.sign({
389
395
  kind: 30078, created_at: Math.floor(Date.now() / 1000),
390
396
  tags: [['d', 'zellous-voice:' + this.roomId], ['action', action], ['channel', this.channelName], ['server', this.serverId]],
391
- content: JSON.stringify({ action, name: this.displayName, channel: this.channelName, ts: Date.now(), rttScores })
397
+ content: JSON.stringify({ action, name: this.displayName, channel: this.channelName, ts: Date.now(), rttScores, capScores, reflexive, uplinkKbps })
392
398
  });
393
399
  this.pool.publish(signed);
394
400
  }
395
401
 
402
+ // Estimated uplink in kbps: max(availableOutgoingBitrate over connected peers).
403
+ // This is the dominant signal for hub-fitness — hub fan-out is bounded by uplink.
404
+ _estimateUplinkKbps() {
405
+ let best = 0;
406
+ for (const [, peer] of this.peers) {
407
+ if (!peer.pc || peer.pc.connectionState !== 'connected') continue;
408
+ const v = peer._lastAvailOutKbps || 0;
409
+ if (v > best) best = v;
410
+ }
411
+ return best;
412
+ }
413
+
414
+ // Map peerPubkey → estimated kbps (most-recent stats sample). Pushed in heartbeat
415
+ // so other nodes can rank us as a hub candidate without their own stats poll.
416
+ _capScores() {
417
+ const out = {};
418
+ for (const [pk, peer] of this.peers) {
419
+ if (peer._lastAvailOutKbps != null) out[pk] = peer._lastAvailOutKbps;
420
+ }
421
+ return out;
422
+ }
423
+
424
+ // Recent successful local public reflexive addresses (host:port) we've seen on
425
+ // candidate pairs. Other peers can synth peer-reflexive ICE candidates from these
426
+ // and start probing without waiting for STUN, dramatically cutting join time.
427
+ _reflexiveAddrs() {
428
+ return this._lastReflexive ? [this._lastReflexive] : [];
429
+ }
430
+
396
431
  _startHeartbeat() { this._stopHeartbeat(); this.heartbeat = setInterval(() => { if (this.actor?.getSnapshot().matches('connected')) this._publishPresence('heartbeat'); }, HEARTBEAT); }
397
432
  _stopHeartbeat() { if (this.heartbeat) { clearInterval(this.heartbeat); this.heartbeat = null; } }
398
433
 
@@ -408,14 +443,43 @@ export class VoiceSession extends EventTarget {
408
443
  if (Date.now() - (data.ts || 0) > PRESENCE_EXPIRY) return;
409
444
  const shortId = 'nostr-' + event.pubkey.slice(0, 12);
410
445
  if (data.rttScores) this.sfu.rttMatrix.set(event.pubkey, data.rttScores);
411
- if (data.action === 'leave') { this.participants.delete(shortId); this._closePeer(event.pubkey); }
446
+ if (data.capScores || data.uplinkKbps != null) {
447
+ if (!this.sfu.capacityMatrix) this.sfu.capacityMatrix = new Map();
448
+ this.sfu.capacityMatrix.set(event.pubkey, { ...(data.capScores || {}), _self: data.uplinkKbps || 0 });
449
+ }
450
+ if (data.reflexive && Array.isArray(data.reflexive) && data.reflexive.length) {
451
+ if (!this.sfu.reflexiveByPeer) this.sfu.reflexiveByPeer = new Map();
452
+ this.sfu.reflexiveByPeer.set(event.pubkey, data.reflexive);
453
+ }
454
+ if (data.action === 'leave') {
455
+ this.participants.delete(shortId);
456
+ this._closePeer(event.pubkey);
457
+ if (this.sfu.hub === event.pubkey) this._sfuOnHubLost();
458
+ }
412
459
  else if (!this.participants.has(shortId)) {
413
460
  this.participants.set(shortId, { identity: data.name || event.pubkey.slice(0, 8), isSpeaking: false, isMuted: false, isLocal: false, hasVideo: false, connectionQuality: 'connecting' });
461
+ this._sfuMaybeElect();
414
462
  this._maybeConnect(event.pubkey);
415
- } else if (!this.peers.has(event.pubkey)) this._maybeConnect(event.pubkey);
463
+ } else {
464
+ this._sfuMaybeElect();
465
+ if (this._sfuShouldHaveConnectionTo(event.pubkey) && !this.peers.has(event.pubkey)) this._maybeConnect(event.pubkey);
466
+ }
416
467
  this._emit('participants', { list: this.getParticipants() });
417
468
  }
418
469
 
470
+ // SFU-only topology: each node holds direct WebRTC PCs only to (a) the elected hub,
471
+ // (b) the warm backup, and (c) every peer if WE are the hub (fan-out). Everyone else
472
+ // is reachable indirectly via the hub's audio fan-out.
473
+ _sfuShouldHaveConnectionTo(peerPubkey) {
474
+ if (this.sfu.hub === this.auth.pubkey) return true; // we are hub → connect to all
475
+ if (peerPubkey === this.sfu.hub) return true;
476
+ if (peerPubkey === this.sfu.warmBackup) return true;
477
+ // Pre-election (no hub yet): connect to the lowest-pubkey peer as bootstrap so
478
+ // we have stats data to publish. Election will reshape immediately.
479
+ if (!this.sfu.hub) return true;
480
+ return false;
481
+ }
482
+
419
483
  _subscribeSignals() {
420
484
  this.pool.subscribe('voice-signals-' + this.roomId,
421
485
  [{ kinds: [30078], '#p': [this.auth.pubkey], '#r': [this.roomId] }],
@@ -425,6 +489,8 @@ export class VoiceSession extends EventTarget {
425
489
  _maybeConnect(peerPubkey) {
426
490
  if (!peerPubkey || peerPubkey === this.auth.pubkey || this.peers.has(peerPubkey)) return;
427
491
  if (this.bans && this.serverId && (this.bans.isBanned?.(this.serverId, peerPubkey) || this.bans.isTimedOut?.(this.serverId, peerPubkey))) return;
492
+ // Topology gate: only form WebRTC PCs the SFU layout calls for.
493
+ if (!this._sfuShouldHaveConnectionTo(peerPubkey)) return;
428
494
  this._cancelReconnect(peerPubkey);
429
495
  const fsmActor = this.xstate.createActor(this.fsm.peerMachine);
430
496
  fsmActor.subscribe((snap) => { const p = this.peers.get(peerPubkey); if (p) p.state = snap.value; });
@@ -439,6 +505,19 @@ export class VoiceSession extends EventTarget {
439
505
  else pc.addTransceiver('audio', { direction: 'recvonly' });
440
506
  }
441
507
  this._wirePeer(peer, peerPubkey, fsmActor, isOfferer);
508
+ // ICE/TURN offload via Nostr: if we already have a heartbeat-published
509
+ // reflexive addr for this peer, queue it to be added the moment remote
510
+ // description lands. Cuts join time vs. waiting for STUN re-gather.
511
+ const known = this.sfu.reflexiveByPeer?.get(peerPubkey);
512
+ if (known && known.length) {
513
+ for (const r of known) {
514
+ if (!r?.addr || !r?.port) continue;
515
+ const proto = (r.protocol || 'udp').toLowerCase();
516
+ const typ = r.type === 'relay' ? 'relay' : 'srflx';
517
+ const sdp = `candidate:0 1 ${proto} 1685987071 ${r.addr} ${r.port} typ ${typ}`;
518
+ peer.bufferedCandidates.push({ candidate: sdp, sdpMid: '0', sdpMLineIndex: 0 });
519
+ }
520
+ }
442
521
  }
443
522
 
444
523
  _wirePeer(peer, peerPubkey, fsmActor, isOfferer) {
@@ -477,7 +556,8 @@ export class VoiceSession extends EventTarget {
477
556
  }
478
557
  if (pc.connectionState === 'disconnected') { fsmActor.send({ type: 'disconnect' }); peer.disconnectTimer = setTimeout(() => this._doIceRestart(peer, peerPubkey, fsmActor), DISCONNECT_GRACE); }
479
558
  if (pc.connectionState === 'failed') this._doIceRestart(peer, peerPubkey, fsmActor);
480
- if (pc.connectionState === 'closed') { this._closePeer(peerPubkey); if (this.sfu.hub === peerPubkey) { this._sfuDissolve(); setTimeout(() => this._sfuMaybeElect(), 500); } }
559
+ if (pc.connectionState === 'closed') { this._closePeer(peerPubkey); if (this.sfu.hub === peerPubkey) this._sfuOnHubLost(); }
560
+ if (pc.connectionState === 'failed' && this.sfu.hub === peerPubkey) this._sfuOnHubLost();
481
561
  };
482
562
  this._ensureDataChannel(peer, peerPubkey, isOfferer);
483
563
  if (isOfferer) {
@@ -588,38 +668,197 @@ export class VoiceSession extends EventTarget {
588
668
  this.retrySchedule[pk] = { attempt: a, timer };
589
669
  }
590
670
 
591
- _sfuStart() { this.sfu.actor = this.xstate.createActor(this.fsm.sfuMachine); this.sfu.actor.start(); this._sfuStopStats(); this.sfu.statsInterval = setInterval(() => this._sfuPoll(), 5000); }
592
- _sfuStop() { this._sfuStopStats(); this.sfu.actor?.send({ type: 'dissolve' }); this.sfu.hub = null; this.sfu.rttMatrix.clear(); }
671
+ // ────────────────────────────────────────────────────────────────────────
672
+ // Always-SFU topology
673
+ //
674
+ // No mesh. Exactly one hub at a time, elected by the highest-uplink peer
675
+ // among everyone in the room. Election is driven entirely by Nostr-published
676
+ // heartbeats — every peer sees the same scores and computes the same winner
677
+ // deterministically, so there's no leader-election race. Tiebreaker: highest
678
+ // pubkey. The runner-up is held as a warm backup so hub failure → instant
679
+ // failover (sub-second) instead of full re-negotiation (~5 s).
680
+ //
681
+ // ICE/TURN offload to Nostr: each heartbeat publishes the local node's most
682
+ // recent successful public reflexive address. New joiners use that to synth
683
+ // peer-reflexive ICE candidates immediately, skipping their own STUN gather
684
+ // round-trip. Result: first audio in ~hundreds of ms instead of seconds.
685
+ // ────────────────────────────────────────────────────────────────────────
686
+ _sfuStart() {
687
+ this.sfu.actor = this.xstate.createActor(this.fsm.sfuMachine);
688
+ this.sfu.actor.start();
689
+ this.sfu.warmBackup = null;
690
+ this.sfu.lastSwitch = 0;
691
+ this.sfu.capacityMatrix = new Map();
692
+ this.sfu.reflexiveByPeer = new Map();
693
+ this._sfuStopStats();
694
+ this.sfu.statsInterval = setInterval(() => this._sfuPoll(), 2500);
695
+ }
696
+ _sfuStop() {
697
+ this._sfuStopStats();
698
+ this.sfu.actor?.send({ type: 'dissolve' });
699
+ this.sfu.hub = null; this.sfu.warmBackup = null;
700
+ this.sfu.rttMatrix.clear();
701
+ this.sfu.capacityMatrix?.clear();
702
+ this.sfu.reflexiveByPeer?.clear();
703
+ this._lastReflexive = null;
704
+ }
593
705
  _sfuStopStats() { if (this.sfu.statsInterval) { clearInterval(this.sfu.statsInterval); this.sfu.statsInterval = null; } if (this.sfu.electionTimer) { clearTimeout(this.sfu.electionTimer); this.sfu.electionTimer = null; } }
706
+
594
707
  async _sfuPoll() {
595
- const scores = {}; const tasks = [];
708
+ const rttScores = {};
709
+ const tasks = [];
596
710
  for (const [pk, peer] of this.peers) {
597
711
  if (!peer.pc || peer.pc.connectionState !== 'connected') continue;
598
- tasks.push(peer.pc.getStats().then(stats => stats.forEach(r => { if (r.type === 'candidate-pair' && r.state === 'succeeded' && r.currentRoundTripTime != null) scores[pk] = Math.round(r.currentRoundTripTime * 1000); })).catch(() => {}));
712
+ tasks.push(peer.pc.getStats().then(stats => {
713
+ let pairRtt = null, availOut = null, lossFrac = 0, reflex = null;
714
+ stats.forEach(r => {
715
+ if (r.type === 'candidate-pair' && r.state === 'succeeded' && (r.nominated || r.selected)) {
716
+ if (r.currentRoundTripTime != null) pairRtt = r.currentRoundTripTime;
717
+ if (r.availableOutgoingBitrate != null) availOut = r.availableOutgoingBitrate;
718
+ // Capture the local end of the selected pair as our public reflexive addr.
719
+ const localId = r.localCandidateId; if (localId) {
720
+ const local = stats.get?.(localId); if (local && (local.candidateType === 'srflx' || local.candidateType === 'prflx' || local.candidateType === 'relay')) {
721
+ reflex = { addr: local.address || local.ip, port: local.port, type: local.candidateType, protocol: local.protocol };
722
+ }
723
+ }
724
+ }
725
+ if (r.type === 'remote-inbound-rtp' && r.kind === 'audio' && r.fractionLost != null) {
726
+ lossFrac = Math.max(lossFrac, r.fractionLost);
727
+ }
728
+ });
729
+ if (pairRtt != null) rttScores[pk] = Math.round(pairRtt * 1000);
730
+ // Per-peer capacity estimate — falls back to RTT/loss heuristic when
731
+ // availableOutgoingBitrate isn't exposed (Firefox, Safari sometimes).
732
+ let capKbps = null;
733
+ if (availOut != null && availOut > 0) capKbps = Math.round(availOut / 1000);
734
+ else if (pairRtt != null) {
735
+ const rttMs = pairRtt * 1000;
736
+ capKbps = Math.round(2000 * Math.max(0, 1 - rttMs / 400) * Math.max(0, 1 - lossFrac * 4));
737
+ }
738
+ if (capKbps != null) peer._lastAvailOutKbps = capKbps;
739
+ if (reflex && reflex.addr && reflex.port) this._lastReflexive = reflex;
740
+ }).catch(() => {}));
599
741
  }
600
742
  await Promise.all(tasks);
601
- this.sfu.rttMatrix.set(this.auth.pubkey, scores);
743
+ this.sfu.rttMatrix.set(this.auth.pubkey, rttScores);
602
744
  this._sfuMaybeElect();
603
745
  }
746
+
604
747
  _sfuMaybeElect() {
605
- if (this.peers.size < 3) { if (this.sfu.actor?.getSnapshot().value !== 'mesh') this._sfuDissolve(); return; }
606
748
  if (this.sfu.electionTimer) return;
607
- this.sfu.electionTimer = setTimeout(() => { this.sfu.electionTimer = null; this._sfuElect(); }, 2000);
749
+ this.sfu.electionTimer = setTimeout(() => { this.sfu.electionTimer = null; this._sfuElect(); }, 600);
750
+ }
751
+
752
+ _sfuRankCandidates() {
753
+ // Score every known participant (including self). Higher = better hub.
754
+ // capacity term — published uplinkKbps from heartbeats (or local stats for self)
755
+ // rtt term — inverse mean RTT, tiebreaker
756
+ // coverage term — how many peers we have data on (favours nodes already
757
+ // talking to many others, reduces reorg churn)
758
+ const all = new Set([this.auth.pubkey, ...this.participants.keys()]);
759
+ // participants.keys() are shortIds; rebuild full pubkeys from peers + self.
760
+ all.clear(); all.add(this.auth.pubkey);
761
+ for (const pk of this.peers.keys()) all.add(pk);
762
+ if (this.sfu.capacityMatrix) for (const pk of this.sfu.capacityMatrix.keys()) all.add(pk);
763
+ if (this.sfu.rttMatrix) for (const pk of this.sfu.rttMatrix.keys()) all.add(pk);
764
+
765
+ const ranked = [];
766
+ for (const pk of all) {
767
+ // Self-uplink: from local stats. Remote uplink: from their heartbeat (_self field).
768
+ let uplink = 0;
769
+ if (pk === this.auth.pubkey) uplink = this._estimateUplinkKbps();
770
+ else { const cap = this.sfu.capacityMatrix?.get(pk); if (cap && typeof cap._self === 'number') uplink = cap._self; }
771
+
772
+ const rtt = this.sfu.rttMatrix.get(pk);
773
+ let rttAvg = Infinity, rttCount = 0;
774
+ if (rtt) { let s = 0; for (const v of Object.values(rtt)) if (typeof v === 'number') { s += v; rttCount++; } if (rttCount) rttAvg = s / rttCount; }
775
+
776
+ const cap = this.sfu.capacityMatrix?.get(pk);
777
+ const capCount = cap ? Object.keys(cap).filter(k => k !== '_self').length : 0;
778
+
779
+ // Score: uplink dominates (it's the bottleneck for hub fan-out).
780
+ // 1 kbps uplink = 1 point. RTT contributes up to 200 points (preferring <200 ms).
781
+ // Coverage contributes (rttCount + capCount) × 30.
782
+ const rttTerm = rttCount ? Math.max(0, 200 - rttAvg) : 0;
783
+ const coverageTerm = (rttCount + capCount) * 30;
784
+ const score = uplink + rttTerm + coverageTerm;
785
+
786
+ ranked.push({ pubkey: pk, score, uplink, rttAvg, capCount });
787
+ }
788
+ // Tiebreak by pubkey for determinism across nodes.
789
+ ranked.sort((a, b) => b.score - a.score || (a.pubkey > b.pubkey ? -1 : 1));
790
+ return ranked;
608
791
  }
792
+
609
793
  _sfuElect() {
610
- const all = [this.auth.pubkey]; for (const pk of this.peers.keys()) all.push(pk);
611
- let best = null, bestAvg = Infinity;
612
- for (const pk of all) {
613
- const s = this.sfu.rttMatrix.get(pk); if (!s) continue;
614
- const vals = Object.values(s).filter(v => typeof v === 'number'); if (!vals.length) continue;
615
- const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
616
- if (avg < bestAvg || (avg === bestAvg && pk > best)) { bestAvg = avg; best = pk; }
794
+ const ranked = this._sfuRankCandidates();
795
+ if (!ranked.length) return;
796
+ const top = ranked[0];
797
+ const runnerUp = ranked[1] || null;
798
+
799
+ // Hysteresis: keep incumbent unless challenger is meaningfully better
800
+ // AND we've held the current hub for at least HUB_HYSTERESIS_MS.
801
+ const incumbent = this.sfu.hub;
802
+ if (incumbent) {
803
+ const incEntry = ranked.find(r => r.pubkey === incumbent);
804
+ if (incEntry) {
805
+ const advantage = top.score - incEntry.score;
806
+ const rel = incEntry.score > 0 ? advantage / incEntry.score : Infinity;
807
+ const recent = Date.now() - this.sfu.lastSwitch < HUB_HYSTERESIS_MS;
808
+ if (top.pubkey === incumbent || rel < HUB_REL_ADVANTAGE || recent) {
809
+ // Even if we keep incumbent, refresh warm-backup choice.
810
+ this.sfu.warmBackup = (runnerUp && runnerUp.pubkey !== incumbent) ? runnerUp.pubkey : null;
811
+ this._sfuApplyTopology(incumbent);
812
+ return;
813
+ }
814
+ }
617
815
  }
618
- if (!best || best === this.sfu.hub) return;
619
- this.sfu.hub = best; this.sfu.actor?.send({ type: 'elected' });
620
- if (best === this.auth.pubkey) this._sfuBecomeHub(); else this._sfuRouteToHub(best);
816
+
817
+ const previousHub = this.sfu.hub;
818
+ this.sfu.hub = top.pubkey;
819
+ this.sfu.warmBackup = (runnerUp && runnerUp.pubkey !== top.pubkey) ? runnerUp.pubkey : null;
820
+ this.sfu.lastSwitch = Date.now();
821
+ this.sfu.actor?.send({ type: 'elected' });
822
+ this._emit('hub-changed', { hub: top.pubkey, previous: previousHub, score: top.score, uplink: top.uplink });
823
+
824
+ if (top.pubkey === this.auth.pubkey) this._sfuBecomeHub();
825
+ else this._sfuRouteToHub(top.pubkey, previousHub);
826
+
827
+ this._sfuApplyTopology(top.pubkey);
621
828
  }
829
+
830
+ // Reconcile the open WebRTC PCs with the elected topology.
831
+ // - If we are hub: keep PCs to every participant, open new ones for missing peers.
832
+ // - Else: keep PCs only to hub + warm backup.
833
+ _sfuApplyTopology(hubPk) {
834
+ if (hubPk === this.auth.pubkey) {
835
+ for (const peerPk of this.participants.keys()) {
836
+ // participants keys are shortIds; iterate via known peer pubkeys from rttMatrix instead.
837
+ }
838
+ // Use capacityMatrix + presence-known pubkeys as the participant list.
839
+ const known = new Set();
840
+ for (const pk of this.sfu.capacityMatrix?.keys() || []) known.add(pk);
841
+ for (const pk of this.sfu.rttMatrix.keys()) known.add(pk);
842
+ for (const pk of known) {
843
+ if (pk === this.auth.pubkey) continue;
844
+ if (!this.peers.has(pk)) this._maybeConnect(pk);
845
+ }
846
+ return;
847
+ }
848
+ // Non-hub: drop everyone except hub + warm backup.
849
+ for (const pk of Array.from(this.peers.keys())) {
850
+ if (pk === hubPk) continue;
851
+ if (pk === this.sfu.warmBackup) continue;
852
+ this._closePeer(pk);
853
+ }
854
+ if (!this.peers.has(hubPk)) this._maybeConnect(hubPk);
855
+ if (this.sfu.warmBackup && !this.peers.has(this.sfu.warmBackup)) this._maybeConnect(this.sfu.warmBackup);
856
+ }
857
+
622
858
  _sfuBecomeHub() {
859
+ // Fan out remote audio receivers to all other peer senders via replaceTrack.
860
+ // This zero-copy forward is the heart of SFU — no transcode, no re-encode,
861
+ // just packet redirection at the RTCPeerConnection layer.
623
862
  for (const [srcPk, srcPeer] of this.peers) {
624
863
  if (!srcPeer.pc?.getReceivers) continue;
625
864
  srcPeer.pc.getReceivers().forEach(recv => {
@@ -632,10 +871,49 @@ export class VoiceSession extends EventTarget {
632
871
  });
633
872
  }
634
873
  }
635
- _sfuRouteToHub(hubPk) {
636
- for (const pk of Array.from(this.peers.keys())) { if (pk === hubPk) continue; this._closePeer(pk); }
874
+
875
+ // Graceful handoff: open new hub PC, wait briefly for first audio, then drop
876
+ // old PCs. The warm backup is preserved through the transition for failover.
877
+ async _sfuRouteToHub(hubPk, previousHub) {
637
878
  if (!this.peers.has(hubPk)) this._maybeConnect(hubPk);
879
+ const hubPeer = this.peers.get(hubPk);
880
+ const ok = await new Promise((resolve) => {
881
+ if (!hubPeer) { resolve(false); return; }
882
+ let done = false;
883
+ const finish = (v) => { if (!done) { done = true; resolve(v); } };
884
+ const timer = setTimeout(() => finish(false), 5000);
885
+ const poll = setInterval(() => {
886
+ try {
887
+ const r = hubPeer.pc?.getReceivers().find(x => x.track?.kind === 'audio');
888
+ if (r?.track && r.track.readyState === 'live') { clearTimeout(timer); clearInterval(poll); finish(true); }
889
+ } catch {}
890
+ }, 100);
891
+ });
892
+ if (!ok && previousHub && previousHub !== hubPk && this.participants.size) {
893
+ // Roll back: new hub didn't deliver audio. Keep old hub.
894
+ this.sfu.hub = previousHub;
895
+ if (!this.peers.has(previousHub)) this._maybeConnect(previousHub);
896
+ }
638
897
  }
898
+
899
+ // Hub-loss handler: wired from peer connectionstatechange when current hub PC dies.
900
+ _sfuOnHubLost() {
901
+ if (!this.sfu.hub) return;
902
+ const hubPeer = this.peers.get(this.sfu.hub);
903
+ if (hubPeer && hubPeer.pc?.connectionState === 'connected') return;
904
+ const promote = this.sfu.warmBackup;
905
+ const lost = this.sfu.hub;
906
+ this.sfu.hub = null;
907
+ if (!promote) { this._sfuMaybeElect(); return; }
908
+ this.sfu.hub = promote;
909
+ this.sfu.warmBackup = null;
910
+ this.sfu.lastSwitch = Date.now();
911
+ this.sfu.actor?.send({ type: 'elected' });
912
+ this._emit('hub-changed', { hub: promote, previous: lost, reason: 'failover' });
913
+ if (promote === this.auth.pubkey) this._sfuBecomeHub();
914
+ else this._sfuApplyTopology(promote);
915
+ }
916
+
639
917
  _sfuDissolve() { this.sfu.actor?.send({ type: 'dissolve' }); this.sfu.hubLostAt = Date.now(); this.sfu.hub = null; }
640
918
 
641
919
  _rttScores() { return this.sfu.rttMatrix.get(this.auth.pubkey) || {}; }