@edge-base/web 0.2.3 → 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.
- package/dist/database-live.d.ts.map +1 -1
- package/dist/database-live.js +5 -3
- package/dist/database-live.js.map +1 -1
- package/dist/room-p2p-media.d.ts +40 -2
- package/dist/room-p2p-media.d.ts.map +1 -1
- package/dist/room-p2p-media.js +691 -42
- package/dist/room-p2p-media.js.map +1 -1
- package/dist/room.d.ts +5 -0
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +18 -0
- package/dist/room.js.map +1 -1
- package/package.json +2 -2
package/dist/room-p2p-media.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
271
|
+
}) ?? Promise.resolve());
|
|
272
|
+
this.requestSyncAllPeerSenders();
|
|
159
273
|
return track;
|
|
160
274
|
}
|
|
161
275
|
async disableAudio() {
|
|
162
276
|
this.releaseLocalTrack('audio');
|
|
163
|
-
this.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
318
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
417
|
-
if (
|
|
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(
|
|
637
|
+
peer.pendingCandidates.push(...candidates);
|
|
423
638
|
return;
|
|
424
639
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
peer.
|
|
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
|
-
|
|
1126
|
+
normalizeCandidates(payload) {
|
|
671
1127
|
if (!payload || typeof payload !== 'object') {
|
|
672
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|