@q9labsai/chalk-client 4.1.15 → 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 (130) hide show
  1. package/README.md +56 -2
  2. package/dist/access/manager.d.ts.map +1 -1
  3. package/dist/{chunk-LQSXEWLL.js → chunk-BD7FRQ67.js} +2061 -326
  4. package/dist/{chunk-6ONSFCIY.js → chunk-E4OK5K6N.js} +7 -3
  5. package/dist/{chunk-NKOKY2AE.js → chunk-EWTITNJ3.js} +15 -101
  6. package/dist/chunk-HW5TTZL2.js +99 -0
  7. package/dist/{chunk-LSDDADCN.js → chunk-M3S7HNQF.js} +1656 -517
  8. package/dist/{chunk-PQNSA7ZN.js → chunk-O7WTAXAY.js} +610 -73
  9. package/dist/{chunk-PWUU6T3R.js → chunk-OKIL2ELF.js} +1185 -874
  10. package/dist/chunk-RCDXIDCN.js +33 -0
  11. package/dist/client.d.ts +1412 -463
  12. package/dist/client.d.ts.map +1 -1
  13. package/dist/connection/dependencies.d.ts +10 -0
  14. package/dist/connection/dependencies.d.ts.map +1 -1
  15. package/dist/connection/index.d.ts +2 -0
  16. package/dist/connection/index.d.ts.map +1 -1
  17. package/dist/connection/lifecycle.d.ts.map +1 -1
  18. package/dist/connection/production.d.ts +4 -1
  19. package/dist/connection/production.d.ts.map +1 -1
  20. package/dist/connection/types.d.ts +2 -2
  21. package/dist/connection/types.d.ts.map +1 -1
  22. package/dist/effect.js +343 -130
  23. package/dist/feedback/collector.d.ts +14 -0
  24. package/dist/feedback/collector.d.ts.map +1 -0
  25. package/dist/feedback/context.d.ts +38 -0
  26. package/dist/feedback/context.d.ts.map +1 -0
  27. package/dist/feedback/controller.d.ts +4 -0
  28. package/dist/feedback/controller.d.ts.map +1 -0
  29. package/dist/feedback/index.d.ts +7 -0
  30. package/dist/feedback/index.d.ts.map +1 -0
  31. package/dist/feedback/transport.d.ts +29 -0
  32. package/dist/feedback/transport.d.ts.map +1 -0
  33. package/dist/feedback/types.d.ts +202 -0
  34. package/dist/feedback/types.d.ts.map +1 -0
  35. package/dist/feedback/validation.d.ts +30 -0
  36. package/dist/feedback/validation.d.ts.map +1 -0
  37. package/dist/generated/http-api.d.ts +1401 -462
  38. package/dist/generated/http-api.d.ts.map +1 -1
  39. package/dist/generated/schemas.d.ts +2601 -858
  40. package/dist/generated/schemas.d.ts.map +1 -1
  41. package/dist/index.d.ts +1 -0
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +150 -35
  44. package/dist/media/camera-uplink.d.ts +13 -0
  45. package/dist/media/camera-uplink.d.ts.map +1 -0
  46. package/dist/media/capture-error.d.ts +27 -0
  47. package/dist/media/capture-error.d.ts.map +1 -0
  48. package/dist/media/client.d.ts +4 -0
  49. package/dist/media/client.d.ts.map +1 -1
  50. package/dist/media/index.d.ts +1 -0
  51. package/dist/media/index.d.ts.map +1 -1
  52. package/dist/media/index.js +5 -1
  53. package/dist/media/plane.d.ts +4 -0
  54. package/dist/media/plane.d.ts.map +1 -1
  55. package/dist/media/remote-track-response.d.ts +6 -0
  56. package/dist/media/remote-track-response.d.ts.map +1 -0
  57. package/dist/media/transport.d.ts.map +1 -1
  58. package/dist/media/types.d.ts +20 -2
  59. package/dist/media/types.d.ts.map +1 -1
  60. package/dist/public-invites/client.d.ts.map +1 -1
  61. package/dist/public-invites/index.js +4 -3
  62. package/dist/public-invites/types.d.ts +4 -2
  63. package/dist/public-invites/types.d.ts.map +1 -1
  64. package/dist/server/access-refresh.d.cts +8 -0
  65. package/dist/server/access-refresh.d.ts +9 -0
  66. package/dist/server/access-refresh.d.ts.map +1 -0
  67. package/dist/server/client.d.ts.map +1 -1
  68. package/dist/server/index.cjs +181 -20
  69. package/dist/server/index.d.cts +2 -1
  70. package/dist/server/index.d.ts +2 -1
  71. package/dist/server/index.d.ts.map +1 -1
  72. package/dist/server/index.js +179 -19
  73. package/dist/server/types.d.cts +74 -0
  74. package/dist/server/types.d.ts +74 -0
  75. package/dist/server/types.d.ts.map +1 -1
  76. package/dist/space-client/chat-controller.d.ts +3 -0
  77. package/dist/space-client/chat-controller.d.ts.map +1 -1
  78. package/dist/space-client/controller-effects.d.ts +6 -0
  79. package/dist/space-client/controller-effects.d.ts.map +1 -1
  80. package/dist/space-client/core.d.ts +12 -1
  81. package/dist/space-client/core.d.ts.map +1 -1
  82. package/dist/space-client/core.test.helpers.d.ts +1 -0
  83. package/dist/space-client/core.test.helpers.d.ts.map +1 -1
  84. package/dist/space-client/effect.d.ts +5 -0
  85. package/dist/space-client/effect.d.ts.map +1 -1
  86. package/dist/space-client/episode-diagnostic-runtime.d.ts +3 -0
  87. package/dist/space-client/episode-diagnostic-runtime.d.ts.map +1 -1
  88. package/dist/space-client/errors.d.ts.map +1 -1
  89. package/dist/space-client/index.d.ts +4 -1
  90. package/dist/space-client/index.d.ts.map +1 -1
  91. package/dist/space-client/media-controller.d.ts.map +1 -1
  92. package/dist/space-client/offline-actions-storage.d.ts +66 -0
  93. package/dist/space-client/offline-actions-storage.d.ts.map +1 -0
  94. package/dist/space-client/offline-actions.d.ts +36 -0
  95. package/dist/space-client/offline-actions.d.ts.map +1 -0
  96. package/dist/space-client/participants-controller.d.ts +2 -1
  97. package/dist/space-client/participants-controller.d.ts.map +1 -1
  98. package/dist/space-client/recording-controller.d.ts +20 -0
  99. package/dist/space-client/recording-controller.d.ts.map +1 -0
  100. package/dist/space-client/reload-rejoin.d.ts +24 -0
  101. package/dist/space-client/reload-rejoin.d.ts.map +1 -0
  102. package/dist/space-client/space-client.d.ts.map +1 -1
  103. package/dist/space-client/store.d.ts +3 -1
  104. package/dist/space-client/store.d.ts.map +1 -1
  105. package/dist/space-client/types.d.ts +40 -4
  106. package/dist/space-client/types.d.ts.map +1 -1
  107. package/dist/sync/index.js +2 -1
  108. package/dist/sync/v1-client.d.ts +1 -5
  109. package/dist/sync/v1-client.d.ts.map +1 -1
  110. package/dist/sync/v1-collaboration-state.d.ts +65 -0
  111. package/dist/sync/v1-collaboration-state.d.ts.map +1 -0
  112. package/dist/sync/v1-command-scheduler.d.ts +54 -0
  113. package/dist/sync/v1-command-scheduler.d.ts.map +1 -0
  114. package/dist/sync/v1-deferred.d.ts +8 -0
  115. package/dist/sync/v1-deferred.d.ts.map +1 -0
  116. package/dist/sync/v1-error.d.ts +5 -0
  117. package/dist/sync/v1-error.d.ts.map +1 -0
  118. package/dist/sync/v1-frame-signature.d.ts +2 -0
  119. package/dist/sync/v1-frame-signature.d.ts.map +1 -0
  120. package/dist/sync/v1-live-target-coordinator.d.ts +32 -0
  121. package/dist/sync/v1-live-target-coordinator.d.ts.map +1 -0
  122. package/dist/sync/v1-types.d.ts +3 -0
  123. package/dist/sync/v1-types.d.ts.map +1 -1
  124. package/dist/telemetry/index.js +9 -7
  125. package/dist/telemetry/reconnect.d.ts +7 -0
  126. package/dist/telemetry/reconnect.d.ts.map +1 -0
  127. package/dist/telemetry/trace.d.ts +1 -1
  128. package/dist/telemetry/trace.d.ts.map +1 -1
  129. package/dist/whiteboard/v1-client.d.ts.map +1 -1
  130. 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,15 +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([]);
