@q9labsai/chalk-client 5.2.6 → 5.3.1

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 (38) hide show
  1. package/README.md +1 -1
  2. package/dist/access/grant.d.ts +2 -0
  3. package/dist/{chunk-I7Y4HR2P.js → chunk-2CEMOTZF.js} +333 -64
  4. package/dist/{chunk-5YPQNMJE.js → chunk-FD7ZSDTM.js} +103 -26
  5. package/dist/{chunk-CJVLTSIO.js → chunk-MHJ4UPM3.js} +165 -6
  6. package/dist/chunk-N4IPKRS6.js +76 -0
  7. package/dist/{chunk-YIDKETOX.js → chunk-O5JB5XVX.js} +840 -38
  8. package/dist/{chunk-WFRTUBN6.js → chunk-VAJ6XNAA.js} +1 -1
  9. package/dist/client.d.ts +182 -1
  10. package/dist/connection/dependencies.d.ts +2 -1
  11. package/dist/effect.js +29 -4
  12. package/dist/generated/http-api.d.ts +180 -1
  13. package/dist/generated/schemas.d.ts +290 -1
  14. package/dist/generated/sync.d.ts +60 -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 +2 -2
  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 +4 -2
  23. package/dist/media/receive-stall.d.ts +52 -0
  24. package/dist/media/types.d.ts +21 -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 +19 -4
  29. package/dist/server/index.js +19 -4
  30. package/dist/server/types.d.cts +2 -0
  31. package/dist/server/types.d.ts +2 -0
  32. package/dist/session/index.d.ts +18 -0
  33. package/dist/space-client/same-identity.d.ts +6 -0
  34. package/dist/space-client/types.d.ts +6 -0
  35. package/dist/sync/index.js +2 -1
  36. package/dist/sync/v1-command-scheduler.d.ts +4 -0
  37. package/dist/sync/v1-types.d.ts +6 -0
  38. package/package.json +3 -1
