@edge-base/web 0.2.4 → 0.2.5

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.
@@ -4,6 +4,14 @@ const DEFAULT_ICE_SERVERS = [
4
4
  { urls: 'stun:stun.l.google.com:19302' },
5
5
  ];
6
6
  const DEFAULT_MEMBER_READY_TIMEOUT_MS = 10_000;
7
+ const DEFAULT_MISSING_MEDIA_GRACE_MS = 1_200;
8
+ const DEFAULT_DISCONNECTED_RECOVERY_DELAY_MS = 1_800;
9
+ const DEFAULT_MAX_RECOVERY_ATTEMPTS = 2;
10
+ const DEFAULT_ICE_BATCH_DELAY_MS = 40;
11
+ const DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS = [160, 320, 640];
12
+ const DEFAULT_MEDIA_HEALTH_CHECK_INTERVAL_MS = 4_000;
13
+ const DEFAULT_VIDEO_FLOW_GRACE_MS = 8_000;
14
+ const DEFAULT_VIDEO_FLOW_STALL_GRACE_MS = 12_000;
7
15
  function buildTrackKey(memberId, trackId) {
8
16
  return `${memberId}:${trackId}`;
9
17
  }
@@ -29,6 +37,64 @@ function serializeCandidate(candidate) {
29
37
  }
30
38
  return candidate;
31
39
  }
