@q9labsai/chalk-client 4.1.14 → 4.1.16

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 (132) hide show
  1. package/README.md +56 -2
  2. package/dist/access/grant.d.ts +1 -0
  3. package/dist/access/grant.d.ts.map +1 -1
  4. package/dist/access/manager.d.ts.map +1 -1
  5. package/dist/{chunk-JCNUF3DP.js → chunk-BD7FRQ67.js} +2082 -328
  6. package/dist/{chunk-2NUTY2VP.js → chunk-E4OK5K6N.js} +7 -3
  7. package/dist/{chunk-NKOKY2AE.js → chunk-EWTITNJ3.js} +15 -101
  8. package/dist/chunk-HW5TTZL2.js +99 -0
  9. package/dist/{chunk-HE6CLPZM.js → chunk-M3S7HNQF.js} +1663 -516
  10. package/dist/{chunk-S5OUHFGC.js → chunk-O7WTAXAY.js} +670 -73
  11. package/dist/{chunk-PWUU6T3R.js → chunk-OKIL2ELF.js} +1185 -874
  12. package/dist/chunk-RCDXIDCN.js +33 -0
  13. package/dist/client.d.ts +1420 -463
  14. package/dist/client.d.ts.map +1 -1
  15. package/dist/connection/dependencies.d.ts +10 -0
  16. package/dist/connection/dependencies.d.ts.map +1 -1
  17. package/dist/connection/index.d.ts +2 -0
  18. package/dist/connection/index.d.ts.map +1 -1
  19. package/dist/connection/lifecycle.d.ts.map +1 -1
  20. package/dist/connection/production.d.ts +4 -1
  21. package/dist/connection/production.d.ts.map +1 -1
  22. package/dist/connection/types.d.ts +2 -2
  23. package/dist/connection/types.d.ts.map +1 -1
  24. package/dist/effect.js +343 -130
  25. package/dist/feedback/collector.d.ts +14 -0
  26. package/dist/feedback/collector.d.ts.map +1 -0
  27. package/dist/feedback/context.d.ts +38 -0
  28. package/dist/feedback/context.d.ts.map +1 -0
  29. package/dist/feedback/controller.d.ts +4 -0
  30. package/dist/feedback/controller.d.ts.map +1 -0
  31. package/dist/feedback/index.d.ts +7 -0
  32. package/dist/feedback/index.d.ts.map +1 -0
  33. package/dist/feedback/transport.d.ts +29 -0
  34. package/dist/feedback/transport.d.ts.map +1 -0
  35. package/dist/feedback/types.d.ts +202 -0
  36. package/dist/feedback/types.d.ts.map +1 -0
  37. package/dist/feedback/validation.d.ts +30 -0
  38. package/dist/feedback/validation.d.ts.map +1 -0
  39. package/dist/generated/http-api.d.ts +1409 -462
  40. package/dist/generated/http-api.d.ts.map +1 -1
  41. package/dist/generated/schemas.d.ts +2613 -858
  42. package/dist/generated/schemas.d.ts.map +1 -1
  43. package/dist/index.d.ts +1 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +130 -15
  46. package/dist/media/camera-uplink.d.ts +13 -0
  47. package/dist/media/camera-uplink.d.ts.map +1 -0
  48. package/dist/media/capture-error.d.ts +27 -0
  49. package/dist/media/capture-error.d.ts.map +1 -0
  50. package/dist/media/client.d.ts +4 -0
  51. package/dist/media/client.d.ts.map +1 -1
  52. package/dist/media/index.d.ts +1 -0
  53. package/dist/media/index.d.ts.map +1 -1
  54. package/dist/media/index.js +5 -1
  55. package/dist/media/plane.d.ts +4 -0
  56. package/dist/media/plane.d.ts.map +1 -1
  57. package/dist/media/remote-track-response.d.ts +6 -0
  58. package/dist/media/remote-track-response.d.ts.map +1 -0
  59. package/dist/media/transport.d.ts.map +1 -1
  60. package/dist/media/types.d.ts +20 -2
  61. package/dist/media/types.d.ts.map +1 -1
  62. package/dist/public-invites/client.d.ts.map +1 -1
  63. package/dist/public-invites/index.js +4 -3
  64. package/dist/public-invites/types.d.ts +4 -2
  65. package/dist/public-invites/types.d.ts.map +1 -1
  66. package/dist/server/access-refresh.d.cts +8 -0
  67. package/dist/server/access-refresh.d.ts +9 -0
  68. package/dist/server/access-refresh.d.ts.map +1 -0
  69. package/dist/server/client.d.ts.map +1 -1
  70. package/dist/server/index.cjs +189 -20
  71. package/dist/server/index.d.cts +2 -1
  72. package/dist/server/index.d.ts +2 -1
  73. package/dist/server/index.d.ts.map +1 -1
  74. package/dist/server/index.js +187 -19
  75. package/dist/server/types.d.cts +74 -0
  76. package/dist/server/types.d.ts +74 -0
  77. package/dist/server/types.d.ts.map +1 -1
  78. package/dist/space-client/chat-controller.d.ts +3 -0
  79. package/dist/space-client/chat-controller.d.ts.map +1 -1
  80. package/dist/space-client/controller-effects.d.ts +6 -0
  81. package/dist/space-client/controller-effects.d.ts.map +1 -1
  82. package/dist/space-client/core.d.ts +12 -1
  83. package/dist/space-client/core.d.ts.map +1 -1
  84. package/dist/space-client/core.test.helpers.d.ts +1 -0
  85. package/dist/space-client/core.test.helpers.d.ts.map +1 -1
  86. package/dist/space-client/effect.d.ts +5 -0
  87. package/dist/space-client/effect.d.ts.map +1 -1
  88. package/dist/space-client/episode-diagnostic-runtime.d.ts +3 -0
  89. package/dist/space-client/episode-diagnostic-runtime.d.ts.map +1 -1
  90. package/dist/space-client/errors.d.ts.map +1 -1
  91. package/dist/space-client/index.d.ts +4 -1
  92. package/dist/space-client/index.d.ts.map +1 -1
  93. package/dist/space-client/media-controller.d.ts.map +1 -1
  94. package/dist/space-client/offline-actions-storage.d.ts +66 -0
  95. package/dist/space-client/offline-actions-storage.d.ts.map +1 -0
  96. package/dist/space-client/offline-actions.d.ts +36 -0
  97. package/dist/space-client/offline-actions.d.ts.map +1 -0
  98. package/dist/space-client/participants-controller.d.ts +2 -1
  99. package/dist/space-client/participants-controller.d.ts.map +1 -1
  100. package/dist/space-client/recording-controller.d.ts +20 -0
  101. package/dist/space-client/recording-controller.d.ts.map +1 -0
  102. package/dist/space-client/reload-rejoin.d.ts +24 -0
  103. package/dist/space-client/reload-rejoin.d.ts.map +1 -0
  104. package/dist/space-client/space-client.d.ts.map +1 -1
  105. package/dist/space-client/store.d.ts +3 -1
  106. package/dist/space-client/store.d.ts.map +1 -1
  107. package/dist/space-client/types.d.ts +40 -4
  108. package/dist/space-client/types.d.ts.map +1 -1
  109. package/dist/sync/index.js +2 -1
  110. package/dist/sync/v1-client.d.ts +1 -5
  111. package/dist/sync/v1-client.d.ts.map +1 -1
  112. package/dist/sync/v1-collaboration-state.d.ts +65 -0
  113. package/dist/sync/v1-collaboration-state.d.ts.map +1 -0
  114. package/dist/sync/v1-command-scheduler.d.ts +54 -0
  115. package/dist/sync/v1-command-scheduler.d.ts.map +1 -0
  116. package/dist/sync/v1-deferred.d.ts +8 -0
  117. package/dist/sync/v1-deferred.d.ts.map +1 -0
  118. package/dist/sync/v1-error.d.ts +5 -0
  119. package/dist/sync/v1-error.d.ts.map +1 -0
  120. package/dist/sync/v1-frame-signature.d.ts +2 -0
  121. package/dist/sync/v1-frame-signature.d.ts.map +1 -0
  122. package/dist/sync/v1-live-target-coordinator.d.ts +32 -0
  123. package/dist/sync/v1-live-target-coordinator.d.ts.map +1 -0
  124. package/dist/sync/v1-types.d.ts +3 -0
  125. package/dist/sync/v1-types.d.ts.map +1 -1
  126. package/dist/telemetry/index.js +9 -7
  127. package/dist/telemetry/reconnect.d.ts +7 -0
  128. package/dist/telemetry/reconnect.d.ts.map +1 -0
  129. package/dist/telemetry/trace.d.ts +1 -1
  130. package/dist/telemetry/trace.d.ts.map +1 -1
  131. package/dist/whiteboard/v1-client.d.ts.map +1 -1
  132. package/package.json +1 -1