138
198
  var INVALID_PUBLICATION_SIGNATURE = "\0invalid";
139
199
  var CONNECTION_TIMEOUT_MS = 8e3;
200
+ var NEGOTIATION_TIMEOUT_MS = 35e3;
201
+ var PEER_OPERATION_TIMEOUT_MS = 8e3;
140
202
  var CloudflareSFUClient = class {
141
203
  #localListeners = /* @__PURE__ */ new Set();
142
204
  #onError;
143
205
  #onRemoteTrack;
206
+ #recordReconnect;
144
207
  #onRtcSummary;
145
208
  #onScreenEnded;
146
209
  #participantId;
@@ -149,17 +212,24 @@ var CloudflareSFUClient = class {
149
212
  #replaceMediaConnection;
150
213
  #remoteListeners = /* @__PURE__ */ new Set();
151
214
  #snapshotListeners = /* @__PURE__ */ new Set();
215
+ #locallyPausedSources = /* @__PURE__ */ new Set();
152
216
  #localTracks = /* @__PURE__ */ new Map();
153
217
  #reusableLocalTransceivers = /* @__PURE__ */ new Map();
218
+ #reusableLocalPublicationIds = /* @__PURE__ */ new Map();
154
219
  #remoteTracks = /* @__PURE__ */ new Map();
155
220
  #bootstrap;
156
221
  #connection;
222
+ #disposeConnectionObservation;
157
223
  #connectionEpoch = 0;
224
+ #retiredLocalConnection = null;
158
225
  #cursor = null;
226
+ #remotePullIncomplete = false;
227
+ #remotePullRetryAfter = /* @__PURE__ */ new Map();
159
228
  #generation = 0;
160
229
  #negotiatedGeneration = null;
161
230
  #replacementAttemptedGeneration = null;
162
231
  #polling = false;
232
+ #pollAfterCurrent = false;
163
233
  #pollTimer;
164
234
  #sdpTail = Promise.resolve();
165
235
  #snapshot;
@@ -171,11 +241,12 @@ var CloudflareSFUClient = class {
171
241
  this.#participantId = options.participantId;
172
242
  this.#bootstrap = options.bootstrap;
173
243
  this.#transport = options.transport;
174
- this.#pollIntervalMs = options.pollIntervalMs ?? 1e3;
244
+ this.#pollIntervalMs = options.pollIntervalMs ?? 15e3;
175
245
  this.#replaceMediaConnection = options.replaceMediaConnection;
176
246
  this.#onError = options.onError;
177
247
  this.#onRemoteTrack = options.onRemoteTrack;
178
248
  this.#onRtcSummary = options.onRtcSummary;
249
+ this.#recordReconnect = options.recordReconnect;
179
250
  this.#onScreenEnded = options.onScreenEnded;
180
251
  this.#peerConnectionFactory = options.peerConnectionFactory;
181
252
  this.#connection = this.#createPeerConnection(options.bootstrap);
@@ -194,6 +265,19 @@ var CloudflareSFUClient = class {
194
265
  subscribe(listener) {
195
266
  return subscribeSnapshot(this.#snapshotListeners, listener);
196
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
+ }
197
281
  prepareLocalTrack(source, track) {
198
282
  this.#requireActive();
199
283
  validateTrackSource(source, track);
@@ -202,7 +286,7 @@ var CloudflareSFUClient = class {
202
286
  source,
203
287
  track,
204
288
  transceiver: this.#reusableLocalTransceivers.get(source) ?? null,
205
- providerPublicationId: null,
289
+ providerPublicationId: this.#reusableLocalPublicationIds.get(source) ?? null,
206
290
  pendingOperationId: null,
207
291
  pendingTrackName: null,
208
292
  desiredEnabled: false,
@@ -210,6 +294,7 @@ var CloudflareSFUClient = class {
210
294
  endedListener: null
211
295
  };
212
296
  this.#reusableLocalTransceivers.delete(source);
297
+ this.#reusableLocalPublicationIds.delete(source);
213
298
  if (source === "screen") {
214
299
  state.endedListener = () => {
215
300
  if (this.#localTracks.get("screen") === state) this.#invokeListener(() => this.#onScreenEnded?.());
@@ -225,6 +310,7 @@ var CloudflareSFUClient = class {
225
310
  if (!state) return;
226
311
  if (state.enabled) await this.#setPreparedTrackEnabled(state, false);
227
312
  if (state.transceiver) this.#reusableLocalTransceivers.set(source, state.transceiver);
313
+ if (state.providerPublicationId) this.#reusableLocalPublicationIds.set(source, state.providerPublicationId);
228
314
  this.#removeOwnedLocalTrack(state);
229
315
  this.#localTracks.delete(source);
230
316
  this.#publishSnapshot();
@@ -248,28 +334,118 @@ var CloudflareSFUClient = class {
248
334
  const generation = this.#generation;
249
335
  try {
250
336
  const transport = this.#requireTransport();
251
- const authoritative = await transport.listPublications();
337
+ const authoritative = await traceReconnect(this.#recordReconnect, "list_publications", () => transport.listPublications());
252
338
  this.#requireGeneration(generation);
253
339
  await this.#reconcileRemotePublications(authoritative, generation);
254
340
  } catch (error) {
255
341
  if (generation === this.#generation && !this.#stopped) this.#reportError(error);
256
342
  throw error;
257
343
  } finally {
258
- 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
+ }
259
352
  }
260
353
  }
261
354
  async setLocalPublicationTarget(target) {
262
355
  const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
263
356
  if (resolved.kind === "result") return resolved.result;
264
357
  const state = resolved.value;
265
- if (state.enabled === target.enabled) return { outcome: "satisfied", errorCode: null };
358
+ if (this.#pausedTarget(target)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
266
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 };
267
363
  await this.#setPreparedTrackEnabled(state, target.enabled, target.operationId);
268
364
  return { outcome: "confirmed", errorCode: null };
269
365
  } catch (error) {
270
366
  if (!this.#stopped) this.#reportError(error);
271
- 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;
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;
272
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
+ });
273
449
  }
274
450
  observeLocalPublications(listener) {
275
451
  this.#localListeners.add(listener);
@@ -285,16 +461,21 @@ var CloudflareSFUClient = class {
285
461
  this.#requireActive();
286
462
  const options = "connectionId" in input ? { bootstrap: input } : input;
287
463
  validateBootstrap(options.bootstrap);
464
+ recordReconnect(this.#recordReconnect, "media_rebuild_start");
288
465
  const generation = ++this.#generation;
289
466
  const connectionEpoch = ++this.#connectionEpoch;
290
467
  this.#polling = false;
468
+ this.#pollAfterCurrent = false;
291
469
  this.#clearPoll();
470
+ this.#remotePullRetryAfter.clear();
292
471
  this.#disposeConnection(false);
293
472
  this.#reusableLocalTransceivers.clear();
473
+ this.#reusableLocalPublicationIds.clear();
294
474
  this.#clearRemoteTracks();
295
475
  this.#cursor = null;
296
476
  this.#negotiatedGeneration = null;
297
477
  this.#replacementAttemptedGeneration = null;
478
+ if (options.bootstrap.connectionId !== this.#bootstrap.connectionId) this.#started = false;
298
479
  this.#bootstrap = options.bootstrap;
299
480
  if (options.transport) this.#transport = options.transport;
300
481
  this.#connection = this.#createPeerConnection(options.bootstrap);
@@ -325,9 +506,12 @@ var CloudflareSFUClient = class {
325
506
  this.#generation++;
326
507
  this.#connectionEpoch++;
327
508
  this.#clearPoll();
509
+ this.#remotePullRetryAfter.clear();
328
510
  this.#polling = false;
511
+ this.#pollAfterCurrent = false;
329
512
  this.#disposeConnection(true);
330
513
  this.#reusableLocalTransceivers.clear();
514
+ this.#reusableLocalPublicationIds.clear();
331
515
  this.#clearRemoteTracks();
332
516
  for (const state of this.#localTracks.values()) this.#removeOwnedLocalTrack(state);
333
517
  this.#localTracks.clear();
@@ -346,14 +530,33 @@ var CloudflareSFUClient = class {
346
530
  return this.#disablePreparedTrack(state);
347
531
  }
348
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
+ }
349
551
  if (operationId && state.pendingOperationId !== operationId) {
350
552
  state.pendingOperationId = operationId;
351
553
  state.pendingTrackName = `${state.source}-${operationId}`;
352
554
  }
353
555
  state.desiredEnabled = true;
354
556
  state.track.enabled = true;
557
+ const generation = this.#generation;
355
558
  try {
356
- await this.#publishPreparedTracks([state], this.#generation);
559
+ await this.#publishPreparedTracks([state], generation);
357
560
  } catch (error) {
358
561
  state.desiredEnabled = false;
359
562
  state.enabled = false;
@@ -365,26 +568,21 @@ var CloudflareSFUClient = class {
365
568
  const transceiver = state.transceiver;
366
569
  state.desiredEnabled = false;
367
570
  state.track.enabled = false;
368
- try {
369
- if (transceiver) await transceiver.sender.replaceTrack(null);
370
- } catch (error) {
371
- state.desiredEnabled = true;
372
- state.track.enabled = true;
373
- throw error;
374
- }
571
+ const detach = transceiver ? this.#boundPeerOperation(transceiver.sender.replaceTrack(null)) : Promise.resolve();
375
572
  state.enabled = false;
376
- state.providerPublicationId = null;
377
573
  state.pendingOperationId = null;
378
574
  state.pendingTrackName = null;
379
575
  this.#publishSnapshot();
380
576
  this.#emitLocal();
577
+ await detach;
381
578
  }
382
579
  async #publishPreparedTracks(states, generation) {
383
580
  if (states.length === 0) return;
384
581
  await this.#serializeSDP(async () => {
385
- await this.#replaceDormantConnectionBeforeNegotiation(generation);
582
+ const pending = await this.#prepareConnectionForPublication(states, generation);
583
+ this.#replacementAttemptedGeneration = null;
386
584
  try {
387
- await this.#publishPreparedTracksSerialized(states, generation, this.#connection, this.#bootstrap.connectionId);
585
+ await this.#publishPreparedTracksSerialized(pending, generation, this.#connection, this.#bootstrap.connectionId);
388
586
  } catch (error) {
389
587
  if (!this.#replaceMediaConnection || !this.#isRetryableConnectionFailure(error) || this.#replacementAttemptedGeneration === generation) throw error;
390
588
  await this.#replaceMediaConnectionForRecovery(generation);
@@ -397,6 +595,25 @@ var CloudflareSFUClient = class {
397
595
  }
398
596
  });
399
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
+ }
400
617
  async #publishPreparedTracksSerialized(states, generation, connection, connectionId) {
401
618
  this.#requireGeneration(generation);
402
619
  const pendingStates = states.filter((state) => !state.enabled);
@@ -404,7 +621,7 @@ var CloudflareSFUClient = class {
404
621
  const wasLive = connectionIsLive(connection);
405
622
  const publications = [];
406
623
  try {
407
- 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));
408
625
  const response = await this.#negotiateLocalPublications(connection, connectionId, publications, generation);
409
626
  if (!wasLive) await this.#waitForConnection(connection, generation);
410
627
  this.#requireGeneration(generation);
@@ -413,21 +630,56 @@ var CloudflareSFUClient = class {
413
630
  this.#publishSnapshot();
414
631
  this.#emitLocal();
415
632
  } catch (error) {
416
- await this.#rollbackLocalOffer(connection);
417
- await this.#discardLocalPublications(publications);
633
+ if (generation === this.#generation) {
634
+ await this.#rollbackLocalOffer(connection);
635
+ await this.#discardLocalPublications(publications);
636
+ }
418
637
  throw error;
419
638
  }
420
639
  }
421
- async #prepareLocalPublication(connection, state) {
640
+ async #prepareLocalPublication(connection, state, generation) {
422
641
  const reusedTransceiver = state.transceiver !== null;
423
- const transceiver = state.transceiver ?? connection.addTransceiver(state.track, { direction: "sendonly" });
424
- 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);
425
645
  state.transceiver = transceiver;
426
646
  return { state, transceiver, trackName: state.pendingTrackName ?? `${state.source}-${globalThis.crypto.randomUUID()}`, reusedTransceiver };
427
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
+ }
428
664
  async #negotiateLocalPublications(connection, connectionId, publications, generation) {
429
- const offer = await connection.createOffer();
430
- 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
+ }
431
683
  const tracks = publications.map(
432
684
  ({ state, transceiver, trackName }) => ({
433
685
  location: "local",
@@ -436,17 +688,20 @@ var CloudflareSFUClient = class {
436
688
  source: state.source
437
689
  })
438
690
  );
439
- 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 }));
440
692
  this.#requireGeneration(generation);
441
- await connection.setRemoteDescription(requireSFUDescription(response.sessionDescription));
693
+ await this.#applyProviderDescription(response, connection);
442
694
  return response;
443
695
  }
444
696
  #confirmLocalPublications(publications, tracks) {
445
697
  for (const { state, transceiver } of publications) {
446
698
  const authoritative = tracks?.find((track) => track.location === "local" && track.mid === transceiver.mid && track.source === state.source);
447
699
  if (!authoritative?.publicationId) throw new CloudflareSFUError("Chalk did not return an authoritative local publication ID", "invalid_publication");
448
- state.desiredEnabled = true;
449
- 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));
450
705
  state.providerPublicationId = authoritative.publicationId;
451
706
  state.pendingOperationId = null;
452
707
  state.pendingTrackName = null;
@@ -482,9 +737,9 @@ var CloudflareSFUClient = class {
482
737
  const cursor = this.#validatedRemotePublicationCursor(authoritative);
483
738
  if (cursor === null) return;
484
739
  const ordering = comparePublicationCursor(this.#cursor, cursor);
485
- if (ordering !== "newer") return;
740
+ if (ordering === "stale" || ordering === "same" && !this.#remotePullIncomplete) return;
486
741
  const desired = this.#desiredRemotePublications(authoritative, cursor);
487
- const toPull = [...desired].filter(([key, publication]) => this.#remoteTracks.get(key)?.publicationId !== publication.publicationId).map(([, publication]) => publication);
742
+ const toPull = this.#pendingRemotePulls(desired);
488
743
  const pulled = await this.#pullWithRecovery(toPull, cursor, generation);
489
744
  if (pulled === null) return;
490
745
  this.#requireGeneration(generation);
@@ -493,10 +748,24 @@ var CloudflareSFUClient = class {
493
748
  this.#remoteTracks.clear();
494
749
  for (const [key, publication] of next) this.#remoteTracks.set(key, publication);
495
750
  this.#cursor = cursor;
751
+ this.#remotePullIncomplete = [...desired].some(([key, publication]) => next.get(key)?.publicationId !== publication.publicationId);
496
752
  for (const publication of pulled) this.#invokeListener(() => this.#onRemoteTrack?.(publication));
497
753
  this.#publishSnapshot();
498
754
  this.#emitRemote();
499
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
+ }
500
769
  #validatedRemotePublicationCursor(authoritative) {
501
770
  if (this.#cursor?.signature === INVALID_PUBLICATION_SIGNATURE && this.#cursor.incarnation === authoritative.incarnation && this.#cursor.sequence === authoritative.sequence) return null;
502
771
  try {
@@ -520,7 +789,7 @@ var CloudflareSFUClient = class {
520
789
  } catch (error) {
521
790
  if (generation !== this.#generation || this.#stopped) throw error;
522
791
  if (!this.#canReplaceConnectionAfterRemotePull(error, generation)) {
523
- this.#observeRemotePublicationCursor(cursor);
792
+ this.#observeRemotePublicationCursor(cursor, retryableRemotePull(error));
524
793
  throw error;
525
794
  }
526
795
  return this.#retryRemotePullOnReplacement(publications, cursor, generation);
@@ -539,7 +808,7 @@ var CloudflareSFUClient = class {
539
808
  return await this.#pull(publications, generation);
540
809
  } catch (error) {
541
810
  if (generation !== this.#generation || this.#stopped) throw error;
542
- this.#observeRemotePublicationCursor(cursor);
811
+ this.#observeRemotePublicationCursor(cursor, retryableRemotePull(error));
543
812
  throw error;
544
813
  }
545
814
  }
@@ -550,7 +819,15 @@ var CloudflareSFUClient = class {
550
819
  const connectionId = this.#bootstrap.connectionId;
551
820
  const requested = publications.map((publication) => {
552
821
  const reference = parseCloudflareSFUPublicationID(publication.publicationId);
553
- 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
+ };
554
831
  });
555
832
  const received = /* @__PURE__ */ new Map();
556
833
  const onTrack = (event) => {
@@ -559,17 +836,20 @@ var CloudflareSFUClient = class {
559
836
  connection.addEventListener("track", onTrack);
560
837
  try {
561
838
  this.#requireGeneration(generation);
562
- 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 }));
563
840
  this.#requireGeneration(generation);
841
+ this.#recordRemotePullErrors(publications, response.trackErrors ?? []);
564
842
  const responseTracks = response.tracks ?? [];
843
+ const matched = matchRemotePullTracks(publications, responseTracks);
844
+ for (const { publication } of matched) this.#remotePullRetryAfter.delete(publication.publicationId);
565
845
  await this.#completeRenegotiation(response, connection, connectionId, generation);
846
+ if (matched.length === 0) return [];
566
847
  await this.#waitForConnection(connection, generation);
567
848
  this.#negotiatedGeneration = generation;
568
- 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));
569
850
  this.#requireGeneration(generation);
570
- return publications.map((publication, index) => {
571
- const responseTrack = responseTracks[index];
572
- const track = responseTrack?.mid === void 0 ? void 0 : received.get(responseTrack.mid);
851
+ return matched.map(({ publication, mid }) => {
852
+ const track = received.get(mid);
573
853
  if (!track) throw new CloudflareSFUError("A negotiated remote track did not arrive", "media_failed");
574
854
  return Object.freeze({ ...publication, track });
575
855
  });
@@ -581,38 +861,151 @@ var CloudflareSFUClient = class {
581
861
  }
582
862
  });
583
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
+ }
584
880
  async #completeRenegotiation(response, connection, connectionId, generation) {
585
881
  if (!response.requiresImmediateRenegotiation) return;
586
- if (!response.sessionDescription) throw new CloudflareSFUError("Cloudflare did not return a remote-track offer", "signaling_failed");
587
- await connection.setRemoteDescription(response.sessionDescription);
588
- const answer = await connection.createAnswer();
589
- await connection.setLocalDescription(answer);
590
- 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)) }));
591
886
  this.#requireGeneration(generation);
592
887
  }
888
+ async #applyProviderDescription(response, connection = this.#connection) {
889
+ await traceReconnect(this.#recordReconnect, "set_remote_description", () => connection.setRemoteDescription(requireSFUDescription(response.sessionDescription)));
890
+ }
593
891
  #serializeSDP(operation) {
594
- 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
+ });
595
916
  this.#sdpTail = result.then(
596
917
  () => void 0,
597
918
  () => void 0
598
919
  );
599
920
  return result;
600
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
+ }
601
946
  #waitForConnection(connection, generation) {
602
- return waitFor(
603
- () => {
604
- this.#requireGeneration(generation);
605
- return connectionIsLive(connection);
606
- },
607
- CONNECTION_TIMEOUT_MS,
608
- "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
+ )
609
958
  );
610
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
+ }
611
976
  #observeConnection(connection, generation, connectionEpoch) {
977
+ this.#disposeConnectionObservation?.();
612
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
+ };
613
1005
  const dispose = () => {
614
1006
  if (disposed) return;
615
1007
  disposed = true;
1008
+ if (cameraTimer !== void 0) globalThis.clearTimeout(cameraTimer);
616
1009
  connection.removeEventListener("connectionstatechange", capture);
617
1010
  connection.removeEventListener("iceconnectionstatechange", capture);
618
1011
  connection.removeEventListener("signalingstatechange", capture);
@@ -625,6 +1018,7 @@ var CloudflareSFUClient = class {
625
1018
  };
626
1019
  const capture = () => {
627
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 });
628
1022
  observe();