@@ -1,3 +1,197 @@
1
+ import {
2
+ withRequestContext
3
+ } from "./chunk-N4IPKRS6.js";
4
+
5
+ // src/media/receive-stall.ts
6
+ var RECEIVE_STALL_WINDOW_MS = 7e3;
7
+ var RECEIVE_STARTUP_GRACE_MS = 2e4;
8
+ var MAX_SAMPLE_GAP_MS = 3e3;
9
+ var ReceiveStallDetector = class {
10
+ #progress = /* @__PURE__ */ new Map();
11
+ #sampledAt;
12
+ forget(publicationId) {
13
+ this.#progress.delete(publicationId);
14
+ }
15
+ clear() {
16
+ this.#progress.clear();
17
+ this.#sampledAt = void 0;
18
+ }
19
+ sample(now, tracks) {
20
+ if (this.#sampledAt !== void 0 && (now - this.#sampledAt > MAX_SAMPLE_GAP_MS || now < this.#sampledAt)) this.clear();
21
+ this.#sampledAt = now;
22
+ const observed = new Set(tracks.map((track) => track.publicationId));
23
+ for (const id of this.#progress.keys()) if (!observed.has(id)) this.#progress.delete(id);
24
+ const observations = tracks.map((track) => ({ id: track.publicationId, outcome: this.#observeTrack(now, track) }));
25
+ return {
26
+ stalled: observations.filter((track) => track.outcome === "stalled").map((track) => track.id),
27
+ resumed: observations.filter((track) => track.outcome === "resumed").map((track) => track.id),
28
+ advanced: observations.filter((track) => track.outcome === "advanced" || track.outcome === "resumed").map((track) => track.id)
29
+ };
30
+ }
31
+ #observeTrack(now, { publicationId, trackId, packets }) {
32
+ const previous = this.#progress.get(publicationId);
33
+ const sameTrack = previous?.trackId === trackId;
34
+ if (sameTrack && previous.packets === packets) {
35
+ const window = previous.flowing ? RECEIVE_STALL_WINDOW_MS : RECEIVE_STARTUP_GRACE_MS;
36
+ return now - previous.advancedAt >= window ? "stalled" : null;
37
+ }
38
+ return this.#advanceTrack(now, { publicationId, trackId, packets }, previous);
39
+ }
40
+ #advanceTrack(now, { publicationId, trackId, packets }, previous) {
41
+ const advanced = previous?.trackId === trackId && packets > previous.packets;
42
+ const flowingSince = advanced && now - previous.advancedAt < RECEIVE_STALL_WINDOW_MS ? previous.flowingSince : now;
43
+ this.#progress.set(publicationId, { trackId, packets, advancedAt: now, flowing: advanced, flowingSince });
44
+ if (!advanced) return null;
45
+ return now - flowingSince >= RECEIVE_STALL_WINDOW_MS ? "resumed" : "advanced";
46
+ }
47
+ };
48
+ var MAX_RECEIVE_REBUILDS = 2;
49
+ var MAX_RECEIVE_OBSERVATIONS = 32;
50
+ var ReceiveStallRecovery = class {
51
+ #detector = new ReceiveStallDetector();
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();
57
+ #identities = /* @__PURE__ */ new Map();
58
+ #expected = /* @__PURE__ */ new Set();
59
+ #screens = /* @__PURE__ */ new Set();
60
+ #stalled = /* @__PURE__ */ new Set();
61
+ #active = [];
62
+ #observations = Object.freeze([]);
63
+ get observations() {
64
+ return this.#observations;
65
+ }
66
+ expect(publications, participantId) {
67
+ const enabled = publications.filter((publication) => publication.participantId !== participantId && publication.enabled);
68
+ this.#expected = new Set(enabled.flatMap((publication) => publication.publicationId ? [publication.publicationId] : []));
69
+ this.#screens = new Set(enabled.filter((publication) => publication.source === "screen").map((publication) => publication.participantId));
70
+ this.#pruneWithdrawn(publications);
71
+ this.#pruneAttempts(publications);
72
+ for (const publication of this.#identities.values()) {
73
+ if (this.#isExpected(publication)) continue;
74
+ this.#detector.forget(publication.publicationId);
75
+ this.#stalled.delete(publication.publicationId);
76
+ this.#awaiting.delete(publication.publicationId);
77
+ }
78
+ this.#active = [...this.#identities.values()].filter((publication) => this.#isExpected(publication)).map((publication) => publication.publicationId);
79
+ }
80
+ suspend() {
81
+ this.#expected.clear();
82
+ this.#screens.clear();
83
+ this.clear();
84
+ }
85
+ #pruneWithdrawn(publications) {
86
+ const advertised = new Set(publications.map((publication) => publication.publicationId));
87
+ const screens = new Set(publications.filter((publication) => publication.source === "screen").map((publication) => publication.participantId));
88
+ for (const [key, publication] of this.#identities) {
89
+ if (advertised.has(publication.publicationId) || publication.source === "screen_audio" && screens.has(publication.participantId)) continue;
90
+ this.#forget(publication.publicationId);
91
+ this.#identities.delete(key);
92
+ }
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
+ }
98
+ isStalled(publicationId) {
99
+ return this.#stalled.has(publicationId);
100
+ }
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));
117
+ }
118
+ reserveRebuild() {
119
+ if (!this.canRebuild()) return false;
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
+ }
124
+ return true;
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
+ }
135
+ clear() {
136
+ this.#detector.clear();
137
+ this.#stalled.clear();
138
+ this.#active = [];
139
+ }
140
+ sample(now, connection, report, tracks) {
141
+ const active = [...tracks].filter((publication) => this.#isExpected(publication));
142
+ for (const publication of active) this.#rememberIdentity(publication);
143
+ this.#active = active.map((publication) => publication.publicationId);
144
+ const samples = active.flatMap((publication) => receivePacketSample(connection, report, publication) ?? []);
145
+ const { stalled, advanced, resumed } = this.#detector.sample(now, samples);
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
+ }
152
+ const newlyStalled = stalled.filter((id) => !this.#stalled.has(id));
153
+ const unrestored = this.#active.filter((id) => this.#awaiting.has(id) && !stalled.includes(id));
154
+ this.#stalled = /* @__PURE__ */ new Set([...stalled, ...unrestored]);
155
+ if (newlyStalled.length) this.#record(now);
156
+ return { stalled: [...stalled, ...unrestored], advanced, newlyStalled };
157
+ }
158
+ #isExpected(publication) {
159
+ return publication.source === "screen_audio" ? this.#screens.has(publication.participantId) : this.#expected.has(publication.publicationId);
160
+ }
161
+ #rememberIdentity(publication) {
162
+ const key = sourceKey(publication);
163
+ const previous = this.#identities.get(key);
164
+ if (previous && previous.publicationId !== publication.publicationId) this.#forget(previous.publicationId);
165
+ this.#identities.set(key, publication);
166
+ this.#sources.set(publication.publicationId, key);
167
+ }
168
+ #forget(id) {
169
+ this.#detector.forget(id);
170
+ this.#stalled.delete(id);
171
+ this.#awaiting.delete(id);
172
+ this.#repulled.delete(id);
173
+ this.#sources.delete(id);
174
+ }
175
+ #record(now) {
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 });
177
+ this.#observations = Object.freeze([...this.#observations.slice(-(MAX_RECEIVE_OBSERVATIONS - 1)), observation]);
178
+ }
179
+ };
180
+ function sourceKey({ participantId, source }) {
181
+ return `${participantId}\0${source}`;
182
+ }
183
+ function receivePacketSample(connection, report, publication) {
184
+ const rows = [...report.values()].filter((row) => row.type === "inbound-rtp" && row.trackIdentifier === publication.track.id);
185
+ if (rows.some((row) => !Number.isSafeInteger(row.packetsReceived) || row.packetsReceived < 0)) return;
186
+ if (publication.track.kind === "audio" && !audioPacketCadenceKnown(connection, report, publication.track, rows)) return;
187
+ return { publicationId: publication.publicationId, trackId: publication.track.id, packets: rows.reduce((sum2, row) => sum2 + row.packetsReceived, 0) };
188
+ }
189
+ function audioPacketCadenceKnown(connection, report, track, rows) {
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 ?? [];
191
+ const opus = negotiated.filter((codec) => codec?.mimeType?.toLowerCase() === "audio/opus");
192
+ return opus.length > 0 && opus.every((codec) => typeof codec.sdpFmtpLine === "string");
193
+ }
194
+
1
195
  // src/media/camera-uplink.ts
2
196
  var CameraUplinkPolicy = class {
3
197
  #healthySamples = 0;
@@ -24,13 +218,223 @@ var CameraUplinkPolicy = class {
24
218
  this.#healthySamples = 0;
25
219
  }
26
220
  };
