@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.
Files changed (36) hide show
  1. package/dist/access/grant.d.ts +2 -0
  2. package/dist/{chunk-GOSWSXTH.js → chunk-MHJ4UPM3.js} +162 -5
  3. package/dist/chunk-N4IPKRS6.js +76 -0
  4. package/dist/{chunk-DHUHTIU7.js → chunk-TA2BJXI7.js} +243 -45
  5. package/dist/{chunk-3TGVTP6Z.js → chunk-UVWRTXNO.js} +894 -72
  6. package/dist/{chunk-O26BHHMI.js → chunk-VAJ6XNAA.js} +1 -1
  7. package/dist/{chunk-XSB2GDRW.js → chunk-XF567GPH.js} +198 -26
  8. package/dist/client.d.ts +180 -0
  9. package/dist/connection/dependencies.d.ts +5 -0
  10. package/dist/connection/production.d.ts +1 -1
  11. package/dist/effect.js +29 -4
  12. package/dist/generated/http-api.d.ts +178 -0
  13. package/dist/generated/schemas.d.ts +287 -0
  14. package/dist/generated/sync.d.ts +53 -1
  15. package/dist/index.d.ts +1 -0
  16. package/dist/index.js +33 -15
  17. package/dist/media/call-stats.d.ts +27 -0
  18. package/dist/media/client.d.ts +1 -0
  19. package/dist/media/ice-servers.d.ts +3 -0
  20. package/dist/media/index.d.ts +2 -0
  21. package/dist/media/index.js +4 -1
  22. package/dist/media/plane.d.ts +1 -0
  23. package/dist/media/receive-stall.d.ts +11 -2
  24. package/dist/media/types.d.ts +19 -0
  25. package/dist/media/video-render-size.d.ts +7 -0
  26. package/dist/media/viewer-layer.d.ts +48 -0
  27. package/dist/public-invites/index.js +3 -2
  28. package/dist/server/index.cjs +18 -4
  29. package/dist/server/index.js +18 -4
  30. package/dist/session/index.d.ts +18 -0
  31. package/dist/sync/index.js +2 -1
  32. package/dist/sync/link-watch.d.ts +67 -0
  33. package/dist/sync/v1-client.d.ts +5 -0
  34. package/dist/sync/v1-command-scheduler.d.ts +4 -0
  35. package/dist/sync/v1-types.d.ts +12 -1
  36. 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
- canRebuild() {
88
- return this.#active.length > 0 && this.#active.every((id) => this.#stalled.has(id) && (this.#attempts.get(id) ?? 0) < MAX_RECEIVE_REBUILDS);
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) this.#attempts.set(id, (this.#attempts.get(id) ?? 0) + 1);
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) this.#attempts.delete(id);
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.#stalled = new Set(stalled);
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 = `${publication.participantId}\0${publication.source}`;
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.#attempts.delete(id);
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((sum, row) => sum + row.packetsReceived, 0) };
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(stats) {
170
- for (const stat of stats) {
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
- if (this.#locallyPausedSources.has(target.source)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
616
- if (state.enabled) return { outcome: "satisfied", errorCode: null };
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 === publicationId) {
685
- this.#pauseLocalSource(state.source, "discovery_absent");
686
- await this.#retireOwnedPublications();
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 = [...this.#ownedPublications].filter((item) => item.lifecycle.kind === "retiring" && item.lifecycle.handle);
705
- const retirement = (async () => {
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.canRebuild() || !this.#replaceMediaConnection) return false;
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 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
- }
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
- async #replaceMediaConnectionForRecovery(generation, cause) {
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
- throw new CloudflareSFUError("A fresh Cloudflare SFU connection is unavailable", "signaling_failed");
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 bootstrap = await replaceMediaConnection();
1479
- this.#requireGeneration(generation);
1480
- validateBootstrap(bootstrap);
1481
- if (bootstrap.connectionId === this.#bootstrap.connectionId) {
1482
- throw new CloudflareSFUError("Participant access did not replace the Cloudflare SFU connection", "signaling_failed");
1483
- }
1484
- this.#fenceLocalPublications();
1485
- const connectionEpoch = ++this.#connectionEpoch;
1486
- this.#disposeConnection(false);
1487
- this.#clearRemoteTracks();
1488
- this.#cursor = null;
1489
- this.#bootstrap = bootstrap;
1490
- this.#rebuildPeerConnection(bootstrap, generation, connectionEpoch);
1491
- this.#replacementCause = cause;
1492
- this.#retiredLocalConnection = null;
1493
- this.#negotiatedGeneration = null;
1494
- for (const state of this.#localTracks.values()) {
1495
- state.transceiver = null;
1496
- state.enabled = false;
1497
- state.providerPublicationId = null;
1498
- }
1499
- this.remotePublicationsChanged();
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: [{ urls: bootstrap.stunServer }], bundlePolicy: "max-bundle" });
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.clear();
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
- return { outcome: ambiguous ? "ambiguous" : "retryable_failure", errorCode: code };
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 ?? globalThis.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 withDeadline(CREDENTIAL_TIMEOUT_MS, (signal) => credential(signal));
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,