629
1023
  const recorder = this.#onRtcSummary;
630
1024
  if (recorder) {
@@ -641,11 +1035,67 @@ var CloudflareSFUClient = class {
641
1035
  }
642
1036
  if (connection.connectionState === "closed") dispose();
643
1037
  };
1038
+ this.#disposeConnectionObservation = dispose;
1039
+ cameraTimer = globalThis.setTimeout(adaptCamera, 1e3);
1040
+ if (this.#recordReconnect) this.#sampleRecoveryTraffic(connection, generation, connectionEpoch);
644
1041
  connection.addEventListener("connectionstatechange", capture);
645
1042
  connection.addEventListener("iceconnectionstatechange", capture);
646
1043
  connection.addEventListener("signalingstatechange", capture);
647
1044
  capture();
648
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
+ }
649
1099
  async #replaceDormantConnectionBeforeNegotiation(generation) {
650
1100
  if (!this.#started || this.#negotiatedGeneration === generation || this.#replacementAttemptedGeneration === generation || !this.#replaceMediaConnection) return;
651
1101
  await this.#replaceMediaConnectionForRecovery(generation);
@@ -656,7 +1106,8 @@ var CloudflareSFUClient = class {
656
1106
  throw new CloudflareSFUError("A fresh Cloudflare SFU connection is unavailable", "signaling_failed");
657
1107
  }
658
1108
  this.#replacementAttemptedGeneration = generation;
659
- const bootstrap = await this.#replaceMediaConnection();
1109
+ const replaceMediaConnection = this.#replaceMediaConnection;
1110
+ const bootstrap = await traceReconnect(this.#recordReconnect, "replace_access", replaceMediaConnection);
660
1111
  this.#requireGeneration(generation);
661
1112
  validateBootstrap(bootstrap);
662
1113
  if (bootstrap.connectionId === this.#bootstrap.connectionId) {
@@ -665,10 +1116,12 @@ var CloudflareSFUClient = class {
665
1116
  const connectionEpoch = ++this.#connectionEpoch;
666
1117
  this.#disposeConnection(false);
667
1118
  this.#reusableLocalTransceivers.clear();
1119
+ this.#reusableLocalPublicationIds.clear();
668
1120
  this.#clearRemoteTracks();
669
1121
  this.#cursor = null;
670
1122
  this.#bootstrap = bootstrap;
671
1123
  this.#connection = this.#createPeerConnection(bootstrap);
1124
+ this.#retiredLocalConnection = null;
672
1125
  this.#negotiatedGeneration = null;
673
1126
  this.#observeConnection(this.#connection, generation, connectionEpoch);
674
1127
  for (const state of this.#localTracks.values()) {
@@ -677,13 +1130,15 @@ var CloudflareSFUClient = class {
677
1130
  state.providerPublicationId = null;
678
1131
  }
679
1132
  this.#setPhase("recovering", null);
1133
+ this.remotePublicationsChanged();
680
1134
  }
681
1135
  async #republishPreparedTracksAfterRecovery(generation) {
682
1136
  const enabled = [...this.#localTracks.values()].filter((state) => state.desiredEnabled && state.track.readyState !== "ended");
683
1137
  await this.#publishPreparedTracks(enabled, generation);
684
1138
  }
685
- #observeRemotePublicationCursor(cursor) {
1139
+ #observeRemotePublicationCursor(cursor, pullIncomplete = false) {
686
1140
  this.#cursor = cursor;
1141
+ this.#remotePullIncomplete = pullIncomplete;
687
1142
  this.#publishSnapshot();
688
1143
  }
689
1144
  #quarantineInvalidRemotePublicationCursor(authoritative) {
@@ -698,6 +1153,8 @@ var CloudflareSFUClient = class {
698
1153
  return create({ iceServers: [{ urls: bootstrap.stunServer }], bundlePolicy: "max-bundle" });
699
1154
  }
700
1155
  #disposeConnection(stopSenders) {
1156
+ this.#disposeConnectionObservation?.();
1157
+ this.#disposeConnectionObservation = void 0;
701
1158
  if (stopSenders) {
702
1159
  for (const sender of this.#connection.getSenders()) {
703
1160
  if (sender.track) safeStopTrack(sender.track, this.#reportError.bind(this));
@@ -737,11 +1194,15 @@ var CloudflareSFUClient = class {
737
1194
  if (this.#stopped || !this.#started || this.#pollTimer !== void 0) return;
738
1195
  this.#pollTimer = globalThis.setTimeout(async () => {
739
1196
  this.#pollTimer = void 0;
1197
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
1198
+ this.#schedulePoll();
1199
+ return;
1200
+ }
740
1201
  try {
741
1202
  await this.refreshRemotePublications();
742
1203
  } catch {
743
1204
  } finally {
744
- this.#schedulePoll();
1205
+ this.#schedulePoll(this.#remotePullIncomplete ? 750 : this.#pollIntervalMs);
745
1206
  }
746
1207
  }, delayMs);
747
1208
  }
@@ -754,7 +1215,7 @@ var CloudflareSFUClient = class {
754
1215
  participantId: this.#participantId,
755
1216
  source: state.source,
756
1217
  enabled: state.enabled,
757
- publicationId: state.enabled ? state.providerPublicationId : null
1218
+ publicationId: state.providerPublicationId
758
1219
  }));
759
1220
  }
760
1221
  #projectRemotePublications() {
@@ -772,7 +1233,7 @@ var CloudflareSFUClient = class {
772
1233
  (state) => Object.freeze({
773
1234
  source: state.source,
774
1235
  enabled: state.enabled,
775
- publicationId: state.enabled ? state.providerPublicationId : null,
1236
+ publicationId: state.providerPublicationId,
776
1237
  track: state.track
777
1238
  })
778
1239
  ) : EMPTY_LOCAL,
@@ -908,10 +1369,10 @@ function desiredRemotePublications(publications, participantId) {
908
1369
  function reconcileRemoteTracks(desired, pulled, current) {
909
1370
  const pulledByKey = new Map(pulled.map((publication) => [publicationKey(publication), publication]));
910
1371
  return new Map(
911
- [...desired].map(([key, publication]) => {
1372
+ [...desired].flatMap(([key, publication]) => {
912
1373
  const track = pulledByKey.get(key) ?? current.get(key);
913
- if (!track || track.publicationId !== publication.publicationId) throw new CloudflareSFUError("Cloudflare SFU did not return a requested remote track", "media_failed");
914
- return [key, track];
1374
+ if (!track || track.publicationId !== publication.publicationId) return [];
1375
+ return [[key, track]];
915
1376
  })
916
1377
  );
917
1378
  }
@@ -973,31 +1434,63 @@ function publicationEqual(left, right) {
973
1434
  function remotePublicationEqual(left, right) {
974
1435
  return right !== void 0 && left.participantId === right.participantId && left.source === right.source && left.publicationId === right.publicationId && left.track === right.track;
975
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
+ }
976
1461
 
977
1462
  // src/media/transport.ts
1463
+ var SIGNALING_TIMEOUT_MS = 8e3;
1464
+ var CREDENTIAL_TIMEOUT_MS = 7e3;
978
1465
  function createCloudflareSFUHTTPTransport(options) {
979
1466
  const fetch = options.fetch ?? globalThis.fetch;
980
1467
  const credential = requireCredential(options);
981
1468
  const base = options.apiBaseURL.replace(/\/$/, "");
982
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");
983
1472
  const request = async (path, init) => {
984
- const token = await credential();
1473
+ const token = await withDeadline(CREDENTIAL_TIMEOUT_MS, (signal) => credential(signal));
985
1474
  if (!token.trim()) throw new CloudflareSFUError("The media credential provider returned an empty token", "signaling_failed");
986
- const response = await fetch(`${mediaPath}/${path}`, {
987
- ...init,
988
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...init?.headers }
989
- });
990
- if (!response.ok) {
991
- throw new CloudflareSFUError(`Chalk SFU signaling failed with HTTP ${response.status}`, "signaling_failed", {
992
- status: response.status,
993
- 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 }
994
1480
  });
995
- }
996
- 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
+ });
997
1489
  };
998
1490
  return {
999
1491
  addTracks: async (input) => {
1000
- const response = await request("tracks", {
1492
+ const path = input.allowPartialRemoteTracks ? "tracks?allow_partial_remote_tracks=true" : "tracks";
1493
+ const response = await request(path, {
1001
1494
  method: "POST",
1002
1495
  body: JSON.stringify({ connection_id: input.connectionId, session_description: input.sessionDescription, tracks: input.tracks })
1003
1496
  });
@@ -1035,6 +1528,25 @@ function createCloudflareSFUHTTPTransport(options) {
1035
1528
  }
1036
1529
  };
1037
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
+ }
1038
1550
  function requireCredential(options) {
1039
1551
  if (options.credential) return options.credential;
1040
1552
  if (options.bearerToken !== void 0) return () => options.bearerToken ?? "";
@@ -1451,6 +1963,30 @@ function toParticipant(participant) {
1451
1963
  };
1452
1964
  }
1453
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
+
1454
1990
  export {
1455
1991
  CloudflareSFUError,
1456
1992
  parseCloudflareSFUPublicationID,
@@ -1463,5 +1999,6 @@ export {
1463
1999
  CloudflareSFUClient,
1464
2000
  createCloudflareSFUHTTPTransport,
1465
2001
  CloudflareRTKError,
1466
- CloudflareRTKClient
2002
+ CloudflareRTKClient,
2003
+ describeMediaCaptureError
1467
2004
  };