40
+ function normalizeIceServerUrls(urls) {
41
+ if (Array.isArray(urls)) {
42
+ return urls.filter((value) => typeof value === 'string' && value.trim().length > 0);
43
+ }
44
+ if (typeof urls === 'string' && urls.trim().length > 0) {
45
+ return [urls];
46
+ }
47
+ return [];
48
+ }
49
+ function normalizeIceServers(iceServers) {
50
+ if (!Array.isArray(iceServers)) {
51
+ return [];
52
+ }
53
+ const normalized = [];
54
+ for (const server of iceServers) {
55
+ const urls = normalizeIceServerUrls(server?.urls);
56
+ if (urls.length === 0) {
57
+ continue;
58
+ }
59
+ normalized.push({
60
+ urls: urls.length === 1 ? urls[0] : urls,
61
+ username: typeof server.username === 'string' ? server.username : undefined,
62
+ credential: typeof server.credential === 'string' ? server.credential : undefined,
63
+ });
64
+ }
65
+ return normalized;
66
+ }
67
+ function getPublishedKindsFromState(state) {
68
+ if (!state) {
69
+ return [];
70
+ }
71
+ const publishedKinds = [];
72
+ if (state.audio?.published)
73
+ publishedKinds.push('audio');
74
+ if (state.video?.published)
75
+ publishedKinds.push('video');
76
+ if (state.screen?.published)
77
+ publishedKinds.push('screen');
78
+ return publishedKinds;
79
+ }
80
+ function isStableAnswerError(error) {
81
+ const message = typeof error === 'object' && error && 'message' in error
82
+ ? String(error.message ?? '')
83
+ : '';
84
+ return (message.includes('Called in wrong state: stable')
85
+ || message.includes('Failed to set remote answer sdp')
86
+ || (message.includes('setRemoteDescription') && message.includes('stable')));
87
+ }
88
+ function isRateLimitError(error) {
89
+ const message = typeof error === 'object' && error && 'message' in error
90
+ ? String(error.message ?? '')
91
+ : String(error ?? '');
92
+ return message.toLowerCase().includes('rate limited');
93
+ }
94
+ function sameIceServer(candidate, urls) {
95
+ const candidateUrls = normalizeIceServerUrls(candidate.urls);
96
+ return candidateUrls.length === urls.length && candidateUrls.every((url, index) => url === urls[index]);
97
+ }
32
98
  export class RoomP2PMediaTransport {
33
99
  room;
34
100
  options;
@@ -38,9 +104,15 @@ export class RoomP2PMediaTransport {
38
104
  remoteTrackKinds = new Map();
39
105
  emittedRemoteTracks = new Set();
40
106
  pendingRemoteTracks = new Map();
107
+ pendingIceCandidates = new Map();
41
108
  subscriptions = [];
42
109
  localMemberId = null;
43
110
  connected = false;
111
+ iceServersResolved = false;
112
+ localUpdateBatchDepth = 0;
113
+ syncAllPeerSendersScheduled = false;
114
+ syncAllPeerSendersPending = false;
115
+ healthCheckTimer = null;
44
116
  constructor(room, options) {
45
117
  this.room = room;
46
118
  this.options = {
@@ -55,6 +127,13 @@ export class RoomP2PMediaTransport {
55
127
  mediaDevices: options?.mediaDevices
56
128
  ?? (typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined),
57
129
  signalPrefix: options?.signalPrefix ?? DEFAULT_SIGNAL_PREFIX,
130
+ turnCredentialTtlSeconds: options?.turnCredentialTtlSeconds ?? 3600,
131
+ missingMediaGraceMs: options?.missingMediaGraceMs ?? DEFAULT_MISSING_MEDIA_GRACE_MS,
132
+ disconnectedRecoveryDelayMs: options?.disconnectedRecoveryDelayMs ?? DEFAULT_DISCONNECTED_RECOVERY_DELAY_MS,
133
+ maxRecoveryAttempts: options?.maxRecoveryAttempts ?? DEFAULT_MAX_RECOVERY_ATTEMPTS,
134
+ mediaHealthCheckIntervalMs: options?.mediaHealthCheckIntervalMs ?? DEFAULT_MEDIA_HEALTH_CHECK_INTERVAL_MS,
135
+ videoFlowGraceMs: options?.videoFlowGraceMs ?? DEFAULT_VIDEO_FLOW_GRACE_MS,
136
+ videoFlowStallGraceMs: options?.videoFlowStallGraceMs ?? DEFAULT_VIDEO_FLOW_STALL_GRACE_MS,
58
137
  };
59
138
  }
60
139
  getSessionId() {
@@ -78,9 +157,11 @@ export class RoomP2PMediaTransport {
78
157
  throw new Error('Join the room before connecting a P2P media transport.');
79
158
  }
80
159
  this.localMemberId = currentMember.memberId;
160
+ await this.resolveRtcConfiguration();
81
161
  this.connected = true;
82
162
  this.hydrateRemoteTrackKinds();
83
163
  this.attachRoomSubscriptions();
164
+ this.startHealthChecks();
84
165
  try {
85
166
  for (const member of this.room.members.list()) {
86
167
  if (member.memberId !== this.localMemberId) {
@@ -105,6 +186,39 @@ export class RoomP2PMediaTransport {
105
186
  }
106
187
  return this.room.members.current();
107
188
  }
189
+ async resolveRtcConfiguration() {
190
+ if (this.iceServersResolved) {
191
+ return;
192
+ }
193
+ const loadIceServers = this.room.media.realtime?.iceServers;
194
+ if (typeof loadIceServers !== 'function') {
195
+ this.iceServersResolved = true;
196
+ return;
197
+ }
198
+ try {
199
+ const response = await loadIceServers({ ttl: this.options.turnCredentialTtlSeconds });
200
+ const realtimeIceServers = normalizeIceServers(response?.iceServers);
201
+ if (realtimeIceServers.length === 0) {
202
+ return;
203
+ }
204
+ const fallbackIceServers = normalizeIceServers(DEFAULT_ICE_SERVERS);
205
+ const mergedIceServers = [
206
+ ...realtimeIceServers,
207
+ ...fallbackIceServers.filter((server) => {
208
+ const urls = normalizeIceServerUrls(server.urls);
209
+ return !realtimeIceServers.some((candidate) => sameIceServer(candidate, urls));
210
+ }),
211
+ ];
212
+ this.options.rtcConfiguration = {
213
+ ...this.options.rtcConfiguration,
214
+ iceServers: mergedIceServers,
215
+ };
216
+ this.iceServersResolved = true;
217
+ }
218
+ catch (error) {
219
+ console.warn('[RoomP2PMediaTransport] Failed to load TURN / ICE credentials. Falling back to default STUN.', error);
220
+ }
221
+ }
108
222
  async enableAudio(constraints = true) {
109
223
  const track = await this.createUserMediaTrack('audio', constraints);
110
224
  if (!track) {
@@ -112,12 +226,12 @@ export class RoomP2PMediaTransport {
112
226
  }
113
227
  const providerSessionId = await this.ensureConnectedMemberId();
114
228
  this.rememberLocalTrack('audio', track, track.getSettings().deviceId, true);
115
- await this.room.media.audio.enable?.({
229
+ await this.withRateLimitRetry('enable audio', () => this.room.media.audio.enable?.({
116
230
  trackId: track.id,
117
231
  deviceId: track.getSettings().deviceId,
118
232
  providerSessionId,
119
- });
120
- this.syncAllPeerSenders();
233
+ }) ?? Promise.resolve());
234
+ this.requestSyncAllPeerSenders();
121
235
  return track;
122
236
  }
123
237
  async enableVideo(constraints = true) {
@@ -127,12 +241,12 @@ export class RoomP2PMediaTransport {
127
241
  }
128
242
  const providerSessionId = await this.ensureConnectedMemberId();
129
243
  this.rememberLocalTrack('video', track, track.getSettings().deviceId, true);
130
- await this.room.media.video.enable?.({
244
+ await this.withRateLimitRetry('enable video', () => this.room.media.video.enable?.({
131
245
  trackId: track.id,
132
246
  deviceId: track.getSettings().deviceId,
133
247
  providerSessionId,
134
- });
135
- this.syncAllPeerSenders();
248
+ }) ?? Promise.resolve());
249
+ this.requestSyncAllPeerSenders();
136
250
  return track;
137
251
  }
138
252
  async startScreenShare(constraints = { video: true, audio: false }) {
@@ -150,28 +264,28 @@ export class RoomP2PMediaTransport {
150
264
  }, { once: true });
151
265
  const providerSessionId = await this.ensureConnectedMemberId();
152
266
  this.rememberLocalTrack('screen', track, track.getSettings().deviceId, true);
153
- await this.room.media.screen.start?.({
267
+ await this.withRateLimitRetry('start screen share', () => this.room.media.screen.start?.({
154
268
  trackId: track.id,
155
269
  deviceId: track.getSettings().deviceId,
156
270
  providerSessionId,
157
- });
158
- this.syncAllPeerSenders();
271
+ }) ?? Promise.resolve());
272
+ this.requestSyncAllPeerSenders();
159
273
  return track;
160
274
  }
161
275
  async disableAudio() {
162
276
  this.releaseLocalTrack('audio');
163
- this.syncAllPeerSenders();
164
- await this.room.media.audio.disable();
277
+ this.requestSyncAllPeerSenders();
278
+ await this.withRateLimitRetry('disable audio', () => this.room.media.audio.disable());
165
279
  }
166
280
  async disableVideo() {
167
281
  this.releaseLocalTrack('video');
168
- this.syncAllPeerSenders();
169
- await this.room.media.video.disable();
282
+ this.requestSyncAllPeerSenders();
283
+ await this.withRateLimitRetry('disable video', () => this.room.media.video.disable());
170
284
  }
171
285
  async stopScreenShare() {
172
286
  this.releaseLocalTrack('screen');
173
- this.syncAllPeerSenders();
174
- await this.room.media.screen.stop();
287
+ this.requestSyncAllPeerSenders();
288
+ await this.withRateLimitRetry('stop screen share', () => this.room.media.screen.stop());
175
289
  }
176
290
  async setMuted(kind, muted) {
177
291
  const localTrack = this.localTracks.get(kind)?.track;
@@ -179,10 +293,23 @@ export class RoomP2PMediaTransport {
179
293
  localTrack.enabled = !muted;
180
294
  }
181
295
  if (kind === 'audio') {
182
- await this.room.media.audio.setMuted?.(muted);
296
+ await this.withRateLimitRetry('set audio muted', () => this.room.media.audio.setMuted?.(muted) ?? Promise.resolve());
183
297
  }
184
298
  else {
185
- await this.room.media.video.setMuted?.(muted);
299
+ await this.withRateLimitRetry('set video muted', () => this.room.media.video.setMuted?.(muted) ?? Promise.resolve());
300
+ }
301
+ }
302
+ async batchLocalUpdates(callback) {
303
+ this.localUpdateBatchDepth += 1;
304
+ try {
305
+ return await callback();
306
+ }
307
+ finally {
308
+ this.localUpdateBatchDepth = Math.max(0, this.localUpdateBatchDepth - 1);
309
+ if (this.localUpdateBatchDepth === 0 && this.syncAllPeerSendersPending) {
310
+ this.syncAllPeerSendersPending = false;
311
+ this.requestSyncAllPeerSenders();
312
+ }
186
313
  }
187
314
  }
188
315
  async switchDevices(payload) {
@@ -213,6 +340,10 @@ export class RoomP2PMediaTransport {
213
340
  destroy() {
214
341
  this.connected = false;
215
342
  this.localMemberId = null;
343
+ if (this.healthCheckTimer != null) {
344
+ globalThis.clearInterval(this.healthCheckTimer);
345
+ this.healthCheckTimer = null;
346
+ }
216
347
  for (const subscription of this.subscriptions.splice(0)) {
217
348
  subscription.unsubscribe();
218
349
  }
@@ -223,6 +354,12 @@ export class RoomP2PMediaTransport {
223
354
  for (const kind of Array.from(this.localTracks.keys())) {
224
355
  this.releaseLocalTrack(kind);
225
356
  }
357
+ for (const pending of this.pendingIceCandidates.values()) {
358
+ if (pending.timer) {
359
+ clearTimeout(pending.timer);
360
+ }
361
+ }
362
+ this.pendingIceCandidates.clear();
226
363
  this.remoteTrackKinds.clear();
227
364
  this.emittedRemoteTracks.clear();
228
365
  this.pendingRemoteTracks.clear();
@@ -234,6 +371,7 @@ export class RoomP2PMediaTransport {
234
371
  this.subscriptions.push(this.room.members.onJoin((member) => {
235
372
  if (member.memberId !== this.localMemberId) {
236
373
  this.ensurePeer(member.memberId);
374
+ this.schedulePeerRecoveryCheck(member.memberId, 'member-join');
237
375
  }
238
376
  }), this.room.members.onSync((members) => {
239
377
  const activeMemberIds = new Set();
@@ -241,6 +379,7 @@ export class RoomP2PMediaTransport {
241
379
  if (member.memberId !== this.localMemberId) {
242
380
  activeMemberIds.add(member.memberId);
243
381
  this.ensurePeer(member.memberId);
382
+ this.schedulePeerRecoveryCheck(member.memberId, 'member-sync');
244
383
  }
245
384
  }
246
385
  for (const memberId of Array.from(this.peers.keys())) {
@@ -259,6 +398,7 @@ export class RoomP2PMediaTransport {
259
398
  }), this.room.media.onTrack((track, member) => {
260
399
  if (member.memberId !== this.localMemberId) {
261
400
  this.ensurePeer(member.memberId);
401
+ this.schedulePeerRecoveryCheck(member.memberId, 'media-track');
262
402
  }
263
403
  this.rememberRemoteTrackKind(track, member);
264
404
  }), this.room.media.onTrackRemoved((track, member) => {
@@ -268,7 +408,17 @@ export class RoomP2PMediaTransport {
268
408
  this.remoteTrackKinds.delete(key);
269
409
  this.emittedRemoteTracks.delete(key);
270
410
  this.pendingRemoteTracks.delete(key);
411
+ this.schedulePeerRecoveryCheck(member.memberId, 'media-track-removed');
271
412
  }));
413
+ if (typeof this.room.media.onStateChange === 'function') {
414
+ this.subscriptions.push(this.room.media.onStateChange((member, state) => {
415
+ if (member.memberId === this.localMemberId) {
416
+ return;
417
+ }
418
+ this.rememberRemoteTrackKindsFromState(member, state);
419
+ this.schedulePeerRecoveryCheck(member.memberId, 'media-state');
420
+ }));
421
+ }
272
422
  }
273
423
  hydrateRemoteTrackKinds() {
274
424
  this.remoteTrackKinds.clear();
@@ -278,6 +428,32 @@ export class RoomP2PMediaTransport {
278
428
  for (const track of mediaMember.tracks) {
279
429
  this.rememberRemoteTrackKind(track, mediaMember.member);
280
430
  }
431
+ this.rememberRemoteTrackKindsFromState(mediaMember.member, mediaMember.state);
432
+ }
433
+ }
434
+ rememberRemoteTrackKindsFromState(member, state) {
435
+ if (member.memberId === this.localMemberId || !state) {
436
+ return;
437
+ }
438
+ const mediaKinds = ['audio', 'video', 'screen'];
439
+ for (const kind of mediaKinds) {
440
+ const kindState = state[kind];
441
+ if (!kindState?.published) {
442
+ continue;
443
+ }
444
+ if (typeof kindState.trackId === 'string' && kindState.trackId) {
445
+ this.rememberRemoteTrackKind({
446
+ kind,
447
+ trackId: kindState.trackId,
448
+ muted: kindState.muted === true,
449
+ deviceId: kindState.deviceId,
450
+ publishedAt: kindState.publishedAt,
451
+ adminDisabled: kindState.adminDisabled,
452
+ providerSessionId: kindState.providerSessionId,
453
+ }, member);
454
+ continue;
455
+ }
456
+ this.flushPendingRemoteTracks(member.memberId, kind);
281
457
  }
282
458
  }
283
459
  rememberRemoteTrackKind(track, member) {
@@ -310,17 +486,32 @@ export class RoomP2PMediaTransport {
310
486
  isSettingRemoteAnswerPending: false,
311
487
  pendingCandidates: [],
312
488
  senders: new Map(),
489
+ pendingNegotiation: false,
490
+ recoveryAttempts: 0,
491
+ recoveryTimer: null,
492
+ healthCheckInFlight: false,
493
+ remoteVideoFlows: new Map(),
313
494
  };
314
495
  pc.onicecandidate = (event) => {
315
- if (!event.candidate)
496
+ if (!event.candidate) {
497
+ void this.flushPendingIceCandidates(memberId);
316
498
  return;
317
- void this.room.signals.sendTo(memberId, this.iceEvent, {
318
- candidate: serializeCandidate(event.candidate),
319
- });
499
+ }
500
+ this.queueIceCandidate(memberId, serializeCandidate(event.candidate));
320
501
  };
321
502
  pc.onnegotiationneeded = () => {
322
503
  void this.negotiatePeer(peer);
323
504
  };
505
+ pc.onsignalingstatechange = () => {
506
+ this.maybeRetryPendingNegotiation(peer);
507
+ };
508
+ pc.oniceconnectionstatechange = () => {
509
+ this.handlePeerConnectivityChange(peer, 'ice');
510
+ };
511
+ pc.onconnectionstatechange = () => {
512
+ this.handlePeerConnectivityChange(peer, 'connection');
513
+ this.maybeRetryPendingNegotiation(peer);
514
+ };
324
515
  pc.ontrack = (event) => {
325
516
  const stream = event.streams[0] ?? new MediaStream([event.track]);
326
517
  const key = buildTrackKey(memberId, event.track.id);
@@ -332,26 +523,33 @@ export class RoomP2PMediaTransport {
332
523
  return;
333
524
  }
334
525
  this.emitRemoteTrack(memberId, event.track, stream, kind);
526
+ this.registerPeerRemoteTrack(peer, event.track, kind);
527
+ this.resetPeerRecovery(peer);
335
528
  };
336
529
  this.peers.set(memberId, peer);
337
530
  this.syncPeerSenders(peer);
531
+ this.schedulePeerRecoveryCheck(memberId, 'peer-created');
338
532
  return peer;
339
533
  }
340
534
  async negotiatePeer(peer) {
341
535
  if (!this.connected
342
- || peer.pc.connectionState === 'closed'
343
- || peer.makingOffer
536
+ || peer.pc.connectionState === 'closed') {
537
+ return;
538
+ }
539
+ if (peer.makingOffer
344
540
  || peer.isSettingRemoteAnswerPending
345
541
  || peer.pc.signalingState !== 'stable') {
542
+ peer.pendingNegotiation = true;
346
543
  return;
347
544
  }
348
545
  try {
546
+ peer.pendingNegotiation = false;
349
547
  peer.makingOffer = true;
350
548
  await peer.pc.setLocalDescription();
351
549
  if (!peer.pc.localDescription) {
352
550
  return;
353
551
  }
354
- await this.room.signals.sendTo(peer.memberId, this.offerEvent, {
552
+ await this.sendSignalWithRetry(peer.memberId, this.offerEvent, {
355
553
  description: serializeDescription(peer.pc.localDescription),
356
554
  });
357
555
  }
@@ -383,6 +581,17 @@ export class RoomP2PMediaTransport {
383
581
  return;
384
582
  }
385
583
  try {
584
+ if (description.type === 'answer'
585
+ && peer.pc.signalingState === 'stable'
586
+ && !peer.isSettingRemoteAnswerPending) {
587
+ return;
588
+ }
589
+ if (description.type === 'offer'
590
+ && offerCollision
591
+ && peer.polite
592
+ && peer.pc.signalingState !== 'stable') {
593
+ await peer.pc.setLocalDescription({ type: 'rollback' });
594
+ }
386
595
  peer.isSettingRemoteAnswerPending = description.type === 'answer';
387
596
  await peer.pc.setRemoteDescription(description);
388
597
  peer.isSettingRemoteAnswerPending = false;
@@ -393,12 +602,15 @@ export class RoomP2PMediaTransport {
393
602
  if (!peer.pc.localDescription) {
394
603
  return;
395
604
  }
396
- await this.room.signals.sendTo(senderId, this.answerEvent, {
605
+ await this.sendSignalWithRetry(senderId, this.answerEvent, {
397
606
  description: serializeDescription(peer.pc.localDescription),
398
607
  });
399
608
  }
400
609
  }
401
610
  catch (error) {
611
+ if (description.type === 'answer' && peer.pc.signalingState === 'stable' && isStableAnswerError(error)) {
612
+ return;
613
+ }
402
614
  console.warn('[RoomP2PMediaTransport] Failed to apply remote session description.', {
403
615
  memberId: senderId,
404
616
  expectedType,
@@ -407,31 +619,36 @@ export class RoomP2PMediaTransport {
407
619
  });
408
620
  peer.isSettingRemoteAnswerPending = false;
409
621
  }
622
+ finally {
623
+ this.maybeRetryPendingNegotiation(peer);
624
+ }
410
625
  }
411
626
  async handleIceSignal(payload, meta) {
412
627
  const senderId = typeof meta.memberId === 'string' && meta.memberId.trim() ? meta.memberId.trim() : '';
413
628
  if (!senderId || senderId === this.localMemberId) {
414
629
  return;
415
630
  }
416
- const candidate = this.normalizeCandidate(payload);
417
- if (!candidate) {
631
+ const candidates = this.normalizeCandidates(payload);
632
+ if (candidates.length === 0) {
418
633
  return;
419
634
  }
420
635
  const peer = this.ensurePeer(senderId);
421
636
  if (!peer.pc.remoteDescription) {
422
- peer.pendingCandidates.push(candidate);
637
+ peer.pendingCandidates.push(...candidates);
423
638
  return;
424
639
  }
425
- try {
426
- await peer.pc.addIceCandidate(candidate);
427
- }
428
- catch (error) {
429
- console.warn('[RoomP2PMediaTransport] Failed to add ICE candidate.', {
430
- memberId: senderId,
431
- error,
432
- });
433
- if (!peer.ignoreOffer) {
434
- peer.pendingCandidates.push(candidate);
640
+ for (const candidate of candidates) {
641
+ try {
642
+ await peer.pc.addIceCandidate(candidate);
643
+ }
644
+ catch (error) {
645
+ console.warn('[RoomP2PMediaTransport] Failed to add ICE candidate.', {
646
+ memberId: senderId,
647
+ error,
648
+ });
649
+ if (!peer.ignoreOffer) {
650
+ peer.pendingCandidates.push(candidate);
651
+ }
435
652
  }
436
653
  }
437
654
  }
@@ -456,6 +673,72 @@ export class RoomP2PMediaTransport {
456
673
  }
457
674
  }
458
675
  }
676
+ queueIceCandidate(memberId, candidate) {
677
+ let pending = this.pendingIceCandidates.get(memberId);
678
+ if (!pending) {
679
+ pending = {
680
+ candidates: [],
681
+ timer: null,
682
+ flushing: false,
683
+ };
684
+ this.pendingIceCandidates.set(memberId, pending);
685
+ }
686
+ pending.candidates.push(candidate);
687
+ if (pending.timer || pending.flushing) {
688
+ return;
689
+ }
690
+ pending.timer = globalThis.setTimeout(() => {
691
+ pending.timer = null;
692
+ void this.flushPendingIceCandidates(memberId);
693
+ }, DEFAULT_ICE_BATCH_DELAY_MS);
694
+ }
695
+ async flushPendingIceCandidates(memberId) {
696
+ const pending = this.pendingIceCandidates.get(memberId);
697
+ if (!pending || pending.flushing) {
698
+ return;
699
+ }
700
+ if (pending.timer) {
701
+ clearTimeout(pending.timer);
702
+ pending.timer = null;
703
+ }
704
+ if (pending.candidates.length === 0) {
705
+ this.pendingIceCandidates.delete(memberId);
706
+ return;
707
+ }
708
+ const batch = pending.candidates.splice(0);
709
+ pending.flushing = true;
710
+ try {
711
+ await this.sendSignalWithRetry(memberId, this.iceEvent, { candidates: batch });
712
+ }
713
+ finally {
714
+ pending.flushing = false;
715
+ if (pending.candidates.length > 0) {
716
+ pending.timer = globalThis.setTimeout(() => {
717
+ pending.timer = null;
718
+ void this.flushPendingIceCandidates(memberId);
719
+ }, 0);
720
+ }
721
+ else {
722
+ this.pendingIceCandidates.delete(memberId);
723
+ }
724
+ }
725
+ }
726
+ requestSyncAllPeerSenders() {
727
+ if (this.localUpdateBatchDepth > 0) {
728
+ this.syncAllPeerSendersPending = true;
729
+ return;
730
+ }
731
+ if (this.syncAllPeerSendersScheduled) {
732
+ this.syncAllPeerSendersPending = true;
733
+ return;
734
+ }
735
+ this.syncAllPeerSendersScheduled = true;
736
+ queueMicrotask(() => {
737
+ this.syncAllPeerSendersScheduled = false;
738
+ this.syncAllPeerSendersPending = false;
739
+ this.syncAllPeerSenders();
740
+ });
741
+ }
459
742
  syncAllPeerSenders() {
460
743
  for (const peer of this.peers.values()) {
461
744
  this.syncPeerSenders(peer);
@@ -515,6 +798,15 @@ export class RoomP2PMediaTransport {
515
798
  for (const handler of this.remoteTrackHandlers) {
516
799
  handler(payload);
517
800
  }
801
+ const peer = this.peers.get(memberId);
802
+ if (peer) {
803
+ if (!this.hasMissingPublishedMedia(memberId)) {
804
+ this.resetPeerRecovery(peer);
805
+ }
806
+ else {
807
+ this.schedulePeerRecoveryCheck(memberId, 'partial-remote-track');
808
+ }
809
+ }
518
810
  }
519
811
  resolveFallbackRemoteTrackKind(memberId, track) {
520
812
  const normalizedKind = normalizeTrackKind(track);
@@ -548,6 +840,11 @@ export class RoomP2PMediaTransport {
548
840
  publishedKinds.add(track.kind);
549
841
  }
550
842
  }
843
+ for (const kind of getPublishedKindsFromState(mediaMember.state)) {
844
+ if (kind === 'video' || kind === 'screen') {
845
+ publishedKinds.add(kind);
846
+ }
847
+ }
551
848
  return Array.from(publishedKinds);
552
849
  }
553
850
  getNextUnassignedPublishedVideoLikeKind(memberId) {
@@ -598,6 +895,10 @@ export class RoomP2PMediaTransport {
598
895
  rollbackConnectedState() {
599
896
  this.connected = false;
600
897
  this.localMemberId = null;
898
+ if (this.healthCheckTimer != null) {
899
+ globalThis.clearInterval(this.healthCheckTimer);
900
+ this.healthCheckTimer = null;
901
+ }
601
902
  for (const subscription of this.subscriptions.splice(0)) {
602
903
  subscription.unsubscribe();
603
904
  }
@@ -605,13 +906,27 @@ export class RoomP2PMediaTransport {
605
906
  this.destroyPeer(peer);
606
907
  }
607
908
  this.peers.clear();
909
+ for (const pending of this.pendingIceCandidates.values()) {
910
+ if (pending.timer) {
911
+ clearTimeout(pending.timer);
912
+ }
913
+ }
914
+ this.pendingIceCandidates.clear();
608
915
  this.remoteTrackKinds.clear();
609
916
  this.emittedRemoteTracks.clear();
610
917
  this.pendingRemoteTracks.clear();
611
918
  }
612
919
  destroyPeer(peer) {
920
+ this.clearPeerRecoveryTimer(peer);
921
+ for (const flow of peer.remoteVideoFlows.values()) {
922
+ flow.cleanup();
923
+ }
924
+ peer.remoteVideoFlows.clear();
613
925
  peer.pc.onicecandidate = null;
614
926
  peer.pc.onnegotiationneeded = null;
927
+ peer.pc.onsignalingstatechange = null;
928
+ peer.pc.oniceconnectionstatechange = null;
929
+ peer.pc.onconnectionstatechange = null;
615
930
  peer.pc.ontrack = null;
616
931
  try {
617
932
  peer.pc.close();
@@ -620,6 +935,147 @@ export class RoomP2PMediaTransport {
620
935
  // Ignore duplicate closes.
621
936
  }
622
937
  }
938
+ startHealthChecks() {
939
+ if (this.healthCheckTimer != null) {
940
+ return;
941
+ }
942
+ this.healthCheckTimer = globalThis.setInterval(() => {
943
+ void this.runHealthChecks();
944
+ }, this.options.mediaHealthCheckIntervalMs);
945
+ }
946
+ async runHealthChecks() {
947
+ if (!this.connected) {
948
+ return;
949
+ }
950
+ for (const peer of this.peers.values()) {
951
+ if (peer.healthCheckInFlight || peer.pc.connectionState === 'closed') {
952
+ continue;
953
+ }
954
+ peer.healthCheckInFlight = true;
955
+ try {
956
+ const issue = await this.inspectPeerVideoHealth(peer);
957
+ if (issue) {
958
+ this.schedulePeerRecoveryCheck(peer.memberId, issue, 0);
959
+ }
960
+ }
961
+ finally {
962
+ peer.healthCheckInFlight = false;
963
+ }
964
+ }
965
+ }
966
+ registerPeerRemoteTrack(peer, track, kind) {
967
+ if (kind !== 'video' && kind !== 'screen') {
968
+ return;
969
+ }
970
+ if (peer.remoteVideoFlows.has(track.id)) {
971
+ return;
972
+ }
973
+ const flow = {
974
+ track,
975
+ receivedAt: Date.now(),
976
+ lastHealthyAt: track.muted ? 0 : Date.now(),
977
+ lastBytesReceived: null,
978
+ lastFramesDecoded: null,
979
+ cleanup: () => { },
980
+ };
981
+ const markHealthy = () => {
982
+ flow.lastHealthyAt = Date.now();
983
+ };
984
+ const handleEnded = () => {
985
+ flow.cleanup();
986
+ peer.remoteVideoFlows.delete(track.id);
987
+ };
988
+ track.addEventListener('unmute', markHealthy);
989
+ track.addEventListener('ended', handleEnded);
990
+ flow.cleanup = () => {
991
+ track.removeEventListener('unmute', markHealthy);
992
+ track.removeEventListener('ended', handleEnded);
993
+ };
994
+ peer.remoteVideoFlows.set(track.id, flow);
995
+ }
996
+ async inspectPeerVideoHealth(peer) {
997
+ if (this.hasMissingPublishedMedia(peer.memberId)) {
998
+ return 'health-missing-published-media';
999
+ }
1000
+ const mediaMember = this.room.media.list().find((entry) => entry.member.memberId === peer.memberId);
1001
+ const publishedVideoState = mediaMember?.state?.video;
1002
+ const publishedScreenState = mediaMember?.state?.screen;
1003
+ const publishedAt = Math.max(publishedVideoState?.publishedAt ?? 0, publishedScreenState?.publishedAt ?? 0);
1004
+ const expectsVideoFlow = Boolean(publishedVideoState?.published
1005
+ || publishedScreenState?.published
1006
+ || mediaMember?.tracks.some((track) => (track.kind === 'video' || track.kind === 'screen') && track.trackId));
1007
+ if (!expectsVideoFlow) {
1008
+ return null;
1009
+ }
1010
+ const videoReceivers = peer.pc
1011
+ .getReceivers()
1012
+ .filter((receiver) => receiver.track?.kind === 'video');
1013
+ if (videoReceivers.length === 0) {
1014
+ const firstObservedAt = Math.max(publishedAt, ...Array.from(peer.remoteVideoFlows.values()).map((flow) => flow.receivedAt));
1015
+ if (firstObservedAt > 0 && Date.now() - firstObservedAt > this.options.videoFlowGraceMs) {
1016
+ return 'health-no-video-receiver';
1017
+ }
1018
+ return null;
1019
+ }
1020
+ let sawHealthyFlow = false;
1021
+ let lastObservedAt = publishedAt;
1022
+ for (const receiver of videoReceivers) {
1023
+ const track = receiver.track;
1024
+ if (!track) {
1025
+ continue;
1026
+ }
1027
+ const flow = peer.remoteVideoFlows.get(track.id);
1028
+ if (!flow) {
1029
+ continue;
1030
+ }
1031
+ lastObservedAt = Math.max(lastObservedAt, flow.receivedAt, flow.lastHealthyAt);
1032
+ if (!track.muted) {
1033
+ flow.lastHealthyAt = Math.max(flow.lastHealthyAt, Date.now());
1034
+ }
1035
+ try {
1036
+ const stats = await receiver.getStats();
1037
+ for (const report of stats.values()) {
1038
+ if (report.type !== 'inbound-rtp' || report.kind !== 'video') {
1039
+ continue;
1040
+ }
1041
+ const bytesReceived = typeof report.bytesReceived === 'number' ? report.bytesReceived : null;
1042
+ const framesDecoded = typeof report.framesDecoded === 'number' ? report.framesDecoded : null;
1043
+ const bytesIncreased = bytesReceived != null
1044
+ && flow.lastBytesReceived != null
1045
+ && bytesReceived > flow.lastBytesReceived;
1046
+ const framesIncreased = framesDecoded != null
1047
+ && flow.lastFramesDecoded != null
1048
+ && framesDecoded > flow.lastFramesDecoded;
1049
+ if ((bytesReceived != null && bytesReceived > 0) || (framesDecoded != null && framesDecoded > 0)) {
1050
+ flow.lastHealthyAt = Math.max(flow.lastHealthyAt, Date.now());
1051
+ }
1052
+ if (bytesIncreased || framesIncreased) {
1053
+ flow.lastHealthyAt = Date.now();
1054
+ }
1055
+ flow.lastBytesReceived = bytesReceived;
1056
+ flow.lastFramesDecoded = framesDecoded;
1057
+ break;
1058
+ }
1059
+ }
1060
+ catch {
1061
+ // Ignore stats read failures and rely on track state.
1062
+ }
1063
+ if (flow.lastHealthyAt > 0) {
1064
+ sawHealthyFlow = true;
1065
+ }
1066
+ lastObservedAt = Math.max(lastObservedAt, flow.lastHealthyAt);
1067
+ }
1068
+ if (sawHealthyFlow) {
1069
+ return null;
1070
+ }
1071
+ if (lastObservedAt > 0 && Date.now() - lastObservedAt > this.options.videoFlowStallGraceMs) {
1072
+ return 'health-stalled-video-flow';
1073
+ }
1074
+ if (publishedAt > 0 && Date.now() - publishedAt > this.options.videoFlowGraceMs) {
1075
+ return 'health-video-flow-timeout';
1076
+ }
1077
+ return null;
1078
+ }
623
1079
  async createUserMediaTrack(kind, constraints) {
624
1080
  const devices = this.options.mediaDevices;
625
1081
  if (!devices?.getUserMedia || constraints === false) {
@@ -667,15 +1123,44 @@ export class RoomP2PMediaTransport {
667
1123
  sdp: typeof raw.sdp === 'string' ? raw.sdp : undefined,
668
1124
  };
669
1125
  }
670
- normalizeCandidate(payload) {
1126
+ normalizeCandidates(payload) {
671
1127
  if (!payload || typeof payload !== 'object') {
672
- return null;
1128
+ return [];
1129
+ }
1130
+ const batch = payload.candidates;
1131
+ if (Array.isArray(batch)) {
1132
+ return batch.filter((candidate) => !!candidate && typeof candidate.candidate === 'string');
673
1133
  }
674
1134
  const raw = payload.candidate;
675
1135
  if (!raw || typeof raw.candidate !== 'string') {
676
- return null;
1136
+ return [];
1137
+ }
1138
+ return [raw];
1139
+ }
1140
+ async sendSignalWithRetry(memberId, event, payload) {
1141
+ await this.withRateLimitRetry(`signal ${event}`, () => this.room.signals.sendTo(memberId, event, payload));
1142
+ }
1143
+ async withRateLimitRetry(label, action) {
1144
+ let lastError;
1145
+ for (let attempt = 0; attempt <= DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS.length; attempt += 1) {
1146
+ try {
1147
+ return await action();
1148
+ }
1149
+ catch (error) {
1150
+ lastError = error;
1151
+ if (!isRateLimitError(error) || attempt === DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS.length) {
1152
+ throw error;
1153
+ }
1154
+ const delayMs = DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS[attempt];
1155
+ console.warn('[RoomP2PMediaTransport] Rate limited room operation. Retrying.', {
1156
+ label,
1157
+ attempt: attempt + 1,
1158
+ delayMs,
1159
+ });
1160
+ await new Promise((resolve) => globalThis.setTimeout(resolve, delayMs));
1161
+ }
677
1162
  }
678
- return raw;
1163
+ throw lastError;
679
1164
  }
680
1165
  get offerEvent() {
681
1166
  return `${this.options.signalPrefix}.offer`;
@@ -686,5 +1171,169 @@ export class RoomP2PMediaTransport {
686
1171
  get iceEvent() {
687
1172
  return `${this.options.signalPrefix}.ice`;
688
1173
  }
1174
+ maybeRetryPendingNegotiation(peer) {
1175
+ if (!peer.pendingNegotiation
1176
+ || !this.connected
1177
+ || peer.pc.connectionState === 'closed'
1178
+ || peer.makingOffer
1179
+ || peer.isSettingRemoteAnswerPending
1180
+ || peer.pc.signalingState !== 'stable') {
1181
+ return;
1182
+ }
1183
+ peer.pendingNegotiation = false;
1184
+ queueMicrotask(() => {
1185
+ void this.negotiatePeer(peer);
1186
+ });
1187
+ }
1188
+ handlePeerConnectivityChange(peer, source) {
1189
+ if (!this.connected || peer.pc.connectionState === 'closed') {
1190
+ return;
1191
+ }
1192
+ const connectionState = peer.pc.connectionState;
1193
+ const iceConnectionState = peer.pc.iceConnectionState;
1194
+ if (connectionState === 'connected'
1195
+ || iceConnectionState === 'connected'
1196
+ || iceConnectionState === 'completed') {
1197
+ this.resetPeerRecovery(peer);
1198
+ return;
1199
+ }
1200
+ if (connectionState === 'failed' || iceConnectionState === 'failed') {
1201
+ this.schedulePeerRecoveryCheck(peer.memberId, `${source}-failed`, 0);
1202
+ return;
1203
+ }
1204
+ if (connectionState === 'disconnected' || iceConnectionState === 'disconnected') {
1205
+ this.schedulePeerRecoveryCheck(peer.memberId, `${source}-disconnected`, this.options.disconnectedRecoveryDelayMs);
1206
+ }
1207
+ }
1208
+ schedulePeerRecoveryCheck(memberId, reason, delayMs = this.options.missingMediaGraceMs) {
1209
+ const peer = this.peers.get(memberId);
1210
+ if (!peer || !this.connected || peer.pc.connectionState === 'closed') {
1211
+ return;
1212
+ }
1213
+ const healthSensitiveReason = reason.includes('health')
1214
+ || reason.includes('stalled')
1215
+ || reason.includes('flow');
1216
+ if (!this.hasMissingPublishedMedia(memberId)
1217
+ && !healthSensitiveReason
1218
+ && !reason.includes('failed')
1219
+ && !reason.includes('disconnected')) {
1220
+ this.resetPeerRecovery(peer);
1221
+ return;
1222
+ }
1223
+ this.clearPeerRecoveryTimer(peer);
1224
+ peer.recoveryTimer = globalThis.setTimeout(() => {
1225
+ peer.recoveryTimer = null;
1226
+ void this.recoverPeer(peer, reason);
1227
+ }, Math.max(0, delayMs));
1228
+ }
1229
+ async recoverPeer(peer, reason) {
1230
+ if (!this.connected || peer.pc.connectionState === 'closed') {
1231
+ return;
1232
+ }
1233
+ const stillMissingPublishedMedia = this.hasMissingPublishedMedia(peer.memberId);
1234
+ const connectivityIssue = peer.pc.connectionState === 'failed'
1235
+ || peer.pc.connectionState === 'disconnected'
1236
+ || peer.pc.iceConnectionState === 'failed'
1237
+ || peer.pc.iceConnectionState === 'disconnected';
1238
+ const healthIssue = !stillMissingPublishedMedia && !connectivityIssue
1239
+ ? await this.inspectPeerVideoHealth(peer)
1240
+ : null;
1241
+ if (!stillMissingPublishedMedia && !connectivityIssue && !healthIssue) {
1242
+ this.resetPeerRecovery(peer);
1243
+ return;
1244
+ }
1245
+ if (peer.recoveryAttempts >= this.options.maxRecoveryAttempts) {
1246
+ this.resetPeer(peer.memberId, reason);
1247
+ return;
1248
+ }
1249
+ peer.recoveryAttempts += 1;
1250
+ this.requestIceRestart(peer, reason);
1251
+ }
1252
+ requestIceRestart(peer, reason) {
1253
+ try {
1254
+ if (typeof peer.pc.restartIce === 'function') {
1255
+ peer.pc.restartIce();
1256
+ }
1257
+ }
1258
+ catch (error) {
1259
+ console.warn('[RoomP2PMediaTransport] Failed to request ICE restart.', {
1260
+ memberId: peer.memberId,
1261
+ reason,
1262
+ error,
1263
+ });
1264
+ }
1265
+ peer.pendingNegotiation = true;
1266
+ this.maybeRetryPendingNegotiation(peer);
1267
+ }
1268
+ resetPeer(memberId, reason) {
1269
+ const existing = this.peers.get(memberId);
1270
+ if (existing) {
1271
+ this.destroyPeer(existing);
1272
+ this.peers.delete(memberId);
1273
+ }
1274
+ const replacement = this.ensurePeer(memberId);
1275
+ replacement.recoveryAttempts = 0;
1276
+ replacement.pendingNegotiation = true;
1277
+ this.maybeRetryPendingNegotiation(replacement);
1278
+ this.schedulePeerRecoveryCheck(memberId, `${reason}:after-reset`);
1279
+ }
1280
+ resetPeerRecovery(peer) {
1281
+ peer.recoveryAttempts = 0;
1282
+ peer.pendingNegotiation = false;
1283
+ this.clearPeerRecoveryTimer(peer);
1284
+ }
1285
+ clearPeerRecoveryTimer(peer) {
1286
+ if (peer.recoveryTimer != null) {
1287
+ globalThis.clearTimeout(peer.recoveryTimer);
1288
+ peer.recoveryTimer = null;
1289
+ }
1290
+ }
1291
+ hasMissingPublishedMedia(memberId) {
1292
+ const mediaMember = this.room.media.list().find((entry) => entry.member.memberId === memberId);
1293
+ if (!mediaMember) {
1294
+ return false;
1295
+ }
1296
+ const publishedKinds = new Set();
1297
+ for (const track of mediaMember.tracks) {
1298
+ if (track.trackId) {
1299
+ publishedKinds.add(track.kind);
1300
+ }
1301
+ }
1302
+ for (const kind of getPublishedKindsFromState(mediaMember.state)) {
1303
+ publishedKinds.add(kind);
1304
+ }
1305
+ const emittedKinds = new Set();
1306
+ for (const key of this.emittedRemoteTracks) {
1307
+ if (!key.startsWith(`${memberId}:`)) {
1308
+ continue;
1309
+ }
1310
+ const kind = this.remoteTrackKinds.get(key);
1311
+ if (kind) {
1312
+ emittedKinds.add(kind);
1313
+ }
1314
+ }
1315
+ let pendingAudioCount = 0;
1316
+ let pendingVideoLikeCount = 0;
1317
+ for (const pending of this.pendingRemoteTracks.values()) {
1318
+ if (pending.memberId !== memberId) {
1319
+ continue;
1320
+ }
1321
+ if (pending.track.kind === 'audio') {
1322
+ pendingAudioCount += 1;
1323
+ }
1324
+ else if (pending.track.kind === 'video') {
1325
+ pendingVideoLikeCount += 1;
1326
+ }
1327
+ }
1328
+ if (publishedKinds.has('audio') && !emittedKinds.has('audio') && pendingAudioCount === 0) {
1329
+ return true;
1330
+ }
1331
+ const expectedVideoLikeKinds = Array.from(publishedKinds).filter((kind) => kind === 'video' || kind === 'screen');
1332
+ if (expectedVideoLikeKinds.length === 0) {
1333
+ return false;
1334
+ }
1335
+ const emittedVideoLikeCount = Array.from(emittedKinds).filter((kind) => kind === 'video' || kind === 'screen').length;
1336
+ return emittedVideoLikeCount + pendingVideoLikeCount < expectedVideoLikeKinds.length;
1337
+ }
689
1338
  }
690
1339
  //# sourceMappingURL=room-p2p-media.js.map