@q9labsai/chalk-client 5.2.6 → 5.3.0
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 +1 -1
- package/dist/{chunk-YIDKETOX.js → chunk-3TGVTP6Z.js} +249 -31
- package/dist/{chunk-I7Y4HR2P.js → chunk-DHUHTIU7.js} +149 -28
- package/dist/{chunk-CJVLTSIO.js → chunk-GOSWSXTH.js} +3 -1
- package/dist/{chunk-WFRTUBN6.js → chunk-O26BHHMI.js} +1 -1
- package/dist/{chunk-5YPQNMJE.js → chunk-XSB2GDRW.js} +32 -8
- package/dist/client.d.ts +2 -1
- package/dist/connection/dependencies.d.ts +2 -1
- package/dist/effect.js +4 -4
- package/dist/generated/http-api.d.ts +2 -1
- package/dist/generated/schemas.d.ts +3 -1
- package/dist/generated/sync.d.ts +45 -0
- package/dist/index.js +5 -5
- package/dist/media/client.d.ts +2 -2
- package/dist/media/index.js +1 -1
- package/dist/media/plane.d.ts +4 -2
- package/dist/media/receive-stall.d.ts +43 -0
- package/dist/media/types.d.ts +2 -0
- package/dist/public-invites/index.js +2 -2
- package/dist/server/index.cjs +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/types.d.cts +2 -0
- package/dist/server/types.d.ts +2 -0
- package/dist/space-client/same-identity.d.ts +6 -0
- package/dist/space-client/types.d.ts +6 -0
- package/dist/sync/index.js +1 -1
- package/dist/sync/v1-types.d.ts +6 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ unsubscribe();
|
|
|
77
77
|
`SpaceSnapshot` contains these slices:
|
|
78
78
|
|
|
79
79
|
- `connection`: status, live Episode summary, and the latest failure
|
|
80
|
-
- `self`: local Participant
|
|
80
|
+
- `self`: local Participant and verified person Identity, role, capabilities, hand state, and `can(capability)`
|
|
81
81
|
- `participants`: roster and admission queue
|
|
82
82
|
- `media`: device selection, local and remote media, screen share, and requests
|
|
83
83
|
- `chat`: messages, pending sends, read receipts, unread count, and pagination
|
|
@@ -1,3 +1,145 @@
|
|
|
1
|
+
// src/media/receive-stall.ts
|
|
2
|
+
var RECEIVE_STALL_WINDOW_MS = 7e3;
|
|
3
|
+
var RECEIVE_STARTUP_GRACE_MS = 2e4;
|
|
4
|
+
var MAX_SAMPLE_GAP_MS = 3e3;
|
|
5
|
+
var ReceiveStallDetector = class {
|
|
6
|
+
#progress = /* @__PURE__ */ new Map();
|
|
7
|
+
#sampledAt;
|
|
8
|
+
forget(publicationId) {
|
|
9
|
+
this.#progress.delete(publicationId);
|
|
10
|
+
}
|
|
11
|
+
clear() {
|
|
12
|
+
this.#progress.clear();
|
|
13
|
+
this.#sampledAt = void 0;
|
|
14
|
+
}
|
|
15
|
+
sample(now, tracks) {
|
|
16
|
+
if (this.#sampledAt !== void 0 && (now - this.#sampledAt > MAX_SAMPLE_GAP_MS || now < this.#sampledAt)) this.clear();
|
|
17
|
+
this.#sampledAt = now;
|
|
18
|
+
const observed = new Set(tracks.map((track) => track.publicationId));
|
|
19
|
+
for (const id of this.#progress.keys()) if (!observed.has(id)) this.#progress.delete(id);
|
|
20
|
+
const observations = tracks.map((track) => ({ id: track.publicationId, outcome: this.#observeTrack(now, track) }));
|
|
21
|
+
return {
|
|
22
|
+
stalled: observations.filter((track) => track.outcome === "stalled").map((track) => track.id),
|
|
23
|
+
resumed: observations.filter((track) => track.outcome === "resumed").map((track) => track.id),
|
|
24
|
+
advanced: observations.filter((track) => track.outcome === "advanced" || track.outcome === "resumed").map((track) => track.id)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
#observeTrack(now, { publicationId, trackId, packets }) {
|
|
28
|
+
const previous = this.#progress.get(publicationId);
|
|
29
|
+
const sameTrack = previous?.trackId === trackId;
|
|
30
|
+
if (sameTrack && previous.packets === packets) {
|
|
31
|
+
const window = previous.flowing ? RECEIVE_STALL_WINDOW_MS : RECEIVE_STARTUP_GRACE_MS;
|
|
32
|
+
return now - previous.advancedAt >= window ? "stalled" : null;
|
|
33
|
+
}
|
|
34
|
+
return this.#advanceTrack(now, { publicationId, trackId, packets }, previous);
|
|
35
|
+
}
|
|
36
|
+
#advanceTrack(now, { publicationId, trackId, packets }, previous) {
|
|
37
|
+
const advanced = previous?.trackId === trackId && packets > previous.packets;
|
|
38
|
+
const flowingSince = advanced && now - previous.advancedAt < RECEIVE_STALL_WINDOW_MS ? previous.flowingSince : now;
|
|
39
|
+
this.#progress.set(publicationId, { trackId, packets, advancedAt: now, flowing: advanced, flowingSince });
|
|
40
|
+
if (!advanced) return null;
|
|
41
|
+
return now - flowingSince >= RECEIVE_STALL_WINDOW_MS ? "resumed" : "advanced";
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
var MAX_RECEIVE_REBUILDS = 2;
|
|
45
|
+
var MAX_RECEIVE_OBSERVATIONS = 32;
|
|
46
|
+
var ReceiveStallRecovery = class {
|
|
47
|
+
#detector = new ReceiveStallDetector();
|
|
48
|
+
#attempts = /* @__PURE__ */ new Map();
|
|
49
|
+
#identities = /* @__PURE__ */ new Map();
|
|
50
|
+
#expected = /* @__PURE__ */ new Set();
|
|
51
|
+
#screens = /* @__PURE__ */ new Set();
|
|
52
|
+
#stalled = /* @__PURE__ */ new Set();
|
|
53
|
+
#active = [];
|
|
54
|
+
#observations = Object.freeze([]);
|
|
55
|
+
get observations() {
|
|
56
|
+
return this.#observations;
|
|
57
|
+
}
|
|
58
|
+
expect(publications, participantId) {
|
|
59
|
+
const enabled = publications.filter((publication) => publication.participantId !== participantId && publication.enabled);
|
|
60
|
+
this.#expected = new Set(enabled.flatMap((publication) => publication.publicationId ? [publication.publicationId] : []));
|
|
61
|
+
this.#screens = new Set(enabled.filter((publication) => publication.source === "screen").map((publication) => publication.participantId));
|
|
62
|
+
this.#pruneWithdrawn(publications);
|
|
63
|
+
for (const publication of this.#identities.values()) {
|
|
64
|
+
if (this.#isExpected(publication)) continue;
|
|
65
|
+
this.#detector.forget(publication.publicationId);
|
|
66
|
+
this.#stalled.delete(publication.publicationId);
|
|
67
|
+
}
|
|
68
|
+
this.#active = [...this.#identities.values()].filter((publication) => this.#isExpected(publication)).map((publication) => publication.publicationId);
|
|
69
|
+
}
|
|
70
|
+
suspend() {
|
|
71
|
+
this.#expected.clear();
|
|
72
|
+
this.#screens.clear();
|
|
73
|
+
this.clear();
|
|
74
|
+
}
|
|
75
|
+
#pruneWithdrawn(publications) {
|
|
76
|
+
const advertised = new Set(publications.map((publication) => publication.publicationId));
|
|
77
|
+
const screens = new Set(publications.filter((publication) => publication.source === "screen").map((publication) => publication.participantId));
|
|
78
|
+
for (const [key, publication] of this.#identities) {
|
|
79
|
+
if (advertised.has(publication.publicationId) || publication.source === "screen_audio" && screens.has(publication.participantId)) continue;
|
|
80
|
+
this.#forget(publication.publicationId);
|
|
81
|
+
this.#identities.delete(key);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
isStalled(publicationId) {
|
|
85
|
+
return this.#stalled.has(publicationId);
|
|
86
|
+
}
|
|
87
|
+
canRebuild() {
|
|
88
|
+
return this.#active.length > 0 && this.#active.every((id) => this.#stalled.has(id) && (this.#attempts.get(id) ?? 0) < MAX_RECEIVE_REBUILDS);
|
|
89
|
+
}
|
|
90
|
+
reserveRebuild() {
|
|
91
|
+
if (!this.canRebuild()) return false;
|
|
92
|
+
for (const id of this.#active) this.#attempts.set(id, (this.#attempts.get(id) ?? 0) + 1);
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
clear() {
|
|
96
|
+
this.#detector.clear();
|
|
97
|
+
this.#stalled.clear();
|
|
98
|
+
this.#active = [];
|
|
99
|
+
}
|
|
100
|
+
sample(now, connection, report, tracks) {
|
|
101
|
+
const active = [...tracks].filter((publication) => this.#isExpected(publication));
|
|
102
|
+
for (const publication of active) this.#rememberIdentity(publication);
|
|
103
|
+
this.#active = active.map((publication) => publication.publicationId);
|
|
104
|
+
const samples = active.flatMap((publication) => receivePacketSample(connection, report, publication) ?? []);
|
|
105
|
+
const { stalled, advanced, resumed } = this.#detector.sample(now, samples);
|
|
106
|
+
for (const id of resumed) this.#attempts.delete(id);
|
|
107
|
+
const newlyStalled = stalled.filter((id) => !this.#stalled.has(id));
|
|
108
|
+
this.#stalled = new Set(stalled);
|
|
109
|
+
if (newlyStalled.length) this.#record(now);
|
|
110
|
+
return { stalled, advanced, newlyStalled };
|
|
111
|
+
}
|
|
112
|
+
#isExpected(publication) {
|
|
113
|
+
return publication.source === "screen_audio" ? this.#screens.has(publication.participantId) : this.#expected.has(publication.publicationId);
|
|
114
|
+
}
|
|
115
|
+
#rememberIdentity(publication) {
|
|
116
|
+
const key = `${publication.participantId}\0${publication.source}`;
|
|
117
|
+
const previous = this.#identities.get(key);
|
|
118
|
+
if (previous && previous.publicationId !== publication.publicationId) this.#forget(previous.publicationId);
|
|
119
|
+
this.#identities.set(key, publication);
|
|
120
|
+
}
|
|
121
|
+
#forget(id) {
|
|
122
|
+
this.#detector.forget(id);
|
|
123
|
+
this.#stalled.delete(id);
|
|
124
|
+
this.#attempts.delete(id);
|
|
125
|
+
}
|
|
126
|
+
#record(now) {
|
|
127
|
+
const observation = Object.freeze({ event: "receive_stalled", scope: this.#active.every((id) => this.#stalled.has(id)) ? "all" : "partial", observedAtMs: now, expectedTracks: this.#active.length, stalledTracks: this.#stalled.size });
|
|
128
|
+
this.#observations = Object.freeze([...this.#observations.slice(-(MAX_RECEIVE_OBSERVATIONS - 1)), observation]);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
function receivePacketSample(connection, report, publication) {
|
|
132
|
+
const rows = [...report.values()].filter((row) => row.type === "inbound-rtp" && row.trackIdentifier === publication.track.id);
|
|
133
|
+
if (rows.some((row) => !Number.isSafeInteger(row.packetsReceived) || row.packetsReceived < 0)) return;
|
|
134
|
+
if (publication.track.kind === "audio" && !audioPacketCadenceKnown(connection, report, publication.track, rows)) return;
|
|
135
|
+
return { publicationId: publication.publicationId, trackId: publication.track.id, packets: rows.reduce((sum, row) => sum + row.packetsReceived, 0) };
|
|
136
|
+
}
|
|
137
|
+
function audioPacketCadenceKnown(connection, report, track, rows) {
|
|
138
|
+
const negotiated = rows.length ? rows.map((row) => row.codecId ? report.get(row.codecId) : void 0) : connection.getReceivers().find((receiver) => receiver.track.id === track.id)?.getParameters().codecs ?? [];
|
|
139
|
+
const opus = negotiated.filter((codec) => codec?.mimeType?.toLowerCase() === "audio/opus");
|
|
140
|
+
return opus.length > 0 && opus.every((codec) => typeof codec.sdpFmtpLine === "string");
|
|
141
|
+
}
|
|
142
|
+
|
|
1
143
|
// src/media/camera-uplink.ts
|
|
2
144
|
var CameraUplinkPolicy = class {
|
|
3
145
|
#healthySamples = 0;
|
|
@@ -234,6 +376,7 @@ function classifyMediaFailure(cause, fallbackStep) {
|
|
|
234
376
|
// src/media/client.ts
|
|
235
377
|
var EMPTY_LOCAL = Object.freeze([]);
|
|
236
378
|
var EMPTY_REMOTE = Object.freeze([]);
|
|
379
|
+
var EMPTY_RECEIVE_STALLS = Object.freeze([]);
|
|
237
380
|
var INVALID_PUBLICATION_SIGNATURE = "\0invalid";
|
|
238
381
|
var CONNECTION_TIMEOUT_MS = 8e3;
|
|
239
382
|
var NEGOTIATION_TIMEOUT_MS = 35e3;
|
|
@@ -265,6 +408,7 @@ var CloudflareSFUClient = class {
|
|
|
265
408
|
#cursor = null;
|
|
266
409
|
#remotePullIncomplete = false;
|
|
267
410
|
#remotePullRetries = /* @__PURE__ */ new Map();
|
|
411
|
+
#receiveProgress = new ReceiveStallRecovery();
|
|
268
412
|
#remoteParticipantIds = null;
|
|
269
413
|
#remoteParticipantRevision = 0;
|
|
270
414
|
#subscribedPublicationIds = /* @__PURE__ */ new Set();
|
|
@@ -281,6 +425,7 @@ var CloudflareSFUClient = class {
|
|
|
281
425
|
#retirement = Promise.resolve();
|
|
282
426
|
#snapshot;
|
|
283
427
|
#started = false;
|
|
428
|
+
#receivingInitial = false;
|
|
284
429
|
#stopped = false;
|
|
285
430
|
#transport;
|
|
286
431
|
constructor(options) {
|
|
@@ -300,6 +445,7 @@ var CloudflareSFUClient = class {
|
|
|
300
445
|
cursor: null,
|
|
301
446
|
localTracks: EMPTY_LOCAL,
|
|
302
447
|
remoteTracks: EMPTY_REMOTE,
|
|
448
|
+
receiveStalls: this.#receiveProgress.observations,
|
|
303
449
|
failure: null
|
|
304
450
|
});
|
|
305
451
|
this.#observeConnection(this.#connection, this.#generation, this.#connectionEpoch);
|
|
@@ -320,7 +466,9 @@ var CloudflareSFUClient = class {
|
|
|
320
466
|
this.#remotePullIncomplete = true;
|
|
321
467
|
this.remotePublicationsChanged();
|
|
322
468
|
}
|
|
323
|
-
remotePublicationsChanged() {
|
|
469
|
+
remotePublicationsChanged(publications, projectionState = "available") {
|
|
470
|
+
if (projectionState === "unavailable") this.#receiveProgress.suspend();
|
|
471
|
+
else if (publications) this.#receiveProgress.expect(publications, this.#participantId);
|
|
324
472
|
if (this.#polling) {
|
|
325
473
|
this.#pollAfterCurrent = true;
|
|
326
474
|
return;
|
|
@@ -385,20 +533,30 @@ var CloudflareSFUClient = class {
|
|
|
385
533
|
this.#publishSnapshot();
|
|
386
534
|
this.#emitLocal();
|
|
387
535
|
}
|
|
388
|
-
async start(localMedia) {
|
|
536
|
+
async start(localMedia, beforePublish) {
|
|
389
537
|
if (this.#started) return;
|
|
390
538
|
this.#requireActive();
|
|
391
|
-
const tracks = localMedia.getTracks().filter((track) => track.kind === "audio" || track.kind === "video");
|
|
392
|
-
for (const track of tracks) this.prepareLocalTrack(track.kind === "audio" ? "microphone" : "camera", track);
|
|
393
539
|
const generation = this.#generation;
|
|
394
540
|
this.#setPhase("connecting", null);
|
|
541
|
+
if (beforePublish) {
|
|
542
|
+
this.#receivingInitial = true;
|
|
543
|
+
this.#schedulePoll(0);
|
|
544
|
+
try {
|
|
545
|
+
localMedia = await beforePublish();
|
|
546
|
+
} finally {
|
|
547
|
+
this.#receivingInitial = false;
|
|
548
|
+
}
|
|
549
|
+
if (generation !== this.#generation || this.#stopped) return;
|
|
550
|
+
}
|
|
551
|
+
const tracks = localMedia.getTracks().filter((track) => track.kind === "audio" || track.kind === "video");
|
|
552
|
+
for (const track of tracks) this.prepareLocalTrack(track.kind === "audio" ? "microphone" : "camera", track);
|
|
395
553
|
await this.#activatePreparedTracks(
|
|
396
554
|
[...this.#localTracks.values()].filter((state) => state.source === "microphone" || state.source === "camera"),
|
|
397
555
|
generation
|
|
398
556
|
);
|
|
399
557
|
}
|
|
400
558
|
async refreshRemotePublications() {
|
|
401
|
-
if (!this.#started || this.#stopped || this.#polling) return;
|
|
559
|
+
if (!this.#started && !this.#receivingInitial || this.#stopped || this.#polling) return;
|
|
402
560
|
this.#polling = true;
|
|
403
561
|
const generation = this.#generation;
|
|
404
562
|
this.#pollFailedPull = false;
|
|
@@ -478,12 +636,34 @@ var CloudflareSFUClient = class {
|
|
|
478
636
|
return enabled ? Promise.resolve() : this.#retireOwnedPublications();
|
|
479
637
|
}
|
|
480
638
|
async closeForcedLocalPublication(source, cause = "forced_pause") {
|
|
639
|
+
const connection = this.#connection;
|
|
640
|
+
const generationAtClose = this.#generation;
|
|
481
641
|
const sources = localSourceGroup(source);
|
|
482
642
|
const ownsPublication = [...this.#ownedPublications].some((item) => sources.includes(item.state.source));
|
|
483
|
-
|
|
484
|
-
|
|
643
|
+
const canRecover = ownsPublication && this.#replaceMediaConnection !== void 0;
|
|
644
|
+
let failedRetirement;
|
|
645
|
+
try {
|
|
646
|
+
await this.setLocalSourceIntent(source, false, cause);
|
|
647
|
+
} catch (error) {
|
|
648
|
+
if (!canRecover) throw error;
|
|
649
|
+
failedRetirement = this.#fenceFailedClose(error, connection, generationAtClose, cause);
|
|
650
|
+
if (!failedRetirement) return;
|
|
651
|
+
}
|
|
652
|
+
if (!canRecover) return;
|
|
653
|
+
await this.#restoreEnabledSourcesAfterClose(source);
|
|
654
|
+
if (this.#retirement !== failedRetirement) await this.#retirement;
|
|
655
|
+
}
|
|
656
|
+
#fenceFailedClose(error, connection, generation, cause) {
|
|
657
|
+
const failures = error instanceof AggregateError ? error.errors : [error];
|
|
658
|
+
if (!failures.every((failure) => this.#isRetryableConnectionFailure(failure))) throw error;
|
|
659
|
+
if (connection !== this.#connection || generation !== this.#generation) return;
|
|
660
|
+
this.#retiredLocalConnection = connection;
|
|
661
|
+
this.#retiredLocalCause = cause;
|
|
662
|
+
return this.#retirement;
|
|
663
|
+
}
|
|
664
|
+
#restoreEnabledSourcesAfterClose(source) {
|
|
485
665
|
const generation = this.#generation;
|
|
486
|
-
|
|
666
|
+
return this.#serializeSDP(async () => {
|
|
487
667
|
const existing = new Set([...this.#localTracks.values()].filter((state) => state.source !== source && state.providerPublicationId !== null));
|
|
488
668
|
const states = await this.#prepareConnectionForPublication([], generation);
|
|
489
669
|
await this.#publishPreparedTracksSerialized(
|
|
@@ -495,7 +675,6 @@ var CloudflareSFUClient = class {
|
|
|
495
675
|
this.#requireGeneration(generation);
|
|
496
676
|
this.#setPhase("live", null);
|
|
497
677
|
});
|
|
498
|
-
await this.#retirement;
|
|
499
678
|
}
|
|
500
679
|
async #retireUnavailableLocalPublication(state) {
|
|
501
680
|
const publicationId = state.providerPublicationId;
|
|
@@ -641,6 +820,7 @@ var CloudflareSFUClient = class {
|
|
|
641
820
|
return;
|
|
642
821
|
}
|
|
643
822
|
this.#stopped = true;
|
|
823
|
+
this.#receiveProgress.expect([], this.#participantId);
|
|
644
824
|
this.#subscribedPublicationIds.clear();
|
|
645
825
|
this.#generation++;
|
|
646
826
|
this.#connectionEpoch++;
|
|
@@ -901,9 +1081,8 @@ var CloudflareSFUClient = class {
|
|
|
901
1081
|
if (cursor === null) return;
|
|
902
1082
|
const participantRevision = this.#remoteParticipantRevision;
|
|
903
1083
|
const desired = this.#desiredRemotePublications(authoritative, cursor);
|
|
904
|
-
const toPull = this.#pendingRemotePulls(desired);
|
|
905
1084
|
this.#pollFailedPull = true;
|
|
906
|
-
const pulled = await this.#pullWithRecovery(
|
|
1085
|
+
const pulled = await this.#pullWithRecovery(desired, cursor, generation);
|
|
907
1086
|
if (pulled === null) return;
|
|
908
1087
|
this.#requireGeneration(generation);
|
|
909
1088
|
const next = reconcileRemoteTracks(desired, pulled, this.#remoteTracks);
|
|
@@ -931,7 +1110,9 @@ var CloudflareSFUClient = class {
|
|
|
931
1110
|
}
|
|
932
1111
|
const now = Date.now();
|
|
933
1112
|
return [...desired].filter(([key, publication]) => {
|
|
934
|
-
if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId)
|
|
1113
|
+
if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) {
|
|
1114
|
+
if (!this.#receiveProgress.canRebuild() || !this.#replaceMediaConnection) return false;
|
|
1115
|
+
}
|
|
935
1116
|
const remaining = (this.#remotePullRetries.get(publication.publicationId)?.retryAfter ?? 0) - now;
|
|
936
1117
|
return remaining <= 0;
|
|
937
1118
|
}).map(([, publication]) => publication);
|
|
@@ -960,7 +1141,14 @@ var CloudflareSFUClient = class {
|
|
|
960
1141
|
#canSubscribeToRemotePublication(publication) {
|
|
961
1142
|
return this.#remoteParticipantIds === null || this.#remoteParticipantIds.has(publication.participantId) || this.#subscribedPublicationIds.has(publication.publicationId);
|
|
962
1143
|
}
|
|
963
|
-
async #pullWithRecovery(
|
|
1144
|
+
async #pullWithRecovery(desired, cursor, generation) {
|
|
1145
|
+
const pending = this.#pendingRemotePulls(desired);
|
|
1146
|
+
const receiveStalled = pending.some((publication) => this.#receiveProgress.isStalled(publication.publicationId));
|
|
1147
|
+
const publications = receiveStalled ? [...desired.values()] : pending;
|
|
1148
|
+
if (receiveStalled) {
|
|
1149
|
+
if (!this.#replaceMediaConnection || !this.#receiveProgress.reserveRebuild()) return [];
|
|
1150
|
+
return this.#retryRemotePullOnReplacement(publications, cursor, generation, "receive_stalled");
|
|
1151
|
+
}
|
|
964
1152
|
try {
|
|
965
1153
|
return await this.#pull(publications, generation);
|
|
966
1154
|
} catch (error) {
|
|
@@ -979,14 +1167,19 @@ var CloudflareSFUClient = class {
|
|
|
979
1167
|
#hasLiveRemoteTracks() {
|
|
980
1168
|
return [...this.#remoteTracks.values()].some((publication) => publication.track.readyState === "live");
|
|
981
1169
|
}
|
|
982
|
-
async #retryRemotePullOnReplacement(publications, cursor, generation) {
|
|
1170
|
+
async #retryRemotePullOnReplacement(publications, cursor, generation, cause = "remote_signaling_failure") {
|
|
983
1171
|
this.#remoteRecoveryGeneration = generation;
|
|
1172
|
+
const connection = this.#connection;
|
|
984
1173
|
try {
|
|
985
|
-
await this.#replaceMediaConnectionForRecovery(generation,
|
|
1174
|
+
await this.#replaceMediaConnectionForRecovery(generation, cause);
|
|
986
1175
|
await this.#republishPreparedTracksAfterRecovery(generation);
|
|
987
1176
|
return await this.#pull(publications, generation);
|
|
988
1177
|
} catch (error) {
|
|
989
1178
|
if (generation !== this.#generation || this.#stopped) throw error;
|
|
1179
|
+
if (cause === "receive_stalled" && connection === this.#connection) {
|
|
1180
|
+
this.#replacementAttemptedGeneration = null;
|
|
1181
|
+
for (const publication of publications) this.#backoffRemotePull(publication.publicationId, true);
|
|
1182
|
+
}
|
|
990
1183
|
this.#observeRemotePublicationCursor(cursor, retryableRemotePull(error));
|
|
991
1184
|
throw error;
|
|
992
1185
|
}
|
|
@@ -1029,7 +1222,7 @@ var CloudflareSFUClient = class {
|
|
|
1029
1222
|
const retryAfter = retryableRemotePull(error) ? Date.now() + 15e3 : Number.POSITIVE_INFINITY;
|
|
1030
1223
|
for (const publication of publications) {
|
|
1031
1224
|
const failures = this.#remotePullRetries.get(publication.publicationId)?.failures ?? 0;
|
|
1032
|
-
this.#remotePullRetries.set(publication.publicationId, { retryAfter, failures });
|
|
1225
|
+
this.#remotePullRetries.set(publication.publicationId, { retryAfter, failures, receiveStalled: this.#remotePullRetries.get(publication.publicationId)?.receiveStalled });
|
|
1033
1226
|
}
|
|
1034
1227
|
}
|
|
1035
1228
|
async #pullBatch(publications, generation) {
|
|
@@ -1056,12 +1249,17 @@ var CloudflareSFUClient = class {
|
|
|
1056
1249
|
connection.addEventListener("track", onTrack);
|
|
1057
1250
|
try {
|
|
1058
1251
|
this.#requireGeneration(generation);
|
|
1059
|
-
const response = await this.#signal(() => this.#addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }), {
|
|
1252
|
+
const response = await this.#signal(() => this.#addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }), {
|
|
1253
|
+
generation,
|
|
1254
|
+
connectionId
|
|
1255
|
+
});
|
|
1060
1256
|
this.#requireGeneration(generation);
|
|
1061
1257
|
this.#recordRemotePullErrors(publications, response.trackErrors ?? []);
|
|
1062
1258
|
const responseTracks = response.tracks ?? [];
|
|
1063
1259
|
const matched = matchRemotePullTracks(publications, responseTracks);
|
|
1064
|
-
for (const { publication } of matched)
|
|
1260
|
+
for (const { publication } of matched) {
|
|
1261
|
+
if (!this.#remotePullRetries.get(publication.publicationId)?.receiveStalled) this.#remotePullRetries.delete(publication.publicationId);
|
|
1262
|
+
}
|
|
1065
1263
|
await this.#completeRenegotiation(response, connection, connectionId, generation);
|
|
1066
1264
|
if (matched.length === 0) return [];
|
|
1067
1265
|
await this.#waitForConnection(connection, generation);
|
|
@@ -1091,13 +1289,33 @@ var CloudflareSFUClient = class {
|
|
|
1091
1289
|
for (const failure of failures) {
|
|
1092
1290
|
const publication = requested.get(`${failure.connectionId}\0${failure.trackName}`);
|
|
1093
1291
|
if (!publication) throw new CloudflareSFUError("Cloudflare SFU returned an unrequested remote track error", "invalid_publication");
|
|
1094
|
-
|
|
1095
|
-
const failureCount = Math.min(previous.failures + 1, 6);
|
|
1096
|
-
const delay = Math.min(15e3, 750 * 2 ** (failureCount - 1));
|
|
1097
|
-
this.#remotePullRetries.set(publication.publicationId, { retryAfter: Date.now() + delay, failures: failureCount });
|
|
1292
|
+
this.#backoffRemotePull(publication.publicationId);
|
|
1098
1293
|
this.#reportError(new CloudflareSFUError(`Cloudflare SFU remote track failed: ${failure.code}`, "media_failed", { providerCode: failure.code }));
|
|
1099
1294
|
}
|
|
1100
1295
|
}
|
|
1296
|
+
#backoffRemotePull(publicationId, receiveStalled = false) {
|
|
1297
|
+
const previous = this.#remotePullRetries.get(publicationId) ?? { retryAfter: 0, failures: 0 };
|
|
1298
|
+
const failures = Math.min(previous.failures + 1, 6);
|
|
1299
|
+
this.#remotePullRetries.set(publicationId, { retryAfter: Date.now() + Math.min(15e3, 750 * 2 ** (failures - 1)), failures, receiveStalled: receiveStalled || previous.receiveStalled });
|
|
1300
|
+
}
|
|
1301
|
+
#canObserveReceiveProgress(connection) {
|
|
1302
|
+
return this.#started && connectionIsLive(connection) && (typeof document === "undefined" || document.visibilityState !== "hidden");
|
|
1303
|
+
}
|
|
1304
|
+
#observeReceiveProgress(connection, report) {
|
|
1305
|
+
if (!this.#canObserveReceiveProgress(connection)) {
|
|
1306
|
+
this.#receiveProgress.clear();
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
const { stalled, advanced } = this.#receiveProgress.sample(performance.now(), connection, report, this.#remoteTracks.values());
|
|
1310
|
+
for (const publicationId of advanced) this.#remotePullRetries.delete(publicationId);
|
|
1311
|
+
this.#publishSnapshot();
|
|
1312
|
+
if (!this.#receiveProgress.canRebuild()) return;
|
|
1313
|
+
for (const publicationId of stalled) {
|
|
1314
|
+
if (!this.#remotePullRetries.get(publicationId)?.receiveStalled) this.#backoffRemotePull(publicationId, true);
|
|
1315
|
+
}
|
|
1316
|
+
this.#remotePullIncomplete = true;
|
|
1317
|
+
this.remotePublicationsChanged();
|
|
1318
|
+
}
|
|
1101
1319
|
async #completeRenegotiation(response, connection, connectionId, generation) {
|
|
1102
1320
|
if (!response.requiresImmediateRenegotiation) return;
|
|
1103
1321
|
await this.#applyProviderDescription(response, connection);
|
|
@@ -1203,14 +1421,12 @@ var CloudflareSFUClient = class {
|
|
|
1203
1421
|
cameraPolicy.reset();
|
|
1204
1422
|
observedCamera = state;
|
|
1205
1423
|
}
|
|
1206
|
-
const sender = this.#cameraSimulcastSender(state);
|
|
1207
|
-
if (!sender) {
|
|
1208
|
-
cameraPolicy.reset();
|
|
1209
|
-
return;
|
|
1210
|
-
}
|
|
1211
1424
|
const report = await connection.getStats();
|
|
1212
|
-
if (!current() ||
|
|
1213
|
-
|
|
1425
|
+
if (!current() || connection.signalingState !== "stable") return;
|
|
1426
|
+
this.#observeReceiveProgress(connection, report);
|
|
1427
|
+
const sender = this.#cameraSimulcastSender(state);
|
|
1428
|
+
if (!sender) cameraPolicy.reset();
|
|
1429
|
+
else await this.#applyCameraUplink(sender, report, cameraPolicy);
|
|
1214
1430
|
} catch {
|
|
1215
1431
|
cameraPolicy.reset();
|
|
1216
1432
|
} finally {
|
|
@@ -1312,6 +1528,7 @@ var CloudflareSFUClient = class {
|
|
|
1312
1528
|
}
|
|
1313
1529
|
}
|
|
1314
1530
|
#disposeConnection(stopSenders) {
|
|
1531
|
+
this.#receiveProgress.clear();
|
|
1315
1532
|
if (!stopSenders) this.#setPhase("recovering", null);
|
|
1316
1533
|
this.#disposeConnectionObservation?.();
|
|
1317
1534
|
this.#disposeConnectionObservation = void 0;
|
|
@@ -1349,7 +1566,7 @@ var CloudflareSFUClient = class {
|
|
|
1349
1566
|
this.#emitRemote();
|
|
1350
1567
|
}
|
|
1351
1568
|
#schedulePoll(delayMs = this.#pollIntervalMs) {
|
|
1352
|
-
if (this.#stopped || !this.#started || this.#pollTimer !== void 0) return;
|
|
1569
|
+
if (this.#stopped || !this.#started && !this.#receivingInitial || this.#pollTimer !== void 0) return;
|
|
1353
1570
|
this.#pollTimer = globalThis.setTimeout(async () => {
|
|
1354
1571
|
this.#pollTimer = void 0;
|
|
1355
1572
|
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
|
@@ -1408,6 +1625,7 @@ var CloudflareSFUClient = class {
|
|
|
1408
1625
|
})
|
|
1409
1626
|
) : EMPTY_LOCAL,
|
|
1410
1627
|
remoteTracks: this.#remoteTracks.size ? [...this.#remoteTracks.values()] : EMPTY_REMOTE,
|
|
1628
|
+
receiveStalls: this.#receiveProgress.observations,
|
|
1411
1629
|
failure
|
|
1412
1630
|
});
|
|
1413
1631
|
if (snapshotEqual(this.#snapshot, next)) return;
|
|
@@ -1532,7 +1750,7 @@ function snapshotEqual(left, right) {
|
|
|
1532
1750
|
return snapshotMetadataEqual(left, right) && left.localTracks.every((publication, index) => publicationEqual(publication, right.localTracks[index])) && left.remoteTracks.every((publication, index) => remotePublicationEqual(publication, right.remoteTracks[index]));
|
|
1533
1751
|
}
|
|
1534
1752
|
function snapshotMetadataEqual(left, right) {
|
|
1535
|
-
return connectionEqual(left.connection, right.connection) && cursorEqual(left.cursor, right.cursor) && failureEqual(left.failure, right.failure) && left.localTracks.length === right.localTracks.length && left.remoteTracks.length === right.remoteTracks.length;
|
|
1753
|
+
return (left.receiveStalls ?? EMPTY_RECEIVE_STALLS) === (right.receiveStalls ?? EMPTY_RECEIVE_STALLS) && connectionEqual(left.connection, right.connection) && cursorEqual(left.cursor, right.cursor) && failureEqual(left.failure, right.failure) && left.localTracks.length === right.localTracks.length && left.remoteTracks.length === right.remoteTracks.length;
|
|
1536
1754
|
}
|
|
1537
1755
|
function connectionEqual(left, right) {
|
|
1538
1756
|
return left.phase === right.phase && left.peerConnectionState === right.peerConnectionState && left.iceConnectionState === right.iceConnectionState;
|