@@ -1,3 +1,44 @@
1
+ import {
2
+ journeyHeaders
3
+ } from "./chunk-HW5TTZL2.js";
4
+ import {
5
+ recordReconnect,
6
+ traceReconnect
7
+ } from "./chunk-RCDXIDCN.js";
8
+
9
+ // src/media/camera-uplink.ts
10
+ var CameraUplinkPolicy = class {
11
+ #healthySamples = 0;
12
+ sample(bitrate, lowActive) {
13
+ if (bitrate === void 0 || !Number.isFinite(bitrate) || bitrate <= 0) {
14
+ this.reset();
15
+ return lowActive;
16
+ }
17
+ if (bitrate < 15e5) {
18
+ this.reset();
19
+ return false;
20
+ }
21
+ if (lowActive) {
22
+ this.reset();
23
+ return true;
24
+ }
25
+ if (bitrate < 2e6) {
26
+ this.reset();
27
+ return false;
28
+ }
29
+ return ++this.#healthySamples >= 5;
30
+ }
31
+ reset() {
32
+ this.#healthySamples = 0;
33
+ }
34
+ };
35
+ function cameraUplinkBitrate(stats) {
36
+ for (const stat of stats) {
37
+ if (stat.type === "candidate-pair" && stat.state === "succeeded" && (stat.nominated || stat.selected)) return stat.availableOutgoingBitrate;
38
+ }
39
+ return void 0;
40
+ }
41
+
1
42
  // src/media/observers.ts
2
43
  function subscribeSnapshot(listeners, listener) {
3
44
  listeners.add(listener);
@@ -132,14 +173,37 @@ async function waitFor(predicate, timeoutMs, timeoutMessage = "Timed out waiting
132
173
  }
133
174
  }
134
175
 
176
+ // src/media/remote-track-response.ts
177
+ function matchRemotePullTracks(publications, tracks) {
178
+ const requested = new Map(
179
+ publications.map((publication) => {
180
+ const reference = parseCloudflareSFUPublicationID(publication.publicationId);
181
+ return [`${reference.connectionId}\0${reference.trackName}`, publication];
182
+ })
183
+ );
184
+ const mids = /* @__PURE__ */ new Set();
185
+ return tracks.map(({ location, sessionId: connectionId, trackName, mid }) => {
186
+ const key = `${connectionId}\0${trackName}`;
187
+ const publication = requested.get(key);
188
+ if (location !== "remote" || !publication || !mid || mids.has(mid)) throw new CloudflareSFUError("Cloudflare SFU returned an invalid remote track identity", "invalid_publication");
189
+ requested.delete(key);
190
+ mids.add(mid);
191
+ return { publication, mid };
192
+ });
193
+ }
194
+
135
195
  // src/media/client.ts
136
196
  var EMPTY_LOCAL = Object.freeze([]);
137
197
  var EMPTY_REMOTE = Object.freeze([]);
198
+ var INVALID_PUBLICATION_SIGNATURE = "\0invalid";
138
199
  var CONNECTION_TIMEOUT_MS = 8e3;
