@edge-base/web 0.2.4 → 0.2.6
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/README.md +17 -2
- package/dist/auth-refresh.d.ts.map +1 -1
- package/dist/auth-refresh.js +6 -5
- package/dist/auth-refresh.js.map +1 -1
- package/dist/client.d.ts +25 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +23 -1
- package/dist/client.js.map +1 -1
- package/dist/database-live.d.ts.map +1 -1
- package/dist/database-live.js +5 -5
- package/dist/database-live.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/room-cloudflare-media.d.ts +6 -1
- package/dist/room-cloudflare-media.d.ts.map +1 -1
- package/dist/room-cloudflare-media.js +113 -1
- package/dist/room-cloudflare-media.js.map +1 -1
- package/dist/room-p2p-media.d.ts +43 -2
- package/dist/room-p2p-media.d.ts.map +1 -1
- package/dist/room-p2p-media.js +827 -42
- package/dist/room-p2p-media.js.map +1 -1
- package/dist/room.d.ts +71 -0
- package/dist/room.d.ts.map +1 -1
- package/dist/room.js +133 -33
- package/dist/room.js.map +1 -1
- package/dist/token-manager.d.ts +5 -1
- package/dist/token-manager.d.ts.map +1 -1
- package/dist/token-manager.js +38 -30
- package/dist/token-manager.js.map +1 -1
- package/llms.txt +10 -3
- package/package.json +2 -2
package/dist/room-p2p-media.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
|
+
import { EdgeBaseError } from '@edge-base/core';
|
|
1
2
|
import { createSubscription } from './room.js';
|
|
2
3
|
const DEFAULT_SIGNAL_PREFIX = 'edgebase.media.p2p';
|
|
3
4
|
const DEFAULT_ICE_SERVERS = [
|
|
4
5
|
{ urls: 'stun:stun.l.google.com:19302' },
|
|
5
6
|
];
|
|
6
7
|
const DEFAULT_MEMBER_READY_TIMEOUT_MS = 10_000;
|
|
8
|
+
const DEFAULT_MISSING_MEDIA_GRACE_MS = 1_200;
|
|
9
|
+
const DEFAULT_DISCONNECTED_RECOVERY_DELAY_MS = 1_800;
|
|
10
|
+
const DEFAULT_MAX_RECOVERY_ATTEMPTS = 2;
|
|
11
|
+
const DEFAULT_ICE_BATCH_DELAY_MS = 40;
|
|
12
|
+
const DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS = [160, 320, 640];
|
|
13
|
+
const DEFAULT_MEDIA_HEALTH_CHECK_INTERVAL_MS = 4_000;
|
|
14
|
+
const DEFAULT_VIDEO_FLOW_GRACE_MS = 8_000;
|
|
15
|
+
const DEFAULT_VIDEO_FLOW_STALL_GRACE_MS = 12_000;
|
|
7
16
|
function buildTrackKey(memberId, trackId) {
|
|
8
17
|
return `${memberId}:${trackId}`;
|
|
9
18
|
}
|
|
@@ -29,6 +38,70 @@ function serializeCandidate(candidate) {
|
|
|
29
38
|
}
|
|
30
39
|
return candidate;
|
|
31
40
|
}
|
|
41
|
+
function normalizeIceServerUrls(urls) {
|
|
42
|
+
if (Array.isArray(urls)) {
|
|
43
|
+
return urls.filter((value) => typeof value === 'string' && value.trim().length > 0);
|
|
44
|
+
}
|
|
45
|
+
if (typeof urls === 'string' && urls.trim().length > 0) {
|
|
46
|
+
return [urls];
|
|
47
|
+
}
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
function normalizeIceServers(iceServers) {
|
|
51
|
+
if (!Array.isArray(iceServers)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const normalized = [];
|
|
55
|
+
for (const server of iceServers) {
|
|
56
|
+
const urls = normalizeIceServerUrls(server?.urls);
|
|
57
|
+
if (urls.length === 0) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
normalized.push({
|
|
61
|
+
urls: urls.length === 1 ? urls[0] : urls,
|
|
62
|
+
username: typeof server.username === 'string' ? server.username : undefined,
|
|
63
|
+
credential: typeof server.credential === 'string' ? server.credential : undefined,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return normalized;
|
|
67
|
+
}
|
|
68
|
+
function getPublishedKindsFromState(state) {
|
|
69
|
+
if (!state) {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
const publishedKinds = [];
|
|
73
|
+
if (state.audio?.published)
|
|
74
|
+
publishedKinds.push('audio');
|
|
75
|
+
if (state.video?.published)
|
|
76
|
+
publishedKinds.push('video');
|
|
77
|
+
if (state.screen?.published)
|
|
78
|
+
publishedKinds.push('screen');
|
|
79
|
+
return publishedKinds;
|
|
80
|
+
}
|
|
81
|
+
function isStableAnswerError(error) {
|
|
82
|
+
const message = typeof error === 'object' && error && 'message' in error
|
|
83
|
+
? String(error.message ?? '')
|
|
84
|
+
: '';
|
|
85
|
+
return (message.includes('Called in wrong state: stable')
|
|
86
|
+
|| message.includes('Failed to set remote answer sdp')
|
|
87
|
+
|| (message.includes('setRemoteDescription') && message.includes('stable')));
|
|
88
|
+
}
|
|
89
|
+
function isRateLimitError(error) {
|
|
90
|
+
const message = typeof error === 'object' && error && 'message' in error
|
|
91
|
+
? String(error.message ?? '')
|
|
92
|
+
: String(error ?? '');
|
|
93
|
+
return message.toLowerCase().includes('rate limited');
|
|
94
|
+
}
|
|
95
|
+
function sameIceServer(candidate, urls) {
|
|
96
|
+
const candidateUrls = normalizeIceServerUrls(candidate.urls);
|
|
97
|
+
return candidateUrls.length === urls.length && candidateUrls.every((url, index) => url === urls[index]);
|
|
98
|
+
}
|
|
99
|
+
function getErrorMessage(error) {
|
|
100
|
+
if (error instanceof Error && typeof error.message === 'string' && error.message.length > 0) {
|
|
101
|
+
return error.message;
|
|
102
|
+
}
|
|
103
|
+
return 'Unknown room media error.';
|
|
104
|
+
}
|
|
32
105
|
export class RoomP2PMediaTransport {
|
|
33
106
|
room;
|
|
34
107
|
options;
|
|
@@ -38,9 +111,15 @@ export class RoomP2PMediaTransport {
|
|
|
38
111
|
remoteTrackKinds = new Map();
|
|
39
112
|
emittedRemoteTracks = new Set();
|
|
40
113
|
pendingRemoteTracks = new Map();
|
|
114
|
+
pendingIceCandidates = new Map();
|
|
41
115
|
subscriptions = [];
|
|
42
116
|
localMemberId = null;
|
|
43
117
|
connected = false;
|
|
118
|
+
iceServersResolved = false;
|
|
119
|
+
localUpdateBatchDepth = 0;
|
|
120
|
+
syncAllPeerSendersScheduled = false;
|
|
121
|
+
syncAllPeerSendersPending = false;
|
|
122
|
+
healthCheckTimer = null;
|
|
44
123
|
constructor(room, options) {
|
|
45
124
|
this.room = room;
|
|
46
125
|
this.options = {
|
|
@@ -55,6 +134,13 @@ export class RoomP2PMediaTransport {
|
|
|
55
134
|
mediaDevices: options?.mediaDevices
|
|
56
135
|
?? (typeof navigator !== 'undefined' ? navigator.mediaDevices : undefined),
|
|
57
136
|
signalPrefix: options?.signalPrefix ?? DEFAULT_SIGNAL_PREFIX,
|
|
137
|
+
turnCredentialTtlSeconds: options?.turnCredentialTtlSeconds ?? 3600,
|
|
138
|
+
missingMediaGraceMs: options?.missingMediaGraceMs ?? DEFAULT_MISSING_MEDIA_GRACE_MS,
|
|
139
|
+
disconnectedRecoveryDelayMs: options?.disconnectedRecoveryDelayMs ?? DEFAULT_DISCONNECTED_RECOVERY_DELAY_MS,
|
|
140
|
+
maxRecoveryAttempts: options?.maxRecoveryAttempts ?? DEFAULT_MAX_RECOVERY_ATTEMPTS,
|
|
141
|
+
mediaHealthCheckIntervalMs: options?.mediaHealthCheckIntervalMs ?? DEFAULT_MEDIA_HEALTH_CHECK_INTERVAL_MS,
|
|
142
|
+
videoFlowGraceMs: options?.videoFlowGraceMs ?? DEFAULT_VIDEO_FLOW_GRACE_MS,
|
|
143
|
+
videoFlowStallGraceMs: options?.videoFlowStallGraceMs ?? DEFAULT_VIDEO_FLOW_STALL_GRACE_MS,
|
|
58
144
|
};
|
|
59
145
|
}
|
|
60
146
|
getSessionId() {
|
|
@@ -73,14 +159,27 @@ export class RoomP2PMediaTransport {
|
|
|
73
159
|
if (payload && typeof payload === 'object' && 'sessionDescription' in payload) {
|
|
74
160
|
throw new Error('RoomP2PMediaTransport.connect() does not accept sessionDescription; use room.signals through the built-in transport instead.');
|
|
75
161
|
}
|
|
162
|
+
const capabilities = await this.collectCapabilities({ includeProviderChecks: false });
|
|
163
|
+
const fatalIssue = capabilities.issues.find((issue) => issue.fatal);
|
|
164
|
+
if (fatalIssue) {
|
|
165
|
+
const error = new EdgeBaseError(400, fatalIssue.message, { preflight: { code: fatalIssue.code, message: fatalIssue.message } }, 'room-media-preflight-failed');
|
|
166
|
+
Object.assign(error, {
|
|
167
|
+
provider: capabilities.provider,
|
|
168
|
+
issue: fatalIssue,
|
|
169
|
+
capabilities,
|
|
170
|
+
});
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
76
173
|
const currentMember = await this.waitForCurrentMember();
|
|
77
174
|
if (!currentMember) {
|
|
78
175
|
throw new Error('Join the room before connecting a P2P media transport.');
|
|
79
176
|
}
|
|
80
177
|
this.localMemberId = currentMember.memberId;
|
|
178
|
+
await this.resolveRtcConfiguration();
|
|
81
179
|
this.connected = true;
|
|
82
180
|
this.hydrateRemoteTrackKinds();
|
|
83
181
|
this.attachRoomSubscriptions();
|
|
182
|
+
this.startHealthChecks();
|
|
84
183
|
try {
|
|
85
184
|
for (const member of this.room.members.list()) {
|
|
86
185
|
if (member.memberId !== this.localMemberId) {
|
|
@@ -94,6 +193,124 @@ export class RoomP2PMediaTransport {
|
|
|
94
193
|
}
|
|
95
194
|
return this.localMemberId;
|
|
96
195
|
}
|
|
196
|
+
async getCapabilities() {
|
|
197
|
+
return this.collectCapabilities({ includeProviderChecks: true });
|
|
198
|
+
}
|
|
199
|
+
async collectCapabilities(options) {
|
|
200
|
+
const issues = [];
|
|
201
|
+
const currentMember = this.room.members.current();
|
|
202
|
+
const roomIssueFatal = !currentMember;
|
|
203
|
+
let room = {
|
|
204
|
+
ok: true,
|
|
205
|
+
type: 'room_connect_ready',
|
|
206
|
+
category: 'ready',
|
|
207
|
+
message: 'Room WebSocket preflight passed',
|
|
208
|
+
};
|
|
209
|
+
if (typeof this.room.checkConnection === 'function') {
|
|
210
|
+
try {
|
|
211
|
+
room = await this.room.checkConnection();
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
issues.push({
|
|
215
|
+
code: 'room_connect_check_failed',
|
|
216
|
+
category: 'room',
|
|
217
|
+
message: `Room connect-check failed: ${getErrorMessage(error)}`,
|
|
218
|
+
fatal: roomIssueFatal,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (!room.ok) {
|
|
223
|
+
issues.push({
|
|
224
|
+
code: room.type,
|
|
225
|
+
category: 'room',
|
|
226
|
+
message: room.message,
|
|
227
|
+
fatal: roomIssueFatal,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
if (!currentMember) {
|
|
231
|
+
issues.push({
|
|
232
|
+
code: 'room_member_not_joined',
|
|
233
|
+
category: 'room',
|
|
234
|
+
message: 'Join the room before connecting a P2P media transport.',
|
|
235
|
+
fatal: true,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const browser = {
|
|
239
|
+
mediaDevices: !!this.options.mediaDevices,
|
|
240
|
+
getUserMedia: typeof this.options.mediaDevices?.getUserMedia === 'function',
|
|
241
|
+
getDisplayMedia: typeof this.options.mediaDevices?.getDisplayMedia === 'function',
|
|
242
|
+
enumerateDevices: typeof this.options.mediaDevices?.enumerateDevices === 'function',
|
|
243
|
+
rtcPeerConnection: typeof this.options.peerConnectionFactory === 'function'
|
|
244
|
+
|| typeof RTCPeerConnection !== 'undefined',
|
|
245
|
+
};
|
|
246
|
+
if (!browser.rtcPeerConnection) {
|
|
247
|
+
issues.push({
|
|
248
|
+
code: 'webrtc_unavailable',
|
|
249
|
+
category: 'browser',
|
|
250
|
+
message: 'RTCPeerConnection is not available in this environment.',
|
|
251
|
+
fatal: true,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
if (!browser.getUserMedia) {
|
|
255
|
+
issues.push({
|
|
256
|
+
code: 'media_devices_get_user_media_unavailable',
|
|
257
|
+
category: 'browser',
|
|
258
|
+
message: 'getUserMedia() is not available; local audio/video capture will be unavailable.',
|
|
259
|
+
fatal: false,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
if (!browser.getDisplayMedia) {
|
|
263
|
+
issues.push({
|
|
264
|
+
code: 'media_devices_get_display_media_unavailable',
|
|
265
|
+
category: 'browser',
|
|
266
|
+
message: 'getDisplayMedia() is not available; screen sharing will be unavailable.',
|
|
267
|
+
fatal: false,
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
let turn;
|
|
271
|
+
const loadIceServers = this.room.media.realtime?.iceServers;
|
|
272
|
+
if (options.includeProviderChecks && typeof loadIceServers === 'function') {
|
|
273
|
+
turn = {
|
|
274
|
+
requested: true,
|
|
275
|
+
available: false,
|
|
276
|
+
iceServerCount: 0,
|
|
277
|
+
};
|
|
278
|
+
try {
|
|
279
|
+
const response = await loadIceServers({ ttl: this.options.turnCredentialTtlSeconds });
|
|
280
|
+
const servers = normalizeIceServers(response?.iceServers);
|
|
281
|
+
turn.available = servers.length > 0;
|
|
282
|
+
turn.iceServerCount = servers.length;
|
|
283
|
+
if (!turn.available) {
|
|
284
|
+
issues.push({
|
|
285
|
+
code: 'turn_credentials_unavailable',
|
|
286
|
+
category: 'provider',
|
|
287
|
+
message: 'No TURN credentials were returned; the transport will fall back to its configured ICE servers.',
|
|
288
|
+
fatal: false,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
turn.error = getErrorMessage(error);
|
|
294
|
+
issues.push({
|
|
295
|
+
code: 'turn_credentials_failed',
|
|
296
|
+
category: 'provider',
|
|
297
|
+
message: `Failed to resolve TURN credentials: ${turn.error}`,
|
|
298
|
+
fatal: false,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
provider: 'p2p',
|
|
304
|
+
canConnect: !issues.some((issue) => issue.fatal),
|
|
305
|
+
issues,
|
|
306
|
+
room,
|
|
307
|
+
joined: !!currentMember,
|
|
308
|
+
currentMemberId: currentMember?.memberId ?? null,
|
|
309
|
+
sessionId: this.getSessionId(),
|
|
310
|
+
browser,
|
|
311
|
+
turn,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
97
314
|
async waitForCurrentMember(timeoutMs = DEFAULT_MEMBER_READY_TIMEOUT_MS) {
|
|
98
315
|
const startedAt = Date.now();
|
|
99
316
|
while (Date.now() - startedAt < timeoutMs) {
|
|
@@ -105,6 +322,39 @@ export class RoomP2PMediaTransport {
|
|
|
105
322
|
}
|
|
106
323
|
return this.room.members.current();
|
|
107
324
|
}
|
|
325
|
+
async resolveRtcConfiguration() {
|
|
326
|
+
if (this.iceServersResolved) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const loadIceServers = this.room.media.realtime?.iceServers;
|
|
330
|
+
if (typeof loadIceServers !== 'function') {
|
|
331
|
+
this.iceServersResolved = true;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
try {
|
|
335
|
+
const response = await loadIceServers({ ttl: this.options.turnCredentialTtlSeconds });
|
|
336
|
+
const realtimeIceServers = normalizeIceServers(response?.iceServers);
|
|
337
|
+
if (realtimeIceServers.length === 0) {
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
const fallbackIceServers = normalizeIceServers(DEFAULT_ICE_SERVERS);
|
|
341
|
+
const mergedIceServers = [
|
|
342
|
+
...realtimeIceServers,
|
|
343
|
+
...fallbackIceServers.filter((server) => {
|
|
344
|
+
const urls = normalizeIceServerUrls(server.urls);
|
|
345
|
+
return !realtimeIceServers.some((candidate) => sameIceServer(candidate, urls));
|
|
346
|
+
}),
|
|
347
|
+
];
|
|
348
|
+
this.options.rtcConfiguration = {
|
|
349
|
+
...this.options.rtcConfiguration,
|
|
350
|
+
iceServers: mergedIceServers,
|
|
351
|
+
};
|
|
352
|
+
this.iceServersResolved = true;
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
console.warn('[RoomP2PMediaTransport] Failed to load TURN / ICE credentials. Falling back to default STUN.', error);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
108
358
|
async enableAudio(constraints = true) {
|
|
109
359
|
const track = await this.createUserMediaTrack('audio', constraints);
|
|
110
360
|
if (!track) {
|
|
@@ -112,12 +362,12 @@ export class RoomP2PMediaTransport {
|
|
|
112
362
|
}
|
|
113
363
|
const providerSessionId = await this.ensureConnectedMemberId();
|
|
114
364
|
this.rememberLocalTrack('audio', track, track.getSettings().deviceId, true);
|
|
115
|
-
await this.room.media.audio.enable?.({
|
|
365
|
+
await this.withRateLimitRetry('enable audio', () => this.room.media.audio.enable?.({
|
|
116
366
|
trackId: track.id,
|
|
117
367
|
deviceId: track.getSettings().deviceId,
|
|
118
368
|
providerSessionId,
|
|
119
|
-
});
|
|
120
|
-
this.
|
|
369
|
+
}) ?? Promise.resolve());
|
|
370
|
+
this.requestSyncAllPeerSenders();
|
|
121
371
|
return track;
|
|
122
372
|
}
|
|
123
373
|
async enableVideo(constraints = true) {
|
|
@@ -127,12 +377,12 @@ export class RoomP2PMediaTransport {
|
|
|
127
377
|
}
|
|
128
378
|
const providerSessionId = await this.ensureConnectedMemberId();
|
|
129
379
|
this.rememberLocalTrack('video', track, track.getSettings().deviceId, true);
|
|
130
|
-
await this.room.media.video.enable?.({
|
|
380
|
+
await this.withRateLimitRetry('enable video', () => this.room.media.video.enable?.({
|
|
131
381
|
trackId: track.id,
|
|
132
382
|
deviceId: track.getSettings().deviceId,
|
|
133
383
|
providerSessionId,
|
|
134
|
-
});
|
|
135
|
-
this.
|
|
384
|
+
}) ?? Promise.resolve());
|
|
385
|
+
this.requestSyncAllPeerSenders();
|
|
136
386
|
return track;
|
|
137
387
|
}
|
|
138
388
|
async startScreenShare(constraints = { video: true, audio: false }) {
|
|
@@ -150,28 +400,28 @@ export class RoomP2PMediaTransport {
|
|
|
150
400
|
}, { once: true });
|
|
151
401
|
const providerSessionId = await this.ensureConnectedMemberId();
|
|
152
402
|
this.rememberLocalTrack('screen', track, track.getSettings().deviceId, true);
|
|
153
|
-
await this.room.media.screen.start?.({
|
|
403
|
+
await this.withRateLimitRetry('start screen share', () => this.room.media.screen.start?.({
|
|
154
404
|
trackId: track.id,
|
|
155
405
|
deviceId: track.getSettings().deviceId,
|
|
156
406
|
providerSessionId,
|
|
157
|
-
});
|
|
158
|
-
this.
|
|
407
|
+
}) ?? Promise.resolve());
|
|
408
|
+
this.requestSyncAllPeerSenders();
|
|
159
409
|
return track;
|
|
160
410
|
}
|
|
161
411
|
async disableAudio() {
|
|
162
412
|
this.releaseLocalTrack('audio');
|
|
163
|
-
this.
|
|
164
|
-
await this.room.media.audio.disable();
|
|
413
|
+
this.requestSyncAllPeerSenders();
|
|
414
|
+
await this.withRateLimitRetry('disable audio', () => this.room.media.audio.disable());
|
|
165
415
|
}
|
|
166
416
|
async disableVideo() {
|
|
167
417
|
this.releaseLocalTrack('video');
|
|
168
|
-
this.
|
|
169
|
-
await this.room.media.video.disable();
|
|
418
|
+
this.requestSyncAllPeerSenders();
|
|
419
|
+
await this.withRateLimitRetry('disable video', () => this.room.media.video.disable());
|
|
170
420
|
}
|
|
171
421
|
async stopScreenShare() {
|
|
172
422
|
this.releaseLocalTrack('screen');
|
|
173
|
-
this.
|
|
174
|
-
await this.room.media.screen.stop();
|
|
423
|
+
this.requestSyncAllPeerSenders();
|
|
424
|
+
await this.withRateLimitRetry('stop screen share', () => this.room.media.screen.stop());
|
|
175
425
|
}
|
|
176
426
|
async setMuted(kind, muted) {
|
|
177
427
|
const localTrack = this.localTracks.get(kind)?.track;
|
|
@@ -179,10 +429,23 @@ export class RoomP2PMediaTransport {
|
|
|
179
429
|
localTrack.enabled = !muted;
|
|
180
430
|
}
|
|
181
431
|
if (kind === 'audio') {
|
|
182
|
-
await this.room.media.audio.setMuted?.(muted);
|
|
432
|
+
await this.withRateLimitRetry('set audio muted', () => this.room.media.audio.setMuted?.(muted) ?? Promise.resolve());
|
|
183
433
|
}
|
|
184
434
|
else {
|
|
185
|
-
await this.room.media.video.setMuted?.(muted);
|
|
435
|
+
await this.withRateLimitRetry('set video muted', () => this.room.media.video.setMuted?.(muted) ?? Promise.resolve());
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async batchLocalUpdates(callback) {
|
|
439
|
+
this.localUpdateBatchDepth += 1;
|
|
440
|
+
try {
|
|
441
|
+
return await callback();
|
|
442
|
+
}
|
|
443
|
+
finally {
|
|
444
|
+
this.localUpdateBatchDepth = Math.max(0, this.localUpdateBatchDepth - 1);
|
|
445
|
+
if (this.localUpdateBatchDepth === 0 && this.syncAllPeerSendersPending) {
|
|
446
|
+
this.syncAllPeerSendersPending = false;
|
|
447
|
+
this.requestSyncAllPeerSenders();
|
|
448
|
+
}
|
|
186
449
|
}
|
|
187
450
|
}
|
|
188
451
|
async switchDevices(payload) {
|
|
@@ -213,6 +476,10 @@ export class RoomP2PMediaTransport {
|
|
|
213
476
|
destroy() {
|
|
214
477
|
this.connected = false;
|
|
215
478
|
this.localMemberId = null;
|
|
479
|
+
if (this.healthCheckTimer != null) {
|
|
480
|
+
globalThis.clearInterval(this.healthCheckTimer);
|
|
481
|
+
this.healthCheckTimer = null;
|
|
482
|
+
}
|
|
216
483
|
for (const subscription of this.subscriptions.splice(0)) {
|
|
217
484
|
subscription.unsubscribe();
|
|
218
485
|
}
|
|
@@ -223,6 +490,12 @@ export class RoomP2PMediaTransport {
|
|
|
223
490
|
for (const kind of Array.from(this.localTracks.keys())) {
|
|
224
491
|
this.releaseLocalTrack(kind);
|
|
225
492
|
}
|
|
493
|
+
for (const pending of this.pendingIceCandidates.values()) {
|
|
494
|
+
if (pending.timer) {
|
|
495
|
+
clearTimeout(pending.timer);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
this.pendingIceCandidates.clear();
|
|
226
499
|
this.remoteTrackKinds.clear();
|
|
227
500
|
this.emittedRemoteTracks.clear();
|
|
228
501
|
this.pendingRemoteTracks.clear();
|
|
@@ -234,6 +507,7 @@ export class RoomP2PMediaTransport {
|
|
|
234
507
|
this.subscriptions.push(this.room.members.onJoin((member) => {
|
|
235
508
|
if (member.memberId !== this.localMemberId) {
|
|
236
509
|
this.ensurePeer(member.memberId);
|
|
510
|
+
this.schedulePeerRecoveryCheck(member.memberId, 'member-join');
|
|
237
511
|
}
|
|
238
512
|
}), this.room.members.onSync((members) => {
|
|
239
513
|
const activeMemberIds = new Set();
|
|
@@ -241,6 +515,7 @@ export class RoomP2PMediaTransport {
|
|
|
241
515
|
if (member.memberId !== this.localMemberId) {
|
|
242
516
|
activeMemberIds.add(member.memberId);
|
|
243
517
|
this.ensurePeer(member.memberId);
|
|
518
|
+
this.schedulePeerRecoveryCheck(member.memberId, 'member-sync');
|
|
244
519
|
}
|
|
245
520
|
}
|
|
246
521
|
for (const memberId of Array.from(this.peers.keys())) {
|
|
@@ -259,6 +534,7 @@ export class RoomP2PMediaTransport {
|
|
|
259
534
|
}), this.room.media.onTrack((track, member) => {
|
|
260
535
|
if (member.memberId !== this.localMemberId) {
|
|
261
536
|
this.ensurePeer(member.memberId);
|
|
537
|
+
this.schedulePeerRecoveryCheck(member.memberId, 'media-track');
|
|
262
538
|
}
|
|
263
539
|
this.rememberRemoteTrackKind(track, member);
|
|
264
540
|
}), this.room.media.onTrackRemoved((track, member) => {
|
|
@@ -268,7 +544,17 @@ export class RoomP2PMediaTransport {
|
|
|
268
544
|
this.remoteTrackKinds.delete(key);
|
|
269
545
|
this.emittedRemoteTracks.delete(key);
|
|
270
546
|
this.pendingRemoteTracks.delete(key);
|
|
547
|
+
this.schedulePeerRecoveryCheck(member.memberId, 'media-track-removed');
|
|
271
548
|
}));
|
|
549
|
+
if (typeof this.room.media.onStateChange === 'function') {
|
|
550
|
+
this.subscriptions.push(this.room.media.onStateChange((member, state) => {
|
|
551
|
+
if (member.memberId === this.localMemberId) {
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
this.rememberRemoteTrackKindsFromState(member, state);
|
|
555
|
+
this.schedulePeerRecoveryCheck(member.memberId, 'media-state');
|
|
556
|
+
}));
|
|
557
|
+
}
|
|
272
558
|
}
|
|
273
559
|
hydrateRemoteTrackKinds() {
|
|
274
560
|
this.remoteTrackKinds.clear();
|
|
@@ -278,6 +564,32 @@ export class RoomP2PMediaTransport {
|
|
|
278
564
|
for (const track of mediaMember.tracks) {
|
|
279
565
|
this.rememberRemoteTrackKind(track, mediaMember.member);
|
|
280
566
|
}
|
|
567
|
+
this.rememberRemoteTrackKindsFromState(mediaMember.member, mediaMember.state);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
rememberRemoteTrackKindsFromState(member, state) {
|
|
571
|
+
if (member.memberId === this.localMemberId || !state) {
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
const mediaKinds = ['audio', 'video', 'screen'];
|
|
575
|
+
for (const kind of mediaKinds) {
|
|
576
|
+
const kindState = state[kind];
|
|
577
|
+
if (!kindState?.published) {
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (typeof kindState.trackId === 'string' && kindState.trackId) {
|
|
581
|
+
this.rememberRemoteTrackKind({
|
|
582
|
+
kind,
|
|
583
|
+
trackId: kindState.trackId,
|
|
584
|
+
muted: kindState.muted === true,
|
|
585
|
+
deviceId: kindState.deviceId,
|
|
586
|
+
publishedAt: kindState.publishedAt,
|
|
587
|
+
adminDisabled: kindState.adminDisabled,
|
|
588
|
+
providerSessionId: kindState.providerSessionId,
|
|
589
|
+
}, member);
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
this.flushPendingRemoteTracks(member.memberId, kind);
|
|
281
593
|
}
|
|
282
594
|
}
|
|
283
595
|
rememberRemoteTrackKind(track, member) {
|
|
@@ -310,17 +622,32 @@ export class RoomP2PMediaTransport {
|
|
|
310
622
|
isSettingRemoteAnswerPending: false,
|
|
311
623
|
pendingCandidates: [],
|
|
312
624
|
senders: new Map(),
|
|
625
|
+
pendingNegotiation: false,
|
|
626
|
+
recoveryAttempts: 0,
|
|
627
|
+
recoveryTimer: null,
|
|
628
|
+
healthCheckInFlight: false,
|
|
629
|
+
remoteVideoFlows: new Map(),
|
|
313
630
|
};
|
|
314
631
|
pc.onicecandidate = (event) => {
|
|
315
|
-
if (!event.candidate)
|
|
632
|
+
if (!event.candidate) {
|
|
633
|
+
void this.flushPendingIceCandidates(memberId);
|
|
316
634
|
return;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
});
|
|
635
|
+
}
|
|
636
|
+
this.queueIceCandidate(memberId, serializeCandidate(event.candidate));
|
|
320
637
|
};
|
|
321
638
|
pc.onnegotiationneeded = () => {
|
|
322
639
|
void this.negotiatePeer(peer);
|
|
323
640
|
};
|
|
641
|
+
pc.onsignalingstatechange = () => {
|
|
642
|
+
this.maybeRetryPendingNegotiation(peer);
|
|
643
|
+
};
|
|
644
|
+
pc.oniceconnectionstatechange = () => {
|
|
645
|
+
this.handlePeerConnectivityChange(peer, 'ice');
|
|
646
|
+
};
|
|
647
|
+
pc.onconnectionstatechange = () => {
|
|
648
|
+
this.handlePeerConnectivityChange(peer, 'connection');
|
|
649
|
+
this.maybeRetryPendingNegotiation(peer);
|
|
650
|
+
};
|
|
324
651
|
pc.ontrack = (event) => {
|
|
325
652
|
const stream = event.streams[0] ?? new MediaStream([event.track]);
|
|
326
653
|
const key = buildTrackKey(memberId, event.track.id);
|
|
@@ -332,26 +659,33 @@ export class RoomP2PMediaTransport {
|
|
|
332
659
|
return;
|
|
333
660
|
}
|
|
334
661
|
this.emitRemoteTrack(memberId, event.track, stream, kind);
|
|
662
|
+
this.registerPeerRemoteTrack(peer, event.track, kind);
|
|
663
|
+
this.resetPeerRecovery(peer);
|
|
335
664
|
};
|
|
336
665
|
this.peers.set(memberId, peer);
|
|
337
666
|
this.syncPeerSenders(peer);
|
|
667
|
+
this.schedulePeerRecoveryCheck(memberId, 'peer-created');
|
|
338
668
|
return peer;
|
|
339
669
|
}
|
|
340
670
|
async negotiatePeer(peer) {
|
|
341
671
|
if (!this.connected
|
|
342
|
-
|| peer.pc.connectionState === 'closed'
|
|
343
|
-
|
|
672
|
+
|| peer.pc.connectionState === 'closed') {
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (peer.makingOffer
|
|
344
676
|
|| peer.isSettingRemoteAnswerPending
|
|
345
677
|
|| peer.pc.signalingState !== 'stable') {
|
|
678
|
+
peer.pendingNegotiation = true;
|
|
346
679
|
return;
|
|
347
680
|
}
|
|
348
681
|
try {
|
|
682
|
+
peer.pendingNegotiation = false;
|
|
349
683
|
peer.makingOffer = true;
|
|
350
684
|
await peer.pc.setLocalDescription();
|
|
351
685
|
if (!peer.pc.localDescription) {
|
|
352
686
|
return;
|
|
353
687
|
}
|
|
354
|
-
await this.
|
|
688
|
+
await this.sendSignalWithRetry(peer.memberId, this.offerEvent, {
|
|
355
689
|
description: serializeDescription(peer.pc.localDescription),
|
|
356
690
|
});
|
|
357
691
|
}
|
|
@@ -383,6 +717,17 @@ export class RoomP2PMediaTransport {
|
|
|
383
717
|
return;
|
|
384
718
|
}
|
|
385
719
|
try {
|
|
720
|
+
if (description.type === 'answer'
|
|
721
|
+
&& peer.pc.signalingState === 'stable'
|
|
722
|
+
&& !peer.isSettingRemoteAnswerPending) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
if (description.type === 'offer'
|
|
726
|
+
&& offerCollision
|
|
727
|
+
&& peer.polite
|
|
728
|
+
&& peer.pc.signalingState !== 'stable') {
|
|
729
|
+
await peer.pc.setLocalDescription({ type: 'rollback' });
|
|
730
|
+
}
|
|
386
731
|
peer.isSettingRemoteAnswerPending = description.type === 'answer';
|
|
387
732
|
await peer.pc.setRemoteDescription(description);
|
|
388
733
|
peer.isSettingRemoteAnswerPending = false;
|
|
@@ -393,12 +738,15 @@ export class RoomP2PMediaTransport {
|
|
|
393
738
|
if (!peer.pc.localDescription) {
|
|
394
739
|
return;
|
|
395
740
|
}
|
|
396
|
-
await this.
|
|
741
|
+
await this.sendSignalWithRetry(senderId, this.answerEvent, {
|
|
397
742
|
description: serializeDescription(peer.pc.localDescription),
|
|
398
743
|
});
|
|
399
744
|
}
|
|
400
745
|
}
|
|
401
746
|
catch (error) {
|
|
747
|
+
if (description.type === 'answer' && peer.pc.signalingState === 'stable' && isStableAnswerError(error)) {
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
402
750
|
console.warn('[RoomP2PMediaTransport] Failed to apply remote session description.', {
|
|
403
751
|
memberId: senderId,
|
|
404
752
|
expectedType,
|
|
@@ -407,31 +755,36 @@ export class RoomP2PMediaTransport {
|
|
|
407
755
|
});
|
|
408
756
|
peer.isSettingRemoteAnswerPending = false;
|
|
409
757
|
}
|
|
758
|
+
finally {
|
|
759
|
+
this.maybeRetryPendingNegotiation(peer);
|
|
760
|
+
}
|
|
410
761
|
}
|
|
411
762
|
async handleIceSignal(payload, meta) {
|
|
412
763
|
const senderId = typeof meta.memberId === 'string' && meta.memberId.trim() ? meta.memberId.trim() : '';
|
|
413
764
|
if (!senderId || senderId === this.localMemberId) {
|
|
414
765
|
return;
|
|
415
766
|
}
|
|
416
|
-
const
|
|
417
|
-
if (
|
|
767
|
+
const candidates = this.normalizeCandidates(payload);
|
|
768
|
+
if (candidates.length === 0) {
|
|
418
769
|
return;
|
|
419
770
|
}
|
|
420
771
|
const peer = this.ensurePeer(senderId);
|
|
421
772
|
if (!peer.pc.remoteDescription) {
|
|
422
|
-
peer.pendingCandidates.push(
|
|
773
|
+
peer.pendingCandidates.push(...candidates);
|
|
423
774
|
return;
|
|
424
775
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
peer.
|
|
776
|
+
for (const candidate of candidates) {
|
|
777
|
+
try {
|
|
778
|
+
await peer.pc.addIceCandidate(candidate);
|
|
779
|
+
}
|
|
780
|
+
catch (error) {
|
|
781
|
+
console.warn('[RoomP2PMediaTransport] Failed to add ICE candidate.', {
|
|
782
|
+
memberId: senderId,
|
|
783
|
+
error,
|
|
784
|
+
});
|
|
785
|
+
if (!peer.ignoreOffer) {
|
|
786
|
+
peer.pendingCandidates.push(candidate);
|
|
787
|
+
}
|
|
435
788
|
}
|
|
436
789
|
}
|
|
437
790
|
}
|
|
@@ -456,6 +809,72 @@ export class RoomP2PMediaTransport {
|
|
|
456
809
|
}
|
|
457
810
|
}
|
|
458
811
|
}
|
|
812
|
+
queueIceCandidate(memberId, candidate) {
|
|
813
|
+
let pending = this.pendingIceCandidates.get(memberId);
|
|
814
|
+
if (!pending) {
|
|
815
|
+
pending = {
|
|
816
|
+
candidates: [],
|
|
817
|
+
timer: null,
|
|
818
|
+
flushing: false,
|
|
819
|
+
};
|
|
820
|
+
this.pendingIceCandidates.set(memberId, pending);
|
|
821
|
+
}
|
|
822
|
+
pending.candidates.push(candidate);
|
|
823
|
+
if (pending.timer || pending.flushing) {
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
pending.timer = globalThis.setTimeout(() => {
|
|
827
|
+
pending.timer = null;
|
|
828
|
+
void this.flushPendingIceCandidates(memberId);
|
|
829
|
+
}, DEFAULT_ICE_BATCH_DELAY_MS);
|
|
830
|
+
}
|
|
831
|
+
async flushPendingIceCandidates(memberId) {
|
|
832
|
+
const pending = this.pendingIceCandidates.get(memberId);
|
|
833
|
+
if (!pending || pending.flushing) {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (pending.timer) {
|
|
837
|
+
clearTimeout(pending.timer);
|
|
838
|
+
pending.timer = null;
|
|
839
|
+
}
|
|
840
|
+
if (pending.candidates.length === 0) {
|
|
841
|
+
this.pendingIceCandidates.delete(memberId);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
const batch = pending.candidates.splice(0);
|
|
845
|
+
pending.flushing = true;
|
|
846
|
+
try {
|
|
847
|
+
await this.sendSignalWithRetry(memberId, this.iceEvent, { candidates: batch });
|
|
848
|
+
}
|
|
849
|
+
finally {
|
|
850
|
+
pending.flushing = false;
|
|
851
|
+
if (pending.candidates.length > 0) {
|
|
852
|
+
pending.timer = globalThis.setTimeout(() => {
|
|
853
|
+
pending.timer = null;
|
|
854
|
+
void this.flushPendingIceCandidates(memberId);
|
|
855
|
+
}, 0);
|
|
856
|
+
}
|
|
857
|
+
else {
|
|
858
|
+
this.pendingIceCandidates.delete(memberId);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
requestSyncAllPeerSenders() {
|
|
863
|
+
if (this.localUpdateBatchDepth > 0) {
|
|
864
|
+
this.syncAllPeerSendersPending = true;
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (this.syncAllPeerSendersScheduled) {
|
|
868
|
+
this.syncAllPeerSendersPending = true;
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
this.syncAllPeerSendersScheduled = true;
|
|
872
|
+
queueMicrotask(() => {
|
|
873
|
+
this.syncAllPeerSendersScheduled = false;
|
|
874
|
+
this.syncAllPeerSendersPending = false;
|
|
875
|
+
this.syncAllPeerSenders();
|
|
876
|
+
});
|
|
877
|
+
}
|
|
459
878
|
syncAllPeerSenders() {
|
|
460
879
|
for (const peer of this.peers.values()) {
|
|
461
880
|
this.syncPeerSenders(peer);
|
|
@@ -515,6 +934,15 @@ export class RoomP2PMediaTransport {
|
|
|
515
934
|
for (const handler of this.remoteTrackHandlers) {
|
|
516
935
|
handler(payload);
|
|
517
936
|
}
|
|
937
|
+
const peer = this.peers.get(memberId);
|
|
938
|
+
if (peer) {
|
|
939
|
+
if (!this.hasMissingPublishedMedia(memberId)) {
|
|
940
|
+
this.resetPeerRecovery(peer);
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
this.schedulePeerRecoveryCheck(memberId, 'partial-remote-track');
|
|
944
|
+
}
|
|
945
|
+
}
|
|
518
946
|
}
|
|
519
947
|
resolveFallbackRemoteTrackKind(memberId, track) {
|
|
520
948
|
const normalizedKind = normalizeTrackKind(track);
|
|
@@ -548,6 +976,11 @@ export class RoomP2PMediaTransport {
|
|
|
548
976
|
publishedKinds.add(track.kind);
|
|
549
977
|
}
|
|
550
978
|
}
|
|
979
|
+
for (const kind of getPublishedKindsFromState(mediaMember.state)) {
|
|
980
|
+
if (kind === 'video' || kind === 'screen') {
|
|
981
|
+
publishedKinds.add(kind);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
551
984
|
return Array.from(publishedKinds);
|
|
552
985
|
}
|
|
553
986
|
getNextUnassignedPublishedVideoLikeKind(memberId) {
|
|
@@ -598,6 +1031,10 @@ export class RoomP2PMediaTransport {
|
|
|
598
1031
|
rollbackConnectedState() {
|
|
599
1032
|
this.connected = false;
|
|
600
1033
|
this.localMemberId = null;
|
|
1034
|
+
if (this.healthCheckTimer != null) {
|
|
1035
|
+
globalThis.clearInterval(this.healthCheckTimer);
|
|
1036
|
+
this.healthCheckTimer = null;
|
|
1037
|
+
}
|
|
601
1038
|
for (const subscription of this.subscriptions.splice(0)) {
|
|
602
1039
|
subscription.unsubscribe();
|
|
603
1040
|
}
|
|
@@ -605,13 +1042,27 @@ export class RoomP2PMediaTransport {
|
|
|
605
1042
|
this.destroyPeer(peer);
|
|
606
1043
|
}
|
|
607
1044
|
this.peers.clear();
|
|
1045
|
+
for (const pending of this.pendingIceCandidates.values()) {
|
|
1046
|
+
if (pending.timer) {
|
|
1047
|
+
clearTimeout(pending.timer);
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
this.pendingIceCandidates.clear();
|
|
608
1051
|
this.remoteTrackKinds.clear();
|
|
609
1052
|
this.emittedRemoteTracks.clear();
|
|
610
1053
|
this.pendingRemoteTracks.clear();
|
|
611
1054
|
}
|
|
612
1055
|
destroyPeer(peer) {
|
|
1056
|
+
this.clearPeerRecoveryTimer(peer);
|
|
1057
|
+
for (const flow of peer.remoteVideoFlows.values()) {
|
|
1058
|
+
flow.cleanup();
|
|
1059
|
+
}
|
|
1060
|
+
peer.remoteVideoFlows.clear();
|
|
613
1061
|
peer.pc.onicecandidate = null;
|
|
614
1062
|
peer.pc.onnegotiationneeded = null;
|
|
1063
|
+
peer.pc.onsignalingstatechange = null;
|
|
1064
|
+
peer.pc.oniceconnectionstatechange = null;
|
|
1065
|
+
peer.pc.onconnectionstatechange = null;
|
|
615
1066
|
peer.pc.ontrack = null;
|
|
616
1067
|
try {
|
|
617
1068
|
peer.pc.close();
|
|
@@ -620,6 +1071,147 @@ export class RoomP2PMediaTransport {
|
|
|
620
1071
|
// Ignore duplicate closes.
|
|
621
1072
|
}
|
|
622
1073
|
}
|
|
1074
|
+
startHealthChecks() {
|
|
1075
|
+
if (this.healthCheckTimer != null) {
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
this.healthCheckTimer = globalThis.setInterval(() => {
|
|
1079
|
+
void this.runHealthChecks();
|
|
1080
|
+
}, this.options.mediaHealthCheckIntervalMs);
|
|
1081
|
+
}
|
|
1082
|
+
async runHealthChecks() {
|
|
1083
|
+
if (!this.connected) {
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
for (const peer of this.peers.values()) {
|
|
1087
|
+
if (peer.healthCheckInFlight || peer.pc.connectionState === 'closed') {
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
peer.healthCheckInFlight = true;
|
|
1091
|
+
try {
|
|
1092
|
+
const issue = await this.inspectPeerVideoHealth(peer);
|
|
1093
|
+
if (issue) {
|
|
1094
|
+
this.schedulePeerRecoveryCheck(peer.memberId, issue, 0);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
finally {
|
|
1098
|
+
peer.healthCheckInFlight = false;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
registerPeerRemoteTrack(peer, track, kind) {
|
|
1103
|
+
if (kind !== 'video' && kind !== 'screen') {
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
if (peer.remoteVideoFlows.has(track.id)) {
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
const flow = {
|
|
1110
|
+
track,
|
|
1111
|
+
receivedAt: Date.now(),
|
|
1112
|
+
lastHealthyAt: track.muted ? 0 : Date.now(),
|
|
1113
|
+
lastBytesReceived: null,
|
|
1114
|
+
lastFramesDecoded: null,
|
|
1115
|
+
cleanup: () => { },
|
|
1116
|
+
};
|
|
1117
|
+
const markHealthy = () => {
|
|
1118
|
+
flow.lastHealthyAt = Date.now();
|
|
1119
|
+
};
|
|
1120
|
+
const handleEnded = () => {
|
|
1121
|
+
flow.cleanup();
|
|
1122
|
+
peer.remoteVideoFlows.delete(track.id);
|
|
1123
|
+
};
|
|
1124
|
+
track.addEventListener('unmute', markHealthy);
|
|
1125
|
+
track.addEventListener('ended', handleEnded);
|
|
1126
|
+
flow.cleanup = () => {
|
|
1127
|
+
track.removeEventListener('unmute', markHealthy);
|
|
1128
|
+
track.removeEventListener('ended', handleEnded);
|
|
1129
|
+
};
|
|
1130
|
+
peer.remoteVideoFlows.set(track.id, flow);
|
|
1131
|
+
}
|
|
1132
|
+
async inspectPeerVideoHealth(peer) {
|
|
1133
|
+
if (this.hasMissingPublishedMedia(peer.memberId)) {
|
|
1134
|
+
return 'health-missing-published-media';
|
|
1135
|
+
}
|
|
1136
|
+
const mediaMember = this.room.media.list().find((entry) => entry.member.memberId === peer.memberId);
|
|
1137
|
+
const publishedVideoState = mediaMember?.state?.video;
|
|
1138
|
+
const publishedScreenState = mediaMember?.state?.screen;
|
|
1139
|
+
const publishedAt = Math.max(publishedVideoState?.publishedAt ?? 0, publishedScreenState?.publishedAt ?? 0);
|
|
1140
|
+
const expectsVideoFlow = Boolean(publishedVideoState?.published
|
|
1141
|
+
|| publishedScreenState?.published
|
|
1142
|
+
|| mediaMember?.tracks.some((track) => (track.kind === 'video' || track.kind === 'screen') && track.trackId));
|
|
1143
|
+
if (!expectsVideoFlow) {
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
const videoReceivers = peer.pc
|
|
1147
|
+
.getReceivers()
|
|
1148
|
+
.filter((receiver) => receiver.track?.kind === 'video');
|
|
1149
|
+
if (videoReceivers.length === 0) {
|
|
1150
|
+
const firstObservedAt = Math.max(publishedAt, ...Array.from(peer.remoteVideoFlows.values()).map((flow) => flow.receivedAt));
|
|
1151
|
+
if (firstObservedAt > 0 && Date.now() - firstObservedAt > this.options.videoFlowGraceMs) {
|
|
1152
|
+
return 'health-no-video-receiver';
|
|
1153
|
+
}
|
|
1154
|
+
return null;
|
|
1155
|
+
}
|
|
1156
|
+
let sawHealthyFlow = false;
|
|
1157
|
+
let lastObservedAt = publishedAt;
|
|
1158
|
+
for (const receiver of videoReceivers) {
|
|
1159
|
+
const track = receiver.track;
|
|
1160
|
+
if (!track) {
|
|
1161
|
+
continue;
|
|
1162
|
+
}
|
|
1163
|
+
const flow = peer.remoteVideoFlows.get(track.id);
|
|
1164
|
+
if (!flow) {
|
|
1165
|
+
continue;
|
|
1166
|
+
}
|
|
1167
|
+
lastObservedAt = Math.max(lastObservedAt, flow.receivedAt, flow.lastHealthyAt);
|
|
1168
|
+
if (!track.muted) {
|
|
1169
|
+
flow.lastHealthyAt = Math.max(flow.lastHealthyAt, Date.now());
|
|
1170
|
+
}
|
|
1171
|
+
try {
|
|
1172
|
+
const stats = await receiver.getStats();
|
|
1173
|
+
for (const report of stats.values()) {
|
|
1174
|
+
if (report.type !== 'inbound-rtp' || report.kind !== 'video') {
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
const bytesReceived = typeof report.bytesReceived === 'number' ? report.bytesReceived : null;
|
|
1178
|
+
const framesDecoded = typeof report.framesDecoded === 'number' ? report.framesDecoded : null;
|
|
1179
|
+
const bytesIncreased = bytesReceived != null
|
|
1180
|
+
&& flow.lastBytesReceived != null
|
|
1181
|
+
&& bytesReceived > flow.lastBytesReceived;
|
|
1182
|
+
const framesIncreased = framesDecoded != null
|
|
1183
|
+
&& flow.lastFramesDecoded != null
|
|
1184
|
+
&& framesDecoded > flow.lastFramesDecoded;
|
|
1185
|
+
if ((bytesReceived != null && bytesReceived > 0) || (framesDecoded != null && framesDecoded > 0)) {
|
|
1186
|
+
flow.lastHealthyAt = Math.max(flow.lastHealthyAt, Date.now());
|
|
1187
|
+
}
|
|
1188
|
+
if (bytesIncreased || framesIncreased) {
|
|
1189
|
+
flow.lastHealthyAt = Date.now();
|
|
1190
|
+
}
|
|
1191
|
+
flow.lastBytesReceived = bytesReceived;
|
|
1192
|
+
flow.lastFramesDecoded = framesDecoded;
|
|
1193
|
+
break;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
catch {
|
|
1197
|
+
// Ignore stats read failures and rely on track state.
|
|
1198
|
+
}
|
|
1199
|
+
if (flow.lastHealthyAt > 0) {
|
|
1200
|
+
sawHealthyFlow = true;
|
|
1201
|
+
}
|
|
1202
|
+
lastObservedAt = Math.max(lastObservedAt, flow.lastHealthyAt);
|
|
1203
|
+
}
|
|
1204
|
+
if (sawHealthyFlow) {
|
|
1205
|
+
return null;
|
|
1206
|
+
}
|
|
1207
|
+
if (lastObservedAt > 0 && Date.now() - lastObservedAt > this.options.videoFlowStallGraceMs) {
|
|
1208
|
+
return 'health-stalled-video-flow';
|
|
1209
|
+
}
|
|
1210
|
+
if (publishedAt > 0 && Date.now() - publishedAt > this.options.videoFlowGraceMs) {
|
|
1211
|
+
return 'health-video-flow-timeout';
|
|
1212
|
+
}
|
|
1213
|
+
return null;
|
|
1214
|
+
}
|
|
623
1215
|
async createUserMediaTrack(kind, constraints) {
|
|
624
1216
|
const devices = this.options.mediaDevices;
|
|
625
1217
|
if (!devices?.getUserMedia || constraints === false) {
|
|
@@ -667,15 +1259,44 @@ export class RoomP2PMediaTransport {
|
|
|
667
1259
|
sdp: typeof raw.sdp === 'string' ? raw.sdp : undefined,
|
|
668
1260
|
};
|
|
669
1261
|
}
|
|
670
|
-
|
|
1262
|
+
normalizeCandidates(payload) {
|
|
671
1263
|
if (!payload || typeof payload !== 'object') {
|
|
672
|
-
return
|
|
1264
|
+
return [];
|
|
1265
|
+
}
|
|
1266
|
+
const batch = payload.candidates;
|
|
1267
|
+
if (Array.isArray(batch)) {
|
|
1268
|
+
return batch.filter((candidate) => !!candidate && typeof candidate.candidate === 'string');
|
|
673
1269
|
}
|
|
674
1270
|
const raw = payload.candidate;
|
|
675
1271
|
if (!raw || typeof raw.candidate !== 'string') {
|
|
676
|
-
return
|
|
1272
|
+
return [];
|
|
1273
|
+
}
|
|
1274
|
+
return [raw];
|
|
1275
|
+
}
|
|
1276
|
+
async sendSignalWithRetry(memberId, event, payload) {
|
|
1277
|
+
await this.withRateLimitRetry(`signal ${event}`, () => this.room.signals.sendTo(memberId, event, payload));
|
|
1278
|
+
}
|
|
1279
|
+
async withRateLimitRetry(label, action) {
|
|
1280
|
+
let lastError;
|
|
1281
|
+
for (let attempt = 0; attempt <= DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS.length; attempt += 1) {
|
|
1282
|
+
try {
|
|
1283
|
+
return await action();
|
|
1284
|
+
}
|
|
1285
|
+
catch (error) {
|
|
1286
|
+
lastError = error;
|
|
1287
|
+
if (!isRateLimitError(error) || attempt === DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS.length) {
|
|
1288
|
+
throw error;
|
|
1289
|
+
}
|
|
1290
|
+
const delayMs = DEFAULT_RATE_LIMIT_RETRY_DELAYS_MS[attempt];
|
|
1291
|
+
console.warn('[RoomP2PMediaTransport] Rate limited room operation. Retrying.', {
|
|
1292
|
+
label,
|
|
1293
|
+
attempt: attempt + 1,
|
|
1294
|
+
delayMs,
|
|
1295
|
+
});
|
|
1296
|
+
await new Promise((resolve) => globalThis.setTimeout(resolve, delayMs));
|
|
1297
|
+
}
|
|
677
1298
|
}
|
|
678
|
-
|
|
1299
|
+
throw lastError;
|
|
679
1300
|
}
|
|
680
1301
|
get offerEvent() {
|
|
681
1302
|
return `${this.options.signalPrefix}.offer`;
|
|
@@ -686,5 +1307,169 @@ export class RoomP2PMediaTransport {
|
|
|
686
1307
|
get iceEvent() {
|
|
687
1308
|
return `${this.options.signalPrefix}.ice`;
|
|
688
1309
|
}
|
|
1310
|
+
maybeRetryPendingNegotiation(peer) {
|
|
1311
|
+
if (!peer.pendingNegotiation
|
|
1312
|
+
|| !this.connected
|
|
1313
|
+
|| peer.pc.connectionState === 'closed'
|
|
1314
|
+
|| peer.makingOffer
|
|
1315
|
+
|| peer.isSettingRemoteAnswerPending
|
|
1316
|
+
|| peer.pc.signalingState !== 'stable') {
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
peer.pendingNegotiation = false;
|
|
1320
|
+
queueMicrotask(() => {
|
|
1321
|
+
void this.negotiatePeer(peer);
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
handlePeerConnectivityChange(peer, source) {
|
|
1325
|
+
if (!this.connected || peer.pc.connectionState === 'closed') {
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
const connectionState = peer.pc.connectionState;
|
|
1329
|
+
const iceConnectionState = peer.pc.iceConnectionState;
|
|
1330
|
+
if (connectionState === 'connected'
|
|
1331
|
+
|| iceConnectionState === 'connected'
|
|
1332
|
+
|| iceConnectionState === 'completed') {
|
|
1333
|
+
this.resetPeerRecovery(peer);
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
if (connectionState === 'failed' || iceConnectionState === 'failed') {
|
|
1337
|
+
this.schedulePeerRecoveryCheck(peer.memberId, `${source}-failed`, 0);
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
if (connectionState === 'disconnected' || iceConnectionState === 'disconnected') {
|
|
1341
|
+
this.schedulePeerRecoveryCheck(peer.memberId, `${source}-disconnected`, this.options.disconnectedRecoveryDelayMs);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
schedulePeerRecoveryCheck(memberId, reason, delayMs = this.options.missingMediaGraceMs) {
|
|
1345
|
+
const peer = this.peers.get(memberId);
|
|
1346
|
+
if (!peer || !this.connected || peer.pc.connectionState === 'closed') {
|
|
1347
|
+
return;
|
|
1348
|
+
}
|
|
1349
|
+
const healthSensitiveReason = reason.includes('health')
|
|
1350
|
+
|| reason.includes('stalled')
|
|
1351
|
+
|| reason.includes('flow');
|
|
1352
|
+
if (!this.hasMissingPublishedMedia(memberId)
|
|
1353
|
+
&& !healthSensitiveReason
|
|
1354
|
+
&& !reason.includes('failed')
|
|
1355
|
+
&& !reason.includes('disconnected')) {
|
|
1356
|
+
this.resetPeerRecovery(peer);
|
|
1357
|
+
return;
|
|
1358
|
+
}
|
|
1359
|
+
this.clearPeerRecoveryTimer(peer);
|
|
1360
|
+
peer.recoveryTimer = globalThis.setTimeout(() => {
|
|
1361
|
+
peer.recoveryTimer = null;
|
|
1362
|
+
void this.recoverPeer(peer, reason);
|
|
1363
|
+
}, Math.max(0, delayMs));
|
|
1364
|
+
}
|
|
1365
|
+
async recoverPeer(peer, reason) {
|
|
1366
|
+
if (!this.connected || peer.pc.connectionState === 'closed') {
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
const stillMissingPublishedMedia = this.hasMissingPublishedMedia(peer.memberId);
|
|
1370
|
+
const connectivityIssue = peer.pc.connectionState === 'failed'
|
|
1371
|
+
|| peer.pc.connectionState === 'disconnected'
|
|
1372
|
+
|| peer.pc.iceConnectionState === 'failed'
|
|
1373
|
+
|| peer.pc.iceConnectionState === 'disconnected';
|
|
1374
|
+
const healthIssue = !stillMissingPublishedMedia && !connectivityIssue
|
|
1375
|
+
? await this.inspectPeerVideoHealth(peer)
|
|
1376
|
+
: null;
|
|
1377
|
+
if (!stillMissingPublishedMedia && !connectivityIssue && !healthIssue) {
|
|
1378
|
+
this.resetPeerRecovery(peer);
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
if (peer.recoveryAttempts >= this.options.maxRecoveryAttempts) {
|
|
1382
|
+
this.resetPeer(peer.memberId, reason);
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
peer.recoveryAttempts += 1;
|
|
1386
|
+
this.requestIceRestart(peer, reason);
|
|
1387
|
+
}
|
|
1388
|
+
requestIceRestart(peer, reason) {
|
|
1389
|
+
try {
|
|
1390
|
+
if (typeof peer.pc.restartIce === 'function') {
|
|
1391
|
+
peer.pc.restartIce();
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
catch (error) {
|
|
1395
|
+
console.warn('[RoomP2PMediaTransport] Failed to request ICE restart.', {
|
|
1396
|
+
memberId: peer.memberId,
|
|
1397
|
+
reason,
|
|
1398
|
+
error,
|
|
1399
|
+
});
|
|
1400
|
+
}
|
|
1401
|
+
peer.pendingNegotiation = true;
|
|
1402
|
+
this.maybeRetryPendingNegotiation(peer);
|
|
1403
|
+
}
|
|
1404
|
+
resetPeer(memberId, reason) {
|
|
1405
|
+
const existing = this.peers.get(memberId);
|
|
1406
|
+
if (existing) {
|
|
1407
|
+
this.destroyPeer(existing);
|
|
1408
|
+
this.peers.delete(memberId);
|
|
1409
|
+
}
|
|
1410
|
+
const replacement = this.ensurePeer(memberId);
|
|
1411
|
+
replacement.recoveryAttempts = 0;
|
|
1412
|
+
replacement.pendingNegotiation = true;
|
|
1413
|
+
this.maybeRetryPendingNegotiation(replacement);
|
|
1414
|
+
this.schedulePeerRecoveryCheck(memberId, `${reason}:after-reset`);
|
|
1415
|
+
}
|
|
1416
|
+
resetPeerRecovery(peer) {
|
|
1417
|
+
peer.recoveryAttempts = 0;
|
|
1418
|
+
peer.pendingNegotiation = false;
|
|
1419
|
+
this.clearPeerRecoveryTimer(peer);
|
|
1420
|
+
}
|
|
1421
|
+
clearPeerRecoveryTimer(peer) {
|
|
1422
|
+
if (peer.recoveryTimer != null) {
|
|
1423
|
+
globalThis.clearTimeout(peer.recoveryTimer);
|
|
1424
|
+
peer.recoveryTimer = null;
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
hasMissingPublishedMedia(memberId) {
|
|
1428
|
+
const mediaMember = this.room.media.list().find((entry) => entry.member.memberId === memberId);
|
|
1429
|
+
if (!mediaMember) {
|
|
1430
|
+
return false;
|
|
1431
|
+
}
|
|
1432
|
+
const publishedKinds = new Set();
|
|
1433
|
+
for (const track of mediaMember.tracks) {
|
|
1434
|
+
if (track.trackId) {
|
|
1435
|
+
publishedKinds.add(track.kind);
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
for (const kind of getPublishedKindsFromState(mediaMember.state)) {
|
|
1439
|
+
publishedKinds.add(kind);
|
|
1440
|
+
}
|
|
1441
|
+
const emittedKinds = new Set();
|
|
1442
|
+
for (const key of this.emittedRemoteTracks) {
|
|
1443
|
+
if (!key.startsWith(`${memberId}:`)) {
|
|
1444
|
+
continue;
|
|
1445
|
+
}
|
|
1446
|
+
const kind = this.remoteTrackKinds.get(key);
|
|
1447
|
+
if (kind) {
|
|
1448
|
+
emittedKinds.add(kind);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
let pendingAudioCount = 0;
|
|
1452
|
+
let pendingVideoLikeCount = 0;
|
|
1453
|
+
for (const pending of this.pendingRemoteTracks.values()) {
|
|
1454
|
+
if (pending.memberId !== memberId) {
|
|
1455
|
+
continue;
|
|
1456
|
+
}
|
|
1457
|
+
if (pending.track.kind === 'audio') {
|
|
1458
|
+
pendingAudioCount += 1;
|
|
1459
|
+
}
|
|
1460
|
+
else if (pending.track.kind === 'video') {
|
|
1461
|
+
pendingVideoLikeCount += 1;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
if (publishedKinds.has('audio') && !emittedKinds.has('audio') && pendingAudioCount === 0) {
|
|
1465
|
+
return true;
|
|
1466
|
+
}
|
|
1467
|
+
const expectedVideoLikeKinds = Array.from(publishedKinds).filter((kind) => kind === 'video' || kind === 'screen');
|
|
1468
|
+
if (expectedVideoLikeKinds.length === 0) {
|
|
1469
|
+
return false;
|
|
1470
|
+
}
|
|
1471
|
+
const emittedVideoLikeCount = Array.from(emittedKinds).filter((kind) => kind === 'video' || kind === 'screen').length;
|
|
1472
|
+
return emittedVideoLikeCount + pendingVideoLikeCount < expectedVideoLikeKinds.length;
|
|
1473
|
+
}
|
|
689
1474
|
}
|
|
690
1475
|
//# sourceMappingURL=room-p2p-media.js.map
|