27
- function cameraUplinkBitrate(stats) {
28
- for (const stat of stats) {
221
+ function cameraUplinkBitrate(stats2) {
222
+ for (const stat of stats2) {
29
223
  if (stat.type === "candidate-pair" && stat.state === "succeeded" && (stat.nominated || stat.selected)) return stat.availableOutgoingBitrate;
30
224
  }
31
225
  return void 0;
32
226
  }
33
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
+
34
438
  // src/media/observers.ts
35
439
  function subscribeSnapshot(listeners, listener) {
36
440
  listeners.add(listener);
@@ -176,6 +580,202 @@ function matchRemotePullTracks(publications, tracks) {
176
580
  });
177
581
  }
178
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
+
179
779
  // src/media/failure-diagnostics.ts
180
780
  var steps = /* @__PURE__ */ new WeakMap();
181
781
  var fetchFailures = /* @__PURE__ */ new WeakSet();
@@ -234,10 +834,13 @@ function classifyMediaFailure(cause, fallbackStep) {
234
834
  // src/media/client.ts
235
835
  var EMPTY_LOCAL = Object.freeze([]);
236
836
  var EMPTY_REMOTE = Object.freeze([]);
837
+ var EMPTY_RECEIVE_STALLS = Object.freeze([]);
237
838
  var INVALID_PUBLICATION_SIGNATURE = "\0invalid";
238
839
  var CONNECTION_TIMEOUT_MS = 8e3;
239
840
  var NEGOTIATION_TIMEOUT_MS = 35e3;
240
841
  var PEER_OPERATION_TIMEOUT_MS = 8e3;
842
+ var VIEWER_LAYER_BATCH_SIZE = 16;
843
+ var VIEWER_LAYER_UPDATE_INTERVAL_MS = 5e3;
241
844
  var CAPTURE_REUSE_WINDOW_MS = 2e4;
242
845
  var CloudflareSFUClient = class {
243
846
  #localListeners = /* @__PURE__ */ new Set();
@@ -265,6 +868,10 @@ var CloudflareSFUClient = class {
265
868
  #cursor = null;
266
869
  #remotePullIncomplete = false;
267
870
  #remotePullRetries = /* @__PURE__ */ new Map();
871
+ #receiveProgress = new ReceiveStallRecovery();
872
+ #viewerLayerPending = /* @__PURE__ */ new Map();
873
+ #viewerLayerUpdating = false;
874
+ #viewerLayerUpdateAt = Number.NEGATIVE_INFINITY;
268
875
  #remoteParticipantIds = null;
269
876
  #remoteParticipantRevision = 0;
270
877
  #subscribedPublicationIds = /* @__PURE__ */ new Set();
@@ -281,8 +888,10 @@ var CloudflareSFUClient = class {
281
888
  #retirement = Promise.resolve();
282
889
  #snapshot;
283
890
  #started = false;
891
+ #receivingInitial = false;
284
892
  #stopped = false;
285
893
  #transport;
894
+ #callStats = createCallStatsCollector({ upload: async (stats2) => this.#transport?.uploadCallStats?.(stats2, false) });
286
895
  constructor(options) {
287
896
  validateClientOptions(options);
288
897
  this.#participantId = options.participantId;
@@ -300,6 +909,7 @@ var CloudflareSFUClient = class {
300
909
  cursor: null,
301
910
  localTracks: EMPTY_LOCAL,
302
911
  remoteTracks: EMPTY_REMOTE,
912
+ receiveStalls: this.#receiveProgress.observations,
303
913
  failure: null
304
914
  });
305
915
  this.#observeConnection(this.#connection, this.#generation, this.#connectionEpoch);
@@ -320,7 +930,9 @@ var CloudflareSFUClient = class {
320
930
  this.#remotePullIncomplete = true;
321
931
  this.remotePublicationsChanged();
322
932
  }
323
- remotePublicationsChanged() {
933
+ remotePublicationsChanged(publications, projectionState = "available") {
934
+ if (projectionState === "unavailable") this.#receiveProgress.suspend();
935
+ else if (publications) this.#receiveProgress.expect(publications, this.#participantId);
324
936
  if (this.#polling) {
325
937
  this.#pollAfterCurrent = true;
326
938
  return;
@@ -385,20 +997,30 @@ var CloudflareSFUClient = class {
385
997
  this.#publishSnapshot();
386
998
  this.#emitLocal();
387
999
  }
388
- async start(localMedia) {
1000
+ async start(localMedia, beforePublish) {
389
1001
  if (this.#started) return;
390
1002
  this.#requireActive();
391
- const tracks = localMedia.getTracks().filter((track) => track.kind === "audio" || track.kind === "video");
392
- for (const track of tracks) this.prepareLocalTrack(track.kind === "audio" ? "microphone" : "camera", track);
393
1003
  const generation = this.#generation;
394
1004
  this.#setPhase("connecting", null);
1005
+ if (beforePublish) {
1006
+ this.#receivingInitial = true;
1007
+ this.#schedulePoll(0);
1008
+ try {
1009
+ localMedia = await beforePublish();
1010
+ } finally {
1011
+ this.#receivingInitial = false;
1012
+ }
1013
+ if (generation !== this.#generation || this.#stopped) return;
1014
+ }
1015
+ const tracks = localMedia.getTracks().filter((track) => track.kind === "audio" || track.kind === "video");
1016
+ for (const track of tracks) this.prepareLocalTrack(track.kind === "audio" ? "microphone" : "camera", track);
395
1017
  await this.#activatePreparedTracks(
396
1018
  [...this.#localTracks.values()].filter((state) => state.source === "microphone" || state.source === "camera"),
397
1019
  generation
398
1020
  );
399
1021
  }
400
1022
  async refreshRemotePublications() {
401
- if (!this.#started || this.#stopped || this.#polling) return;
1023
+ if (!this.#started && !this.#receivingInitial || this.#stopped || this.#polling) return;
402
1024
  this.#polling = true;
403
1025
  const generation = this.#generation;
404
1026
  this.#pollFailedPull = false;
@@ -478,12 +1100,34 @@ var CloudflareSFUClient = class {
478
1100
  return enabled ? Promise.resolve() : this.#retireOwnedPublications();
479
1101
  }
480
1102
  async closeForcedLocalPublication(source, cause = "forced_pause") {
1103
+ const connection = this.#connection;
1104
+ const generationAtClose = this.#generation;
481
1105
  const sources = localSourceGroup(source);
482
1106
  const ownsPublication = [...this.#ownedPublications].some((item) => sources.includes(item.state.source));
483
- await this.setLocalSourceIntent(source, false, cause);
484
- if (!ownsPublication || !this.#replaceMediaConnection) return;
1107
+ const canRecover = ownsPublication && this.#replaceMediaConnection !== void 0;
1108
+ let failedRetirement;
1109
+ try {
1110
+ await this.setLocalSourceIntent(source, false, cause);
1111
+ } catch (error) {
1112
+ if (!canRecover) throw error;
1113
+ failedRetirement = this.#fenceFailedClose(error, connection, generationAtClose, cause);
1114
+ if (!failedRetirement) return;
1115
+ }
1116
+ if (!canRecover) return;
1117
+ await this.#restoreEnabledSourcesAfterClose(source);
1118
+ if (this.#retirement !== failedRetirement) await this.#retirement;
1119
+ }
1120
+ #fenceFailedClose(error, connection, generation, cause) {
1121
+ const failures = error instanceof AggregateError ? error.errors : [error];
1122
+ if (!failures.every((failure) => this.#isRetryableConnectionFailure(failure))) throw error;
1123
+ if (connection !== this.#connection || generation !== this.#generation) return;
1124
+ this.#retiredLocalConnection = connection;
1125
+ this.#retiredLocalCause = cause;
1126
+ return this.#retirement;
1127
+ }
1128
+ #restoreEnabledSourcesAfterClose(source) {
485
1129
  const generation = this.#generation;
486
- await this.#serializeSDP(async () => {
1130
+ return this.#serializeSDP(async () => {
487
1131
  const existing = new Set([...this.#localTracks.values()].filter((state) => state.source !== source && state.providerPublicationId !== null));
488
1132
  const states = await this.#prepareConnectionForPublication([], generation);
489
1133
  await this.#publishPreparedTracksSerialized(
@@ -495,7 +1139,6 @@ var CloudflareSFUClient = class {
495
1139
  this.#requireGeneration(generation);
496
1140
  this.#setPhase("live", null);
497
1141
  });
498
- await this.#retirement;
499
1142
  }
500
1143
  async #retireUnavailableLocalPublication(state) {
501
1144
  const publicationId = state.providerPublicationId;
@@ -641,6 +1284,9 @@ var CloudflareSFUClient = class {
641
1284
  return;
642
1285
  }
643
1286
  this.#stopped = true;
1287
+ const transport = this.#transport;
1288
+ void this.#callStats.flush(async (stats2) => transport?.uploadCallStats?.(stats2, true));
1289
+ this.#receiveProgress.expect([], this.#participantId);
644
1290
  this.#subscribedPublicationIds.clear();
645
1291
  this.#generation++;
646
1292
  this.#connectionEpoch++;
@@ -901,9 +1547,8 @@ var CloudflareSFUClient = class {
901
1547
  if (cursor === null) return;
902
1548
  const participantRevision = this.#remoteParticipantRevision;
903
1549
  const desired = this.#desiredRemotePublications(authoritative, cursor);
904
- const toPull = this.#pendingRemotePulls(desired);
905
1550
  this.#pollFailedPull = true;
906
- const pulled = await this.#pullWithRecovery(toPull, cursor, generation);
1551
+ const pulled = await this.#pullWithRecovery(desired, cursor, generation);
907
1552
  if (pulled === null) return;
908
1553
  this.#requireGeneration(generation);
909
1554
  const next = reconcileRemoteTracks(desired, pulled, this.#remoteTracks);
@@ -931,7 +1576,9 @@ var CloudflareSFUClient = class {
931
1576
  }
932
1577
  const now = Date.now();
933
1578
  return [...desired].filter(([key, publication]) => {
934
- if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) return false;
1579
+ if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) {
1580
+ if (!this.#receiveProgress.canRepull(publication.publicationId) && !(this.#replaceMediaConnection && this.#receiveProgress.canRebuild(publication.publicationId))) return false;
1581
+ }
935
1582
  const remaining = (this.#remotePullRetries.get(publication.publicationId)?.retryAfter ?? 0) - now;
936
1583
  return remaining <= 0;
937
1584
  }).map(([, publication]) => publication);
@@ -960,7 +1607,11 @@ var CloudflareSFUClient = class {
960
1607
  #canSubscribeToRemotePublication(publication) {
961
1608
  return this.#remoteParticipantIds === null || this.#remoteParticipantIds.has(publication.participantId) || this.#subscribedPublicationIds.has(publication.publicationId);
962
1609
  }
963
- async #pullWithRecovery(publications, cursor, generation) {
1610
+ async #pullWithRecovery(desired, cursor, generation) {
1611
+ const pending = this.#pendingRemotePulls(desired);
1612
+ const stalled = pending.filter((publication) => this.#receiveProgress.isStalled(publication.publicationId));
1613
+ if (stalled.length) return this.#recoverStalledRemotePublications(stalled, desired, cursor, generation);
1614
+ const publications = pending;
964
1615
  try {
965
1616
  return await this.#pull(publications, generation);
966
1617
  } catch (error) {
@@ -972,6 +1623,46 @@ var CloudflareSFUClient = class {
972
1623
  return this.#retryRemotePullOnReplacement(publications, cursor, generation);
973
1624
  }
974
1625
  }
1626
+ async #recoverStalledRemotePublications(stalled, desired, cursor, generation) {
1627
+ if (stalled.every((publication) => this.#receiveProgress.canRepull(publication.publicationId))) {
1628
+ try {
1629
+ return await this.#repullStalledRemotePublications(stalled, generation);
1630
+ } catch (error) {
1631
+ this.#requireGeneration(generation);
1632
+ if (!this.#replaceMediaConnection) throw error;
1633
+ }
1634
+ }
1635
+ if (!this.#replaceMediaConnection || !this.#receiveProgress.reserveRebuild()) return [];
1636
+ return this.#retryRemotePullOnReplacement([...desired.values()], cursor, generation, "receive_stalled");
1637
+ }
1638
+ async #repullStalledRemotePublications(stalled, generation) {
1639
+ for (const publication of stalled) this.#receiveProgress.reserveRepull(publication.publicationId);
1640
+ const retired = await this.#retireStalledReceivers(stalled, generation);
1641
+ if (!retired.length) return [];
1642
+ const pulled = await this.#pullBatch(retired, generation);
1643
+ if (pulled?.length !== retired.length) throw new CloudflareSFUError("Stalled receive subscriptions were not restored", "media_failed");
1644
+ for (const publication of retired) this.#receiveProgress.restarted(publication.publicationId);
1645
+ return pulled;
1646
+ }
1647
+ async #retireStalledReceivers(publications, generation) {
1648
+ return this.#serializeSDP(async () => {
1649
+ this.#requireGeneration(generation);
1650
+ const stalled = publications.filter((publication) => this.#receiveProgress.isStalled(publication.publicationId));
1651
+ if (!stalled.length) return [];
1652
+ const connection = this.#connection;
1653
+ const receivers = stalled.map((publication) => {
1654
+ const remote = [...this.#remoteTracks.values()].find((track) => track.publicationId === publication.publicationId);
1655
+ const transceiver = connection.getTransceivers().find((item) => item.receiver.track === remote?.track);
1656
+ if (!transceiver || transceiver.mid === null) throw new CloudflareSFUError("The stalled receiver has no negotiated MID", "media_failed");
1657
+ return { publication, transceiver, mid: transceiver.mid };
1658
+ });
1659
+ const connectionId = this.#bootstrap.connectionId;
1660
+ 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 });
1661
+ this.#requireGeneration(generation);
1662
+ for (const { transceiver } of receivers) transceiver.stop();
1663
+ return stalled;
1664
+ });
1665
+ }
975
1666
  #canReplaceConnectionAfterRemotePull(error, generation) {
976
1667
  const gone = error instanceof CloudflareSFUError && (error.options.status === 404 || error.options.status === 410);
977
1668
  return this.#isRetryableConnectionFailure(error) && (gone || !this.#hasLiveRemoteTracks()) && this.#replaceMediaConnection !== void 0 && this.#replacementAttemptedGeneration !== generation;
@@ -979,14 +1670,19 @@ var CloudflareSFUClient = class {
979
1670
  #hasLiveRemoteTracks() {
980
1671
  return [...this.#remoteTracks.values()].some((publication) => publication.track.readyState === "live");
981
1672
  }
982
- async #retryRemotePullOnReplacement(publications, cursor, generation) {
1673
+ async #retryRemotePullOnReplacement(publications, cursor, generation, cause = "remote_signaling_failure") {
983
1674
  this.#remoteRecoveryGeneration = generation;
1675
+ const connection = this.#connection;
984
1676
  try {
985
- await this.#replaceMediaConnectionForRecovery(generation, "remote_signaling_failure");
1677
+ await this.#replaceMediaConnectionForRecovery(generation, cause);
986
1678
  await this.#republishPreparedTracksAfterRecovery(generation);
987
1679
  return await this.#pull(publications, generation);
988
1680
  } catch (error) {
989
1681
  if (generation !== this.#generation || this.#stopped) throw error;
1682
+ if (cause === "receive_stalled" && connection === this.#connection) {
1683
+ this.#replacementAttemptedGeneration = null;
1684
+ for (const publication of publications) this.#backoffRemotePull(publication.publicationId, true);
1685
+ }
990
1686
  this.#observeRemotePublicationCursor(cursor, retryableRemotePull(error));
991
1687
  throw error;
992
1688
  }
@@ -1029,7 +1725,7 @@ var CloudflareSFUClient = class {
1029
1725
  const retryAfter = retryableRemotePull(error) ? Date.now() + 15e3 : Number.POSITIVE_INFINITY;
1030
1726
  for (const publication of publications) {
1031
1727
  const failures = this.#remotePullRetries.get(publication.publicationId)?.failures ?? 0;
1032
- this.#remotePullRetries.set(publication.publicationId, { retryAfter, failures });
1728
+ this.#remotePullRetries.set(publication.publicationId, { retryAfter, failures, receiveStalled: this.#remotePullRetries.get(publication.publicationId)?.receiveStalled });
1033
1729
  }
1034
1730
  }
1035
1731
  async #pullBatch(publications, generation) {
@@ -1056,12 +1752,17 @@ var CloudflareSFUClient = class {
1056
1752
  connection.addEventListener("track", onTrack);
1057
1753
  try {
1058
1754
  this.#requireGeneration(generation);
1059
- const response = await this.#signal(() => this.#addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }), { generation, connectionId });
1755
+ const response = await this.#signal(() => this.#addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }), {
1756
+ generation,
1757
+ connectionId
1758
+ });
1060
1759
  this.#requireGeneration(generation);
1061
1760
  this.#recordRemotePullErrors(publications, response.trackErrors ?? []);
1062
1761
  const responseTracks = response.tracks ?? [];
1063
1762
  const matched = matchRemotePullTracks(publications, responseTracks);
1064
- for (const { publication } of matched) this.#remotePullRetries.delete(publication.publicationId);
1763
+ for (const { publication } of matched) {
1764
+ if (!this.#remotePullRetries.get(publication.publicationId)?.receiveStalled) this.#remotePullRetries.delete(publication.publicationId);
1765
+ }
1065
1766
  await this.#completeRenegotiation(response, connection, connectionId, generation);
1066
1767
  if (matched.length === 0) return [];
1067
1768
  await this.#waitForConnection(connection, generation);
@@ -1091,13 +1792,82 @@ var CloudflareSFUClient = class {
1091
1792
  for (const failure of failures) {
1092
1793
  const publication = requested.get(`${failure.connectionId}\0${failure.trackName}`);
1093
1794
  if (!publication) throw new CloudflareSFUError("Cloudflare SFU returned an unrequested remote track error", "invalid_publication");
1094
- const previous = this.#remotePullRetries.get(publication.publicationId) ?? { retryAfter: 0, failures: 0 };
1095
- const failureCount = Math.min(previous.failures + 1, 6);
1096
- const delay = Math.min(15e3, 750 * 2 ** (failureCount - 1));
1097
- this.#remotePullRetries.set(publication.publicationId, { retryAfter: Date.now() + delay, failures: failureCount });
1795
+ this.#backoffRemotePull(publication.publicationId);
1098
1796
  this.#reportError(new CloudflareSFUError(`Cloudflare SFU remote track failed: ${failure.code}`, "media_failed", { providerCode: failure.code }));
1099
1797
  }
1100
1798
  }
1799
+ #backoffRemotePull(publicationId, receiveStalled = false) {
1800
+ const previous = this.#remotePullRetries.get(publicationId) ?? { retryAfter: 0, failures: 0 };
1801
+ const failures = Math.min(previous.failures + 1, 6);
1802
+ this.#remotePullRetries.set(publicationId, { retryAfter: Date.now() + Math.min(15e3, 750 * 2 ** (failures - 1)), failures, receiveStalled: receiveStalled || previous.receiveStalled });
1803
+ }
1804
+ #adaptViewerLayers(connection, report, selector, current) {
1805
+ if (!this.#transport?.updateTracks || !this.#canObserveReceiveProgress(connection)) return;
1806
+ const now = performance.now();
1807
+ const cameras = [...this.#remoteTracks.values()].filter(({ source, track }) => source === "camera" && track.readyState === "live");
1808
+ this.#queueViewerLayers(selector.sample(now, report, cameras));
1809
+ if (this.#viewerLayerUpdating || now < this.#viewerLayerUpdateAt) return;
1810
+ const batch = [...this.#viewerLayerPending.values()].flatMap((change) => {
1811
+ const mid = connection.getTransceivers().find((transceiver) => transceiver.receiver.track === change.track)?.mid;
1812
+ return mid === null || mid === void 0 ? [] : [{ change, mid }];
1813
+ }).slice(0, VIEWER_LAYER_BATCH_SIZE);
1814
+ if (!batch.length) return;
1815
+ this.#viewerLayerUpdating = true;
1816
+ this.#viewerLayerUpdateAt = now + VIEWER_LAYER_UPDATE_INTERVAL_MS;
1817
+ void this.#requestViewerLayers(this.#transport, batch).then((failed) => {
1818
+ this.#viewerLayerUpdating = false;
1819
+ if (!current()) return;
1820
+ for (const { change } of batch) {
1821
+ const pending = this.#viewerLayerPending.get(change.publicationId);
1822
+ if (pending?.track === change.track && pending.layer === change.layer) this.#viewerLayerPending.delete(change.publicationId);
1823
+ if (failed.has(change.publicationId)) selector.failed(change, performance.now());
1824
+ else selector.applied(change);
1825
+ }
1826
+ });
1827
+ }
1828
+ #queueViewerLayers(changes) {
1829
+ const active = new Set(changes.map(({ publicationId }) => publicationId));
1830
+ for (const id of this.#viewerLayerPending.keys()) if (!active.has(id)) this.#viewerLayerPending.delete(id);
1831
+ for (const change of changes) this.#viewerLayerPending.set(change.publicationId, change);
1832
+ }
1833
+ // A layer switch is an optimization: when the SFU refuses it, the pulled layer keeps playing.
1834
+ async #requestViewerLayers(transport, batch) {
1835
+ try {
1836
+ const response = await transport.updateTracks?.({
1837
+ connectionId: this.#bootstrap.connectionId,
1838
+ tracks: batch.map(({ change, mid }) => {
1839
+ const { connectionId, trackName } = parseCloudflareSFUPublicationID(change.publicationId);
1840
+ return { location: "remote", mid, sessionId: connectionId, trackName, simulcast: { preferredRid: change.layer, priorityOrdering: "none", ridNotAvailable: "asciibetical" } };
1841
+ })
1842
+ });
1843
+ return new Set(
1844
+ batch.filter(({ change }) => {
1845
+ const { connectionId, trackName } = parseCloudflareSFUPublicationID(change.publicationId);
1846
+ return response?.trackErrors?.some((error) => error.connectionId === connectionId && error.trackName === trackName);
1847
+ }).map(({ change }) => change.publicationId)
1848
+ );
1849
+ } catch {
1850
+ return new Set(batch.map(({ change }) => change.publicationId));
1851
+ }
1852
+ }
1853
+ #canObserveReceiveProgress(connection) {
1854
+ return this.#started && connectionIsLive(connection) && (typeof document === "undefined" || document.visibilityState !== "hidden");
1855
+ }
1856
+ #observeReceiveProgress(connection, report) {
1857
+ if (!this.#canObserveReceiveProgress(connection)) {
1858
+ this.#receiveProgress.clear();
1859
+ return;
1860
+ }
1861
+ const { stalled, advanced } = this.#receiveProgress.sample(performance.now(), connection, report, this.#remoteTracks.values());
1862
+ for (const publicationId of advanced) this.#remotePullRetries.delete(publicationId);
1863
+ this.#publishSnapshot();
1864
+ if (!stalled.some((id) => this.#receiveProgress.canRepull(id) || this.#replaceMediaConnection && this.#receiveProgress.canRebuild(id))) return;
1865
+ for (const publicationId of stalled) {
1866
+ if (!this.#remotePullRetries.get(publicationId)?.receiveStalled) this.#backoffRemotePull(publicationId, true);
1867
+ }
1868
+ this.#remotePullIncomplete = true;
1869
+ this.remotePublicationsChanged();
1870
+ }
1101
1871
  async #completeRenegotiation(response, connection, connectionId, generation) {
1102
1872
  if (!response.requiresImmediateRenegotiation) return;
1103
1873
  await this.#applyProviderDescription(response, connection);
@@ -1193,6 +1963,7 @@ var CloudflareSFUClient = class {
1193
1963
  let disposed = false;
1194
1964
  let cameraTimer;
1195
1965
  const cameraPolicy = new CameraUplinkPolicy();
1966
+ const viewerLayers = new ViewerLayerSelector();
1196
1967
  let observedCamera;
1197
1968
  const current = () => !disposed && generation === this.#generation && connectionEpoch === this.#connectionEpoch && !this.#stopped && connection.connectionState !== "failed" && connection.connectionState !== "closed";
1198
1969
  const adaptCamera = async () => {
@@ -1203,14 +1974,14 @@ var CloudflareSFUClient = class {
1203
1974
  cameraPolicy.reset();
1204
1975
  observedCamera = state;
1205
1976
  }
1206
- const sender = this.#cameraSimulcastSender(state);
1207
- if (!sender) {
1208
- cameraPolicy.reset();
1209
- return;
1210
- }
1211
1977
  const report = await connection.getStats();
1212
- if (!current() || this.#cameraSimulcastSender(state) !== sender || connection.signalingState !== "stable") return;
1213
- await this.#applyCameraUplink(sender, report, cameraPolicy);
1978
+ if (!current() || connection.signalingState !== "stable") return;
1979
+ this.#observeReceiveProgress(connection, report);
1980
+ this.#adaptViewerLayers(connection, report, viewerLayers, current);
1981
+ this.#callStats.observe(report);
1982
+ const sender = this.#cameraSimulcastSender(state);
1983
+ if (!sender) cameraPolicy.reset();
1984
+ else await this.#applyCameraUplink(sender, report, cameraPolicy);
1214
1985
  } catch {
1215
1986
  cameraPolicy.reset();
1216
1987
  } finally {
@@ -1220,6 +1991,7 @@ var CloudflareSFUClient = class {
1220
1991
  const dispose = () => {
1221
1992
  if (disposed) return;
1222
1993
  disposed = true;
1994
+ this.#viewerLayerPending.clear();
1223
1995
  if (cameraTimer !== void 0) globalThis.clearTimeout(cameraTimer);
1224
1996
  connection.removeEventListener("connectionstatechange", capture);
1225
1997
  connection.removeEventListener("iceconnectionstatechange", capture);
@@ -1300,11 +2072,12 @@ var CloudflareSFUClient = class {
1300
2072
  }
1301
2073
  #createPeerConnection(bootstrap) {
1302
2074
  const create = this.#peerConnectionFactory ?? ((configuration) => new RTCPeerConnection(configuration));
1303
- return create({ iceServers: [{ urls: bootstrap.stunServer }], bundlePolicy: "max-bundle" });
2075
+ return create({ iceServers: iceServersFor(bootstrap), bundlePolicy: "max-bundle" });
1304
2076
  }
1305
2077
  #rebuildPeerConnection(bootstrap, generation, connectionEpoch) {
1306
2078
  try {
1307
2079
  this.#connection = this.#createPeerConnection(bootstrap);
2080
+ this.#callStats.reset();
1308
2081
  this.#observeConnection(this.#connection, generation, connectionEpoch);
1309
2082
  } catch (error) {
1310
2083
  this.#setFailure(error, "media_failed");
@@ -1312,6 +2085,7 @@ var CloudflareSFUClient = class {
1312
2085
  }
1313
2086
  }
1314
2087
  #disposeConnection(stopSenders) {
2088
+ this.#receiveProgress.replaced();
1315
2089
  if (!stopSenders) this.#setPhase("recovering", null);
1316
2090
  this.#disposeConnectionObservation?.();
1317
2091
  this.#disposeConnectionObservation = void 0;
@@ -1349,7 +2123,7 @@ var CloudflareSFUClient = class {
1349
2123
  this.#emitRemote();
1350
2124
  }
1351
2125
  #schedulePoll(delayMs = this.#pollIntervalMs) {
1352
- if (this.#stopped || !this.#started || this.#pollTimer !== void 0) return;
2126
+ if (this.#stopped || !this.#started && !this.#receivingInitial || this.#pollTimer !== void 0) return;
1353
2127
  this.#pollTimer = globalThis.setTimeout(async () => {
1354
2128
  this.#pollTimer = void 0;
1355
2129
  if (typeof document !== "undefined" && document.visibilityState === "hidden") {
@@ -1408,6 +2182,7 @@ var CloudflareSFUClient = class {
1408
2182
  })
1409
2183
  ) : EMPTY_LOCAL,
1410
2184
  remoteTracks: this.#remoteTracks.size ? [...this.#remoteTracks.values()] : EMPTY_REMOTE,
2185
+ receiveStalls: this.#receiveProgress.observations,
1411
2186
  failure
1412
2187
  });
1413
2188
  if (snapshotEqual(this.#snapshot, next)) return;
@@ -1532,7 +2307,7 @@ function snapshotEqual(left, right) {
1532
2307
  return snapshotMetadataEqual(left, right) && left.localTracks.every((publication, index) => publicationEqual(publication, right.localTracks[index])) && left.remoteTracks.every((publication, index) => remotePublicationEqual(publication, right.remoteTracks[index]));
1533
2308
  }
1534
2309
  function snapshotMetadataEqual(left, right) {
1535
- return connectionEqual(left.connection, right.connection) && cursorEqual(left.cursor, right.cursor) && failureEqual(left.failure, right.failure) && left.localTracks.length === right.localTracks.length && left.remoteTracks.length === right.remoteTracks.length;
2310
+ return (left.receiveStalls ?? EMPTY_RECEIVE_STALLS) === (right.receiveStalls ?? EMPTY_RECEIVE_STALLS) && connectionEqual(left.connection, right.connection) && cursorEqual(left.cursor, right.cursor) && failureEqual(left.failure, right.failure) && left.localTracks.length === right.localTracks.length && left.remoteTracks.length === right.remoteTracks.length;
1536
2311
  }
1537
2312
  function connectionEqual(left, right) {
1538
2313
  return left.phase === right.phase && left.peerConnectionState === right.peerConnectionState && left.iceConnectionState === right.iceConnectionState;
@@ -1581,16 +2356,23 @@ function mediaTargetFailure(error) {
1581
2356
  var SIGNALING_TIMEOUT_MS = 8e3;
1582
2357
  var CREDENTIAL_TIMEOUT_MS = 7e3;
1583
2358
  function createCloudflareSFUHTTPTransport(options) {
1584
- const fetch = options.fetch ?? globalThis.fetch;
2359
+ const fetch = withRequestContext(options.fetch);
1585
2360
  const credential = requireCredential(options);
2361
+ let lastToken = "";
1586
2362
  const base = options.apiBaseURL.replace(/\/$/, "");
1587
2363
  const mediaPath = `${base}/v1/tenants/${encodeURIComponent(options.tenantId)}/spaces/${encodeURIComponent(options.spaceId)}/episodes/${encodeURIComponent(options.episodeId)}/participants/${encodeURIComponent(options.participantId)}/media/sfu`;
2364
+ const statsPath = `${base}/v1/tenants/${encodeURIComponent(options.tenantId)}/call-stats`;
1588
2365
  const timeoutMs = options.requestTimeoutMs ?? SIGNALING_TIMEOUT_MS;
1589
2366
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new CloudflareSFUError("The signaling deadline is invalid", "signaling_failed");
2367
+ const freshToken = async () => {
2368
+ const token = await withDeadline(CREDENTIAL_TIMEOUT_MS, (signal) => credential(signal));
2369
+ if (!token.trim()) throw new CloudflareSFUError("The media credential provider returned an empty token", "signaling_failed");
2370
+ lastToken = token;
2371
+ return token;
2372
+ };
1590
2373
  const request = async (path, init) => {
1591
2374
  try {
1592
- const token = await withDeadline(CREDENTIAL_TIMEOUT_MS, (signal) => credential(signal));
1593
- if (!token.trim()) throw new CloudflareSFUError("The media credential provider returned an empty token", "signaling_failed");
2375
+ const token = await freshToken();
1594
2376
  return await withDeadline(timeoutMs, async (signal) => {
1595
2377
  const startedAt = performance.now();
1596
2378
  const response = await fetch(`${mediaPath}/${path}`, {
@@ -1640,15 +2422,34 @@ function createCloudflareSFUHTTPTransport(options) {
1640
2422
  connection_id: input.connectionId,
1641
2423
  session_description: input.sessionDescription,
1642
2424
  tracks: input.tracks.map((track) => ({ mid: track.mid, source: track.source, publication_id: track.publicationId })),
1643
- force: input.force
2425
+ force: input.force,
2426
+ ...input.receiveOnly ? { receive_only: true } : {}
1644
2427
  })
1645
2428
  }),
2429
+ updateTracks: async (input) => request("tracks/update", {
2430
+ method: "PUT",
2431
+ body: JSON.stringify({ connection_id: input.connectionId, tracks: input.tracks })
2432
+ }),
1646
2433
  renegotiate: async (input) => {
1647
2434
  await request("renegotiate", {
1648
2435
  method: "POST",
1649
2436
  body: JSON.stringify({ connection_id: input.connectionId, session_description: input.sessionDescription })
1650
2437
  });
1651
2438
  },
2439
+ uploadCallStats: async (stats2, closing) => {
2440
+ const token = closing ? lastToken : await freshToken();
2441
+ if (!token) return;
2442
+ await withDeadline(timeoutMs, async (signal) => {
2443
+ const response = await fetch(statsPath, {
2444
+ method: "POST",
2445
+ signal,
2446
+ keepalive: true,
2447
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
2448
+ body: JSON.stringify(stats2)
2449
+ });
2450
+ if (!response.ok) throw new CloudflareSFUError(`Chalk call stats upload failed with HTTP ${response.status}`, "signaling_failed", { status: response.status });
2451
+ });
2452
+ },
1652
2453
  listPublications: async () => {
1653
2454
  const response = await request("publications");
1654
2455
  return {
@@ -1713,6 +2514,7 @@ function describeMediaCaptureError(cause) {
1713
2514
  }
1714
2515
 
1715
2516
  export {
2517
+ setVideoRenderSize,
1716
2518
  CloudflareSFUError,
1717
2519
  parseCloudflareSFUPublicationID,
1718
2520
  requireDescription,