200
+ var NEGOTIATION_TIMEOUT_MS = 35e3;
201
+ var PEER_OPERATION_TIMEOUT_MS = 8e3;
139
202
  var CloudflareSFUClient = class {
140
203
  #localListeners = /* @__PURE__ */ new Set();
141
204
  #onError;
142
205
  #onRemoteTrack;
206
+ #recordReconnect;
143
207
  #onRtcSummary;
144
208
  #onScreenEnded;
145
209
  #participantId;
@@ -148,17 +212,24 @@ var CloudflareSFUClient = class {
148
212
  #replaceMediaConnection;
149
213
  #remoteListeners = /* @__PURE__ */ new Set();
150
214
  #snapshotListeners = /* @__PURE__ */ new Set();
215
+ #locallyPausedSources = /* @__PURE__ */ new Set();
151
216
  #localTracks = /* @__PURE__ */ new Map();
152
217
  #reusableLocalTransceivers = /* @__PURE__ */ new Map();
218
+ #reusableLocalPublicationIds = /* @__PURE__ */ new Map();
153
219
  #remoteTracks = /* @__PURE__ */ new Map();
154
220
  #bootstrap;
155
221
  #connection;
222
+ #disposeConnectionObservation;
156
223
  #connectionEpoch = 0;
224
+ #retiredLocalConnection = null;
157
225
  #cursor = null;
226
+ #remotePullIncomplete = false;
227
+ #remotePullRetryAfter = /* @__PURE__ */ new Map();
158
228
  #generation = 0;
159
229
  #negotiatedGeneration = null;
160
230
  #replacementAttemptedGeneration = null;
161
231
  #polling = false;
232
+ #pollAfterCurrent = false;
162
233
  #pollTimer;
163
234
  #sdpTail = Promise.resolve();
164
235
  #snapshot;
@@ -170,11 +241,12 @@ var CloudflareSFUClient = class {
170
241
  this.#participantId = options.participantId;
171
242
  this.#bootstrap = options.bootstrap;
172
243
  this.#transport = options.transport;
173
- this.#pollIntervalMs = options.pollIntervalMs ?? 1e3;
244
+ this.#pollIntervalMs = options.pollIntervalMs ?? 15e3;
174
245
  this.#replaceMediaConnection = options.replaceMediaConnection;
175
246
  this.#onError = options.onError;
176
247
  this.#onRemoteTrack = options.onRemoteTrack;
177
248
  this.#onRtcSummary = options.onRtcSummary;
249
+ this.#recordReconnect = options.recordReconnect;
178
250
  this.#onScreenEnded = options.onScreenEnded;
179
251
  this.#peerConnectionFactory = options.peerConnectionFactory;
180
252
  this.#connection = this.#createPeerConnection(options.bootstrap);
@@ -193,6 +265,19 @@ var CloudflareSFUClient = class {
193
265
  subscribe(listener) {
194
266
  return subscribeSnapshot(this.#snapshotListeners, listener);
195
267
  }
268
+ remotePublicationResumed(publicationId) {
269
+ this.#remotePullRetryAfter.delete(publicationId);
270
+ this.remotePublicationsChanged();
271
+ }
272
+ remotePublicationsChanged() {
273
+ recordReconnect(this.#recordReconnect, "remote_publications_changed");
274
+ if (this.#polling) {
275
+ this.#pollAfterCurrent = true;
276
+ return;
277
+ }
278
+ this.#clearPoll();
279
+ this.#schedulePoll(0);
280
+ }
196
281
  prepareLocalTrack(source, track) {
197
282
  this.#requireActive();
198
283
  validateTrackSource(source, track);
@@ -201,7 +286,7 @@ var CloudflareSFUClient = class {
201
286
  source,
202
287
  track,
203
288
  transceiver: this.#reusableLocalTransceivers.get(source) ?? null,
204
- providerPublicationId: null,
289
+ providerPublicationId: this.#reusableLocalPublicationIds.get(source) ?? null,
205
290
  pendingOperationId: null,
206
291
  pendingTrackName: null,
207
292
  desiredEnabled: false,
@@ -209,6 +294,7 @@ var CloudflareSFUClient = class {
209
294
  endedListener: null
210
295
  };
211
296
  this.#reusableLocalTransceivers.delete(source);
297
+ this.#reusableLocalPublicationIds.delete(source);
212
298
  if (source === "screen") {
213
299
  state.endedListener = () => {
214
300
  if (this.#localTracks.get("screen") === state) this.#invokeListener(() => this.#onScreenEnded?.());
@@ -224,6 +310,7 @@ var CloudflareSFUClient = class {
224
310
  if (!state) return;
225
311
  if (state.enabled) await this.#setPreparedTrackEnabled(state, false);
226
312
  if (state.transceiver) this.#reusableLocalTransceivers.set(source, state.transceiver);
313
+ if (state.providerPublicationId) this.#reusableLocalPublicationIds.set(source, state.providerPublicationId);
227
314
  this.#removeOwnedLocalTrack(state);
228
315
  this.#localTracks.delete(source);
229
316
  this.#publishSnapshot();
@@ -247,28 +334,118 @@ var CloudflareSFUClient = class {
247
334
  const generation = this.#generation;
248
335
  try {
249
336
  const transport = this.#requireTransport();
250
- const authoritative = await transport.listPublications();
337
+ const authoritative = await traceReconnect(this.#recordReconnect, "list_publications", () => transport.listPublications());
251
338
  this.#requireGeneration(generation);
252
339
  await this.#reconcileRemotePublications(authoritative, generation);
253
340
  } catch (error) {
254
341
  if (generation === this.#generation && !this.#stopped) this.#reportError(error);
255
342
  throw error;
256
343
  } finally {
257
- if (generation === this.#generation) this.#polling = false;
344
+ if (generation === this.#generation) {
345
+ this.#polling = false;
346
+ if (this.#pollAfterCurrent) {
347
+ this.#pollAfterCurrent = false;
348
+ this.#clearPoll();
349
+ this.#schedulePoll(0);
350
+ }
351
+ }
258
352
  }
259
353
  }
260
354
  async setLocalPublicationTarget(target) {
261
355
  const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
262
356
  if (resolved.kind === "result") return resolved.result;
263
357
  const state = resolved.value;
264
- if (state.enabled === target.enabled) return { outcome: "satisfied", errorCode: null };
358
+ if (this.#pausedTarget(target)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
265
359
  try {
360
+ if (target.enabled) await this.#retireUnavailableLocalPublication(state);
361
+ if (this.#pausedTarget(target)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
362
+ if (state.enabled === target.enabled) return { outcome: "satisfied", errorCode: null };
266
363
  await this.#setPreparedTrackEnabled(state, target.enabled, target.operationId);
267
364
  return { outcome: "confirmed", errorCode: null };
268
365
  } catch (error) {
269
366
  if (!this.#stopped) this.#reportError(error);
270
- return { outcome: "retryable_failure", errorCode: error instanceof CloudflareSFUError ? error.code : "media_failed" };
367
+ return mediaTargetFailure(error);
368
+ }
369
+ }
370
+ #pausedTarget(target) {
371
+ return target.enabled && this.#locallyPausedSources.has(target.source);
372
+ }
373
+ setLocalSourceIntent(source, enabled) {
374
+ if (enabled) {
375
+ this.#locallyPausedSources.delete(source);
376
+ return;
271
377
  }
378
+ this.#locallyPausedSources.add(source);
379
+ const state = this.#localTracks.get(source);
380
+ if (!state) return;
381
+ state.desiredEnabled = false;
382
+ state.track.enabled = false;
383
+ }
384
+ async closeForcedLocalPublication(source) {
385
+ this.setLocalSourceIntent(source, false);
386
+ await this.#retireLocalPublication(source);
387
+ if (!this.#replaceMediaConnection) return;
388
+ const generation = this.#generation;
389
+ await this.#serializeSDP(async () => {
390
+ const existing = new Set([...this.#localTracks.values()].filter((state) => state.source !== source && state.providerPublicationId !== null));
391
+ const states = await this.#prepareConnectionForPublication([], generation);
392
+ await this.#publishPreparedTracksSerialized(
393
+ states.filter((state) => existing.has(state)),
394
+ generation,
395
+ this.#connection,
396
+ this.#bootstrap.connectionId
397
+ );
398
+ this.#requireGeneration(generation);
399
+ this.#setPhase("live", null);
400
+ });
401
+ }
402
+ async #retireUnavailableLocalPublication(state) {
403
+ const publicationId = state.providerPublicationId;
404
+ if (!publicationId) return;
405
+ const authoritative = await traceReconnect(this.#recordReconnect, "list_publications", () => this.#requireTransport().listPublications());
406
+ if (authoritative.publications.some((publication) => publication.publicationId === publicationId)) return;
407
+ if (state.providerPublicationId === publicationId) await this.#retireLocalPublication(state.source);
408
+ }
409
+ async #retireLocalPublication(source) {
410
+ const state = this.#localTracks.get(source);
411
+ const transceiver = state?.transceiver ?? this.#reusableLocalTransceivers.get(source);
412
+ const publicationId = state?.providerPublicationId ?? this.#reusableLocalPublicationIds.get(source);
413
+ this.#reusableLocalTransceivers.delete(source);
414
+ this.#reusableLocalPublicationIds.delete(source);
415
+ if (state) {
416
+ state.track.enabled = false;
417
+ state.desiredEnabled = false;
418
+ state.enabled = false;
419
+ state.providerPublicationId = null;
420
+ state.transceiver = null;
421
+ }
422
+ this.#publishSnapshot();
423
+ this.#emitLocal();
424
+ if (!transceiver || !publicationId) return;
425
+ const connection = this.#connection;
426
+ const connectionId = this.#bootstrap.connectionId;
427
+ this.#retiredLocalConnection = connection;
428
+ await this.#serializeSDP(async () => {
429
+ if (connection !== this.#connection) return;
430
+ const mid = requireTransceiverMid(transceiver);
431
+ await this.#boundPeerOperation(transceiver.sender.replaceTrack(null));
432
+ transceiver.stop();
433
+ const offer = await traceReconnect(this.#recordReconnect, "create_offer", () => connection.createOffer());
434
+ await traceReconnect(this.#recordReconnect, "set_local_description", () => connection.setLocalDescription(offer));
435
+ try {
436
+ const response = await this.#requireTransport().closeTracks({
437
+ connectionId,
438
+ ...providerDescription(requireDescription(offer)),
439
+ tracks: [{ mid, source, publicationId }],
440
+ force: false
441
+ });
442
+ await this.#applyProviderDescription(response, connection);
443
+ } catch (error) {
444
+ await this.#rollbackLocalOffer(connection);
445
+ if (!this.#stopped) this.#reportError(error);
446
+ throw error;
447
+ }
448
+ });
272
449
  }
273
450
  observeLocalPublications(listener) {
274
451
  this.#localListeners.add(listener);
@@ -284,16 +461,21 @@ var CloudflareSFUClient = class {
284
461
  this.#requireActive();
285
462
  const options = "connectionId" in input ? { bootstrap: input } : input;
286
463
  validateBootstrap(options.bootstrap);
464
+ recordReconnect(this.#recordReconnect, "media_rebuild_start");
287
465
  const generation = ++this.#generation;
288
466
  const connectionEpoch = ++this.#connectionEpoch;
289
467
  this.#polling = false;
468
+ this.#pollAfterCurrent = false;
290
469
  this.#clearPoll();
470
+ this.#remotePullRetryAfter.clear();
291
471
  this.#disposeConnection(false);
292
472
  this.#reusableLocalTransceivers.clear();
473
+ this.#reusableLocalPublicationIds.clear();
293
474
  this.#clearRemoteTracks();
294
475
  this.#cursor = null;
295
476
  this.#negotiatedGeneration = null;
296
477
  this.#replacementAttemptedGeneration = null;
478
+ if (options.bootstrap.connectionId !== this.#bootstrap.connectionId) this.#started = false;
297
479
  this.#bootstrap = options.bootstrap;
298
480
  if (options.transport) this.#transport = options.transport;
299
481
  this.#connection = this.#createPeerConnection(options.bootstrap);
@@ -324,9 +506,12 @@ var CloudflareSFUClient = class {
324
506
  this.#generation++;
325
507
  this.#connectionEpoch++;
326
508
  this.#clearPoll();
509
+ this.#remotePullRetryAfter.clear();
327
510
  this.#polling = false;
511
+ this.#pollAfterCurrent = false;
328
512
  this.#disposeConnection(true);
329
513
  this.#reusableLocalTransceivers.clear();
514
+ this.#reusableLocalPublicationIds.clear();
330
515
  this.#clearRemoteTracks();
331
516
  for (const state of this.#localTracks.values()) this.#removeOwnedLocalTrack(state);
332
517
  this.#localTracks.clear();
@@ -345,14 +530,33 @@ var CloudflareSFUClient = class {
345
530
  return this.#disablePreparedTrack(state);
346
531
  }
347
532
  async #enablePreparedTrack(state, operationId) {
533
+ if (state.providerPublicationId && state.transceiver) {
534
+ state.desiredEnabled = true;
535
+ state.track.enabled = true;
536
+ try {
537
+ await this.#boundPeerOperation(state.transceiver.sender.replaceTrack(state.track));
538
+ } catch (error) {
539
+ state.desiredEnabled = false;
540
+ state.track.enabled = false;
541
+ throw error;
542
+ }
543
+ state.enabled = !this.#locallyPausedSources.has(state.source);
544
+ state.desiredEnabled = state.enabled;
545
+ state.track.enabled = state.enabled;
546
+ if (!state.enabled) await this.#boundPeerOperation(state.transceiver.sender.replaceTrack(null));
547
+ this.#publishSnapshot();
548
+ this.#emitLocal();
549
+ return;
550
+ }
348
551
  if (operationId && state.pendingOperationId !== operationId) {
349
552
  state.pendingOperationId = operationId;
350
553
  state.pendingTrackName = `${state.source}-${operationId}`;
351
554
  }
352
555
  state.desiredEnabled = true;
353
556
  state.track.enabled = true;
557
+ const generation = this.#generation;
354
558
  try {
355
- await this.#publishPreparedTracks([state], this.#generation);
559
+ await this.#publishPreparedTracks([state], generation);
356
560
  } catch (error) {
357
561
  state.desiredEnabled = false;
358
562
  state.enabled = false;
@@ -364,26 +568,21 @@ var CloudflareSFUClient = class {
364
568
  const transceiver = state.transceiver;
365
569
  state.desiredEnabled = false;
366
570
  state.track.enabled = false;
367
- try {
368
- if (transceiver) await transceiver.sender.replaceTrack(null);
369
- } catch (error) {
370
- state.desiredEnabled = true;
371
- state.track.enabled = true;
372
- throw error;
373
- }
571
+ const detach = transceiver ? this.#boundPeerOperation(transceiver.sender.replaceTrack(null)) : Promise.resolve();
374
572
  state.enabled = false;
375
- state.providerPublicationId = null;
376
573
  state.pendingOperationId = null;
377
574
  state.pendingTrackName = null;
378
575
  this.#publishSnapshot();
379
576
  this.#emitLocal();
577
+ await detach;
380
578
  }
381
579
  async #publishPreparedTracks(states, generation) {
382
580
  if (states.length === 0) return;
383
581
  await this.#serializeSDP(async () => {
384
- await this.#replaceDormantConnectionBeforeNegotiation(generation);
582
+ const pending = await this.#prepareConnectionForPublication(states, generation);
583
+ this.#replacementAttemptedGeneration = null;
385
584
  try {
386
- await this.#publishPreparedTracksSerialized(states, generation, this.#connection, this.#bootstrap.connectionId);
585
+ await this.#publishPreparedTracksSerialized(pending, generation, this.#connection, this.#bootstrap.connectionId);
387
586
  } catch (error) {
388
587
  if (!this.#replaceMediaConnection || !this.#isRetryableConnectionFailure(error) || this.#replacementAttemptedGeneration === generation) throw error;
389
588
  await this.#replaceMediaConnectionForRecovery(generation);
@@ -396,6 +595,25 @@ var CloudflareSFUClient = class {
396
595
  }
397
596
  });
398
597
  }
598
+ async #prepareConnectionForPublication(states, generation) {
599
+ if (this.#retiredLocalConnection !== this.#connection || !this.#replaceMediaConnection) {
600
+ await this.#replaceDormantConnectionBeforeNegotiation(generation);
601
+ return states;
602
+ }
603
+ const authoritative = await traceReconnect(this.#recordReconnect, "list_publications", () => this.#requireTransport().listPublications());
604
+ this.#pauseUnavailableOtherSources(authoritative.publications, states);
605
+ this.#replacementAttemptedGeneration = null;
606
+ await this.#replaceMediaConnectionForRecovery(generation);
607
+ return [...this.#localTracks.values()].filter((state) => (state.desiredEnabled || states.includes(state)) && state.track.readyState !== "ended");
608
+ }
609
+ #pauseUnavailableOtherSources(publications, states) {
610
+ for (const state of this.#localTracks.values()) {
611
+ if (states.includes(state) || !state.providerPublicationId) continue;
612
+ if (publications.some((publication) => publication.publicationId === state.providerPublicationId)) continue;
613
+ state.desiredEnabled = false;
614
+ state.track.enabled = false;
615
+ }
616
+ }
399
617
  async #publishPreparedTracksSerialized(states, generation, connection, connectionId) {
400
618
  this.#requireGeneration(generation);
401
619
  const pendingStates = states.filter((state) => !state.enabled);
@@ -403,7 +621,7 @@ var CloudflareSFUClient = class {
403
621
  const wasLive = connectionIsLive(connection);
404
622
  const publications = [];
405
623
  try {
406
- for (const state of pendingStates) publications.push(await this.#prepareLocalPublication(connection, state));
624
+ for (const state of pendingStates) publications.push(await this.#prepareLocalPublication(connection, state, generation));
407
625
  const response = await this.#negotiateLocalPublications(connection, connectionId, publications, generation);
408
626
  if (!wasLive) await this.#waitForConnection(connection, generation);
409
627
  this.#requireGeneration(generation);
@@ -412,21 +630,56 @@ var CloudflareSFUClient = class {
412
630
  this.#publishSnapshot();
413
631
  this.#emitLocal();
414
632
  } catch (error) {
415
- await this.#rollbackLocalOffer(connection);
416
- await this.#discardLocalPublications(publications);
633
+ if (generation === this.#generation) {
634
+ await this.#rollbackLocalOffer(connection);
635
+ await this.#discardLocalPublications(publications);
636
+ }
417
637
  throw error;
418
638
  }
419
639
  }
420
- async #prepareLocalPublication(connection, state) {
640
+ async #prepareLocalPublication(connection, state, generation) {
421
641
  const reusedTransceiver = state.transceiver !== null;
422
- const transceiver = state.transceiver ?? connection.addTransceiver(state.track, { direction: "sendonly" });
423
- if (reusedTransceiver) await transceiver.sender.replaceTrack(state.track);
642
+ const transceiver = state.transceiver ?? this.#addLocalTransceiver(connection, state);
643
+ if (reusedTransceiver) await this.#boundPeerOperation(transceiver.sender.replaceTrack(state.track));
644
+ this.#requireGeneration(generation);
424
645
  state.transceiver = transceiver;
425
646
  return { state, transceiver, trackName: state.pendingTrackName ?? `${state.source}-${globalThis.crypto.randomUUID()}`, reusedTransceiver };
426
647
  }
648
+ #addLocalTransceiver(connection, state) {
649
+ if (state.source !== "camera" || firefoxCameraSimulcastUnsupported()) return connection.addTransceiver(state.track, { direction: "sendonly" });
650
+ const sendEncodings = [
651
+ { rid: "h", scaleResolutionDownBy: 1, maxBitrate: 25e5, scalabilityMode: "L1T1" },
652
+ { rid: "l", scaleResolutionDownBy: 2, maxBitrate: 65e4, scalabilityMode: "L1T1" }
653
+ ];
654
+ try {
655
+ return connection.addTransceiver(state.track, {
656
+ direction: "sendonly",
657
+ sendEncodings
658
+ });
659
+ } catch (error) {
660
+ if (!simulcastUnsupported(error)) throw error;
661
+ return connection.addTransceiver(state.track, { direction: "sendonly" });
662
+ }
663
+ }
427
664
  async #negotiateLocalPublications(connection, connectionId, publications, generation) {
428
- const offer = await connection.createOffer();
429
- await connection.setLocalDescription(offer);
665
+ let offer;
666
+ try {
667
+ offer = await traceReconnect(this.#recordReconnect, "create_offer", () => connection.createOffer());
668
+ await traceReconnect(this.#recordReconnect, "set_local_description", () => connection.setLocalDescription(offer));
669
+ } catch (error) {
670
+ const cameras = publications.filter(({ state, transceiver }) => state.source === "camera" && transceiver.sender.getParameters().encodings.length > 1);
671
+ if (!simulcastUnsupported(error) || cameras.length === 0) throw error;
672
+ await this.#rollbackLocalOffer(connection);
673
+ this.#requireGeneration(generation);
674
+ for (const publication of cameras) {
675
+ await this.#boundPeerOperation(publication.transceiver.sender.replaceTrack(null));
676
+ publication.transceiver.stop();
677
+ publication.transceiver = connection.addTransceiver(publication.state.track, { direction: "sendonly" });
678
+ publication.state.transceiver = publication.transceiver;
679
+ }
680
+ offer = await traceReconnect(this.#recordReconnect, "create_offer", () => connection.createOffer());
681
+ await traceReconnect(this.#recordReconnect, "set_local_description", () => connection.setLocalDescription(offer));
682
+ }
430
683
  const tracks = publications.map(
431
684
  ({ state, transceiver, trackName }) => ({
432
685
  location: "local",
@@ -435,17 +688,20 @@ var CloudflareSFUClient = class {
435
688
  source: state.source
436
689
  })
437
690
  );
438
- const response = await this.#requireTransport().addTracks({ connectionId, sessionDescription: requireDescription(offer), tracks });
691
+ const response = await traceReconnect(this.#recordReconnect, "publish_tracks", () => this.#requireTransport().addTracks({ connectionId, ...providerDescription(requireDescription(offer)), tracks }));
439
692
  this.#requireGeneration(generation);
440
- await connection.setRemoteDescription(requireSFUDescription(response.sessionDescription));
693
+ await this.#applyProviderDescription(response, connection);
441
694
  return response;
442
695
  }
443
696
  #confirmLocalPublications(publications, tracks) {
444
697
  for (const { state, transceiver } of publications) {
445
698
  const authoritative = tracks?.find((track) => track.location === "local" && track.mid === transceiver.mid && track.source === state.source);
446
699
  if (!authoritative?.publicationId) throw new CloudflareSFUError("Chalk did not return an authoritative local publication ID", "invalid_publication");
447
- state.desiredEnabled = true;
448
- state.enabled = true;
700
+ if (state.transceiver !== transceiver) continue;
701
+ state.enabled = !this.#locallyPausedSources.has(state.source);
702
+ state.desiredEnabled = state.enabled;
703
+ state.track.enabled = state.enabled;
704
+ if (!state.enabled) void transceiver.sender.replaceTrack(null).catch((error) => this.#reportError(error));
449
705
  state.providerPublicationId = authoritative.publicationId;
450
706
  state.pendingOperationId = null;
451
707
  state.pendingTrackName = null;
@@ -478,12 +734,13 @@ var CloudflareSFUClient = class {
478
734
  }
479
735
  }
480
736
  async #reconcileRemotePublications(authoritative, generation) {
481
- const cursor = validatePublicationSnapshot(authoritative);
737
+ const cursor = this.#validatedRemotePublicationCursor(authoritative);
738
+ if (cursor === null) return;
482
739
  const ordering = comparePublicationCursor(this.#cursor, cursor);
483
- if (ordering !== "newer") return;
484
- const desired = desiredRemotePublications(authoritative.publications, this.#participantId);
485
- const toPull = [...desired].filter(([key, publication]) => this.#remoteTracks.get(key)?.publicationId !== publication.publicationId).map(([, publication]) => publication);
486
- const pulled = await this.#pull(toPull, generation);
740
+ if (ordering === "stale" || ordering === "same" && !this.#remotePullIncomplete) return;
741
+ const desired = this.#desiredRemotePublications(authoritative, cursor);
742
+ const toPull = this.#pendingRemotePulls(desired);
743
+ const pulled = await this.#pullWithRecovery(toPull, cursor, generation);
487
744
  if (pulled === null) return;
488
745
  this.#requireGeneration(generation);
489
746
  const next = reconcileRemoteTracks(desired, pulled, this.#remoteTracks);
@@ -491,10 +748,70 @@ var CloudflareSFUClient = class {
491
748
  this.#remoteTracks.clear();
492
749
  for (const [key, publication] of next) this.#remoteTracks.set(key, publication);
493
750
  this.#cursor = cursor;
751
+ this.#remotePullIncomplete = [...desired].some(([key, publication]) => next.get(key)?.publicationId !== publication.publicationId);
494
752
  for (const publication of pulled) this.#invokeListener(() => this.#onRemoteTrack?.(publication));
495
753
  this.#publishSnapshot();
496
754
  this.#emitRemote();
497
755
  }
756
+ #pendingRemotePulls(desired) {
757
+ const desiredIds = new Set([...desired.values()].map((publication) => publication.publicationId));
758
+ for (const publicationId of this.#remotePullRetryAfter.keys()) {
759
+ if (!desiredIds.has(publicationId)) this.#remotePullRetryAfter.delete(publicationId);
760
+ }
761
+ const now = Date.now();
762
+ return [...desired].filter(([key, publication]) => {
763
+ if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) return false;
764
+ const remaining = (this.#remotePullRetryAfter.get(publication.publicationId) ?? 0) - now;
765
+ if (remaining > 0) recordReconnect(this.#recordReconnect, "remote_pull_deferred", { remaining_ms: remaining, source: publication.source });
766
+ return remaining <= 0;
767
+ }).map(([, publication]) => publication);
768
+ }
769
+ #validatedRemotePublicationCursor(authoritative) {
770
+ if (this.#cursor?.signature === INVALID_PUBLICATION_SIGNATURE && this.#cursor.incarnation === authoritative.incarnation && this.#cursor.sequence === authoritative.sequence) return null;
771
+ try {
772
+ return validatePublicationSnapshot(authoritative);
773
+ } catch (error) {
774
+ this.#quarantineInvalidRemotePublicationCursor(authoritative);
775
+ throw error;
776
+ }
777
+ }
778
+ #desiredRemotePublications(authoritative, cursor) {
779
+ try {
780
+ return desiredRemotePublications(authoritative.publications, this.#participantId);
781
+ } catch (error) {
782
+ this.#observeRemotePublicationCursor(cursor);
783
+ throw error;
784
+ }
785
+ }
786
+ async #pullWithRecovery(publications, cursor, generation) {
787
+ try {
788
+ return await this.#pull(publications, generation);
789
+ } catch (error) {
790
+ if (generation !== this.#generation || this.#stopped) throw error;
791
+ if (!this.#canReplaceConnectionAfterRemotePull(error, generation)) {
792
+ this.#observeRemotePublicationCursor(cursor, retryableRemotePull(error));
793
+ throw error;
794
+ }
795
+ return this.#retryRemotePullOnReplacement(publications, cursor, generation);
796
+ }
797
+ }
798
+ #canReplaceConnectionAfterRemotePull(error, generation) {
799
+ return error instanceof CloudflareSFUError && error.code === "signaling_failed" && !this.#hasLiveRemoteTracks() && this.#replaceMediaConnection !== void 0 && this.#replacementAttemptedGeneration !== generation;
800
+ }
801
+ #hasLiveRemoteTracks() {
802
+ return [...this.#remoteTracks.values()].some((publication) => publication.track.readyState === "live");
803
+ }
804
+ async #retryRemotePullOnReplacement(publications, cursor, generation) {
805
+ try {
806
+ await this.#replaceMediaConnectionForRecovery(generation);
807
+ await this.#republishPreparedTracksAfterRecovery(generation);
808
+ return await this.#pull(publications, generation);
809
+ } catch (error) {
810
+ if (generation !== this.#generation || this.#stopped) throw error;
811
+ this.#observeRemotePublicationCursor(cursor, retryableRemotePull(error));
812
+ throw error;
813
+ }
814
+ }
498
815
  async #pull(publications, generation) {
499
816
  if (publications.length === 0) return [];
500
817
  return this.#serializeSDP(async () => {
@@ -502,7 +819,15 @@ var CloudflareSFUClient = class {
502
819
  const connectionId = this.#bootstrap.connectionId;
503
820
  const requested = publications.map((publication) => {
504
821
  const reference = parseCloudflareSFUPublicationID(publication.publicationId);
505
- return { location: "remote", sessionId: reference.connectionId, trackName: reference.trackName };
822
+ return {
823
+ location: "remote",
824
+ sessionId: reference.connectionId,
825
+ trackName: reference.trackName,
826
+ // h sorts before l: prefer full camera quality, but keep video flowing
827
+ // when the publisher pauses h. Do not silently downgrade a live h layer.
828
+ // https://developers.cloudflare.com/realtime/sfu/features/simulcast/#quality-control
829
+ ...publication.source === "camera" ? { simulcast: { preferredRid: "h", priorityOrdering: "none", ridNotAvailable: "asciibetical" } } : {}
830
+ };
506
831
  });
507
832
  const received = /* @__PURE__ */ new Map();
508
833
  const onTrack = (event) => {
@@ -511,17 +836,20 @@ var CloudflareSFUClient = class {
511
836
  connection.addEventListener("track", onTrack);
512
837
  try {
513
838
  this.#requireGeneration(generation);
514
- const response = await this.#requireTransport().addTracks({ connectionId, tracks: requested });
839
+ const response = await traceReconnect(this.#recordReconnect, "pull_tracks", () => this.#requireTransport().addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }));
515
840
  this.#requireGeneration(generation);
841
+ this.#recordRemotePullErrors(publications, response.trackErrors ?? []);
516
842
  const responseTracks = response.tracks ?? [];
843
+ const matched = matchRemotePullTracks(publications, responseTracks);
844
+ for (const { publication } of matched) this.#remotePullRetryAfter.delete(publication.publicationId);
517
845
  await this.#completeRenegotiation(response, connection, connectionId, generation);
846
+ if (matched.length === 0) return [];
518
847
  await this.#waitForConnection(connection, generation);
519
848
  this.#negotiatedGeneration = generation;
520
- await waitFor(() => responseTracks.every((track) => track.mid !== void 0 && received.has(track.mid)), 5e3);
849
+ await traceReconnect(this.#recordReconnect, "remote_track_arrival", () => waitFor(() => responseTracks.every((track) => track.mid !== void 0 && received.has(track.mid)), 5e3));
521
850
  this.#requireGeneration(generation);
522
- return publications.map((publication, index) => {
523
- const responseTrack = responseTracks[index];
524
- const track = responseTrack?.mid === void 0 ? void 0 : received.get(responseTrack.mid);
851
+ return matched.map(({ publication, mid }) => {
852
+ const track = received.get(mid);
525
853
  if (!track) throw new CloudflareSFUError("A negotiated remote track did not arrive", "media_failed");
526
854
  return Object.freeze({ ...publication, track });
527
855
  });
@@ -533,38 +861,151 @@ var CloudflareSFUClient = class {
533
861
  }
534
862
  });
535
863
  }
864
+ #recordRemotePullErrors(publications, failures) {
865
+ const requested = new Map(
866
+ publications.map((publication) => {
867
+ const reference = parseCloudflareSFUPublicationID(publication.publicationId);
868
+ return [`${reference.connectionId}\0${reference.trackName}`, publication];
869
+ })
870
+ );
871
+ for (const failure of failures) {
872
+ const publication = requested.get(`${failure.connectionId}\0${failure.trackName}`);
873
+ if (!publication) throw new CloudflareSFUError("Cloudflare SFU returned an unrequested remote track error", "invalid_publication");
874
+ const delay = 750;
875
+ this.#remotePullRetryAfter.set(publication.publicationId, Date.now() + delay);
876
+ recordReconnect(this.#recordReconnect, "remote_pull_backoff", { delay_ms: delay, retry_kind: failure.code === "empty_track_error" ? "empty_track" : "provider_error", source: publication.source });
877
+ this.#reportError(new CloudflareSFUError(`Cloudflare SFU remote track failed: ${failure.code}`, "media_failed", { providerCode: failure.code }));
878
+ }
879
+ }
536
880
  async #completeRenegotiation(response, connection, connectionId, generation) {
537
881
  if (!response.requiresImmediateRenegotiation) return;
538
- if (!response.sessionDescription) throw new CloudflareSFUError("Cloudflare did not return a remote-track offer", "signaling_failed");
539
- await connection.setRemoteDescription(response.sessionDescription);
540
- const answer = await connection.createAnswer();
541
- await connection.setLocalDescription(answer);
542
- await this.#requireTransport().renegotiate({ connectionId, sessionDescription: requireDescription(answer) });
882
+ await this.#applyProviderDescription(response, connection);
883
+ const answer = await traceReconnect(this.#recordReconnect, "create_answer", () => connection.createAnswer());
884
+ await traceReconnect(this.#recordReconnect, "set_local_answer", () => connection.setLocalDescription(answer));
885
+ await traceReconnect(this.#recordReconnect, "renegotiate", () => this.#requireTransport().renegotiate({ connectionId, ...providerDescription(requireDescription(answer)) }));
543
886
  this.#requireGeneration(generation);
544
887
  }
888
+ async #applyProviderDescription(response, connection = this.#connection) {
889
+ await traceReconnect(this.#recordReconnect, "set_remote_description", () => connection.setRemoteDescription(requireSFUDescription(response.sessionDescription)));
890
+ }
545
891
  #serializeSDP(operation) {
546
- const result = this.#sdpTail.then(operation, operation);
892
+ const generation = this.#generation;
893
+ const queuedAt = performance.now();
894
+ let expired = false;
895
+ let timer;
896
+ const timeout = new CloudflareSFUError("Cloudflare SFU negotiation timed out. Try again.", "negotiation_timeout");
897
+ const deadline = new Promise((_resolve, reject) => {
898
+ timer = globalThis.setTimeout(() => {
899
+ expired = true;
900
+ if (generation === this.#generation && !this.#stopped) this.#expireNegotiation(timeout);
901
+ reject(timeout);
902
+ }, NEGOTIATION_TIMEOUT_MS);
903
+ });
904
+ const run = () => {
905
+ if (expired || generation !== this.#generation) throw timeout;
906
+ recordReconnect(this.#recordReconnect, "sdp_queue", { duration_ms: performance.now() - queuedAt });
907
+ return operation();
908
+ };
909
+ const pending = this.#sdpTail.then(run, run);
910
+ const result = Promise.race([pending, deadline]).catch((error) => {
911
+ if (error instanceof CloudflareSFUError && error.code === "signaling_timeout" && generation === this.#generation && !this.#stopped) this.#expireNegotiation(error);
912
+ throw error;
913
+ }).finally(() => {
914
+ if (timer !== void 0) globalThis.clearTimeout(timer);
915
+ });
547
916
  this.#sdpTail = result.then(
548
917
  () => void 0,
549
918
  () => void 0
550
919
  );
551
920
  return result;
552
921
  }
922
+ async #boundPeerOperation(operation) {
923
+ const generation = this.#generation;
924
+ let timer;
925
+ const timeout = new CloudflareSFUError("The browser media operation timed out. Try again.", "negotiation_timeout");
926
+ const deadline = new Promise((_resolve, reject) => {
927
+ timer = globalThis.setTimeout(() => {
928
+ if (generation === this.#generation && !this.#stopped) this.#expireNegotiation(timeout);
929
+ reject(timeout);
930
+ }, PEER_OPERATION_TIMEOUT_MS);
931
+ });
932
+ try {
933
+ await Promise.race([operation, deadline]);
934
+ } finally {
935
+ if (timer !== void 0) globalThis.clearTimeout(timer);
936
+ }
937
+ }
938
+ #expireNegotiation(error) {
939
+ this.#generation++;
940
+ this.#connectionEpoch++;
941
+ this.#clearPoll();
942
+ this.#polling = false;
943
+ this.#disposeConnection(false);
944
+ this.#setFailure(error, "media_failed");
945
+ }
553
946
  #waitForConnection(connection, generation) {
554
- return waitFor(
555
- () => {
556
- this.#requireGeneration(generation);
557
- return connectionIsLive(connection);
558
- },
559
- CONNECTION_TIMEOUT_MS,
560
- "Timed out waiting for the Cloudflare SFU peer connection"
947
+ return traceReconnect(
948
+ this.#recordReconnect,
949
+ "connection_ready",
950
+ () => waitFor(
951
+ () => {
952
+ this.#requireGeneration(generation);
953
+ return connectionIsLive(connection);
954
+ },
955
+ CONNECTION_TIMEOUT_MS,
956
+ "Timed out waiting for the Cloudflare SFU peer connection"
957
+ )
561
958
  );
562
959
  }
960
+ #cameraSimulcastSender(state) {
961
+ if (!state?.enabled) return void 0;
962
+ const sender = state.transceiver?.sender;
963
+ if (!sender?.track || sender.getParameters().encodings.length < 2) return void 0;
964
+ return sender;
965
+ }
966
+ async #applyCameraUplink(sender, report, policy) {
967
+ const parameters = sender.getParameters();
968
+ const low = parameters.encodings.find((encoding) => encoding.rid === "l");
969
+ if (!low) return;
970
+ const active = policy.sample(cameraUplinkBitrate(report.values()), low.active !== false);
971
+ if (active === (low.active !== false)) return;
972
+ low.active = active;
973
+ parameters.degradationPreference = "maintain-framerate";
974
+ await sender.setParameters(parameters);
975
+ }
563
976
  #observeConnection(connection, generation, connectionEpoch) {
977
+ this.#disposeConnectionObservation?.();
564
978
  let disposed = false;
979
+ let cameraTimer;
980
+ const cameraPolicy = new CameraUplinkPolicy();
981
+ let observedCamera;
982
+ const current = () => !disposed && generation === this.#generation && connectionEpoch === this.#connectionEpoch && !this.#stopped && connection.connectionState !== "failed" && connection.connectionState !== "closed";
983
+ const adaptCamera = async () => {
984
+ if (!current()) return;
985
+ try {
986
+ const state = this.#localTracks.get("camera");
987
+ if (state !== observedCamera) {
988
+ cameraPolicy.reset();
989
+ observedCamera = state;
990
+ }
991
+ const sender = this.#cameraSimulcastSender(state);
992
+ if (!sender) {
993
+ cameraPolicy.reset();
994
+ return;
995
+ }
996
+ const report = await connection.getStats();
997
+ if (!current() || this.#cameraSimulcastSender(state) !== sender || connection.signalingState !== "stable") return;
998
+ await this.#applyCameraUplink(sender, report, cameraPolicy);
999
+ } catch {
1000
+ cameraPolicy.reset();
1001
+ } finally {
1002
+ if (current()) cameraTimer = globalThis.setTimeout(adaptCamera, 1e3);
1003
+ }
1004
+ };
565
1005
  const dispose = () => {
566
1006
  if (disposed) return;
567
1007
  disposed = true;
1008
+ if (cameraTimer !== void 0) globalThis.clearTimeout(cameraTimer);
568
1009
  connection.removeEventListener("connectionstatechange", capture);
569
1010
  connection.removeEventListener("iceconnectionstatechange", capture);
570
1011
  connection.removeEventListener("signalingstatechange", capture);
@@ -577,6 +1018,7 @@ var CloudflareSFUClient = class {
577
1018
  };
578
1019
  const capture = () => {
579
1020
  if (disposed || generation !== this.#generation || connectionEpoch !== this.#connectionEpoch || this.#stopped) return;
1021
+ recordReconnect(this.#recordReconnect, "rtc_state", { connection_state: connection.connectionState, ice_connection_state: connection.iceConnectionState, signaling_state: connection.signalingState });
580
1022
  observe();
581
1023
  const recorder = this.#onRtcSummary;
582
1024
  if (recorder) {
@@ -593,11 +1035,67 @@ var CloudflareSFUClient = class {
593
1035
  }
594
1036
  if (connection.connectionState === "closed") dispose();
595
1037
  };
1038
+ this.#disposeConnectionObservation = dispose;
1039
+ cameraTimer = globalThis.setTimeout(adaptCamera, 1e3);
1040
+ if (this.#recordReconnect) this.#sampleRecoveryTraffic(connection, generation, connectionEpoch);
596
1041
  connection.addEventListener("connectionstatechange", capture);
597
1042
  connection.addEventListener("iceconnectionstatechange", capture);
598
1043
  connection.addEventListener("signalingstatechange", capture);
599
1044
  capture();
600
1045
  }
1046
+ #sampleRecoveryTraffic(connection, generation, connectionEpoch) {
1047
+ const traffic = /* @__PURE__ */ new Map();
1048
+ let dtlsState;
1049
+ const current = () => generation === this.#generation && connectionEpoch === this.#connectionEpoch && !this.#stopped && connection.connectionState !== "closed" && connection.connectionState !== "failed";
1050
+ const sample = async () => {
1051
+ if (!current()) return;
1052
+ try {
1053
+ const report = await connection.getStats();
1054
+ if (!current()) return;
1055
+ const now = performance.now();
1056
+ report.forEach((entry) => {
1057
+ if (entry.type === "transport") {
1058
+ const nextState = rtcString(entry, "dtlsState");
1059
+ if (nextState !== dtlsState) recordReconnect(this.#recordReconnect, "dtls_state", { dtls_state: nextState ?? "unknown" });
1060
+ dtlsState = nextState;
1061
+ }
1062
+ this.#observeRecoveryTraffic(entry, traffic, now);
1063
+ });
1064
+ } catch {
1065
+ if (current()) recordReconnect(this.#recordReconnect, "rtc_stats_unavailable");
1066
+ }
1067
+ if (current()) globalThis.setTimeout(() => void sample(), 250);
1068
+ };
1069
+ void sample();
1070
+ }
1071
+ #observeRecoveryTraffic(entry, traffic, now) {
1072
+ if (entry.type !== "inbound-rtp" && entry.type !== "outbound-rtp") return;
1073
+ const direction = entry.type === "inbound-rtp" ? "inbound" : "outbound";
1074
+ const kind = rtcString(entry, "kind") ?? "unknown";
1075
+ const previous = traffic.get(entry.id);
1076
+ const next = previous ?? {
1077
+ packets: { count: 0, lastProgress: now, stalled: false },
1078
+ frames: { count: 0, lastProgress: now, stalled: false }
1079
+ };
1080
+ const packets = recoveryTrafficCount(entry, direction, "packet");
1081
+ const frames = recoveryTrafficCount(entry, direction, "frame");
1082
+ this.#observeTrafficCounter(next.packets, { count: packets, now, direction, kind, unit: "packet", existed: previous !== void 0 });
1083
+ this.#observeTrafficCounter(next.frames, { count: frames, now, direction, kind, unit: "frame", existed: previous !== void 0 });
1084
+ traffic.set(entry.id, next);
1085
+ }
1086
+ #observeTrafficCounter(progress, sample) {
1087
+ const attributes = { direction: sample.direction, kind: sample.kind };
1088
+ if (sample.count > progress.count) {
1089
+ if (progress.count === 0) recordReconnect(this.#recordReconnect, `first_${sample.unit}`, attributes);
1090
+ else if (progress.stalled) recordReconnect(this.#recordReconnect, sample.unit === "packet" ? "rtp_resumed" : "frame_resumed", attributes);
1091
+ progress.lastProgress = sample.now;
1092
+ progress.stalled = false;
1093
+ } else if (trafficStalled(progress, sample)) {
1094
+ progress.stalled = true;
1095
+ recordReconnect(this.#recordReconnect, sample.unit === "packet" ? "rtp_stall" : "frame_stall", attributes);
1096
+ }
1097
+ progress.count = sample.count;
1098
+ }
601
1099
  async #replaceDormantConnectionBeforeNegotiation(generation) {
602
1100
  if (!this.#started || this.#negotiatedGeneration === generation || this.#replacementAttemptedGeneration === generation || !this.#replaceMediaConnection) return;
603
1101
  await this.#replaceMediaConnectionForRecovery(generation);
@@ -608,7 +1106,8 @@ var CloudflareSFUClient = class {
608
1106
  throw new CloudflareSFUError("A fresh Cloudflare SFU connection is unavailable", "signaling_failed");
609
1107
  }
610
1108
  this.#replacementAttemptedGeneration = generation;
611
- const bootstrap = await this.#replaceMediaConnection();
1109
+ const replaceMediaConnection = this.#replaceMediaConnection;
1110
+ const bootstrap = await traceReconnect(this.#recordReconnect, "replace_access", replaceMediaConnection);
612
1111
  this.#requireGeneration(generation);
613
1112
  validateBootstrap(bootstrap);
614
1113
  if (bootstrap.connectionId === this.#bootstrap.connectionId) {
@@ -617,10 +1116,12 @@ var CloudflareSFUClient = class {
617
1116
  const connectionEpoch = ++this.#connectionEpoch;
618
1117
  this.#disposeConnection(false);
619
1118
  this.#reusableLocalTransceivers.clear();
1119
+ this.#reusableLocalPublicationIds.clear();
620
1120
  this.#clearRemoteTracks();
621
1121
  this.#cursor = null;
622
1122
  this.#bootstrap = bootstrap;
623
1123
  this.#connection = this.#createPeerConnection(bootstrap);
1124
+ this.#retiredLocalConnection = null;
624
1125
  this.#negotiatedGeneration = null;
625
1126
  this.#observeConnection(this.#connection, generation, connectionEpoch);
626
1127
  for (const state of this.#localTracks.values()) {
@@ -629,6 +1130,20 @@ var CloudflareSFUClient = class {
629
1130
  state.providerPublicationId = null;
630
1131
  }
631
1132
  this.#setPhase("recovering", null);
1133
+ this.remotePublicationsChanged();
1134
+ }
1135
+ async #republishPreparedTracksAfterRecovery(generation) {
1136
+ const enabled = [...this.#localTracks.values()].filter((state) => state.desiredEnabled && state.track.readyState !== "ended");
1137
+ await this.#publishPreparedTracks(enabled, generation);
1138
+ }
1139
+ #observeRemotePublicationCursor(cursor, pullIncomplete = false) {
1140
+ this.#cursor = cursor;
1141
+ this.#remotePullIncomplete = pullIncomplete;
1142
+ this.#publishSnapshot();
1143
+ }
1144
+ #quarantineInvalidRemotePublicationCursor(authoritative) {
1145
+ if (!Number.isSafeInteger(authoritative.incarnation) || authoritative.incarnation < 0 || !Number.isSafeInteger(authoritative.sequence) || authoritative.sequence < 0) return;
1146
+ this.#observeRemotePublicationCursor({ incarnation: authoritative.incarnation, sequence: authoritative.sequence, signature: INVALID_PUBLICATION_SIGNATURE });
632
1147
  }
633
1148
  #isRetryableConnectionFailure(error) {
634
1149
  return error instanceof CloudflareSFUError && error.code === "signaling_failed" && error.options.retryableConnection === true;
@@ -638,6 +1153,8 @@ var CloudflareSFUClient = class {
638
1153
  return create({ iceServers: [{ urls: bootstrap.stunServer }], bundlePolicy: "max-bundle" });
639
1154
  }
640
1155
  #disposeConnection(stopSenders) {
1156
+ this.#disposeConnectionObservation?.();
1157
+ this.#disposeConnectionObservation = void 0;
641
1158
  if (stopSenders) {
642
1159
  for (const sender of this.#connection.getSenders()) {
643
1160
  if (sender.track) safeStopTrack(sender.track, this.#reportError.bind(this));
@@ -677,11 +1194,15 @@ var CloudflareSFUClient = class {
677
1194
  if (this.#stopped || !this.#started || this.#pollTimer !== void 0) return;
678
1195
  this.#pollTimer = globalThis.setTimeout(async () => {
679
1196
  this.#pollTimer = void 0;
1197
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
1198
+ this.#schedulePoll();
1199
+ return;
1200
+ }
680
1201
  try {
681
1202
  await this.refreshRemotePublications();
682
1203
  } catch {
683
1204
  } finally {
684
- this.#schedulePoll();
1205
+ this.#schedulePoll(this.#remotePullIncomplete ? 750 : this.#pollIntervalMs);
685
1206
  }
686
1207
  }, delayMs);
687
1208
  }
@@ -694,7 +1215,7 @@ var CloudflareSFUClient = class {
694
1215
  participantId: this.#participantId,
695
1216
  source: state.source,
696
1217
  enabled: state.enabled,
697
- publicationId: state.enabled ? state.providerPublicationId : null
1218
+ publicationId: state.providerPublicationId
698
1219
  }));
699
1220
  }
700
1221
  #projectRemotePublications() {
@@ -712,7 +1233,7 @@ var CloudflareSFUClient = class {
712
1233
  (state) => Object.freeze({
713
1234
  source: state.source,
714
1235
  enabled: state.enabled,
715
- publicationId: state.enabled ? state.providerPublicationId : null,
1236
+ publicationId: state.providerPublicationId,
716
1237
  track: state.track
717
1238
  })
718
1239
  ) : EMPTY_LOCAL,
@@ -848,10 +1369,10 @@ function desiredRemotePublications(publications, participantId) {
848
1369
  function reconcileRemoteTracks(desired, pulled, current) {
849
1370
  const pulledByKey = new Map(pulled.map((publication) => [publicationKey(publication), publication]));
850
1371
  return new Map(
851
- [...desired].map(([key, publication]) => {
1372
+ [...desired].flatMap(([key, publication]) => {
852
1373
  const track = pulledByKey.get(key) ?? current.get(key);
853
- if (!track || track.publicationId !== publication.publicationId) throw new CloudflareSFUError("Cloudflare SFU did not return a requested remote track", "media_failed");
854
- return [key, track];
1374
+ if (!track || track.publicationId !== publication.publicationId) return [];
1375
+ return [[key, track]];
855
1376
  })
856
1377
  );
857
1378
  }
@@ -913,31 +1434,63 @@ function publicationEqual(left, right) {
913
1434
  function remotePublicationEqual(left, right) {
914
1435
  return right !== void 0 && left.participantId === right.participantId && left.source === right.source && left.publicationId === right.publicationId && left.track === right.track;
915
1436
  }
1437
+ function providerDescription(description) {
1438
+ return { sessionDescription: description };
1439
+ }
1440
+ function retryableRemotePull(error) {
1441
+ return error instanceof CloudflareSFUError && ["signaling_failed", "signaling_timeout", "media_failed", "negotiation_timeout"].includes(error.code);
1442
+ }
1443
+ function firefoxCameraSimulcastUnsupported() {
1444
+ return typeof navigator !== "undefined" && /\bFirefox\/\d+/i.test(navigator.userAgent);
1445
+ }
1446
+ function simulcastUnsupported(error) {
1447
+ return error instanceof Error && ["NotSupportedError", "OperationError", "TypeError"].includes(error.name);
1448
+ }
1449
+ function mediaTargetFailure(error) {
1450
+ const code = error instanceof CloudflareSFUError ? error.code : "media_failed";
1451
+ const ambiguous = code === "signaling_timeout" || code === "negotiation_timeout";
1452
+ return { outcome: ambiguous ? "ambiguous" : "retryable_failure", errorCode: code };
1453
+ }
1454
+ function recoveryTrafficCount(entry, direction, unit) {
1455
+ const property = unit === "packet" ? direction === "inbound" ? "packetsReceived" : "packetsSent" : direction === "inbound" ? "framesDecoded" : "framesEncoded";
1456
+ return rtcNumber(entry, property) ?? 0;
1457
+ }
1458
+ function trafficStalled(progress, sample) {
1459
+ return sample.existed && !progress.stalled && sample.now - progress.lastProgress >= 1e3 && (sample.unit === "packet" || progress.count > 0);
1460
+ }
916
1461
 
917
1462
  // src/media/transport.ts
1463
+ var SIGNALING_TIMEOUT_MS = 8e3;
1464
+ var CREDENTIAL_TIMEOUT_MS = 7e3;
918
1465
  function createCloudflareSFUHTTPTransport(options) {
919
1466
  const fetch = options.fetch ?? globalThis.fetch;
920
1467
  const credential = requireCredential(options);
921
1468
  const base = options.apiBaseURL.replace(/\/$/, "");
922
1469
  const mediaPath = `${base}/v1/tenants/${encodeURIComponent(options.tenantId)}/spaces/${encodeURIComponent(options.spaceId)}/episodes/${encodeURIComponent(options.episodeId)}/participants/${encodeURIComponent(options.participantId)}/media/sfu`;
1470
+ const timeoutMs = options.requestTimeoutMs ?? SIGNALING_TIMEOUT_MS;
1471
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new CloudflareSFUError("The signaling deadline is invalid", "signaling_failed");
923
1472
  const request = async (path, init) => {
924
- const token = await credential();
1473
+ const token = await withDeadline(CREDENTIAL_TIMEOUT_MS, (signal) => credential(signal));
925
1474
  if (!token.trim()) throw new CloudflareSFUError("The media credential provider returned an empty token", "signaling_failed");
926
- const response = await fetch(`${mediaPath}/${path}`, {
927
- ...init,
928
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...init?.headers }
929
- });
930
- if (!response.ok) {
931
- throw new CloudflareSFUError(`Chalk SFU signaling failed with HTTP ${response.status}`, "signaling_failed", {
932
- status: response.status,
933
- retryableConnection: response.status === 404 || response.status === 410 || response.status >= 500 && response.status < 600
1475
+ return withDeadline(timeoutMs, async (signal) => {
1476
+ const response = await fetch(`${mediaPath}/${path}`, {
1477
+ ...init,
1478
+ signal,
1479
+ headers: { ...options.telemetry ? journeyHeaders(options.telemetry) : {}, Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...init?.headers }
934
1480
  });
935
- }
936
- return await response.json();
1481
+ if (!response.ok) {
1482
+ throw new CloudflareSFUError(`Chalk SFU signaling failed with HTTP ${response.status}`, "signaling_failed", {
1483
+ status: response.status,
1484
+ retryableConnection: response.status === 404 || response.status === 410 || response.status >= 500 && response.status < 600
1485
+ });
1486
+ }
1487
+ return await response.json();
1488
+ });
937
1489
  };
938
1490
  return {
939
1491
  addTracks: async (input) => {
940
- const response = await request("tracks", {
1492
+ const path = input.allowPartialRemoteTracks ? "tracks?allow_partial_remote_tracks=true" : "tracks";
1493
+ const response = await request(path, {
941
1494
  method: "POST",
942
1495
  body: JSON.stringify({ connection_id: input.connectionId, session_description: input.sessionDescription, tracks: input.tracks })
943
1496
  });
@@ -975,6 +1528,25 @@ function createCloudflareSFUHTTPTransport(options) {
975
1528
  }
976
1529
  };
977
1530
  }
1531
+ async function withDeadline(timeoutMs, operation) {
1532
+ const controller = new AbortController();
1533
+ const timeout = new CloudflareSFUError("Cloudflare SFU signaling timed out. Try again.", "signaling_timeout");
1534
+ let timer;
1535
+ const deadline = new Promise((_resolve, reject) => {
1536
+ timer = globalThis.setTimeout(() => {
1537
+ controller.abort();
1538
+ reject(timeout);
1539
+ }, timeoutMs);
1540
+ });
1541
+ try {
1542
+ return await Promise.race([Promise.resolve().then(() => operation(controller.signal)), deadline]);
1543
+ } catch (error) {
1544
+ if (controller.signal.aborted) throw timeout;
1545
+ throw error;
1546
+ } finally {
1547
+ if (timer !== void 0) globalThis.clearTimeout(timer);
1548
+ }
1549
+ }
978
1550
  function requireCredential(options) {
979
1551
  if (options.credential) return options.credential;
980
1552
  if (options.bearerToken !== void 0) return () => options.bearerToken ?? "";
@@ -1391,6 +1963,30 @@ function toParticipant(participant) {
1391
1963
  };
1392
1964
  }
1393
1965
 
1966
+ // src/media/capture-error.ts
1967
+ function describeMediaCaptureError(cause) {
1968
+ const name = typeof cause === "object" && cause !== null && "name" in cause ? cause.name : void 0;
1969
+ switch (name) {
1970
+ case "NotAllowedError":
1971
+ case "PermissionDeniedError":
1972
+ return { code: "permission_denied", recoverable: true, message: "Camera or microphone permission was denied. Allow access in your device settings and try again." };
1973
+ case "NotFoundError":
1974
+ case "DevicesNotFoundError":
1975
+ return { code: "device_not_found", recoverable: true, message: "No matching camera or microphone was found. Connect an input device or enter with that device off." };
1976
+ case "NotReadableError":
1977
+ case "TrackStartError":
1978
+ return { code: "device_busy", recoverable: true, message: "The camera or microphone could not start. Close other apps using it and try again." };
1979
+ case "OverconstrainedError":
1980
+ case "ConstraintNotSatisfiedError":
1981
+ return { code: "device_constraint_invalid", recoverable: true, message: "The selected camera or microphone is unavailable. Select another input device and try again." };
1982
+ case "SecurityError":
1983
+ case "NotSupportedError":
1984
+ return { code: "unsupported_environment", recoverable: false, message: "Media capture is unavailable in this environment. Use a supported browser over HTTPS or the native app." };
1985
+ default:
1986
+ return { code: "media_capture_failed", recoverable: true, message: "Media capture failed. Try again or enter with devices off." };
1987
+ }
1988
+ }
1989
+
1394
1990
  export {
1395
1991
  CloudflareSFUError,
1396
1992
  parseCloudflareSFUPublicationID,
@@ -1403,5 +1999,6 @@ export {
1403
1999
  CloudflareSFUClient,
1404
2000
  createCloudflareSFUHTTPTransport,
1405
2001
  CloudflareRTKError,
1406
- CloudflareRTKClient
2002
+ CloudflareRTKClient,
2003
+ describeMediaCaptureError
1407
2004
  };