@q9labsai/chalk-client 5.3.0 → 5.4.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/dist/access/grant.d.ts +2 -0
- package/dist/{chunk-GOSWSXTH.js → chunk-MHJ4UPM3.js} +162 -5
- package/dist/chunk-N4IPKRS6.js +76 -0
- package/dist/{chunk-DHUHTIU7.js → chunk-TA2BJXI7.js} +243 -45
- package/dist/{chunk-3TGVTP6Z.js → chunk-UVWRTXNO.js} +894 -72
- package/dist/{chunk-O26BHHMI.js → chunk-VAJ6XNAA.js} +1 -1
- package/dist/{chunk-XSB2GDRW.js → chunk-XF567GPH.js} +198 -26
- package/dist/client.d.ts +180 -0
- package/dist/connection/dependencies.d.ts +5 -0
- package/dist/connection/production.d.ts +1 -1
- package/dist/effect.js +29 -4
- package/dist/generated/http-api.d.ts +178 -0
- package/dist/generated/schemas.d.ts +287 -0
- package/dist/generated/sync.d.ts +53 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +33 -15
- package/dist/media/call-stats.d.ts +27 -0
- package/dist/media/client.d.ts +1 -0
- package/dist/media/ice-servers.d.ts +3 -0
- package/dist/media/index.d.ts +2 -0
- package/dist/media/index.js +4 -1
- package/dist/media/plane.d.ts +1 -0
- package/dist/media/receive-stall.d.ts +11 -2
- package/dist/media/types.d.ts +19 -0
- package/dist/media/video-render-size.d.ts +7 -0
- package/dist/media/viewer-layer.d.ts +48 -0
- package/dist/public-invites/index.js +3 -2
- package/dist/server/index.cjs +18 -4
- package/dist/server/index.js +18 -4
- package/dist/session/index.d.ts +18 -0
- package/dist/sync/index.js +2 -1
- package/dist/sync/link-watch.d.ts +67 -0
- package/dist/sync/v1-client.d.ts +5 -0
- package/dist/sync/v1-command-scheduler.d.ts +4 -0
- package/dist/sync/v1-types.d.ts +12 -1
- package/package.json +2 -1
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
withRequestContext
|
|
3
|
+
} from "./chunk-N4IPKRS6.js";
|
|
4
|
+
|
|
1
5
|
// src/media/receive-stall.ts
|
|
2
6
|
var RECEIVE_STALL_WINDOW_MS = 7e3;
|
|
3
7
|
var RECEIVE_STARTUP_GRACE_MS = 2e4;
|
|
@@ -46,6 +50,10 @@ var MAX_RECEIVE_OBSERVATIONS = 32;
|
|
|
46
50
|
var ReceiveStallRecovery = class {
|
|
47
51
|
#detector = new ReceiveStallDetector();
|
|
48
52
|
#attempts = /* @__PURE__ */ new Map();
|
|
53
|
+
#sources = /* @__PURE__ */ new Map();
|
|
54
|
+
#repulled = /* @__PURE__ */ new Set();
|
|
55
|
+
// Re-pulled but not yet restored. Retired receivers vanish from stats, so this cannot depend on detector samples.
|
|
56
|
+
#awaiting = /* @__PURE__ */ new Set();
|
|
49
57
|
#identities = /* @__PURE__ */ new Map();
|
|
50
58
|
#expected = /* @__PURE__ */ new Set();
|
|
51
59
|
#screens = /* @__PURE__ */ new Set();
|
|
@@ -60,10 +68,12 @@ var ReceiveStallRecovery = class {
|
|
|
60
68
|
this.#expected = new Set(enabled.flatMap((publication) => publication.publicationId ? [publication.publicationId] : []));
|
|
61
69
|
this.#screens = new Set(enabled.filter((publication) => publication.source === "screen").map((publication) => publication.participantId));
|
|
62
70
|
this.#pruneWithdrawn(publications);
|
|
71
|
+
this.#pruneAttempts(publications);
|
|
63
72
|
for (const publication of this.#identities.values()) {
|
|
64
73
|
if (this.#isExpected(publication)) continue;
|
|
65
74
|
this.#detector.forget(publication.publicationId);
|
|
66
75
|
this.#stalled.delete(publication.publicationId);
|
|
76
|
+
this.#awaiting.delete(publication.publicationId);
|
|
67
77
|
}
|
|
68
78
|
this.#active = [...this.#identities.values()].filter((publication) => this.#isExpected(publication)).map((publication) => publication.publicationId);
|
|
69
79
|
}
|
|
@@ -81,17 +91,47 @@ var ReceiveStallRecovery = class {
|
|
|
81
91
|
this.#identities.delete(key);
|
|
82
92
|
}
|
|
83
93
|
}
|
|
94
|
+
#pruneAttempts(publications) {
|
|
95
|
+
const advertised = new Set(publications.flatMap((publication) => publication.source === "screen" ? [sourceKey(publication), sourceKey({ ...publication, source: "screen_audio" })] : [sourceKey(publication)]));
|
|
96
|
+
for (const key of this.#attempts.keys()) if (!advertised.has(key)) this.#attempts.delete(key);
|
|
97
|
+
}
|
|
84
98
|
isStalled(publicationId) {
|
|
85
99
|
return this.#stalled.has(publicationId);
|
|
86
100
|
}
|
|
87
|
-
|
|
88
|
-
return this.#
|
|
101
|
+
canRepull(id) {
|
|
102
|
+
return this.#stalled.has(id) && !this.#repulled.has(id);
|
|
103
|
+
}
|
|
104
|
+
reserveRepull(id) {
|
|
105
|
+
if (!this.canRepull(id)) return false;
|
|
106
|
+
this.#repulled.add(id);
|
|
107
|
+
this.#awaiting.add(id);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
restarted(id) {
|
|
111
|
+
this.#detector.forget(id);
|
|
112
|
+
this.#stalled.delete(id);
|
|
113
|
+
this.#awaiting.delete(id);
|
|
114
|
+
}
|
|
115
|
+
canRebuild(id) {
|
|
116
|
+
return this.#active.some((active) => (id === void 0 || id === active) && this.#stalled.has(active) && this.#rebuildsLeft(active));
|
|
89
117
|
}
|
|
90
118
|
reserveRebuild() {
|
|
91
119
|
if (!this.canRebuild()) return false;
|
|
92
|
-
for (const id of this.#active)
|
|
120
|
+
for (const id of this.#active) {
|
|
121
|
+
const source = this.#sources.get(id);
|
|
122
|
+
if (source !== void 0 && this.canRebuild(id)) this.#attempts.set(source, (this.#attempts.get(source) ?? 0) + 1);
|
|
123
|
+
}
|
|
93
124
|
return true;
|
|
94
125
|
}
|
|
126
|
+
#rebuildsLeft(id) {
|
|
127
|
+
const source = this.#sources.get(id);
|
|
128
|
+
return source !== void 0 && (this.#attempts.get(source) ?? 0) < MAX_RECEIVE_REBUILDS;
|
|
129
|
+
}
|
|
130
|
+
/** The media connection was replaced: its receivers are gone, so nothing is awaiting a restore. */
|
|
131
|
+
replaced() {
|
|
132
|
+
this.#awaiting.clear();
|
|
133
|
+
this.clear();
|
|
134
|
+
}
|
|
95
135
|
clear() {
|
|
96
136
|
this.#detector.clear();
|
|
97
137
|
this.#stalled.clear();
|
|
@@ -103,36 +143,48 @@ var ReceiveStallRecovery = class {
|
|
|
103
143
|
this.#active = active.map((publication) => publication.publicationId);
|
|
104
144
|
const samples = active.flatMap((publication) => receivePacketSample(connection, report, publication) ?? []);
|
|
105
145
|
const { stalled, advanced, resumed } = this.#detector.sample(now, samples);
|
|
106
|
-
for (const id of resumed)
|
|
146
|
+
for (const id of resumed) {
|
|
147
|
+
const source = this.#sources.get(id);
|
|
148
|
+
if (source !== void 0) this.#attempts.delete(source);
|
|
149
|
+
this.#repulled.delete(id);
|
|
150
|
+
this.#awaiting.delete(id);
|
|
151
|
+
}
|
|
107
152
|
const newlyStalled = stalled.filter((id) => !this.#stalled.has(id));
|
|
108
|
-
this.#
|
|
153
|
+
const unrestored = this.#active.filter((id) => this.#awaiting.has(id) && !stalled.includes(id));
|
|
154
|
+
this.#stalled = /* @__PURE__ */ new Set([...stalled, ...unrestored]);
|
|
109
155
|
if (newlyStalled.length) this.#record(now);
|
|
110
|
-
return { stalled, advanced, newlyStalled };
|
|
156
|
+
return { stalled: [...stalled, ...unrestored], advanced, newlyStalled };
|
|
111
157
|
}
|
|
112
158
|
#isExpected(publication) {
|
|
113
159
|
return publication.source === "screen_audio" ? this.#screens.has(publication.participantId) : this.#expected.has(publication.publicationId);
|
|
114
160
|
}
|
|
115
161
|
#rememberIdentity(publication) {
|
|
116
|
-
const key =
|
|
162
|
+
const key = sourceKey(publication);
|
|
117
163
|
const previous = this.#identities.get(key);
|
|
118
164
|
if (previous && previous.publicationId !== publication.publicationId) this.#forget(previous.publicationId);
|
|
119
165
|
this.#identities.set(key, publication);
|
|
166
|
+
this.#sources.set(publication.publicationId, key);
|
|
120
167
|
}
|
|
121
168
|
#forget(id) {
|
|
122
169
|
this.#detector.forget(id);
|
|
123
170
|
this.#stalled.delete(id);
|
|
124
|
-
this.#
|
|
171
|
+
this.#awaiting.delete(id);
|
|
172
|
+
this.#repulled.delete(id);
|
|
173
|
+
this.#sources.delete(id);
|
|
125
174
|
}
|
|
126
175
|
#record(now) {
|
|
127
176
|
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
177
|
this.#observations = Object.freeze([...this.#observations.slice(-(MAX_RECEIVE_OBSERVATIONS - 1)), observation]);
|
|
129
178
|
}
|
|
130
179
|
};
|
|
180
|
+
function sourceKey({ participantId, source }) {
|
|
181
|
+
return `${participantId}\0${source}`;
|
|
182
|
+
}
|
|
131
183
|
function receivePacketSample(connection, report, publication) {
|
|
132
184
|
const rows = [...report.values()].filter((row) => row.type === "inbound-rtp" && row.trackIdentifier === publication.track.id);
|
|
133
185
|
if (rows.some((row) => !Number.isSafeInteger(row.packetsReceived) || row.packetsReceived < 0)) return;
|
|
134
186
|
if (publication.track.kind === "audio" && !audioPacketCadenceKnown(connection, report, publication.track, rows)) return;
|
|
135
|
-
return { publicationId: publication.publicationId, trackId: publication.track.id, packets: rows.reduce((
|
|
187
|
+
return { publicationId: publication.publicationId, trackId: publication.track.id, packets: rows.reduce((sum2, row) => sum2 + row.packetsReceived, 0) };
|
|
136
188
|
}
|
|
137
189
|
function audioPacketCadenceKnown(connection, report, track, rows) {
|
|
138
190
|
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 ?? [];
|
|
@@ -166,13 +218,223 @@ var CameraUplinkPolicy = class {
|
|
|
166
218
|
this.#healthySamples = 0;
|
|
167
219
|
}
|
|
168
220
|
};
|
|
169
|
-
function cameraUplinkBitrate(
|
|
170
|
-
for (const stat of
|
|
221
|
+
function cameraUplinkBitrate(stats2) {
|
|
222
|
+
for (const stat of stats2) {
|
|
171
223
|
if (stat.type === "candidate-pair" && stat.state === "succeeded" && (stat.nominated || stat.selected)) return stat.availableOutgoingBitrate;
|
|
172
224
|
}
|
|
173
225
|
return void 0;
|
|
174
226
|
}
|
|
175
227
|
|
|
228
|
+
// src/media/video-render-size.ts
|
|
229
|
+
var sizes = /* @__PURE__ */ new WeakMap();
|
|
230
|
+
function setVideoRenderSize(track, size) {
|
|
231
|
+
if (size === null) sizes.delete(track);
|
|
232
|
+
else sizes.set(track, size);
|
|
233
|
+
}
|
|
234
|
+
function videoRenderSize(track) {
|
|
235
|
+
return sizes.get(track);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/media/viewer-layer.ts
|
|
239
|
+
var LOW_TILE_MAX_HEIGHT = 400;
|
|
240
|
+
var HIGH_TILE_MIN_HEIGHT = 480;
|
|
241
|
+
var BAD_LOSS = 0.05;
|
|
242
|
+
var SEVERE_LOSS = 0.15;
|
|
243
|
+
var SEVERE_FREEZE_SECONDS = 1;
|
|
244
|
+
var GOOD_LOSS = 0.02;
|
|
245
|
+
var MIN_FRAMES_PER_SECOND = 8;
|
|
246
|
+
var DROP_AFTER_BAD_SAMPLES = 2;
|
|
247
|
+
var RISE_AFTER_GOOD_SAMPLES = 15;
|
|
248
|
+
var RISE_AFTER_TILE_GROWS_SAMPLES = 2;
|
|
249
|
+
var SETTLE_SAMPLES = 2;
|
|
250
|
+
var FAILED_PROBE_WITHIN_SAMPLES = 30;
|
|
251
|
+
var STABLE_SAMPLES_RESETTING_BACKOFF = 60;
|
|
252
|
+
var MAX_RISE_BACKOFF = 8;
|
|
253
|
+
var FRAME_RATE_COMPARISON_SAMPLES = 3;
|
|
254
|
+
var ViewerLayerPolicy = class {
|
|
255
|
+
#layer = "h";
|
|
256
|
+
#tileIsSmall = false;
|
|
257
|
+
#limitedByNetwork = false;
|
|
258
|
+
#badSamples = 0;
|
|
259
|
+
#goodSamples = 0;
|
|
260
|
+
#stableSamples = 0;
|
|
261
|
+
#samplesSinceRise = Number.POSITIVE_INFINITY;
|
|
262
|
+
#settling = 0;
|
|
263
|
+
#backoff = 1;
|
|
264
|
+
#frameRateProbe;
|
|
265
|
+
#senderFrameRate;
|
|
266
|
+
#unimprovedSamples = 0;
|
|
267
|
+
get layer() {
|
|
268
|
+
return this.#layer;
|
|
269
|
+
}
|
|
270
|
+
/** Drops fast, rises slowly: a failed attempt at h doubles the wait before the next one. */
|
|
271
|
+
sample(sample) {
|
|
272
|
+
this.#tileIsSmall = tileIsSmall(this.#tileIsSmall, sample.tileHeight);
|
|
273
|
+
if (this.#layer === "h" && sample.framesPerSecond !== void 0 && sample.framesPerSecond >= MIN_FRAMES_PER_SECOND) this.#senderFrameRate = void 0;
|
|
274
|
+
const checkFrameRate = this.#layer === "h" && (this.#senderFrameRate === void 0 || (sample.framesPerSecond ?? 0) < this.#senderFrameRate * 0.75);
|
|
275
|
+
const health = this.#settling > 0 ? this.#settle() : classify(sample, checkFrameRate);
|
|
276
|
+
return this.#layer === "h" ? this.#sampleHigh(health, sample) : this.#sampleLow(health, sample);
|
|
277
|
+
}
|
|
278
|
+
#settle() {
|
|
279
|
+
this.#settling--;
|
|
280
|
+
return "unknown";
|
|
281
|
+
}
|
|
282
|
+
#sampleHigh(health, sample) {
|
|
283
|
+
this.#samplesSinceRise++;
|
|
284
|
+
if (this.#tileIsSmall) return this.#drop(false);
|
|
285
|
+
if (health === "bad" || health === "severe") {
|
|
286
|
+
this.#stableSamples = 0;
|
|
287
|
+
if (health === "severe" || ++this.#badSamples >= DROP_AFTER_BAD_SAMPLES) return this.#drop(true, frameRateProbe(sample, health));
|
|
288
|
+
return this.#layer;
|
|
289
|
+
}
|
|
290
|
+
this.#badSamples = 0;
|
|
291
|
+
if (health === "good" && ++this.#stableSamples >= STABLE_SAMPLES_RESETTING_BACKOFF) this.#backoff = 1;
|
|
292
|
+
return this.#layer;
|
|
293
|
+
}
|
|
294
|
+
#sampleLow(health, sample) {
|
|
295
|
+
if (this.#tileIsSmall || health !== "good") {
|
|
296
|
+
this.#goodSamples = 0;
|
|
297
|
+
this.#unimprovedSamples = 0;
|
|
298
|
+
return this.#layer;
|
|
299
|
+
}
|
|
300
|
+
if (this.#detectSenderLimit(sample.framesPerSecond)) {
|
|
301
|
+
this.#senderFrameRate = this.#frameRateProbe;
|
|
302
|
+
this.#backoff = 1;
|
|
303
|
+
return this.#rise();
|
|
304
|
+
}
|
|
305
|
+
const needed = this.#limitedByNetwork ? RISE_AFTER_GOOD_SAMPLES * this.#backoff : RISE_AFTER_TILE_GROWS_SAMPLES;
|
|
306
|
+
if (++this.#goodSamples < needed) return this.#layer;
|
|
307
|
+
return this.#rise();
|
|
308
|
+
}
|
|
309
|
+
#detectSenderLimit(fps) {
|
|
310
|
+
if (this.#frameRateProbe !== void 0 && fps !== void 0 && fps > 0 && fps <= this.#frameRateProbe * 1.25) return ++this.#unimprovedSamples >= FRAME_RATE_COMPARISON_SAMPLES;
|
|
311
|
+
this.#unimprovedSamples = 0;
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
#drop(byNetwork, frameRate) {
|
|
315
|
+
this.#frameRateProbe = frameRate;
|
|
316
|
+
this.#unimprovedSamples = 0;
|
|
317
|
+
if (byNetwork) {
|
|
318
|
+
if (this.#samplesSinceRise < FAILED_PROBE_WITHIN_SAMPLES) this.#backoff = Math.min(this.#backoff * 2, MAX_RISE_BACKOFF);
|
|
319
|
+
this.#limitedByNetwork = true;
|
|
320
|
+
}
|
|
321
|
+
return this.#switchTo("l");
|
|
322
|
+
}
|
|
323
|
+
#rise() {
|
|
324
|
+
this.#limitedByNetwork = false;
|
|
325
|
+
this.#samplesSinceRise = 0;
|
|
326
|
+
return this.#switchTo("h");
|
|
327
|
+
}
|
|
328
|
+
#switchTo(layer) {
|
|
329
|
+
this.#layer = layer;
|
|
330
|
+
this.#badSamples = 0;
|
|
331
|
+
this.#goodSamples = 0;
|
|
332
|
+
this.#stableSamples = 0;
|
|
333
|
+
this.#settling = SETTLE_SAMPLES;
|
|
334
|
+
return layer;
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
function tileIsSmall(previous, height) {
|
|
338
|
+
if (height === void 0) return previous;
|
|
339
|
+
if (height <= LOW_TILE_MAX_HEIGHT) return true;
|
|
340
|
+
return height < HIGH_TILE_MIN_HEIGHT ? previous : false;
|
|
341
|
+
}
|
|
342
|
+
function isSevere({ lossFraction, freezeSeconds }) {
|
|
343
|
+
return freezeSeconds >= SEVERE_FREEZE_SECONDS || lossFraction !== void 0 && lossFraction >= SEVERE_LOSS;
|
|
344
|
+
}
|
|
345
|
+
function isBad({ lossFraction, framesPerSecond, newFreezes }, checkFrameRate) {
|
|
346
|
+
if (newFreezes > 0 || lossFraction !== void 0 && lossFraction >= BAD_LOSS) return true;
|
|
347
|
+
return checkFrameRate && lossFraction !== void 0 && framesPerSecond !== void 0 && framesPerSecond < MIN_FRAMES_PER_SECOND;
|
|
348
|
+
}
|
|
349
|
+
function frameRateProbe(sample, health) {
|
|
350
|
+
return health === "severe" || isBad(sample, false) ? void 0 : sample.framesPerSecond;
|
|
351
|
+
}
|
|
352
|
+
function classify(sample, checkFrameRate) {
|
|
353
|
+
if (isSevere(sample)) return "severe";
|
|
354
|
+
if (isBad(sample, checkFrameRate)) return "bad";
|
|
355
|
+
const { lossFraction, framesPerSecond } = sample;
|
|
356
|
+
if (lossFraction === void 0 || framesPerSecond === void 0) return "unknown";
|
|
357
|
+
return lossFraction < GOOD_LOSS ? "good" : "unknown";
|
|
358
|
+
}
|
|
359
|
+
var ViewerStatsReader = class {
|
|
360
|
+
#previous = /* @__PURE__ */ new Map();
|
|
361
|
+
read(stats2, trackId) {
|
|
362
|
+
const row = [...stats2].find((stat) => stat.type === "inbound-rtp" && stat.kind === "video" && stat.trackIdentifier === trackId);
|
|
363
|
+
const counters = row && inboundCounters(row);
|
|
364
|
+
if (!counters) return void 0;
|
|
365
|
+
const previous = this.#previous.get(trackId);
|
|
366
|
+
this.#previous.set(trackId, counters);
|
|
367
|
+
if (!previous || counters.timestamp <= previous.timestamp) return void 0;
|
|
368
|
+
const received = counters.packetsReceived - previous.packetsReceived;
|
|
369
|
+
const lost = Math.max(0, counters.packetsLost - previous.packetsLost);
|
|
370
|
+
const frames = counters.framesDecoded - previous.framesDecoded;
|
|
371
|
+
if (received < 0 || frames < 0) return void 0;
|
|
372
|
+
return {
|
|
373
|
+
lossFraction: received + lost > 0 ? lost / (received + lost) : void 0,
|
|
374
|
+
framesPerSecond: frames * 1e3 / (counters.timestamp - previous.timestamp),
|
|
375
|
+
newFreezes: Math.max(0, counters.freezeCount - previous.freezeCount),
|
|
376
|
+
freezeSeconds: Math.max(0, counters.freezeSeconds - previous.freezeSeconds)
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
forget(trackId) {
|
|
380
|
+
this.#previous.delete(trackId);
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
function inboundCounters(row) {
|
|
384
|
+
const { timestamp, packetsReceived, packetsLost, framesDecoded } = row;
|
|
385
|
+
if (timestamp === void 0 || packetsReceived === void 0 || packetsLost === void 0 || framesDecoded === void 0) return void 0;
|
|
386
|
+
return { timestamp, packetsReceived, packetsLost, framesDecoded, freezeCount: row.freezeCount ?? 0, freezeSeconds: row.totalFreezesDuration ?? 0 };
|
|
387
|
+
}
|
|
388
|
+
var RETRY_AFTER_FAILED_SWITCH_MS = 5e3;
|
|
389
|
+
var MAX_RETRY_DELAY_MS = 6e4;
|
|
390
|
+
var ViewerLayerSelector = class {
|
|
391
|
+
#views = /* @__PURE__ */ new Map();
|
|
392
|
+
#reader = new ViewerStatsReader();
|
|
393
|
+
sample(now, report, cameras) {
|
|
394
|
+
const rows = [...report.values()];
|
|
395
|
+
const active = new Set(cameras.map((camera) => camera.publicationId));
|
|
396
|
+
for (const [publicationId, view] of this.#views) {
|
|
397
|
+
if (!active.has(publicationId)) {
|
|
398
|
+
this.#reader.forget(view.track.id);
|
|
399
|
+
this.#views.delete(publicationId);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const changes = [];
|
|
403
|
+
for (const camera of cameras) {
|
|
404
|
+
const view = this.#view(camera);
|
|
405
|
+
const stats2 = this.#reader.read(rows, camera.track.id);
|
|
406
|
+
if (!stats2) continue;
|
|
407
|
+
const layer = view.policy.layer === view.applied ? view.policy.sample({ ...stats2, tileHeight: videoRenderSize(camera.track)?.height }) : view.policy.layer;
|
|
408
|
+
if (layer !== view.applied && now >= view.retryAt) changes.push({ ...camera, layer });
|
|
409
|
+
}
|
|
410
|
+
return changes;
|
|
411
|
+
}
|
|
412
|
+
applied({ publicationId, track, layer }) {
|
|
413
|
+
const view = this.#views.get(publicationId);
|
|
414
|
+
if (!view || view.track !== track) return;
|
|
415
|
+
view.applied = layer;
|
|
416
|
+
view.failures = 0;
|
|
417
|
+
}
|
|
418
|
+
failed({ publicationId, track }, now) {
|
|
419
|
+
const view = this.#views.get(publicationId);
|
|
420
|
+
if (!view || view.track !== track) return;
|
|
421
|
+
view.retryAt = now + Math.min(MAX_RETRY_DELAY_MS, RETRY_AFTER_FAILED_SWITCH_MS * 2 ** view.failures);
|
|
422
|
+
view.failures++;
|
|
423
|
+
}
|
|
424
|
+
// A re-pull of the same publication gives a new receiver, and the SFU starts it on h.
|
|
425
|
+
#view({ publicationId, track }) {
|
|
426
|
+
let view = this.#views.get(publicationId);
|
|
427
|
+
if (!view) {
|
|
428
|
+
view = { policy: new ViewerLayerPolicy(), track, applied: "h", retryAt: 0, failures: 0 };
|
|
429
|
+
this.#views.set(publicationId, view);
|
|
430
|
+
} else if (view.track !== track) {
|
|
431
|
+
this.#reader.forget(view.track.id);
|
|
432
|
+
Object.assign(view, { track, applied: "h", retryAt: 0, failures: 0 });
|
|
433
|
+
}
|
|
434
|
+
return view;
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
176
438
|
// src/media/observers.ts
|
|
177
439
|
function subscribeSnapshot(listeners, listener) {
|
|
178
440
|
listeners.add(listener);
|
|
@@ -318,6 +580,202 @@ function matchRemotePullTracks(publications, tracks) {
|
|
|
318
580
|
});
|
|
319
581
|
}
|
|
320
582
|
|
|
583
|
+
// src/media/call-stats.ts
|
|
584
|
+
var CALL_STATS_WINDOW_MS = 1e4;
|
|
585
|
+
var CALL_STATS_UPLOAD_SAMPLES = 6;
|
|
586
|
+
var CALL_STATS_MAX_BATCH = 30;
|
|
587
|
+
var CALL_STATS_MAX_WINDOW_MS = 6e4;
|
|
588
|
+
var CALL_STATS_CALL_CAP = 360;
|
|
589
|
+
var RID_ORDER = ["h", "m", "l"];
|
|
590
|
+
var MAX_ELAPSED_MS = 2147483647;
|
|
591
|
+
function createCallStatsCollector(options) {
|
|
592
|
+
const now = options.now ?? (() => Date.now());
|
|
593
|
+
const startedAt = now();
|
|
594
|
+
let buffer = [];
|
|
595
|
+
let produced = 0;
|
|
596
|
+
let uploading = null;
|
|
597
|
+
let baseline = null;
|
|
598
|
+
const gauges = { rtt: newGauges(), sendJitter: newGauges(), recvJitter: newGauges() };
|
|
599
|
+
let latest = null;
|
|
600
|
+
const closeWindow = (at) => {
|
|
601
|
+
if (!baseline || !latest) return;
|
|
602
|
+
const windowMs = at - baseline.at;
|
|
603
|
+
if (windowMs < 1e3) return;
|
|
604
|
+
const sample = summarise(latest, baseline, windowMs, at - startedAt, gauges);
|
|
605
|
+
baseline = readBaseline(latest, at);
|
|
606
|
+
resetGauges(gauges);
|
|
607
|
+
if (windowMs > CALL_STATS_MAX_WINDOW_MS || produced >= CALL_STATS_CALL_CAP) return;
|
|
608
|
+
produced++;
|
|
609
|
+
buffer.push(sample);
|
|
610
|
+
if (buffer.length > CALL_STATS_MAX_BATCH) buffer = buffer.slice(-CALL_STATS_MAX_BATCH);
|
|
611
|
+
};
|
|
612
|
+
const send = (upload) => {
|
|
613
|
+
if (uploading || buffer.length === 0) return uploading ?? Promise.resolve();
|
|
614
|
+
const samples = buffer;
|
|
615
|
+
buffer = [];
|
|
616
|
+
uploading = upload({ version: 1, samples }).catch(() => {
|
|
617
|
+
}).finally(() => {
|
|
618
|
+
uploading = null;
|
|
619
|
+
});
|
|
620
|
+
return uploading;
|
|
621
|
+
};
|
|
622
|
+
return {
|
|
623
|
+
observe(report) {
|
|
624
|
+
const at = now();
|
|
625
|
+
latest = report;
|
|
626
|
+
baseline ??= readBaseline(report, at);
|
|
627
|
+
addGauges(report, gauges);
|
|
628
|
+
if (at - baseline.at >= CALL_STATS_WINDOW_MS) closeWindow(at);
|
|
629
|
+
if (buffer.length >= CALL_STATS_UPLOAD_SAMPLES) void send(options.upload);
|
|
630
|
+
},
|
|
631
|
+
async flush(upload) {
|
|
632
|
+
closeWindow(now());
|
|
633
|
+
if (uploading) await uploading;
|
|
634
|
+
await send(upload);
|
|
635
|
+
},
|
|
636
|
+
reset() {
|
|
637
|
+
baseline = null;
|
|
638
|
+
latest = null;
|
|
639
|
+
resetGauges(gauges);
|
|
640
|
+
}
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function summarise(report, baseline, windowMs, elapsedMs, gauges) {
|
|
644
|
+
const delta = (type, field, kind) => sumDelta(report, baseline, type, field, kind);
|
|
645
|
+
const recvLost = delta("inbound-rtp", "packetsLost");
|
|
646
|
+
const pair = selectedPair(report);
|
|
647
|
+
const local = pair ? report.get(String(pair.localCandidateId)) : void 0;
|
|
648
|
+
const remote = pair ? report.get(String(pair.remoteCandidateId)) : void 0;
|
|
649
|
+
return {
|
|
650
|
+
elapsed_ms: Math.min(Math.round(elapsedMs), MAX_ELAPSED_MS),
|
|
651
|
+
window_ms: Math.round(windowMs),
|
|
652
|
+
connection_type: connectionType(local),
|
|
653
|
+
local_candidate_type: candidateType(local),
|
|
654
|
+
remote_candidate_type: candidateType(remote),
|
|
655
|
+
relay_protocol: relayProtocol(local),
|
|
656
|
+
rtt_ms: scaled(mean(gauges.rtt), 1e3, 6e4),
|
|
657
|
+
send_bitrate_kbps: scaled(delta("outbound-rtp", "bytesSent"), 8 / windowMs, 1e5),
|
|
658
|
+
send_loss_pct: lossPct(delta("remote-inbound-rtp", "packetsLost"), delta("outbound-rtp", "packetsSent")),
|
|
659
|
+
send_jitter_ms: scaled(mean(gauges.sendJitter), 1e3, 6e4),
|
|
660
|
+
send_layer: sendLayer(report, baseline),
|
|
661
|
+
recv_bitrate_kbps: scaled(delta("inbound-rtp", "bytesReceived"), 8 / windowMs, 1e5),
|
|
662
|
+
recv_loss_pct: lossPct(recvLost, sum(recvLost, delta("inbound-rtp", "packetsReceived"))),
|
|
663
|
+
recv_jitter_ms: scaled(mean(gauges.recvJitter), 1e3, 6e4),
|
|
664
|
+
recv_height: scaled(recvHeight(report), 1, 4320),
|
|
665
|
+
freeze_count: scaled(delta("inbound-rtp", "freezeCount", "video"), 1, 1e4),
|
|
666
|
+
freeze_ms: scaled(delta("inbound-rtp", "totalFreezesDuration", "video"), 1e3, 6e5)
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
function scaled(value, factor, max) {
|
|
670
|
+
return value === null ? void 0 : clampInt(value * factor, max);
|
|
671
|
+
}
|
|
672
|
+
function stats(report, type, kind) {
|
|
673
|
+
const found = [];
|
|
674
|
+
report.forEach((stat) => {
|
|
675
|
+
if (stat.type !== type) return;
|
|
676
|
+
if (kind && (stat.kind ?? stat.mediaType) !== kind) return;
|
|
677
|
+
found.push(stat);
|
|
678
|
+
});
|
|
679
|
+
return found;
|
|
680
|
+
}
|
|
681
|
+
function readBaseline(report, at) {
|
|
682
|
+
const counters = /* @__PURE__ */ new Map();
|
|
683
|
+
report.forEach((stat) => {
|
|
684
|
+
for (const field of COUNTER_FIELDS) {
|
|
685
|
+
const value = stat[field];
|
|
686
|
+
if (typeof value === "number") counters.set(`${String(stat.id)}:${field}`, value);
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
return { at, counters };
|
|
690
|
+
}
|
|
691
|
+
var COUNTER_FIELDS = ["packetsSent", "packetsLost", "packetsReceived", "bytesSent", "bytesReceived", "totalFreezesDuration", "freezeCount"];
|
|
692
|
+
function sumDelta(report, baseline, type, field, kind) {
|
|
693
|
+
let total = 0;
|
|
694
|
+
let seen = false;
|
|
695
|
+
for (const stat of stats(report, type, kind)) {
|
|
696
|
+
const current = stat[field];
|
|
697
|
+
const before = baseline.counters.get(`${String(stat.id)}:${field}`);
|
|
698
|
+
if (typeof current !== "number" || before === void 0) continue;
|
|
699
|
+
seen = true;
|
|
700
|
+
total += Math.max(0, current - before);
|
|
701
|
+
}
|
|
702
|
+
return seen ? total : null;
|
|
703
|
+
}
|
|
704
|
+
function lossPct(lost, total) {
|
|
705
|
+
if (lost === null || total === null || total <= 0) return void 0;
|
|
706
|
+
return Math.min(100, Math.round(lost / total * 1e4) / 100);
|
|
707
|
+
}
|
|
708
|
+
function sum(a, b) {
|
|
709
|
+
return a === null || b === null ? null : a + b;
|
|
710
|
+
}
|
|
711
|
+
function newGauges() {
|
|
712
|
+
return { sum: 0, count: 0 };
|
|
713
|
+
}
|
|
714
|
+
function resetGauges(gauges) {
|
|
715
|
+
for (const gauge of Object.values(gauges)) {
|
|
716
|
+
gauge.sum = 0;
|
|
717
|
+
gauge.count = 0;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
function addGauge(gauge, value) {
|
|
721
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return;
|
|
722
|
+
gauge.sum += value;
|
|
723
|
+
gauge.count++;
|
|
724
|
+
}
|
|
725
|
+
function addGauges(report, gauges) {
|
|
726
|
+
const pair = selectedPair(report);
|
|
727
|
+
addGauge(gauges.rtt, pair?.currentRoundTripTime);
|
|
728
|
+
for (const stat of stats(report, "remote-inbound-rtp")) addGauge(gauges.sendJitter, stat.jitter);
|
|
729
|
+
for (const stat of stats(report, "inbound-rtp")) addGauge(gauges.recvJitter, stat.jitter);
|
|
730
|
+
}
|
|
731
|
+
function mean(gauge) {
|
|
732
|
+
return gauge.count === 0 ? null : gauge.sum / gauge.count;
|
|
733
|
+
}
|
|
734
|
+
function clampInt(value, max) {
|
|
735
|
+
return Math.min(max, Math.max(0, Math.round(value)));
|
|
736
|
+
}
|
|
737
|
+
function selectedPair(report) {
|
|
738
|
+
const transport = stats(report, "transport").find((stat) => typeof stat.selectedCandidatePairId === "string");
|
|
739
|
+
if (transport) return report.get(String(transport.selectedCandidatePairId));
|
|
740
|
+
return stats(report, "candidate-pair").find((stat) => stat.state === "succeeded" && (stat.selected === true || stat.nominated === true));
|
|
741
|
+
}
|
|
742
|
+
function connectionType(local) {
|
|
743
|
+
if (!local) return "unknown";
|
|
744
|
+
if (local.candidateType === "relay") return "turn";
|
|
745
|
+
if (local.protocol === "udp" || local.protocol === "tcp") return local.protocol;
|
|
746
|
+
return "unknown";
|
|
747
|
+
}
|
|
748
|
+
function candidateType(candidate) {
|
|
749
|
+
const type = candidate?.candidateType;
|
|
750
|
+
return type === "host" || type === "srflx" || type === "prflx" || type === "relay" ? type : void 0;
|
|
751
|
+
}
|
|
752
|
+
function relayProtocol(local) {
|
|
753
|
+
const protocol = local?.relayProtocol;
|
|
754
|
+
return protocol === "udp" || protocol === "tcp" || protocol === "tls" ? protocol : void 0;
|
|
755
|
+
}
|
|
756
|
+
function sendLayer(report, baseline) {
|
|
757
|
+
const active = /* @__PURE__ */ new Set();
|
|
758
|
+
for (const stat of stats(report, "outbound-rtp", "video")) {
|
|
759
|
+
const before = baseline.counters.get(`${String(stat.id)}:bytesSent`);
|
|
760
|
+
if (typeof stat.rid !== "string" || typeof stat.bytesSent !== "number" || before === void 0) continue;
|
|
761
|
+
if (stat.bytesSent > before) active.add(stat.rid);
|
|
762
|
+
}
|
|
763
|
+
return RID_ORDER.find((rid) => active.has(rid));
|
|
764
|
+
}
|
|
765
|
+
function recvHeight(report) {
|
|
766
|
+
let height = null;
|
|
767
|
+
for (const stat of stats(report, "inbound-rtp", "video")) {
|
|
768
|
+
if (typeof stat.frameHeight === "number") height = Math.max(height ?? 0, stat.frameHeight);
|
|
769
|
+
}
|
|
770
|
+
return height;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// src/media/ice-servers.ts
|
|
774
|
+
function iceServersFor(bootstrap) {
|
|
775
|
+
if (!bootstrap.iceServers || bootstrap.iceServers.length === 0) return [{ urls: bootstrap.stunServer }];
|
|
776
|
+
return bootstrap.iceServers.map(({ urls, username, credential }) => username === void 0 || credential === void 0 ? { urls: [...urls] } : { urls: [...urls], username, credential });
|
|
777
|
+
}
|
|
778
|
+
|
|
321
779
|
// src/media/failure-diagnostics.ts
|
|
322
780
|
var steps = /* @__PURE__ */ new WeakMap();
|
|
323
781
|
var fetchFailures = /* @__PURE__ */ new WeakSet();
|
|
@@ -379,8 +837,12 @@ var EMPTY_REMOTE = Object.freeze([]);
|
|
|
379
837
|
var EMPTY_RECEIVE_STALLS = Object.freeze([]);
|
|
380
838
|
var INVALID_PUBLICATION_SIGNATURE = "\0invalid";
|
|
381
839
|
var CONNECTION_TIMEOUT_MS = 8e3;
|
|
840
|
+
var RECOVERY_BACKOFF_MS = [1e3, 2e3, 4e3, 5e3];
|
|
841
|
+
var RECOVERY_HEALTHY_RESET_MS = 1e4;
|
|
382
842
|
var NEGOTIATION_TIMEOUT_MS = 35e3;
|
|
383
843
|
var PEER_OPERATION_TIMEOUT_MS = 8e3;
|
|
844
|
+
var VIEWER_LAYER_BATCH_SIZE = 16;
|
|
845
|
+
var VIEWER_LAYER_UPDATE_INTERVAL_MS = 5e3;
|
|
384
846
|
var CAPTURE_REUSE_WINDOW_MS = 2e4;
|
|
385
847
|
var CloudflareSFUClient = class {
|
|
386
848
|
#localListeners = /* @__PURE__ */ new Set();
|
|
@@ -397,6 +859,8 @@ var CloudflareSFUClient = class {
|
|
|
397
859
|
#locallyPausedSources = /* @__PURE__ */ new Map();
|
|
398
860
|
#localTracks = /* @__PURE__ */ new Map();
|
|
399
861
|
#ownedPublications = /* @__PURE__ */ new Set();
|
|
862
|
+
#localRecoveries = /* @__PURE__ */ new Map();
|
|
863
|
+
#recentRecoveries = /* @__PURE__ */ new Map();
|
|
400
864
|
#remoteTracks = /* @__PURE__ */ new Map();
|
|
401
865
|
#bootstrap;
|
|
402
866
|
#connection;
|
|
@@ -405,10 +869,14 @@ var CloudflareSFUClient = class {
|
|
|
405
869
|
#retiredLocalConnection = null;
|
|
406
870
|
#retiredLocalCause = "forced_pause";
|
|
407
871
|
#replacementCause;
|
|
872
|
+
#replacement = null;
|
|
408
873
|
#cursor = null;
|
|
409
874
|
#remotePullIncomplete = false;
|
|
410
875
|
#remotePullRetries = /* @__PURE__ */ new Map();
|
|
411
876
|
#receiveProgress = new ReceiveStallRecovery();
|
|
877
|
+
#viewerLayerPending = /* @__PURE__ */ new Map();
|
|
878
|
+
#viewerLayerUpdating = false;
|
|
879
|
+
#viewerLayerUpdateAt = Number.NEGATIVE_INFINITY;
|
|
412
880
|
#remoteParticipantIds = null;
|
|
413
881
|
#remoteParticipantRevision = 0;
|
|
414
882
|
#subscribedPublicationIds = /* @__PURE__ */ new Set();
|
|
@@ -428,6 +896,7 @@ var CloudflareSFUClient = class {
|
|
|
428
896
|
#receivingInitial = false;
|
|
429
897
|
#stopped = false;
|
|
430
898
|
#transport;
|
|
899
|
+
#callStats = createCallStatsCollector({ upload: async (stats2) => this.#transport?.uploadCallStats?.(stats2, false) });
|
|
431
900
|
constructor(options) {
|
|
432
901
|
validateClientOptions(options);
|
|
433
902
|
this.#participantId = options.participantId;
|
|
@@ -460,6 +929,12 @@ var CloudflareSFUClient = class {
|
|
|
460
929
|
this.#remotePullRetries.delete(publicationId);
|
|
461
930
|
this.remotePublicationsChanged();
|
|
462
931
|
}
|
|
932
|
+
localPublicationWithdrawn(publicationId) {
|
|
933
|
+
const owned = [...this.#ownedPublications].find((publication) => publication.lifecycle.kind === "confirmed" && publication.lifecycle.handle.publicationId === publicationId && publication.state.providerPublicationId === publicationId);
|
|
934
|
+
const state = owned?.state;
|
|
935
|
+
if (!state || !state.desiredEnabled || this.#localTracks.get(state.source) !== state) return;
|
|
936
|
+
this.#beginLocalRecovery(state, publicationId, false);
|
|
937
|
+
}
|
|
463
938
|
remoteParticipantsChanged(participantIds) {
|
|
464
939
|
this.#remoteParticipantIds = new Set(participantIds);
|
|
465
940
|
this.#remoteParticipantRevision++;
|
|
@@ -497,11 +972,13 @@ var CloudflareSFUClient = class {
|
|
|
497
972
|
};
|
|
498
973
|
track.addEventListener("ended", state.endedListener);
|
|
499
974
|
}
|
|
975
|
+
this.#dropLocalRecovery(source);
|
|
500
976
|
this.#localTracks.set(source, state);
|
|
501
977
|
this.#publishSnapshot();
|
|
502
978
|
this.#emitLocal();
|
|
503
979
|
}
|
|
504
980
|
async clearPreparedLocalTrack(source, options = {}) {
|
|
981
|
+
this.#dropLocalRecovery(source);
|
|
505
982
|
const state = this.#localTracks.get(source);
|
|
506
983
|
if (!state) return;
|
|
507
984
|
if (state.clearing) return state.clearing;
|
|
@@ -514,6 +991,7 @@ var CloudflareSFUClient = class {
|
|
|
514
991
|
}
|
|
515
992
|
async #clearPreparedTrack(state, stopTrack = true) {
|
|
516
993
|
const source = state.source;
|
|
994
|
+
this.#dropLocalRecovery(source);
|
|
517
995
|
if (source === "screen" || source === "screen_audio") {
|
|
518
996
|
try {
|
|
519
997
|
this.#pauseLocalSource(source, "capture_cleanup");
|
|
@@ -564,6 +1042,7 @@ var CloudflareSFUClient = class {
|
|
|
564
1042
|
const transport = this.#requireTransport();
|
|
565
1043
|
const authoritative = await this.#signal(() => transport.listPublications());
|
|
566
1044
|
this.#requireGeneration(generation);
|
|
1045
|
+
this.#republishUnlistedLocalPublications(authoritative);
|
|
567
1046
|
await this.#reconcileRemotePublications(authoritative, generation);
|
|
568
1047
|
this.#requireGeneration(generation);
|
|
569
1048
|
if (!this.#remotePullIncomplete) this.#pollFailures = 0;
|
|
@@ -574,6 +1053,161 @@ var CloudflareSFUClient = class {
|
|
|
574
1053
|
this.#finishPublicationPoll(generation);
|
|
575
1054
|
}
|
|
576
1055
|
}
|
|
1056
|
+
// The registry drops a publication when listeners keep failing to pull it.
|
|
1057
|
+
// A publisher that still believes it is sending must publish again, so
|
|
1058
|
+
// listeners get a new publication ID instead of retrying a dead one.
|
|
1059
|
+
#republishUnlistedLocalPublications(authoritative) {
|
|
1060
|
+
const unlisted = [...this.#localTracks.values()].filter((state) => state.enabled && state.desiredEnabled && state.providerPublicationId !== null && !authoritative.publications.some((publication) => publication.publicationId === state.providerPublicationId));
|
|
1061
|
+
for (const state of unlisted) this.#beginLocalRecovery(state, state.providerPublicationId, true);
|
|
1062
|
+
}
|
|
1063
|
+
#beginLocalRecovery(state, deadPublicationId, relist) {
|
|
1064
|
+
if (!this.#canBeginLocalRecovery(state)) return;
|
|
1065
|
+
const now = Date.now();
|
|
1066
|
+
const delayMs = this.#recoveryStartDelay(state.source, now);
|
|
1067
|
+
const recovery = { state, deadPublicationId, relist, retiring: false, attempt: 0, timer: void 0, running: false };
|
|
1068
|
+
this.#localRecoveries.set(state.source, recovery);
|
|
1069
|
+
this.#scheduleLocalRecovery(recovery, delayMs);
|
|
1070
|
+
}
|
|
1071
|
+
#canBeginLocalRecovery(state) {
|
|
1072
|
+
return state.enabled && state.desiredEnabled && !this.#stopped && !this.#locallyPausedSources.has(state.source) && !this.#localRecoveries.has(state.source);
|
|
1073
|
+
}
|
|
1074
|
+
#recoveryStartDelay(source, now) {
|
|
1075
|
+
const recent = this.#recentRecoveries.get(source);
|
|
1076
|
+
if (!recent || now - recent.healthySince >= RECOVERY_HEALTHY_RESET_MS) {
|
|
1077
|
+
this.#recentRecoveries.set(source, { count: 0, healthySince: now });
|
|
1078
|
+
return 0;
|
|
1079
|
+
}
|
|
1080
|
+
this.#recentRecoveries.set(source, { count: recent.count + 1, healthySince: now });
|
|
1081
|
+
return recoveryBackoffMs(recent.count);
|
|
1082
|
+
}
|
|
1083
|
+
#scheduleLocalRecovery(recovery, delayMs) {
|
|
1084
|
+
recovery.timer = globalThis.setTimeout(() => {
|
|
1085
|
+
recovery.timer = void 0;
|
|
1086
|
+
if (this.#localRecoveries.get(recovery.state.source) !== recovery) return;
|
|
1087
|
+
void this.#runLocalRecovery(recovery.state.source);
|
|
1088
|
+
}, delayMs);
|
|
1089
|
+
}
|
|
1090
|
+
#canRecover(recovery) {
|
|
1091
|
+
return this.#localRecoveries.get(recovery.state.source) === recovery && !this.#stopped && !this.#locallyPausedSources.has(recovery.state.source) && this.#localTracks.get(recovery.state.source) === recovery.state;
|
|
1092
|
+
}
|
|
1093
|
+
async #runLocalRecovery(source) {
|
|
1094
|
+
const recovery = this.#localRecoveries.get(source);
|
|
1095
|
+
if (!recovery || recovery.running || this.#discardUnrecoverableIntent(recovery)) return;
|
|
1096
|
+
recovery.running = true;
|
|
1097
|
+
try {
|
|
1098
|
+
await this.#attemptLocalRecovery(recovery);
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
await this.#retryLocalRecovery(recovery, error);
|
|
1101
|
+
} finally {
|
|
1102
|
+
recovery.running = false;
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
async #attemptLocalRecovery(recovery) {
|
|
1106
|
+
if (!await this.#relistLocalRecoveryIfNeeded(recovery) || !this.#canContinueLocalRecovery(recovery)) {
|
|
1107
|
+
this.#dropLocalRecovery(recovery.state.source, recovery);
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
this.#retireDeadLocalPublication(recovery);
|
|
1111
|
+
if (await this.#retireLocalRecoveryIfNeeded(recovery)) return;
|
|
1112
|
+
const published = await this.#enablePreparedTrack(recovery.state);
|
|
1113
|
+
if (this.#discardUnrecoverableIntent(recovery)) return;
|
|
1114
|
+
if (!this.#settleLocalRecovery(recovery, published)) this.#scheduleLocalRecoveryRetry(recovery);
|
|
1115
|
+
}
|
|
1116
|
+
async #relistLocalRecoveryIfNeeded(recovery) {
|
|
1117
|
+
const { state, deadPublicationId } = recovery;
|
|
1118
|
+
if (!recovery.relist || deadPublicationId === null) return true;
|
|
1119
|
+
const authoritative = await this.#signal(() => this.#requireTransport().listPublications());
|
|
1120
|
+
if (!this.#canRecover(recovery) || state.providerPublicationId !== deadPublicationId || !state.desiredEnabled) return false;
|
|
1121
|
+
if (authoritative.publications.some((publication) => publication.publicationId === deadPublicationId)) {
|
|
1122
|
+
this.#dropLocalRecovery(state.source, recovery);
|
|
1123
|
+
return false;
|
|
1124
|
+
}
|
|
1125
|
+
recovery.relist = false;
|
|
1126
|
+
return true;
|
|
1127
|
+
}
|
|
1128
|
+
#canContinueLocalRecovery(recovery) {
|
|
1129
|
+
if (!this.#canRecover(recovery) || recovery.retiring) return this.#canRecover(recovery);
|
|
1130
|
+
if (recovery.deadPublicationId !== null && recovery.state.providerPublicationId !== recovery.deadPublicationId) return false;
|
|
1131
|
+
return recovery.state.desiredEnabled;
|
|
1132
|
+
}
|
|
1133
|
+
#retireDeadLocalPublication(recovery) {
|
|
1134
|
+
if (recovery.retiring || recovery.deadPublicationId === null || recovery.state.providerPublicationId !== recovery.deadPublicationId) return;
|
|
1135
|
+
recovery.retiring = true;
|
|
1136
|
+
this.#pauseLocalSource(recovery.state.source, "discovery_absent", { group: false });
|
|
1137
|
+
}
|
|
1138
|
+
async #retireLocalRecoveryIfNeeded(recovery) {
|
|
1139
|
+
if (!this.#hasRetiringLocalPublications(recovery.state.source)) return false;
|
|
1140
|
+
await this.#retireLocalRecoveryPublications(recovery.state.source);
|
|
1141
|
+
return this.#discardUnrecoverableIntent(recovery);
|
|
1142
|
+
}
|
|
1143
|
+
async #retryLocalRecovery(recovery, error) {
|
|
1144
|
+
if (this.#discardUnrecoverableIntent(recovery)) return;
|
|
1145
|
+
this.#reportLocalRecoveryError(error);
|
|
1146
|
+
await this.#replaceConnectionForLocalRecovery(error);
|
|
1147
|
+
this.#scheduleLocalRecoveryRetry(recovery);
|
|
1148
|
+
}
|
|
1149
|
+
#discardUnrecoverableIntent(recovery) {
|
|
1150
|
+
if (this.#canRecover(recovery)) return false;
|
|
1151
|
+
this.#dropLocalRecovery(recovery.state.source, recovery);
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
#settleLocalRecovery(recovery, published) {
|
|
1155
|
+
const source = recovery.state.source;
|
|
1156
|
+
if (!published && this.#locallyPausedSources.has(source)) {
|
|
1157
|
+
this.#dropLocalRecovery(source, recovery);
|
|
1158
|
+
return true;
|
|
1159
|
+
}
|
|
1160
|
+
const publicationId = recovery.state.providerPublicationId;
|
|
1161
|
+
if (!published || publicationId === null || publicationId === recovery.deadPublicationId) return false;
|
|
1162
|
+
const recent = this.#recentRecoveries.get(source);
|
|
1163
|
+
if (recent) this.#recentRecoveries.set(source, { ...recent, healthySince: Date.now() });
|
|
1164
|
+
this.#dropLocalRecovery(source, recovery);
|
|
1165
|
+
return true;
|
|
1166
|
+
}
|
|
1167
|
+
#reportLocalRecoveryError(error) {
|
|
1168
|
+
if (error instanceof AggregateError) {
|
|
1169
|
+
for (const cause of error.errors) this.#reportError(cause);
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
this.#reportError(error);
|
|
1173
|
+
}
|
|
1174
|
+
async #replaceConnectionForLocalRecovery(error) {
|
|
1175
|
+
const failures = error instanceof AggregateError ? error.errors : [error];
|
|
1176
|
+
if (!this.#replaceMediaConnection || failures.length === 0 || !failures.every((failure) => this.#isRetryableConnectionFailure(failure))) return;
|
|
1177
|
+
const generation = this.#generation;
|
|
1178
|
+
try {
|
|
1179
|
+
await this.#replaceMediaConnectionForRecovery(generation, "local_signaling_failure");
|
|
1180
|
+
} catch {
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
try {
|
|
1184
|
+
await this.#republishPreparedTracksAfterRecovery(generation);
|
|
1185
|
+
} catch (republishError) {
|
|
1186
|
+
this.#reportLocalRecoveryError(republishError);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
#scheduleLocalRecoveryRetry(recovery) {
|
|
1190
|
+
if (!this.#canRecover(recovery)) {
|
|
1191
|
+
this.#dropLocalRecovery(recovery.state.source, recovery);
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
this.#scheduleLocalRecovery(recovery, recoveryBackoffMs(recovery.attempt++));
|
|
1195
|
+
}
|
|
1196
|
+
#hasRetiringLocalPublications(source) {
|
|
1197
|
+
return [...this.#ownedPublications].some((publication) => publication.state.source === source && publication.lifecycle.kind === "retiring" && publication.lifecycle.handle !== null);
|
|
1198
|
+
}
|
|
1199
|
+
#retireLocalRecoveryPublications(source) {
|
|
1200
|
+
return this.#retireOwnedPublications(() => {
|
|
1201
|
+
}, { sources: [source], goneIsComplete: true });
|
|
1202
|
+
}
|
|
1203
|
+
#dropLocalRecovery(source, expected) {
|
|
1204
|
+
for (const item of localSourceGroup(source)) {
|
|
1205
|
+
const recovery = this.#localRecoveries.get(item);
|
|
1206
|
+
if (!recovery || expected && recovery !== expected) continue;
|
|
1207
|
+
if (recovery.timer !== void 0) globalThis.clearTimeout(recovery.timer);
|
|
1208
|
+
this.#localRecoveries.delete(item);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
577
1211
|
#finishPublicationPoll(generation) {
|
|
578
1212
|
if (generation !== this.#generation) return;
|
|
579
1213
|
this.#polling = false;
|
|
@@ -589,9 +1223,11 @@ var CloudflareSFUClient = class {
|
|
|
589
1223
|
return this.#enablePublicationTarget({ ...target, source: "screen_audio" });
|
|
590
1224
|
}
|
|
591
1225
|
async #pausePublicationTarget(target) {
|
|
1226
|
+
this.#dropLocalRecovery(target.source);
|
|
592
1227
|
const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
|
|
593
1228
|
if (resolved.kind === "result" && resolved.result.outcome !== "satisfied") return resolved.result;
|
|
594
1229
|
const outcome = resolved.kind === "state" && resolved.value.enabled ? "confirmed" : "satisfied";
|
|
1230
|
+
this.#dropLocalRecovery(target.source);
|
|
595
1231
|
if (target.source !== "screen" && this.#locallyPausedSources.get(target.source) === "capture_cleanup") {
|
|
596
1232
|
await this.pauseLocalCapture(target.source);
|
|
597
1233
|
return { outcome, errorCode: null };
|
|
@@ -604,6 +1240,25 @@ var CloudflareSFUClient = class {
|
|
|
604
1240
|
return mediaTargetFailure(error);
|
|
605
1241
|
}
|
|
606
1242
|
}
|
|
1243
|
+
async #waitForReplacementBeforeEnable(state, generation, replacement) {
|
|
1244
|
+
if (replacement?.generation !== generation) return null;
|
|
1245
|
+
state.desiredEnabled = true;
|
|
1246
|
+
await replacement.promise.catch(() => {
|
|
1247
|
+
});
|
|
1248
|
+
if (this.#locallyPausedSources.has(state.source)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
|
|
1249
|
+
if (!state.desiredEnabled) return { outcome: "satisfied", errorCode: null };
|
|
1250
|
+
state.track.enabled = true;
|
|
1251
|
+
return null;
|
|
1252
|
+
}
|
|
1253
|
+
#postReplacementEnableOutcome(state, generation, replacement) {
|
|
1254
|
+
if (this.#locallyPausedSources.has(state.source)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
|
|
1255
|
+
if (replacement?.generation === generation && !state.desiredEnabled) return { outcome: "satisfied", errorCode: null };
|
|
1256
|
+
if (state.enabled) {
|
|
1257
|
+
state.track.enabled = true;
|
|
1258
|
+
return { outcome: "satisfied", errorCode: null };
|
|
1259
|
+
}
|
|
1260
|
+
return null;
|
|
1261
|
+
}
|
|
607
1262
|
async #enablePublicationTarget(target) {
|
|
608
1263
|
const generation = this.#generation;
|
|
609
1264
|
const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
|
|
@@ -611,9 +1266,12 @@ var CloudflareSFUClient = class {
|
|
|
611
1266
|
const state = resolved.value;
|
|
612
1267
|
if (this.#locallyPausedSources.has(target.source)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
|
|
613
1268
|
try {
|
|
1269
|
+
const replacement = this.#replacement;
|
|
1270
|
+
const replacementOutcome = await this.#waitForReplacementBeforeEnable(state, generation, replacement);
|
|
1271
|
+
if (replacementOutcome) return replacementOutcome;
|
|
614
1272
|
await this.#retireUnavailableLocalPublication(state);
|
|
615
|
-
|
|
616
|
-
if (
|
|
1273
|
+
const postReplacementOutcome = this.#postReplacementEnableOutcome(state, generation, replacement);
|
|
1274
|
+
if (postReplacementOutcome) return postReplacementOutcome;
|
|
617
1275
|
if (!await this.#enablePreparedTrack(state)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
|
|
618
1276
|
return { outcome: "confirmed", errorCode: null };
|
|
619
1277
|
} catch (error) {
|
|
@@ -622,12 +1280,14 @@ var CloudflareSFUClient = class {
|
|
|
622
1280
|
}
|
|
623
1281
|
}
|
|
624
1282
|
async pauseLocalCapture(source) {
|
|
1283
|
+
this.#dropLocalRecovery(source);
|
|
625
1284
|
this.#locallyPausedSources.set(source, "capture_cleanup");
|
|
626
1285
|
const state = this.#localTracks.get(source);
|
|
627
1286
|
if (state) await this.#disablePreparedTrack(state);
|
|
628
1287
|
}
|
|
629
1288
|
setLocalSourceIntent(source, enabled, cause = "forced_pause") {
|
|
630
1289
|
const sources = localSourceGroup(source);
|
|
1290
|
+
if (!enabled) this.#dropLocalRecovery(source);
|
|
631
1291
|
for (const item of sources) {
|
|
632
1292
|
if (enabled) this.#locallyPausedSources.delete(item);
|
|
633
1293
|
else this.#locallyPausedSources.set(item, cause);
|
|
@@ -681,13 +1341,16 @@ var CloudflareSFUClient = class {
|
|
|
681
1341
|
if (!publicationId) return;
|
|
682
1342
|
const authoritative = await this.#signal(() => this.#requireTransport().listPublications());
|
|
683
1343
|
if (authoritative.publications.some((publication) => publication.publicationId === publicationId)) return;
|
|
684
|
-
if (state.providerPublicationId
|
|
685
|
-
|
|
686
|
-
|
|
1344
|
+
if (state.providerPublicationId !== publicationId || this.#localRecoveries.has(state.source)) return;
|
|
1345
|
+
if (state.enabled && state.desiredEnabled) {
|
|
1346
|
+
this.#beginLocalRecovery(state, publicationId, true);
|
|
1347
|
+
return;
|
|
687
1348
|
}
|
|
1349
|
+
this.#pauseLocalSource(state.source, "discovery_absent");
|
|
1350
|
+
await this.#retireOwnedPublications(void 0, { goneIsComplete: true });
|
|
688
1351
|
}
|
|
689
|
-
#pauseLocalSource(source, cause = "forced_pause") {
|
|
690
|
-
const sources = localSourceGroup(source);
|
|
1352
|
+
#pauseLocalSource(source, cause = "forced_pause", options = {}) {
|
|
1353
|
+
const sources = options.group === false ? [source] : localSourceGroup(source);
|
|
691
1354
|
for (const publication of this.#ownedPublications) {
|
|
692
1355
|
if (!sources.includes(publication.state.source)) continue;
|
|
693
1356
|
this.#markPublicationRetiring(publication, cause);
|
|
@@ -700,25 +1363,36 @@ var CloudflareSFUClient = class {
|
|
|
700
1363
|
this.#publishSnapshot();
|
|
701
1364
|
this.#emitLocal();
|
|
702
1365
|
}
|
|
703
|
-
#retireOwnedPublications(reportFailure = this.#reportError.bind(this)) {
|
|
704
|
-
const publications =
|
|
705
|
-
const retirement = (
|
|
706
|
-
const errors = [];
|
|
707
|
-
for (const publication of publications) {
|
|
708
|
-
try {
|
|
709
|
-
await this.#serializeSDP(() => this.#closeOwnedPublication(publication), true);
|
|
710
|
-
} catch (error) {
|
|
711
|
-
errors.push(error);
|
|
712
|
-
if (!this.#stopped) reportFailure(error);
|
|
713
|
-
}
|
|
714
|
-
}
|
|
715
|
-
if (this.#stopped && this.#ownedPublications.size === 0) this.#transport = void 0;
|
|
716
|
-
if (errors.length) throw new AggregateError(errors, "Media retirement failed");
|
|
717
|
-
})();
|
|
1366
|
+
#retireOwnedPublications(reportFailure = this.#reportError.bind(this), options) {
|
|
1367
|
+
const publications = this.#retiringOwnedPublications(options?.sources);
|
|
1368
|
+
const retirement = this.#runOwnedRetirement(publications, reportFailure, options?.goneIsComplete === true);
|
|
718
1369
|
void retirement.catch(() => {
|
|
719
1370
|
});
|
|
720
1371
|
return this.#retirement = retirement;
|
|
721
1372
|
}
|
|
1373
|
+
#retiringOwnedPublications(sources) {
|
|
1374
|
+
return [...this.#ownedPublications].filter((item) => item.lifecycle.kind === "retiring" && item.lifecycle.handle && (!sources || sources.includes(item.state.source)));
|
|
1375
|
+
}
|
|
1376
|
+
async #runOwnedRetirement(publications, reportFailure, goneIsComplete) {
|
|
1377
|
+
const errors = [];
|
|
1378
|
+
for (const publication of publications) {
|
|
1379
|
+
try {
|
|
1380
|
+
await this.#serializeSDP(() => this.#closeOwnedPublication(publication), true);
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
if (goneIsComplete && this.#retireGonePublication(publication, error)) continue;
|
|
1383
|
+
errors.push(error);
|
|
1384
|
+
if (!this.#stopped) reportFailure(error);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
if (this.#stopped && this.#ownedPublications.size === 0) this.#transport = void 0;
|
|
1388
|
+
if (errors.length) throw new AggregateError(errors, "Media retirement failed");
|
|
1389
|
+
}
|
|
1390
|
+
#retireGonePublication(publication, error) {
|
|
1391
|
+
if (!isGonePublicationFailure(error)) return false;
|
|
1392
|
+
publication.lifecycle = { kind: "retired" };
|
|
1393
|
+
this.#ownedPublications.delete(publication);
|
|
1394
|
+
return true;
|
|
1395
|
+
}
|
|
722
1396
|
#markPublicationRetiring(publication, cause = "publish_failure") {
|
|
723
1397
|
const phase = publication.lifecycle;
|
|
724
1398
|
if (phase.kind === "pending" || phase.kind === "confirmed") publication.lifecycle = { kind: "retiring", handle: phase.kind === "confirmed" ? phase.handle : null, detached: false, cause };
|
|
@@ -820,7 +1494,11 @@ var CloudflareSFUClient = class {
|
|
|
820
1494
|
return;
|
|
821
1495
|
}
|
|
822
1496
|
this.#stopped = true;
|
|
1497
|
+
const transport = this.#transport;
|
|
1498
|
+
void this.#callStats.flush(async (stats2) => transport?.uploadCallStats?.(stats2, true));
|
|
823
1499
|
this.#receiveProgress.expect([], this.#participantId);
|
|
1500
|
+
for (const source of [...this.#localRecoveries.keys()]) this.#dropLocalRecovery(source);
|
|
1501
|
+
this.#recentRecoveries.clear();
|
|
824
1502
|
this.#subscribedPublicationIds.clear();
|
|
825
1503
|
this.#generation++;
|
|
826
1504
|
this.#connectionEpoch++;
|
|
@@ -1111,7 +1789,7 @@ var CloudflareSFUClient = class {
|
|
|
1111
1789
|
const now = Date.now();
|
|
1112
1790
|
return [...desired].filter(([key, publication]) => {
|
|
1113
1791
|
if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) {
|
|
1114
|
-
if (!this.#receiveProgress.
|
|
1792
|
+
if (!this.#receiveProgress.canRepull(publication.publicationId) && !(this.#replaceMediaConnection && this.#receiveProgress.canRebuild(publication.publicationId))) return false;
|
|
1115
1793
|
}
|
|
1116
1794
|
const remaining = (this.#remotePullRetries.get(publication.publicationId)?.retryAfter ?? 0) - now;
|
|
1117
1795
|
return remaining <= 0;
|
|
@@ -1143,12 +1821,9 @@ var CloudflareSFUClient = class {
|
|
|
1143
1821
|
}
|
|
1144
1822
|
async #pullWithRecovery(desired, cursor, generation) {
|
|
1145
1823
|
const pending = this.#pendingRemotePulls(desired);
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
if (!this.#replaceMediaConnection || !this.#receiveProgress.reserveRebuild()) return [];
|
|
1150
|
-
return this.#retryRemotePullOnReplacement(publications, cursor, generation, "receive_stalled");
|
|
1151
|
-
}
|
|
1824
|
+
const stalled = pending.filter((publication) => this.#receiveProgress.isStalled(publication.publicationId));
|
|
1825
|
+
if (stalled.length) return this.#recoverStalledRemotePublications(stalled, desired, cursor, generation);
|
|
1826
|
+
const publications = pending;
|
|
1152
1827
|
try {
|
|
1153
1828
|
return await this.#pull(publications, generation);
|
|
1154
1829
|
} catch (error) {
|
|
@@ -1160,6 +1835,46 @@ var CloudflareSFUClient = class {
|
|
|
1160
1835
|
return this.#retryRemotePullOnReplacement(publications, cursor, generation);
|
|
1161
1836
|
}
|
|
1162
1837
|
}
|
|
1838
|
+
async #recoverStalledRemotePublications(stalled, desired, cursor, generation) {
|
|
1839
|
+
if (stalled.every((publication) => this.#receiveProgress.canRepull(publication.publicationId))) {
|
|
1840
|
+
try {
|
|
1841
|
+
return await this.#repullStalledRemotePublications(stalled, generation);
|
|
1842
|
+
} catch (error) {
|
|
1843
|
+
this.#requireGeneration(generation);
|
|
1844
|
+
if (!this.#replaceMediaConnection) throw error;
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
if (!this.#replaceMediaConnection || !this.#receiveProgress.reserveRebuild()) return [];
|
|
1848
|
+
return this.#retryRemotePullOnReplacement([...desired.values()], cursor, generation, "receive_stalled");
|
|
1849
|
+
}
|
|
1850
|
+
async #repullStalledRemotePublications(stalled, generation) {
|
|
1851
|
+
for (const publication of stalled) this.#receiveProgress.reserveRepull(publication.publicationId);
|
|
1852
|
+
const retired = await this.#retireStalledReceivers(stalled, generation);
|
|
1853
|
+
if (!retired.length) return [];
|
|
1854
|
+
const pulled = await this.#pullBatch(retired, generation);
|
|
1855
|
+
if (pulled?.length !== retired.length) throw new CloudflareSFUError("Stalled receive subscriptions were not restored", "media_failed");
|
|
1856
|
+
for (const publication of retired) this.#receiveProgress.restarted(publication.publicationId);
|
|
1857
|
+
return pulled;
|
|
1858
|
+
}
|
|
1859
|
+
async #retireStalledReceivers(publications, generation) {
|
|
1860
|
+
return this.#serializeSDP(async () => {
|
|
1861
|
+
this.#requireGeneration(generation);
|
|
1862
|
+
const stalled = publications.filter((publication) => this.#receiveProgress.isStalled(publication.publicationId));
|
|
1863
|
+
if (!stalled.length) return [];
|
|
1864
|
+
const connection = this.#connection;
|
|
1865
|
+
const receivers = stalled.map((publication) => {
|
|
1866
|
+
const remote = [...this.#remoteTracks.values()].find((track) => track.publicationId === publication.publicationId);
|
|
1867
|
+
const transceiver = connection.getTransceivers().find((item) => item.receiver.track === remote?.track);
|
|
1868
|
+
if (!transceiver || transceiver.mid === null) throw new CloudflareSFUError("The stalled receiver has no negotiated MID", "media_failed");
|
|
1869
|
+
return { publication, transceiver, mid: transceiver.mid };
|
|
1870
|
+
});
|
|
1871
|
+
const connectionId = this.#bootstrap.connectionId;
|
|
1872
|
+
await this.#signal(() => this.#requireTransport().closeTracks({ connectionId, receiveOnly: true, force: true, tracks: receivers.map(({ publication, mid }) => ({ mid, source: publication.source, publicationId: publication.publicationId })) }), { generation, connectionId });
|
|
1873
|
+
this.#requireGeneration(generation);
|
|
1874
|
+
for (const { transceiver } of receivers) transceiver.stop();
|
|
1875
|
+
return stalled;
|
|
1876
|
+
});
|
|
1877
|
+
}
|
|
1163
1878
|
#canReplaceConnectionAfterRemotePull(error, generation) {
|
|
1164
1879
|
const gone = error instanceof CloudflareSFUError && (error.options.status === 404 || error.options.status === 410);
|
|
1165
1880
|
return this.#isRetryableConnectionFailure(error) && (gone || !this.#hasLiveRemoteTracks()) && this.#replaceMediaConnection !== void 0 && this.#replacementAttemptedGeneration !== generation;
|
|
@@ -1298,6 +2013,55 @@ var CloudflareSFUClient = class {
|
|
|
1298
2013
|
const failures = Math.min(previous.failures + 1, 6);
|
|
1299
2014
|
this.#remotePullRetries.set(publicationId, { retryAfter: Date.now() + Math.min(15e3, 750 * 2 ** (failures - 1)), failures, receiveStalled: receiveStalled || previous.receiveStalled });
|
|
1300
2015
|
}
|
|
2016
|
+
#adaptViewerLayers(connection, report, selector, current) {
|
|
2017
|
+
if (!this.#transport?.updateTracks || !this.#canObserveReceiveProgress(connection)) return;
|
|
2018
|
+
const now = performance.now();
|
|
2019
|
+
const cameras = [...this.#remoteTracks.values()].filter(({ source, track }) => source === "camera" && track.readyState === "live");
|
|
2020
|
+
this.#queueViewerLayers(selector.sample(now, report, cameras));
|
|
2021
|
+
if (this.#viewerLayerUpdating || now < this.#viewerLayerUpdateAt) return;
|
|
2022
|
+
const batch = [...this.#viewerLayerPending.values()].flatMap((change) => {
|
|
2023
|
+
const mid = connection.getTransceivers().find((transceiver) => transceiver.receiver.track === change.track)?.mid;
|
|
2024
|
+
return mid === null || mid === void 0 ? [] : [{ change, mid }];
|
|
2025
|
+
}).slice(0, VIEWER_LAYER_BATCH_SIZE);
|
|
2026
|
+
if (!batch.length) return;
|
|
2027
|
+
this.#viewerLayerUpdating = true;
|
|
2028
|
+
this.#viewerLayerUpdateAt = now + VIEWER_LAYER_UPDATE_INTERVAL_MS;
|
|
2029
|
+
void this.#requestViewerLayers(this.#transport, batch).then((failed) => {
|
|
2030
|
+
this.#viewerLayerUpdating = false;
|
|
2031
|
+
if (!current()) return;
|
|
2032
|
+
for (const { change } of batch) {
|
|
2033
|
+
const pending = this.#viewerLayerPending.get(change.publicationId);
|
|
2034
|
+
if (pending?.track === change.track && pending.layer === change.layer) this.#viewerLayerPending.delete(change.publicationId);
|
|
2035
|
+
if (failed.has(change.publicationId)) selector.failed(change, performance.now());
|
|
2036
|
+
else selector.applied(change);
|
|
2037
|
+
}
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
2040
|
+
#queueViewerLayers(changes) {
|
|
2041
|
+
const active = new Set(changes.map(({ publicationId }) => publicationId));
|
|
2042
|
+
for (const id of this.#viewerLayerPending.keys()) if (!active.has(id)) this.#viewerLayerPending.delete(id);
|
|
2043
|
+
for (const change of changes) this.#viewerLayerPending.set(change.publicationId, change);
|
|
2044
|
+
}
|
|
2045
|
+
// A layer switch is an optimization: when the SFU refuses it, the pulled layer keeps playing.
|
|
2046
|
+
async #requestViewerLayers(transport, batch) {
|
|
2047
|
+
try {
|
|
2048
|
+
const response = await transport.updateTracks?.({
|
|
2049
|
+
connectionId: this.#bootstrap.connectionId,
|
|
2050
|
+
tracks: batch.map(({ change, mid }) => {
|
|
2051
|
+
const { connectionId, trackName } = parseCloudflareSFUPublicationID(change.publicationId);
|
|
2052
|
+
return { location: "remote", mid, sessionId: connectionId, trackName, simulcast: { preferredRid: change.layer, priorityOrdering: "none", ridNotAvailable: "asciibetical" } };
|
|
2053
|
+
})
|
|
2054
|
+
});
|
|
2055
|
+
return new Set(
|
|
2056
|
+
batch.filter(({ change }) => {
|
|
2057
|
+
const { connectionId, trackName } = parseCloudflareSFUPublicationID(change.publicationId);
|
|
2058
|
+
return response?.trackErrors?.some((error) => error.connectionId === connectionId && error.trackName === trackName);
|
|
2059
|
+
}).map(({ change }) => change.publicationId)
|
|
2060
|
+
);
|
|
2061
|
+
} catch {
|
|
2062
|
+
return new Set(batch.map(({ change }) => change.publicationId));
|
|
2063
|
+
}
|
|
2064
|
+
}
|
|
1301
2065
|
#canObserveReceiveProgress(connection) {
|
|
1302
2066
|
return this.#started && connectionIsLive(connection) && (typeof document === "undefined" || document.visibilityState !== "hidden");
|
|
1303
2067
|
}
|
|
@@ -1309,7 +2073,7 @@ var CloudflareSFUClient = class {
|
|
|
1309
2073
|
const { stalled, advanced } = this.#receiveProgress.sample(performance.now(), connection, report, this.#remoteTracks.values());
|
|
1310
2074
|
for (const publicationId of advanced) this.#remotePullRetries.delete(publicationId);
|
|
1311
2075
|
this.#publishSnapshot();
|
|
1312
|
-
if (!this.#receiveProgress.canRebuild()) return;
|
|
2076
|
+
if (!stalled.some((id) => this.#receiveProgress.canRepull(id) || this.#replaceMediaConnection && this.#receiveProgress.canRebuild(id))) return;
|
|
1313
2077
|
for (const publicationId of stalled) {
|
|
1314
2078
|
if (!this.#remotePullRetries.get(publicationId)?.receiveStalled) this.#backoffRemotePull(publicationId, true);
|
|
1315
2079
|
}
|
|
@@ -1411,6 +2175,7 @@ var CloudflareSFUClient = class {
|
|
|
1411
2175
|
let disposed = false;
|
|
1412
2176
|
let cameraTimer;
|
|
1413
2177
|
const cameraPolicy = new CameraUplinkPolicy();
|
|
2178
|
+
const viewerLayers = new ViewerLayerSelector();
|
|
1414
2179
|
let observedCamera;
|
|
1415
2180
|
const current = () => !disposed && generation === this.#generation && connectionEpoch === this.#connectionEpoch && !this.#stopped && connection.connectionState !== "failed" && connection.connectionState !== "closed";
|
|
1416
2181
|
const adaptCamera = async () => {
|
|
@@ -1424,6 +2189,8 @@ var CloudflareSFUClient = class {
|
|
|
1424
2189
|
const report = await connection.getStats();
|
|
1425
2190
|
if (!current() || connection.signalingState !== "stable") return;
|
|
1426
2191
|
this.#observeReceiveProgress(connection, report);
|
|
2192
|
+
this.#adaptViewerLayers(connection, report, viewerLayers, current);
|
|
2193
|
+
this.#callStats.observe(report);
|
|
1427
2194
|
const sender = this.#cameraSimulcastSender(state);
|
|
1428
2195
|
if (!sender) cameraPolicy.reset();
|
|
1429
2196
|
else await this.#applyCameraUplink(sender, report, cameraPolicy);
|
|
@@ -1436,6 +2203,7 @@ var CloudflareSFUClient = class {
|
|
|
1436
2203
|
const dispose = () => {
|
|
1437
2204
|
if (disposed) return;
|
|
1438
2205
|
disposed = true;
|
|
2206
|
+
this.#viewerLayerPending.clear();
|
|
1439
2207
|
if (cameraTimer !== void 0) globalThis.clearTimeout(cameraTimer);
|
|
1440
2208
|
connection.removeEventListener("connectionstatechange", capture);
|
|
1441
2209
|
connection.removeEventListener("iceconnectionstatechange", capture);
|
|
@@ -1468,35 +2236,49 @@ var CloudflareSFUClient = class {
|
|
|
1468
2236
|
this.#replacementCause = void 0;
|
|
1469
2237
|
return this.#requireTransport().addTracks({ ...input, replacementCause });
|
|
1470
2238
|
}
|
|
1471
|
-
|
|
2239
|
+
#replaceMediaConnectionForRecovery(generation, cause) {
|
|
1472
2240
|
this.#requireGeneration(generation);
|
|
2241
|
+
if (this.#replacement?.generation === generation) return this.#replacement.promise;
|
|
1473
2242
|
if (!this.#replaceMediaConnection || this.#replacementAttemptedGeneration === generation) {
|
|
1474
|
-
|
|
2243
|
+
return Promise.reject(new CloudflareSFUError("A fresh Cloudflare SFU connection is unavailable", "signaling_failed"));
|
|
1475
2244
|
}
|
|
1476
2245
|
this.#replacementAttemptedGeneration = generation;
|
|
1477
2246
|
const replaceMediaConnection = this.#replaceMediaConnection;
|
|
1478
|
-
const
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
state.
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
2247
|
+
const promise = Promise.resolve().then(async () => {
|
|
2248
|
+
const bootstrap = await replaceMediaConnection();
|
|
2249
|
+
this.#requireGeneration(generation);
|
|
2250
|
+
validateBootstrap(bootstrap);
|
|
2251
|
+
if (bootstrap.connectionId === this.#bootstrap.connectionId) {
|
|
2252
|
+
throw new CloudflareSFUError("Participant access did not replace the Cloudflare SFU connection", "signaling_failed");
|
|
2253
|
+
}
|
|
2254
|
+
this.#fenceLocalPublications();
|
|
2255
|
+
const connectionEpoch = ++this.#connectionEpoch;
|
|
2256
|
+
this.#disposeConnection(false);
|
|
2257
|
+
this.#clearRemoteTracks();
|
|
2258
|
+
this.#cursor = null;
|
|
2259
|
+
this.#bootstrap = bootstrap;
|
|
2260
|
+
this.#rebuildPeerConnection(bootstrap, generation, connectionEpoch);
|
|
2261
|
+
this.#replacementCause = cause;
|
|
2262
|
+
this.#retiredLocalConnection = null;
|
|
2263
|
+
this.#negotiatedGeneration = null;
|
|
2264
|
+
for (const state of this.#localTracks.values()) {
|
|
2265
|
+
state.transceiver = null;
|
|
2266
|
+
state.enabled = false;
|
|
2267
|
+
state.providerPublicationId = null;
|
|
2268
|
+
}
|
|
2269
|
+
this.remotePublicationsChanged();
|
|
2270
|
+
});
|
|
2271
|
+
const replacement = { generation, promise };
|
|
2272
|
+
this.#replacement = replacement;
|
|
2273
|
+
void promise.then(
|
|
2274
|
+
() => {
|
|
2275
|
+
if (this.#replacement === replacement) this.#replacement = null;
|
|
2276
|
+
},
|
|
2277
|
+
() => {
|
|
2278
|
+
if (this.#replacement === replacement) this.#replacement = null;
|
|
2279
|
+
}
|
|
2280
|
+
);
|
|
2281
|
+
return promise;
|
|
1500
2282
|
}
|
|
1501
2283
|
async #republishPreparedTracksAfterRecovery(generation) {
|
|
1502
2284
|
const enabled = [...this.#localTracks.values()].filter((state) => state.desiredEnabled && state.track.readyState !== "ended");
|
|
@@ -1516,11 +2298,12 @@ var CloudflareSFUClient = class {
|
|
|
1516
2298
|
}
|
|
1517
2299
|
#createPeerConnection(bootstrap) {
|
|
1518
2300
|
const create = this.#peerConnectionFactory ?? ((configuration) => new RTCPeerConnection(configuration));
|
|
1519
|
-
return create({ iceServers:
|
|
2301
|
+
return create({ iceServers: iceServersFor(bootstrap), bundlePolicy: "max-bundle" });
|
|
1520
2302
|
}
|
|
1521
2303
|
#rebuildPeerConnection(bootstrap, generation, connectionEpoch) {
|
|
1522
2304
|
try {
|
|
1523
2305
|
this.#connection = this.#createPeerConnection(bootstrap);
|
|
2306
|
+
this.#callStats.reset();
|
|
1524
2307
|
this.#observeConnection(this.#connection, generation, connectionEpoch);
|
|
1525
2308
|
} catch (error) {
|
|
1526
2309
|
this.#setFailure(error, "media_failed");
|
|
@@ -1528,7 +2311,7 @@ var CloudflareSFUClient = class {
|
|
|
1528
2311
|
}
|
|
1529
2312
|
}
|
|
1530
2313
|
#disposeConnection(stopSenders) {
|
|
1531
|
-
this.#receiveProgress.
|
|
2314
|
+
this.#receiveProgress.replaced();
|
|
1532
2315
|
if (!stopSenders) this.#setPhase("recovering", null);
|
|
1533
2316
|
this.#disposeConnectionObservation?.();
|
|
1534
2317
|
this.#disposeConnectionObservation = void 0;
|
|
@@ -1775,9 +2558,19 @@ async function createLocalOffer(connection) {
|
|
|
1775
2558
|
function localSourceGroup(source) {
|
|
1776
2559
|
return source === "screen" ? [source, "screen_audio"] : [source];
|
|
1777
2560
|
}
|
|
2561
|
+
function recoveryBackoffMs(attempt) {
|
|
2562
|
+
return RECOVERY_BACKOFF_MS[Math.min(attempt, RECOVERY_BACKOFF_MS.length - 1)] ?? RECOVERY_BACKOFF_MS[RECOVERY_BACKOFF_MS.length - 1];
|
|
2563
|
+
}
|
|
2564
|
+
function isGonePublicationFailure(error) {
|
|
2565
|
+
const failures = error instanceof AggregateError ? error.errors : [error];
|
|
2566
|
+
return failures.length > 0 && failures.every((failure) => failure instanceof CloudflareSFUError && (failure.options.status === 404 || failure.options.status === 410));
|
|
2567
|
+
}
|
|
1778
2568
|
function isRateLimited(error) {
|
|
1779
2569
|
return error instanceof CloudflareSFUError && error.options.status === 429;
|
|
1780
2570
|
}
|
|
2571
|
+
function unwrapAggregateErrors(error) {
|
|
2572
|
+
return error instanceof AggregateError ? error.errors.flatMap(unwrapAggregateErrors) : [error];
|
|
2573
|
+
}
|
|
1781
2574
|
function retryableRemotePull(error) {
|
|
1782
2575
|
if (!(error instanceof CloudflareSFUError)) return false;
|
|
1783
2576
|
if (error.code === "signaling_failed") return error.options.retryableConnection === true || error.options.status === 429;
|
|
@@ -1792,23 +2585,32 @@ function simulcastUnsupported(error) {
|
|
|
1792
2585
|
function mediaTargetFailure(error) {
|
|
1793
2586
|
const code = error instanceof CloudflareSFUError ? error.code : "media_failed";
|
|
1794
2587
|
const ambiguous = code === "signaling_timeout" || code === "negotiation_timeout";
|
|
1795
|
-
|
|
2588
|
+
const failures = unwrapAggregateErrors(error);
|
|
2589
|
+
const terminal = failures.length > 0 && failures.every((failure) => failure instanceof CloudflareSFUError && failure.code === "signaling_failed" && typeof failure.options.status === "number" && failure.options.retryableConnection !== true && !isRateLimited(failure));
|
|
2590
|
+
return { outcome: ambiguous ? "ambiguous" : terminal ? "terminal_failure" : "retryable_failure", errorCode: code };
|
|
1796
2591
|
}
|
|
1797
2592
|
|
|
1798
2593
|
// src/media/transport.ts
|
|
1799
2594
|
var SIGNALING_TIMEOUT_MS = 8e3;
|
|
1800
2595
|
var CREDENTIAL_TIMEOUT_MS = 7e3;
|
|
1801
2596
|
function createCloudflareSFUHTTPTransport(options) {
|
|
1802
|
-
const fetch = options.fetch
|
|
2597
|
+
const fetch = withRequestContext(options.fetch);
|
|
1803
2598
|
const credential = requireCredential(options);
|
|
2599
|
+
let lastToken = "";
|
|
1804
2600
|
const base = options.apiBaseURL.replace(/\/$/, "");
|
|
1805
2601
|
const mediaPath = `${base}/v1/tenants/${encodeURIComponent(options.tenantId)}/spaces/${encodeURIComponent(options.spaceId)}/episodes/${encodeURIComponent(options.episodeId)}/participants/${encodeURIComponent(options.participantId)}/media/sfu`;
|
|
2602
|
+
const statsPath = `${base}/v1/tenants/${encodeURIComponent(options.tenantId)}/call-stats`;
|
|
1806
2603
|
const timeoutMs = options.requestTimeoutMs ?? SIGNALING_TIMEOUT_MS;
|
|
1807
2604
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new CloudflareSFUError("The signaling deadline is invalid", "signaling_failed");
|
|
2605
|
+
const freshToken = async () => {
|
|
2606
|
+
const token = await withDeadline(CREDENTIAL_TIMEOUT_MS, (signal) => credential(signal));
|
|
2607
|
+
if (!token.trim()) throw new CloudflareSFUError("The media credential provider returned an empty token", "signaling_failed");
|
|
2608
|
+
lastToken = token;
|
|
2609
|
+
return token;
|
|
2610
|
+
};
|
|
1808
2611
|
const request = async (path, init) => {
|
|
1809
2612
|
try {
|
|
1810
|
-
const token = await
|
|
1811
|
-
if (!token.trim()) throw new CloudflareSFUError("The media credential provider returned an empty token", "signaling_failed");
|
|
2613
|
+
const token = await freshToken();
|
|
1812
2614
|
return await withDeadline(timeoutMs, async (signal) => {
|
|
1813
2615
|
const startedAt = performance.now();
|
|
1814
2616
|
const response = await fetch(`${mediaPath}/${path}`, {
|
|
@@ -1858,15 +2660,34 @@ function createCloudflareSFUHTTPTransport(options) {
|
|
|
1858
2660
|
connection_id: input.connectionId,
|
|
1859
2661
|
session_description: input.sessionDescription,
|
|
1860
2662
|
tracks: input.tracks.map((track) => ({ mid: track.mid, source: track.source, publication_id: track.publicationId })),
|
|
1861
|
-
force: input.force
|
|
2663
|
+
force: input.force,
|
|
2664
|
+
...input.receiveOnly ? { receive_only: true } : {}
|
|
1862
2665
|
})
|
|
1863
2666
|
}),
|
|
2667
|
+
updateTracks: async (input) => request("tracks/update", {
|
|
2668
|
+
method: "PUT",
|
|
2669
|
+
body: JSON.stringify({ connection_id: input.connectionId, tracks: input.tracks })
|
|
2670
|
+
}),
|
|
1864
2671
|
renegotiate: async (input) => {
|
|
1865
2672
|
await request("renegotiate", {
|
|
1866
2673
|
method: "POST",
|
|
1867
2674
|
body: JSON.stringify({ connection_id: input.connectionId, session_description: input.sessionDescription })
|
|
1868
2675
|
});
|
|
1869
2676
|
},
|
|
2677
|
+
uploadCallStats: async (stats2, closing) => {
|
|
2678
|
+
const token = closing ? lastToken : await freshToken();
|
|
2679
|
+
if (!token) return;
|
|
2680
|
+
await withDeadline(timeoutMs, async (signal) => {
|
|
2681
|
+
const response = await fetch(statsPath, {
|
|
2682
|
+
method: "POST",
|
|
2683
|
+
signal,
|
|
2684
|
+
keepalive: true,
|
|
2685
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
2686
|
+
body: JSON.stringify(stats2)
|
|
2687
|
+
});
|
|
2688
|
+
if (!response.ok) throw new CloudflareSFUError(`Chalk call stats upload failed with HTTP ${response.status}`, "signaling_failed", { status: response.status });
|
|
2689
|
+
});
|
|
2690
|
+
},
|
|
1870
2691
|
listPublications: async () => {
|
|
1871
2692
|
const response = await request("publications");
|
|
1872
2693
|
return {
|
|
@@ -1931,6 +2752,7 @@ function describeMediaCaptureError(cause) {
|
|
|
1931
2752
|
}
|
|
1932
2753
|
|
|
1933
2754
|
export {
|
|
2755
|
+
setVideoRenderSize,
|
|
1934
2756
|
CloudflareSFUError,
|
|
1935
2757
|
parseCloudflareSFUPublicationID,
|
|
1936
2758
|
requireDescription,
|