@q9labsai/chalk-client 5.2.5 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,145 @@
1
+ // src/media/receive-stall.ts
2
+ var RECEIVE_STALL_WINDOW_MS = 7e3;
3
+ var RECEIVE_STARTUP_GRACE_MS = 2e4;
4
+ var MAX_SAMPLE_GAP_MS = 3e3;
5
+ var ReceiveStallDetector = class {
6
+ #progress = /* @__PURE__ */ new Map();
7
+ #sampledAt;
8
+ forget(publicationId) {
9
+ this.#progress.delete(publicationId);
10
+ }
11
+ clear() {
12
+ this.#progress.clear();
13
+ this.#sampledAt = void 0;
14
+ }
15
+ sample(now, tracks) {
16
+ if (this.#sampledAt !== void 0 && (now - this.#sampledAt > MAX_SAMPLE_GAP_MS || now < this.#sampledAt)) this.clear();
17
+ this.#sampledAt = now;
18
+ const observed = new Set(tracks.map((track) => track.publicationId));
19
+ for (const id of this.#progress.keys()) if (!observed.has(id)) this.#progress.delete(id);
20
+ const observations = tracks.map((track) => ({ id: track.publicationId, outcome: this.#observeTrack(now, track) }));
21
+ return {
22
+ stalled: observations.filter((track) => track.outcome === "stalled").map((track) => track.id),
23
+ resumed: observations.filter((track) => track.outcome === "resumed").map((track) => track.id),
24
+ advanced: observations.filter((track) => track.outcome === "advanced" || track.outcome === "resumed").map((track) => track.id)
25
+ };
26
+ }
27
+ #observeTrack(now, { publicationId, trackId, packets }) {
28
+ const previous = this.#progress.get(publicationId);
29
+ const sameTrack = previous?.trackId === trackId;
30
+ if (sameTrack && previous.packets === packets) {
31
+ const window = previous.flowing ? RECEIVE_STALL_WINDOW_MS : RECEIVE_STARTUP_GRACE_MS;
32
+ return now - previous.advancedAt >= window ? "stalled" : null;
33
+ }
34
+ return this.#advanceTrack(now, { publicationId, trackId, packets }, previous);
35
+ }
36
+ #advanceTrack(now, { publicationId, trackId, packets }, previous) {
37
+ const advanced = previous?.trackId === trackId && packets > previous.packets;
38
+ const flowingSince = advanced && now - previous.advancedAt < RECEIVE_STALL_WINDOW_MS ? previous.flowingSince : now;
39
+ this.#progress.set(publicationId, { trackId, packets, advancedAt: now, flowing: advanced, flowingSince });
40
+ if (!advanced) return null;
41
+ return now - flowingSince >= RECEIVE_STALL_WINDOW_MS ? "resumed" : "advanced";
42
+ }
43
+ };
44
+ var MAX_RECEIVE_REBUILDS = 2;
45
+ var MAX_RECEIVE_OBSERVATIONS = 32;
46
+ var ReceiveStallRecovery = class {
47
+ #detector = new ReceiveStallDetector();
48
+ #attempts = /* @__PURE__ */ new Map();
49
+ #identities = /* @__PURE__ */ new Map();
50
+ #expected = /* @__PURE__ */ new Set();
51
+ #screens = /* @__PURE__ */ new Set();
52
+ #stalled = /* @__PURE__ */ new Set();
53
+ #active = [];
54
+ #observations = Object.freeze([]);
55
+ get observations() {
56
+ return this.#observations;
57
+ }
58
+ expect(publications, participantId) {
59
+ const enabled = publications.filter((publication) => publication.participantId !== participantId && publication.enabled);
60
+ this.#expected = new Set(enabled.flatMap((publication) => publication.publicationId ? [publication.publicationId] : []));
61
+ this.#screens = new Set(enabled.filter((publication) => publication.source === "screen").map((publication) => publication.participantId));
62
+ this.#pruneWithdrawn(publications);
63
+ for (const publication of this.#identities.values()) {
64
+ if (this.#isExpected(publication)) continue;
65
+ this.#detector.forget(publication.publicationId);
66
+ this.#stalled.delete(publication.publicationId);
67
+ }
68
+ this.#active = [...this.#identities.values()].filter((publication) => this.#isExpected(publication)).map((publication) => publication.publicationId);
69
+ }
70
+ suspend() {
71
+ this.#expected.clear();
72
+ this.#screens.clear();
73
+ this.clear();
74
+ }
75
+ #pruneWithdrawn(publications) {
76
+ const advertised = new Set(publications.map((publication) => publication.publicationId));
77
+ const screens = new Set(publications.filter((publication) => publication.source === "screen").map((publication) => publication.participantId));
78
+ for (const [key, publication] of this.#identities) {
79
+ if (advertised.has(publication.publicationId) || publication.source === "screen_audio" && screens.has(publication.participantId)) continue;
80
+ this.#forget(publication.publicationId);
81
+ this.#identities.delete(key);
82
+ }
83
+ }
84
+ isStalled(publicationId) {
85
+ return this.#stalled.has(publicationId);
86
+ }
87
+ canRebuild() {
88
+ return this.#active.length > 0 && this.#active.every((id) => this.#stalled.has(id) && (this.#attempts.get(id) ?? 0) < MAX_RECEIVE_REBUILDS);
89
+ }
90
+ reserveRebuild() {
91
+ if (!this.canRebuild()) return false;
92
+ for (const id of this.#active) this.#attempts.set(id, (this.#attempts.get(id) ?? 0) + 1);
93
+ return true;
94
+ }
95
+ clear() {
96
+ this.#detector.clear();
97
+ this.#stalled.clear();
98
+ this.#active = [];
99
+ }
100
+ sample(now, connection, report, tracks) {
101
+ const active = [...tracks].filter((publication) => this.#isExpected(publication));
102
+ for (const publication of active) this.#rememberIdentity(publication);
103
+ this.#active = active.map((publication) => publication.publicationId);
104
+ const samples = active.flatMap((publication) => receivePacketSample(connection, report, publication) ?? []);
105
+ const { stalled, advanced, resumed } = this.#detector.sample(now, samples);
106
+ for (const id of resumed) this.#attempts.delete(id);
107
+ const newlyStalled = stalled.filter((id) => !this.#stalled.has(id));
108
+ this.#stalled = new Set(stalled);
109
+ if (newlyStalled.length) this.#record(now);
110
+ return { stalled, advanced, newlyStalled };
111
+ }
112
+ #isExpected(publication) {
113
+ return publication.source === "screen_audio" ? this.#screens.has(publication.participantId) : this.#expected.has(publication.publicationId);
114
+ }
115
+ #rememberIdentity(publication) {
116
+ const key = `${publication.participantId}\0${publication.source}`;
117
+ const previous = this.#identities.get(key);
118
+ if (previous && previous.publicationId !== publication.publicationId) this.#forget(previous.publicationId);
119
+ this.#identities.set(key, publication);
120
+ }
121
+ #forget(id) {
122
+ this.#detector.forget(id);
123
+ this.#stalled.delete(id);
124
+ this.#attempts.delete(id);
125
+ }
126
+ #record(now) {
127
+ const observation = Object.freeze({ event: "receive_stalled", scope: this.#active.every((id) => this.#stalled.has(id)) ? "all" : "partial", observedAtMs: now, expectedTracks: this.#active.length, stalledTracks: this.#stalled.size });
128
+ this.#observations = Object.freeze([...this.#observations.slice(-(MAX_RECEIVE_OBSERVATIONS - 1)), observation]);
129
+ }
130
+ };
131
+ function receivePacketSample(connection, report, publication) {
132
+ const rows = [...report.values()].filter((row) => row.type === "inbound-rtp" && row.trackIdentifier === publication.track.id);
133
+ if (rows.some((row) => !Number.isSafeInteger(row.packetsReceived) || row.packetsReceived < 0)) return;
134
+ if (publication.track.kind === "audio" && !audioPacketCadenceKnown(connection, report, publication.track, rows)) return;
135
+ return { publicationId: publication.publicationId, trackId: publication.track.id, packets: rows.reduce((sum, row) => sum + row.packetsReceived, 0) };
136
+ }
137
+ function audioPacketCadenceKnown(connection, report, track, rows) {
138
+ const negotiated = rows.length ? rows.map((row) => row.codecId ? report.get(row.codecId) : void 0) : connection.getReceivers().find((receiver) => receiver.track.id === track.id)?.getParameters().codecs ?? [];
139
+ const opus = negotiated.filter((codec) => codec?.mimeType?.toLowerCase() === "audio/opus");
140
+ return opus.length > 0 && opus.every((codec) => typeof codec.sdpFmtpLine === "string");
141
+ }
142
+
1
143
  // src/media/camera-uplink.ts
2
144
  var CameraUplinkPolicy = class {
3
145
  #healthySamples = 0;
@@ -234,10 +376,12 @@ function classifyMediaFailure(cause, fallbackStep) {
234
376
  // src/media/client.ts
235
377
  var EMPTY_LOCAL = Object.freeze([]);
236
378
  var EMPTY_REMOTE = Object.freeze([]);
379
+ var EMPTY_RECEIVE_STALLS = Object.freeze([]);
237
380
  var INVALID_PUBLICATION_SIGNATURE = "\0invalid";
238
381
  var CONNECTION_TIMEOUT_MS = 8e3;
239
382
  var NEGOTIATION_TIMEOUT_MS = 35e3;
240
383
  var PEER_OPERATION_TIMEOUT_MS = 8e3;
384
+ var CAPTURE_REUSE_WINDOW_MS = 2e4;
241
385
  var CloudflareSFUClient = class {
242
386
  #localListeners = /* @__PURE__ */ new Set();
243
387
  #onError;
@@ -250,7 +394,7 @@ var CloudflareSFUClient = class {
250
394
  #remoteListeners = /* @__PURE__ */ new Set();
251
395
  #snapshotListeners = /* @__PURE__ */ new Set();
252
396
  #failureOwners = /* @__PURE__ */ new WeakMap();
253
- #locallyPausedSources = /* @__PURE__ */ new Set();
397
+ #locallyPausedSources = /* @__PURE__ */ new Map();
254
398
  #localTracks = /* @__PURE__ */ new Map();
255
399
  #ownedPublications = /* @__PURE__ */ new Set();
256
400
  #remoteTracks = /* @__PURE__ */ new Map();
@@ -259,9 +403,12 @@ var CloudflareSFUClient = class {
259
403
  #disposeConnectionObservation;
260
404
  #connectionEpoch = 0;
261
405
  #retiredLocalConnection = null;
406
+ #retiredLocalCause = "forced_pause";
407
+ #replacementCause;
262
408
  #cursor = null;
263
409
  #remotePullIncomplete = false;
264
- #remotePullRetryAfter = /* @__PURE__ */ new Map();
410
+ #remotePullRetries = /* @__PURE__ */ new Map();
411
+ #receiveProgress = new ReceiveStallRecovery();
265
412
  #remoteParticipantIds = null;
266
413
  #remoteParticipantRevision = 0;
267
414
  #subscribedPublicationIds = /* @__PURE__ */ new Set();
@@ -278,6 +425,7 @@ var CloudflareSFUClient = class {
278
425
  #retirement = Promise.resolve();
279
426
  #snapshot;
280
427
  #started = false;
428
+ #receivingInitial = false;
281
429
  #stopped = false;
282
430
  #transport;
283
431
  constructor(options) {
@@ -297,6 +445,7 @@ var CloudflareSFUClient = class {
297
445
  cursor: null,
298
446
  localTracks: EMPTY_LOCAL,
299
447
  remoteTracks: EMPTY_REMOTE,
448
+ receiveStalls: this.#receiveProgress.observations,
300
449
  failure: null
301
450
  });
302
451
  this.#observeConnection(this.#connection, this.#generation, this.#connectionEpoch);
@@ -308,7 +457,7 @@ var CloudflareSFUClient = class {
308
457
  return subscribeSnapshot(this.#snapshotListeners, listener);
309
458
  }
310
459
  remotePublicationResumed(publicationId) {
311
- this.#remotePullRetryAfter.delete(publicationId);
460
+ this.#remotePullRetries.delete(publicationId);
312
461
  this.remotePublicationsChanged();
313
462
  }
314
463
  remoteParticipantsChanged(participantIds) {
@@ -317,7 +466,9 @@ var CloudflareSFUClient = class {
317
466
  this.#remotePullIncomplete = true;
318
467
  this.remotePublicationsChanged();
319
468
  }
320
- remotePublicationsChanged() {
469
+ remotePublicationsChanged(publications, projectionState = "available") {
470
+ if (projectionState === "unavailable") this.#receiveProgress.suspend();
471
+ else if (publications) this.#receiveProgress.expect(publications, this.#participantId);
321
472
  if (this.#polling) {
322
473
  this.#pollAfterCurrent = true;
323
474
  return;
@@ -363,9 +514,9 @@ var CloudflareSFUClient = class {
363
514
  }
364
515
  async #clearPreparedTrack(state, stopTrack = true) {
365
516
  const source = state.source;
366
- if (source === "screen" || source === "screen_audio" || state.track.readyState === "ended") {
517
+ if (source === "screen" || source === "screen_audio") {
367
518
  try {
368
- this.#pauseLocalSource(source);
519
+ this.#pauseLocalSource(source, "capture_cleanup");
369
520
  await this.#retireOwnedPublications();
370
521
  } finally {
371
522
  this.#removeOwnedLocalTrack(state, stopTrack);
@@ -382,20 +533,30 @@ var CloudflareSFUClient = class {
382
533
  this.#publishSnapshot();
383
534
  this.#emitLocal();
384
535
  }
385
- async start(localMedia) {
536
+ async start(localMedia, beforePublish) {
386
537
  if (this.#started) return;
387
538
  this.#requireActive();
388
- const tracks = localMedia.getTracks().filter((track) => track.kind === "audio" || track.kind === "video");
389
- for (const track of tracks) this.prepareLocalTrack(track.kind === "audio" ? "microphone" : "camera", track);
390
539
  const generation = this.#generation;
391
540
  this.#setPhase("connecting", null);
541
+ if (beforePublish) {
542
+ this.#receivingInitial = true;
543
+ this.#schedulePoll(0);
544
+ try {
545
+ localMedia = await beforePublish();
546
+ } finally {
547
+ this.#receivingInitial = false;
548
+ }
549
+ if (generation !== this.#generation || this.#stopped) return;
550
+ }
551
+ const tracks = localMedia.getTracks().filter((track) => track.kind === "audio" || track.kind === "video");
552
+ for (const track of tracks) this.prepareLocalTrack(track.kind === "audio" ? "microphone" : "camera", track);
392
553
  await this.#activatePreparedTracks(
393
554
  [...this.#localTracks.values()].filter((state) => state.source === "microphone" || state.source === "camera"),
394
555
  generation
395
556
  );
396
557
  }
397
558
  async refreshRemotePublications() {
398
- if (!this.#started || this.#stopped || this.#polling) return;
559
+ if (!this.#started && !this.#receivingInitial || this.#stopped || this.#polling) return;
399
560
  this.#polling = true;
400
561
  const generation = this.#generation;
401
562
  this.#pollFailedPull = false;
@@ -431,6 +592,10 @@ var CloudflareSFUClient = class {
431
592
  const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
432
593
  if (resolved.kind === "result" && resolved.result.outcome !== "satisfied") return resolved.result;
433
594
  const outcome = resolved.kind === "state" && resolved.value.enabled ? "confirmed" : "satisfied";
595
+ if (target.source !== "screen" && this.#locallyPausedSources.get(target.source) === "capture_cleanup") {
596
+ await this.pauseLocalCapture(target.source);
597
+ return { outcome, errorCode: null };
598
+ }
434
599
  this.#pauseLocalSource(target.source);
435
600
  try {
436
601
  await this.#retireOwnedPublications();
@@ -449,29 +614,56 @@ var CloudflareSFUClient = class {
449
614
  await this.#retireUnavailableLocalPublication(state);
450
615
  if (this.#locallyPausedSources.has(target.source)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
451
616
  if (state.enabled) return { outcome: "satisfied", errorCode: null };
452
- await this.#enablePreparedTrack(state);
617
+ if (!await this.#enablePreparedTrack(state)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
453
618
  return { outcome: "confirmed", errorCode: null };
454
619
  } catch (error) {
455
620
  if (generation === this.#generation && !this.#stopped) this.#reportError(error);
456
621
  return mediaTargetFailure(error);
457
622
  }
458
623
  }
459
- setLocalSourceIntent(source, enabled) {
624
+ async pauseLocalCapture(source) {
625
+ this.#locallyPausedSources.set(source, "capture_cleanup");
626
+ const state = this.#localTracks.get(source);
627
+ if (state) await this.#disablePreparedTrack(state);
628
+ }
629
+ setLocalSourceIntent(source, enabled, cause = "forced_pause") {
460
630
  const sources = localSourceGroup(source);
461
631
  for (const item of sources) {
462
632
  if (enabled) this.#locallyPausedSources.delete(item);
463
- else this.#locallyPausedSources.add(item);
633
+ else this.#locallyPausedSources.set(item, cause);
464
634
  }
465
- if (!enabled) this.#pauseLocalSource(source);
635
+ if (!enabled) this.#pauseLocalSource(source, cause);
466
636
  return enabled ? Promise.resolve() : this.#retireOwnedPublications();
467
637
  }
468
- async closeForcedLocalPublication(source) {
638
+ async closeForcedLocalPublication(source, cause = "forced_pause") {
639
+ const connection = this.#connection;
640
+ const generationAtClose = this.#generation;
469
641
  const sources = localSourceGroup(source);
470
642
  const ownsPublication = [...this.#ownedPublications].some((item) => sources.includes(item.state.source));
471
- await this.setLocalSourceIntent(source, false);
472
- if (!ownsPublication || !this.#replaceMediaConnection) return;
643
+ const canRecover = ownsPublication && this.#replaceMediaConnection !== void 0;
644
+ let failedRetirement;
645
+ try {
646
+ await this.setLocalSourceIntent(source, false, cause);
647
+ } catch (error) {
648
+ if (!canRecover) throw error;
649
+ failedRetirement = this.#fenceFailedClose(error, connection, generationAtClose, cause);
650
+ if (!failedRetirement) return;
651
+ }
652
+ if (!canRecover) return;
653
+ await this.#restoreEnabledSourcesAfterClose(source);
654
+ if (this.#retirement !== failedRetirement) await this.#retirement;
655
+ }
656
+ #fenceFailedClose(error, connection, generation, cause) {
657
+ const failures = error instanceof AggregateError ? error.errors : [error];
658
+ if (!failures.every((failure) => this.#isRetryableConnectionFailure(failure))) throw error;
659
+ if (connection !== this.#connection || generation !== this.#generation) return;
660
+ this.#retiredLocalConnection = connection;
661
+ this.#retiredLocalCause = cause;
662
+ return this.#retirement;
663
+ }
664
+ #restoreEnabledSourcesAfterClose(source) {
473
665
  const generation = this.#generation;
474
- await this.#serializeSDP(async () => {
666
+ return this.#serializeSDP(async () => {
475
667
  const existing = new Set([...this.#localTracks.values()].filter((state) => state.source !== source && state.providerPublicationId !== null));
476
668
  const states = await this.#prepareConnectionForPublication([], generation);
477
669
  await this.#publishPreparedTracksSerialized(
@@ -483,7 +675,6 @@ var CloudflareSFUClient = class {
483
675
  this.#requireGeneration(generation);
484
676
  this.#setPhase("live", null);
485
677
  });
486
- await this.#retirement;
487
678
  }
488
679
  async #retireUnavailableLocalPublication(state) {
489
680
  const publicationId = state.providerPublicationId;
@@ -491,16 +682,15 @@ var CloudflareSFUClient = class {
491
682
  const authoritative = await this.#signal(() => this.#requireTransport().listPublications());
492
683
  if (authoritative.publications.some((publication) => publication.publicationId === publicationId)) return;
493
684
  if (state.providerPublicationId === publicationId) {
494
- this.#pauseLocalSource(state.source);
685
+ this.#pauseLocalSource(state.source, "discovery_absent");
495
686
  await this.#retireOwnedPublications();
496
687
  }
497
688
  }
498
- #pauseLocalSource(source) {
689
+ #pauseLocalSource(source, cause = "forced_pause") {
499
690
  const sources = localSourceGroup(source);
500
691
  for (const publication of this.#ownedPublications) {
501
692
  if (!sources.includes(publication.state.source)) continue;
502
- this.#markPublicationRetiring(publication);
503
- this.#retiredLocalConnection = this.#connection;
693
+ this.#markPublicationRetiring(publication, cause);
504
694
  }
505
695
  for (const state of this.#localTracks.values()) {
506
696
  if (!sources.includes(state.source)) continue;
@@ -510,7 +700,7 @@ var CloudflareSFUClient = class {
510
700
  this.#publishSnapshot();
511
701
  this.#emitLocal();
512
702
  }
513
- #retireOwnedPublications() {
703
+ #retireOwnedPublications(reportFailure = this.#reportError.bind(this)) {
514
704
  const publications = [...this.#ownedPublications].filter((item) => item.lifecycle.kind === "retiring" && item.lifecycle.handle);
515
705
  const retirement = (async () => {
516
706
  const errors = [];
@@ -519,7 +709,7 @@ var CloudflareSFUClient = class {
519
709
  await this.#serializeSDP(() => this.#closeOwnedPublication(publication), true);
520
710
  } catch (error) {
521
711
  errors.push(error);
522
- if (!this.#stopped) this.#reportError(error);
712
+ if (!this.#stopped) reportFailure(error);
523
713
  }
524
714
  }
525
715
  if (this.#stopped && this.#ownedPublications.size === 0) this.#transport = void 0;
@@ -529,15 +719,24 @@ var CloudflareSFUClient = class {
529
719
  });
530
720
  return this.#retirement = retirement;
531
721
  }
532
- #markPublicationRetiring(publication) {
722
+ #markPublicationRetiring(publication, cause = "publish_failure") {
533
723
  const phase = publication.lifecycle;
534
- if (phase.kind === "pending" || phase.kind === "confirmed") publication.lifecycle = { kind: "retiring", handle: phase.kind === "confirmed" ? phase.handle : null, detached: false };
724
+ if (phase.kind === "pending" || phase.kind === "confirmed") publication.lifecycle = { kind: "retiring", handle: phase.kind === "confirmed" ? phase.handle : null, detached: false, cause };
725
+ }
726
+ #markBundledTransportRetiring(publication) {
727
+ const bundleTag = /(?:^|\r?\n)a=group:BUNDLE[ \t]+(\S+)/.exec(publication.connection.localDescription?.sdp ?? "")?.[1];
728
+ if (!bundleTag || publication.transceiver.mid !== null && publication.transceiver.mid === bundleTag) {
729
+ if (this.#retiredLocalConnection === publication.connection) return;
730
+ this.#retiredLocalConnection = publication.connection;
731
+ if (publication.lifecycle.kind === "retiring") this.#retiredLocalCause = publication.lifecycle.cause;
732
+ }
535
733
  }
536
734
  async #closeOwnedPublication(publication) {
537
735
  const phase = publication.lifecycle;
538
736
  if (phase.kind !== "retiring" || !phase.handle) return;
539
737
  const handle = phase.handle;
540
738
  const { connection, connectionId, transceiver, state } = publication;
739
+ this.#markBundledTransportRetiring(publication);
541
740
  let closed = connection.connectionState === "closed";
542
741
  try {
543
742
  if (!phase.detached && !closed) {
@@ -574,13 +773,16 @@ var CloudflareSFUClient = class {
574
773
  this.#requireActive();
575
774
  const options = "connectionId" in input ? { bootstrap: input } : input;
576
775
  validateBootstrap(options.bootstrap);
577
- if (options.bootstrap.connectionId !== this.#bootstrap.connectionId) this.#fenceLocalPublications();
776
+ if (options.bootstrap.connectionId !== this.#bootstrap.connectionId) {
777
+ this.#fenceLocalPublications();
778
+ this.#replacementCause = "access_refresh";
779
+ }
578
780
  const generation = ++this.#generation;
579
781
  const connectionEpoch = ++this.#connectionEpoch;
580
782
  this.#polling = false;
581
783
  this.#pollAfterCurrent = false;
582
784
  this.#clearPoll();
583
- this.#remotePullRetryAfter.clear();
785
+ this.#remotePullRetries.clear();
584
786
  this.#disposeConnection(false);
585
787
  for (const publication of this.#ownedPublications) this.#markPublicationRetiring(publication);
586
788
  void this.#retireOwnedPublications();
@@ -618,11 +820,12 @@ var CloudflareSFUClient = class {
618
820
  return;
619
821
  }
620
822
  this.#stopped = true;
823
+ this.#receiveProgress.expect([], this.#participantId);
621
824
  this.#subscribedPublicationIds.clear();
622
825
  this.#generation++;
623
826
  this.#connectionEpoch++;
624
827
  this.#clearPoll();
625
- this.#remotePullRetryAfter.clear();
828
+ this.#remotePullRetries.clear();
626
829
  this.#polling = false;
627
830
  this.#pollAfterCurrent = false;
628
831
  this.#disposeConnection(true);
@@ -645,35 +848,64 @@ var CloudflareSFUClient = class {
645
848
  }
646
849
  async #enablePreparedTrack(state) {
647
850
  const generation = this.#generation;
648
- state.desiredEnabled = true;
649
- state.track.enabled = true;
650
851
  try {
651
- if (state.providerPublicationId && state.transceiver) {
652
- await this.#boundPeerOperation(state.transceiver.sender.replaceTrack(state.track));
653
- state.enabled = !this.#locallyPausedSources.has(state.source);
654
- state.desiredEnabled = state.enabled;
655
- state.track.enabled = state.enabled;
656
- if (!state.enabled) {
657
- this.#pauseLocalSource(state.source);
658
- await this.#retireOwnedPublications();
659
- }
660
- this.#publishSnapshot();
661
- this.#emitLocal();
662
- return;
663
- }
852
+ await this.#retireExpiredCapture(state);
853
+ if (this.#locallyPausedSources.has(state.source)) return false;
854
+ state.desiredEnabled = true;
855
+ state.track.enabled = true;
856
+ if (state.providerPublicationId && state.transceiver) return await this.#reattachLocalTrack(state, state.transceiver);
664
857
  await this.#publishPreparedTracks([state], generation);
665
858
  if (this.#remoteRecoveryGeneration !== generation) this.#replacementAttemptedGeneration = null;
859
+ return state.enabled;
666
860
  } catch (error) {
667
861
  if (generation === this.#generation) {
668
- this.#pauseLocalSource(state.source);
862
+ this.#pauseLocalSource(state.source, "enable_failure");
669
863
  await this.#retireOwnedPublications().catch(() => {
670
864
  });
671
865
  }
672
866
  throw error;
673
867
  }
674
868
  }
869
+ async #retireExpiredCapture(state) {
870
+ const publication = [...this.#ownedPublications].find((item) => item.transceiver === state.transceiver && item.lifecycle.kind === "confirmed");
871
+ if (!publication || publication.detachedAt === null || Date.now() - publication.detachedAt < CAPTURE_REUSE_WINDOW_MS) return;
872
+ this.#pauseLocalSource(state.source, "capture_cleanup");
873
+ try {
874
+ await this.#retireOwnedPublications((error) => {
875
+ if (!this.#isExpiredRetiredConnection(error)) this.#reportError(error);
876
+ });
877
+ } catch (error) {
878
+ if (!this.#isExpiredRetiredConnection(error)) throw error;
879
+ this.#fenceLocalPublications();
880
+ }
881
+ }
882
+ #isExpiredRetiredConnection(error) {
883
+ if (!this.#replaceMediaConnection || this.#retiredLocalConnection !== this.#connection) return false;
884
+ if (error instanceof AggregateError) return error.errors.every((cause) => this.#isRetryableConnectionFailure(cause));
885
+ return this.#isRetryableConnectionFailure(error);
886
+ }
887
+ async #reattachLocalTrack(state, transceiver) {
888
+ await this.#boundPeerOperation(transceiver.sender.replaceTrack(state.track));
889
+ const cause = this.#locallyPausedSources.get(state.source);
890
+ state.enabled = cause === void 0;
891
+ state.desiredEnabled = state.enabled;
892
+ state.track.enabled = state.enabled;
893
+ if (cause === "capture_cleanup") await this.#disablePreparedTrack(state);
894
+ else if (cause) {
895
+ this.#pauseLocalSource(state.source, cause);
896
+ await this.#retireOwnedPublications();
897
+ } else {
898
+ for (const publication of this.#ownedPublications) if (publication.transceiver === transceiver) publication.detachedAt = null;
899
+ }
900
+ this.#publishSnapshot();
901
+ this.#emitLocal();
902
+ return state.enabled;
903
+ }
675
904
  async #disablePreparedTrack(state) {
676
905
  const transceiver = state.transceiver;
906
+ for (const publication of this.#ownedPublications) {
907
+ if (publication.transceiver === transceiver && publication.lifecycle.kind === "confirmed") publication.detachedAt ??= Date.now();
908
+ }
677
909
  state.desiredEnabled = false;
678
910
  state.track.enabled = false;
679
911
  const detach = transceiver ? this.#boundPeerOperation(transceiver.sender.replaceTrack(null)) : Promise.resolve();
@@ -691,7 +923,7 @@ var CloudflareSFUClient = class {
691
923
  await this.#publishPreparedTracksSerialized(pending, generation, this.#connection, this.#bootstrap.connectionId);
692
924
  } catch (error) {
693
925
  if (!this.#replaceMediaConnection || !this.#isRetryableConnectionFailure(error) || this.#replacementAttemptedGeneration === generation) throw error;
694
- await this.#replaceMediaConnectionForRecovery(generation);
926
+ await this.#replaceMediaConnectionForRecovery(generation, "local_signaling_failure");
695
927
  await this.#publishPreparedTracksSerialized(
696
928
  [...this.#localTracks.values()].filter((state) => (state.desiredEnabled || states.includes(state)) && state.track.readyState !== "ended"),
697
929
  generation,
@@ -713,14 +945,14 @@ var CloudflareSFUClient = class {
713
945
  const authoritative = await this.#signal(() => this.#requireTransport().listPublications());
714
946
  this.#pauseUnavailableOtherSources(authoritative.publications, states);
715
947
  this.#replacementAttemptedGeneration = null;
716
- await this.#replaceMediaConnectionForRecovery(generation);
948
+ await this.#replaceMediaConnectionForRecovery(generation, this.#retiredLocalCause);
717
949
  return [...this.#localTracks.values()].filter((state) => (state.desiredEnabled || states.includes(state)) && state.track.readyState !== "ended");
718
950
  }
719
951
  #pauseUnavailableOtherSources(publications, states) {
720
952
  for (const state of this.#localTracks.values()) {
721
953
  if (states.includes(state) || !state.providerPublicationId) continue;
722
954
  if (publications.some((publication) => publication.publicationId === state.providerPublicationId)) continue;
723
- this.#pauseLocalSource(state.source);
955
+ this.#pauseLocalSource(state.source, "discovery_absent");
724
956
  }
725
957
  void this.#retireOwnedPublications();
726
958
  }
@@ -750,7 +982,7 @@ var CloudflareSFUClient = class {
750
982
  }
751
983
  async #prepareLocalPublication(connection, state, generation) {
752
984
  const transceiver = state.transceiver ?? this.#addLocalTransceiver(connection, state);
753
- const publication = { state, transceiver, trackName: `${state.source}-${globalThis.crypto.randomUUID()}`, connection, connectionId: this.#bootstrap.connectionId, lifecycle: { kind: "pending" } };
985
+ const publication = { state, transceiver, trackName: `${state.source}-${globalThis.crypto.randomUUID()}`, connection, connectionId: this.#bootstrap.connectionId, detachedAt: null, lifecycle: { kind: "pending" } };
754
986
  this.#ownedPublications.add(publication);
755
987
  if (state.transceiver) await this.#boundPeerOperation(transceiver.sender.replaceTrack(state.track));
756
988
  this.#requireGeneration(generation);
@@ -798,7 +1030,7 @@ var CloudflareSFUClient = class {
798
1030
  source: state.source
799
1031
  })
800
1032
  );
801
- const response = await this.#signal(() => this.#requireTransport().addTracks({ connectionId, sessionDescription, tracks }), { generation, connectionId });
1033
+ const response = await this.#signal(() => this.#addTracks({ connectionId, sessionDescription, tracks }), { generation, connectionId });
802
1034
  this.#recordLocalPublicationHandles(publications, response.tracks);
803
1035
  this.#requireGeneration(generation);
804
1036
  await this.#applyProviderDescription(response, connection);
@@ -824,7 +1056,7 @@ var CloudflareSFUClient = class {
824
1056
  state.providerPublicationId = lifecycle.handle.publicationId;
825
1057
  continue;
826
1058
  }
827
- this.#markPublicationRetiring(publication);
1059
+ this.#markPublicationRetiring(publication, this.#locallyPausedSources.get(state.source) ?? "publish_failure");
828
1060
  if (state.transceiver === transceiver) {
829
1061
  this.#clearLocalPublication(state);
830
1062
  }
@@ -849,9 +1081,8 @@ var CloudflareSFUClient = class {
849
1081
  if (cursor === null) return;
850
1082
  const participantRevision = this.#remoteParticipantRevision;
851
1083
  const desired = this.#desiredRemotePublications(authoritative, cursor);
852
- const toPull = this.#pendingRemotePulls(desired);
853
1084
  this.#pollFailedPull = true;
854
- const pulled = await this.#pullWithRecovery(toPull, cursor, generation);
1085
+ const pulled = await this.#pullWithRecovery(desired, cursor, generation);
855
1086
  if (pulled === null) return;
856
1087
  this.#requireGeneration(generation);
857
1088
  const next = reconcileRemoteTracks(desired, pulled, this.#remoteTracks);
@@ -871,16 +1102,18 @@ var CloudflareSFUClient = class {
871
1102
  }
872
1103
  #pendingRemotePulls(desired) {
873
1104
  const desiredIds = new Set([...desired.values()].map((publication) => publication.publicationId));
874
- for (const publicationId of this.#remotePullRetryAfter.keys()) {
875
- if (!desiredIds.has(publicationId)) this.#remotePullRetryAfter.delete(publicationId);
1105
+ for (const publicationId of this.#remotePullRetries.keys()) {
1106
+ if (!desiredIds.has(publicationId)) this.#remotePullRetries.delete(publicationId);
876
1107
  }
877
1108
  for (const publicationId of this.#subscribedPublicationIds) {
878
1109
  if (!desiredIds.has(publicationId)) this.#subscribedPublicationIds.delete(publicationId);
879
1110
  }
880
1111
  const now = Date.now();
881
1112
  return [...desired].filter(([key, publication]) => {
882
- if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) return false;
883
- const remaining = (this.#remotePullRetryAfter.get(publication.publicationId) ?? 0) - now;
1113
+ if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) {
1114
+ if (!this.#receiveProgress.canRebuild() || !this.#replaceMediaConnection) return false;
1115
+ }
1116
+ const remaining = (this.#remotePullRetries.get(publication.publicationId)?.retryAfter ?? 0) - now;
884
1117
  return remaining <= 0;
885
1118
  }).map(([, publication]) => publication);
886
1119
  }
@@ -908,7 +1141,14 @@ var CloudflareSFUClient = class {
908
1141
  #canSubscribeToRemotePublication(publication) {
909
1142
  return this.#remoteParticipantIds === null || this.#remoteParticipantIds.has(publication.participantId) || this.#subscribedPublicationIds.has(publication.publicationId);
910
1143
  }
911
- async #pullWithRecovery(publications, cursor, generation) {
1144
+ async #pullWithRecovery(desired, cursor, generation) {
1145
+ const pending = this.#pendingRemotePulls(desired);
1146
+ const receiveStalled = pending.some((publication) => this.#receiveProgress.isStalled(publication.publicationId));
1147
+ const publications = receiveStalled ? [...desired.values()] : pending;
1148
+ if (receiveStalled) {
1149
+ if (!this.#replaceMediaConnection || !this.#receiveProgress.reserveRebuild()) return [];
1150
+ return this.#retryRemotePullOnReplacement(publications, cursor, generation, "receive_stalled");
1151
+ }
912
1152
  try {
913
1153
  return await this.#pull(publications, generation);
914
1154
  } catch (error) {
@@ -921,19 +1161,25 @@ var CloudflareSFUClient = class {
921
1161
  }
922
1162
  }
923
1163
  #canReplaceConnectionAfterRemotePull(error, generation) {
924
- return this.#isRetryableConnectionFailure(error) && !this.#hasLiveRemoteTracks() && this.#replaceMediaConnection !== void 0 && this.#replacementAttemptedGeneration !== generation;
1164
+ const gone = error instanceof CloudflareSFUError && (error.options.status === 404 || error.options.status === 410);
1165
+ return this.#isRetryableConnectionFailure(error) && (gone || !this.#hasLiveRemoteTracks()) && this.#replaceMediaConnection !== void 0 && this.#replacementAttemptedGeneration !== generation;
925
1166
  }
926
1167
  #hasLiveRemoteTracks() {
927
1168
  return [...this.#remoteTracks.values()].some((publication) => publication.track.readyState === "live");
928
1169
  }
929
- async #retryRemotePullOnReplacement(publications, cursor, generation) {
1170
+ async #retryRemotePullOnReplacement(publications, cursor, generation, cause = "remote_signaling_failure") {
930
1171
  this.#remoteRecoveryGeneration = generation;
1172
+ const connection = this.#connection;
931
1173
  try {
932
- await this.#replaceMediaConnectionForRecovery(generation);
1174
+ await this.#replaceMediaConnectionForRecovery(generation, cause);
933
1175
  await this.#republishPreparedTracksAfterRecovery(generation);
934
1176
  return await this.#pull(publications, generation);
935
1177
  } catch (error) {
936
1178
  if (generation !== this.#generation || this.#stopped) throw error;
1179
+ if (cause === "receive_stalled" && connection === this.#connection) {
1180
+ this.#replacementAttemptedGeneration = null;
1181
+ for (const publication of publications) this.#backoffRemotePull(publication.publicationId, true);
1182
+ }
937
1183
  this.#observeRemotePublicationCursor(cursor, retryableRemotePull(error));
938
1184
  throw error;
939
1185
  }
@@ -974,7 +1220,10 @@ var CloudflareSFUClient = class {
974
1220
  }
975
1221
  #deferRemotePulls(publications, error) {
976
1222
  const retryAfter = retryableRemotePull(error) ? Date.now() + 15e3 : Number.POSITIVE_INFINITY;
977
- for (const publication of publications) this.#remotePullRetryAfter.set(publication.publicationId, retryAfter);
1223
+ for (const publication of publications) {
1224
+ const failures = this.#remotePullRetries.get(publication.publicationId)?.failures ?? 0;
1225
+ this.#remotePullRetries.set(publication.publicationId, { retryAfter, failures, receiveStalled: this.#remotePullRetries.get(publication.publicationId)?.receiveStalled });
1226
+ }
978
1227
  }
979
1228
  async #pullBatch(publications, generation) {
980
1229
  if (publications.length === 0) return [];
@@ -1000,12 +1249,17 @@ var CloudflareSFUClient = class {
1000
1249
  connection.addEventListener("track", onTrack);
1001
1250
  try {
1002
1251
  this.#requireGeneration(generation);
1003
- const response = await this.#signal(() => this.#requireTransport().addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }), { generation, connectionId });
1252
+ const response = await this.#signal(() => this.#addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }), {
1253
+ generation,
1254
+ connectionId
1255
+ });
1004
1256
  this.#requireGeneration(generation);
1005
1257
  this.#recordRemotePullErrors(publications, response.trackErrors ?? []);
1006
1258
  const responseTracks = response.tracks ?? [];
1007
1259
  const matched = matchRemotePullTracks(publications, responseTracks);
1008
- for (const { publication } of matched) this.#remotePullRetryAfter.delete(publication.publicationId);
1260
+ for (const { publication } of matched) {
1261
+ if (!this.#remotePullRetries.get(publication.publicationId)?.receiveStalled) this.#remotePullRetries.delete(publication.publicationId);
1262
+ }
1009
1263
  await this.#completeRenegotiation(response, connection, connectionId, generation);
1010
1264
  if (matched.length === 0) return [];
1011
1265
  await this.#waitForConnection(connection, generation);
@@ -1035,11 +1289,33 @@ var CloudflareSFUClient = class {
1035
1289
  for (const failure of failures) {
1036
1290
  const publication = requested.get(`${failure.connectionId}\0${failure.trackName}`);
1037
1291
  if (!publication) throw new CloudflareSFUError("Cloudflare SFU returned an unrequested remote track error", "invalid_publication");
1038
- const delay = 15e3;
1039
- this.#remotePullRetryAfter.set(publication.publicationId, Date.now() + delay);
1292
+ this.#backoffRemotePull(publication.publicationId);
1040
1293
  this.#reportError(new CloudflareSFUError(`Cloudflare SFU remote track failed: ${failure.code}`, "media_failed", { providerCode: failure.code }));
1041
1294
  }
1042
1295
  }
1296
+ #backoffRemotePull(publicationId, receiveStalled = false) {
1297
+ const previous = this.#remotePullRetries.get(publicationId) ?? { retryAfter: 0, failures: 0 };
1298
+ const failures = Math.min(previous.failures + 1, 6);
1299
+ this.#remotePullRetries.set(publicationId, { retryAfter: Date.now() + Math.min(15e3, 750 * 2 ** (failures - 1)), failures, receiveStalled: receiveStalled || previous.receiveStalled });
1300
+ }
1301
+ #canObserveReceiveProgress(connection) {
1302
+ return this.#started && connectionIsLive(connection) && (typeof document === "undefined" || document.visibilityState !== "hidden");
1303
+ }
1304
+ #observeReceiveProgress(connection, report) {
1305
+ if (!this.#canObserveReceiveProgress(connection)) {
1306
+ this.#receiveProgress.clear();
1307
+ return;
1308
+ }
1309
+ const { stalled, advanced } = this.#receiveProgress.sample(performance.now(), connection, report, this.#remoteTracks.values());
1310
+ for (const publicationId of advanced) this.#remotePullRetries.delete(publicationId);
1311
+ this.#publishSnapshot();
1312
+ if (!this.#receiveProgress.canRebuild()) return;
1313
+ for (const publicationId of stalled) {
1314
+ if (!this.#remotePullRetries.get(publicationId)?.receiveStalled) this.#backoffRemotePull(publicationId, true);
1315
+ }
1316
+ this.#remotePullIncomplete = true;
1317
+ this.remotePublicationsChanged();
1318
+ }
1043
1319
  async #completeRenegotiation(response, connection, connectionId, generation) {
1044
1320
  if (!response.requiresImmediateRenegotiation) return;
1045
1321
  await this.#applyProviderDescription(response, connection);
@@ -1145,14 +1421,12 @@ var CloudflareSFUClient = class {
1145
1421
  cameraPolicy.reset();
1146
1422
  observedCamera = state;
1147
1423
  }
1148
- const sender = this.#cameraSimulcastSender(state);
1149
- if (!sender) {
1150
- cameraPolicy.reset();
1151
- return;
1152
- }
1153
1424
  const report = await connection.getStats();
1154
- if (!current() || this.#cameraSimulcastSender(state) !== sender || connection.signalingState !== "stable") return;
1155
- await this.#applyCameraUplink(sender, report, cameraPolicy);
1425
+ if (!current() || connection.signalingState !== "stable") return;
1426
+ this.#observeReceiveProgress(connection, report);
1427
+ const sender = this.#cameraSimulcastSender(state);
1428
+ if (!sender) cameraPolicy.reset();
1429
+ else await this.#applyCameraUplink(sender, report, cameraPolicy);
1156
1430
  } catch {
1157
1431
  cameraPolicy.reset();
1158
1432
  } finally {
@@ -1187,9 +1461,14 @@ var CloudflareSFUClient = class {
1187
1461
  }
1188
1462
  async #replaceDormantConnectionBeforeNegotiation(generation) {
1189
1463
  if (!this.#started || this.#negotiatedGeneration === generation || this.#replacementAttemptedGeneration === generation || !this.#replaceMediaConnection) return;
1190
- await this.#replaceMediaConnectionForRecovery(generation);
1464
+ await this.#replaceMediaConnectionForRecovery(generation, "dormant_connection");
1465
+ }
1466
+ #addTracks(input) {
1467
+ const replacementCause = this.#replacementCause;
1468
+ this.#replacementCause = void 0;
1469
+ return this.#requireTransport().addTracks({ ...input, replacementCause });
1191
1470
  }
1192
- async #replaceMediaConnectionForRecovery(generation) {
1471
+ async #replaceMediaConnectionForRecovery(generation, cause) {
1193
1472
  this.#requireGeneration(generation);
1194
1473
  if (!this.#replaceMediaConnection || this.#replacementAttemptedGeneration === generation) {
1195
1474
  throw new CloudflareSFUError("A fresh Cloudflare SFU connection is unavailable", "signaling_failed");
@@ -1209,6 +1488,7 @@ var CloudflareSFUClient = class {
1209
1488
  this.#cursor = null;
1210
1489
  this.#bootstrap = bootstrap;
1211
1490
  this.#rebuildPeerConnection(bootstrap, generation, connectionEpoch);
1491
+ this.#replacementCause = cause;
1212
1492
  this.#retiredLocalConnection = null;
1213
1493
  this.#negotiatedGeneration = null;
1214
1494
  for (const state of this.#localTracks.values()) {
@@ -1248,6 +1528,7 @@ var CloudflareSFUClient = class {
1248
1528
  }
1249
1529
  }
1250
1530
  #disposeConnection(stopSenders) {
1531
+ this.#receiveProgress.clear();
1251
1532
  if (!stopSenders) this.#setPhase("recovering", null);
1252
1533
  this.#disposeConnectionObservation?.();
1253
1534
  this.#disposeConnectionObservation = void 0;
@@ -1285,7 +1566,7 @@ var CloudflareSFUClient = class {
1285
1566
  this.#emitRemote();
1286
1567
  }
1287
1568
  #schedulePoll(delayMs = this.#pollIntervalMs) {
1288
- if (this.#stopped || !this.#started || this.#pollTimer !== void 0) return;
1569
+ if (this.#stopped || !this.#started && !this.#receivingInitial || this.#pollTimer !== void 0) return;
1289
1570
  this.#pollTimer = globalThis.setTimeout(async () => {
1290
1571
  this.#pollTimer = void 0;
1291
1572
  if (typeof document !== "undefined" && document.visibilityState === "hidden") {
@@ -1344,6 +1625,7 @@ var CloudflareSFUClient = class {
1344
1625
  })
1345
1626
  ) : EMPTY_LOCAL,
1346
1627
  remoteTracks: this.#remoteTracks.size ? [...this.#remoteTracks.values()] : EMPTY_REMOTE,
1628
+ receiveStalls: this.#receiveProgress.observations,
1347
1629
  failure
1348
1630
  });
1349
1631
  if (snapshotEqual(this.#snapshot, next)) return;
@@ -1468,7 +1750,7 @@ function snapshotEqual(left, right) {
1468
1750
  return snapshotMetadataEqual(left, right) && left.localTracks.every((publication, index) => publicationEqual(publication, right.localTracks[index])) && left.remoteTracks.every((publication, index) => remotePublicationEqual(publication, right.remoteTracks[index]));
1469
1751
  }
1470
1752
  function snapshotMetadataEqual(left, right) {
1471
- return connectionEqual(left.connection, right.connection) && cursorEqual(left.cursor, right.cursor) && failureEqual(left.failure, right.failure) && left.localTracks.length === right.localTracks.length && left.remoteTracks.length === right.remoteTracks.length;
1753
+ return (left.receiveStalls ?? EMPTY_RECEIVE_STALLS) === (right.receiveStalls ?? EMPTY_RECEIVE_STALLS) && connectionEqual(left.connection, right.connection) && cursorEqual(left.cursor, right.cursor) && failureEqual(left.failure, right.failure) && left.localTracks.length === right.localTracks.length && left.remoteTracks.length === right.remoteTracks.length;
1472
1754
  }
1473
1755
  function connectionEqual(left, right) {
1474
1756
  return left.phase === right.phase && left.peerConnectionState === right.peerConnectionState && left.iceConnectionState === right.iceConnectionState;
@@ -1557,7 +1839,10 @@ function createCloudflareSFUHTTPTransport(options) {
1557
1839
  };
1558
1840
  return {
1559
1841
  addTracks: async (input) => {
1560
- const path = input.allowPartialRemoteTracks ? "tracks?allow_partial_remote_tracks=true" : "tracks";
1842
+ const query = new URLSearchParams();
1843
+ if (input.allowPartialRemoteTracks) query.set("allow_partial_remote_tracks", "true");
1844
+ if (input.replacementCause) query.set("replacement_cause", input.replacementCause);
1845
+ const path = query.size ? `tracks?${query}` : "tracks";
1561
1846
  const response = await request(path, {
1562
1847
  method: "POST",
1563
1848
  body: JSON.stringify({ connection_id: input.connectionId, session_description: input.sessionDescription, tracks: input.tracks })