@q9labsai/chalk-client 4.1.15 → 4.1.17

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 (149) hide show
  1. package/README.md +100 -5
  2. package/dist/access/grant.d.ts.map +1 -1
  3. package/dist/access/manager.d.ts.map +1 -1
  4. package/dist/{chunk-PQNSA7ZN.js → chunk-2DZUHV6I.js} +782 -122
  5. package/dist/{chunk-6ONSFCIY.js → chunk-A7BVZEFM.js} +7 -3
  6. package/dist/{chunk-LQSXEWLL.js → chunk-AUQTQOLR.js} +2685 -511
  7. package/dist/{chunk-PWUU6T3R.js → chunk-CZUJNAUI.js} +1196 -874
  8. package/dist/{chunk-NKOKY2AE.js → chunk-EWTITNJ3.js} +15 -101
  9. package/dist/chunk-HW5TTZL2.js +99 -0
  10. package/dist/chunk-RCDXIDCN.js +33 -0
  11. package/dist/{chunk-LSDDADCN.js → chunk-ZRMGKMX4.js} +1728 -517
  12. package/dist/client.d.ts +1452 -465
  13. package/dist/client.d.ts.map +1 -1
  14. package/dist/connection/dependencies.d.ts +11 -0
  15. package/dist/connection/dependencies.d.ts.map +1 -1
  16. package/dist/connection/diagnostics.d.ts +1 -1
  17. package/dist/connection/diagnostics.d.ts.map +1 -1
  18. package/dist/connection/index.d.ts +3 -0
  19. package/dist/connection/index.d.ts.map +1 -1
  20. package/dist/connection/lifecycle.d.ts +4 -1
  21. package/dist/connection/lifecycle.d.ts.map +1 -1
  22. package/dist/connection/media-devices.d.ts.map +1 -1
  23. package/dist/connection/production.d.ts +4 -1
  24. package/dist/connection/production.d.ts.map +1 -1
  25. package/dist/connection/types.d.ts +2 -2
  26. package/dist/connection/types.d.ts.map +1 -1
  27. package/dist/effect.js +355 -130
  28. package/dist/feedback/collector.d.ts +14 -0
  29. package/dist/feedback/collector.d.ts.map +1 -0
  30. package/dist/feedback/context.d.ts +38 -0
  31. package/dist/feedback/context.d.ts.map +1 -0
  32. package/dist/feedback/controller.d.ts +4 -0
  33. package/dist/feedback/controller.d.ts.map +1 -0
  34. package/dist/feedback/index.d.ts +7 -0
  35. package/dist/feedback/index.d.ts.map +1 -0
  36. package/dist/feedback/transport.d.ts +29 -0
  37. package/dist/feedback/transport.d.ts.map +1 -0
  38. package/dist/feedback/types.d.ts +202 -0
  39. package/dist/feedback/types.d.ts.map +1 -0
  40. package/dist/feedback/validation.d.ts +30 -0
  41. package/dist/feedback/validation.d.ts.map +1 -0
  42. package/dist/foreground-deadline.d.ts +9 -0
  43. package/dist/foreground-deadline.d.ts.map +1 -0
  44. package/dist/generated/http-api.d.ts +1438 -461
  45. package/dist/generated/http-api.d.ts.map +1 -1
  46. package/dist/generated/schemas.d.ts +2667 -842
  47. package/dist/generated/schemas.d.ts.map +1 -1
  48. package/dist/index.d.ts +1 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +136 -21
  51. package/dist/media/camera-uplink.d.ts +13 -0
  52. package/dist/media/camera-uplink.d.ts.map +1 -0
  53. package/dist/media/capture-error.d.ts +27 -0
  54. package/dist/media/capture-error.d.ts.map +1 -0
  55. package/dist/media/client.d.ts +4 -0
  56. package/dist/media/client.d.ts.map +1 -1
  57. package/dist/media/index.d.ts +1 -0
  58. package/dist/media/index.d.ts.map +1 -1
  59. package/dist/media/index.js +5 -1
  60. package/dist/media/plane.d.ts +4 -0
  61. package/dist/media/plane.d.ts.map +1 -1
  62. package/dist/media/remote-track-response.d.ts +6 -0
  63. package/dist/media/remote-track-response.d.ts.map +1 -0
  64. package/dist/media/rtk.d.ts.map +1 -1
  65. package/dist/media/transport.d.ts.map +1 -1
  66. package/dist/media/types.d.ts +20 -2
  67. package/dist/media/types.d.ts.map +1 -1
  68. package/dist/public-invites/client.d.ts.map +1 -1
  69. package/dist/public-invites/index.js +4 -3
  70. package/dist/public-invites/types.d.ts +4 -2
  71. package/dist/public-invites/types.d.ts.map +1 -1
  72. package/dist/server/access-refresh.d.cts +13 -0
  73. package/dist/server/access-refresh.d.ts +14 -0
  74. package/dist/server/access-refresh.d.ts.map +1 -0
  75. package/dist/server/client.d.ts.map +1 -1
  76. package/dist/server/index.cjs +189 -23
  77. package/dist/server/index.d.cts +2 -1
  78. package/dist/server/index.d.ts +2 -1
  79. package/dist/server/index.d.ts.map +1 -1
  80. package/dist/server/index.js +187 -22
  81. package/dist/server/types.d.cts +76 -1
  82. package/dist/server/types.d.ts +76 -1
  83. package/dist/server/types.d.ts.map +1 -1
  84. package/dist/space-client/chat-controller.d.ts +3 -0
  85. package/dist/space-client/chat-controller.d.ts.map +1 -1
  86. package/dist/space-client/controller-effects.d.ts +6 -0
  87. package/dist/space-client/controller-effects.d.ts.map +1 -1
  88. package/dist/space-client/core.d.ts +12 -1
  89. package/dist/space-client/core.d.ts.map +1 -1
  90. package/dist/space-client/core.test.helpers.d.ts +2 -1
  91. package/dist/space-client/core.test.helpers.d.ts.map +1 -1
  92. package/dist/space-client/effect.d.ts +5 -0
  93. package/dist/space-client/effect.d.ts.map +1 -1
  94. package/dist/space-client/episode-diagnostic-runtime.d.ts +3 -0
  95. package/dist/space-client/episode-diagnostic-runtime.d.ts.map +1 -1
  96. package/dist/space-client/errors.d.ts.map +1 -1
  97. package/dist/space-client/index.d.ts +4 -1
  98. package/dist/space-client/index.d.ts.map +1 -1
  99. package/dist/space-client/media-controller.d.ts.map +1 -1
  100. package/dist/space-client/offline-actions-storage.d.ts +66 -0
  101. package/dist/space-client/offline-actions-storage.d.ts.map +1 -0
  102. package/dist/space-client/offline-actions.d.ts +36 -0
  103. package/dist/space-client/offline-actions.d.ts.map +1 -0
  104. package/dist/space-client/participants-controller.d.ts +2 -1
  105. package/dist/space-client/participants-controller.d.ts.map +1 -1
  106. package/dist/space-client/recording-controller.d.ts +20 -0
  107. package/dist/space-client/recording-controller.d.ts.map +1 -0
  108. package/dist/space-client/reload-rejoin.d.ts +24 -0
  109. package/dist/space-client/reload-rejoin.d.ts.map +1 -0
  110. package/dist/space-client/space-client.d.ts.map +1 -1
  111. package/dist/space-client/store.d.ts +3 -1
  112. package/dist/space-client/store.d.ts.map +1 -1
  113. package/dist/space-client/terminal.test.helpers.d.ts +115 -0
  114. package/dist/space-client/terminal.test.helpers.d.ts.map +1 -0
  115. package/dist/space-client/types.d.ts +42 -4
  116. package/dist/space-client/types.d.ts.map +1 -1
  117. package/dist/sync/index.js +2 -1
  118. package/dist/sync/v1-client.d.ts +2 -5
  119. package/dist/sync/v1-client.d.ts.map +1 -1
  120. package/dist/sync/v1-collaboration-state.d.ts +65 -0
  121. package/dist/sync/v1-collaboration-state.d.ts.map +1 -0
  122. package/dist/sync/v1-command-scheduler.d.ts +54 -0
  123. package/dist/sync/v1-command-scheduler.d.ts.map +1 -0
  124. package/dist/sync/v1-deferred.d.ts +8 -0
  125. package/dist/sync/v1-deferred.d.ts.map +1 -0
  126. package/dist/sync/v1-error.d.ts +5 -0
  127. package/dist/sync/v1-error.d.ts.map +1 -0
  128. package/dist/sync/v1-frame-signature.d.ts +2 -0
  129. package/dist/sync/v1-frame-signature.d.ts.map +1 -0
  130. package/dist/sync/v1-live-target-coordinator.d.ts +32 -0
  131. package/dist/sync/v1-live-target-coordinator.d.ts.map +1 -0
  132. package/dist/sync/v1-types.d.ts +3 -0
  133. package/dist/sync/v1-types.d.ts.map +1 -1
  134. package/dist/telemetry/index.js +9 -7
  135. package/dist/telemetry/reconnect.d.ts +7 -0
  136. package/dist/telemetry/reconnect.d.ts.map +1 -0
  137. package/dist/telemetry/trace.d.ts +1 -1
  138. package/dist/telemetry/trace.d.ts.map +1 -1
  139. package/dist/webhooks/generated/event-v1.d.cts +10 -2
  140. package/dist/webhooks/generated/event-v1.d.ts +10 -2
  141. package/dist/webhooks/generated/event-v1.d.ts.map +1 -1
  142. package/dist/webhooks/generated/fixtures-v1.d.cts +2 -2
  143. package/dist/webhooks/generated/fixtures-v1.d.ts +2 -2
  144. package/dist/webhooks/index.cjs +10 -2
  145. package/dist/webhooks/index.js +10 -2
  146. package/dist/webhooks/test.cjs +2 -2
  147. package/dist/webhooks/test.js +2 -2
  148. package/dist/whiteboard/v1-client.d.ts.map +1 -1
  149. package/package.json +1 -1
@@ -1,3 +1,44 @@
1
+ import {
2
+ recordReconnect,
3
+ traceReconnect
4
+ } from "./chunk-RCDXIDCN.js";
5
+ import {
6
+ journeyHeaders
7
+ } from "./chunk-HW5TTZL2.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,26 @@ 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;
233
+ #pollFailures = 0;
234
+ #pollFailedPull = false;
163
235
  #pollTimer;
164
236
  #sdpTail = Promise.resolve();
165
237
  #snapshot;
@@ -171,11 +243,12 @@ var CloudflareSFUClient = class {
171
243
  this.#participantId = options.participantId;
172
244
  this.#bootstrap = options.bootstrap;
173
245
  this.#transport = options.transport;
174
- this.#pollIntervalMs = options.pollIntervalMs ?? 1e3;
246
+ this.#pollIntervalMs = options.pollIntervalMs ?? 15e3;
175
247
  this.#replaceMediaConnection = options.replaceMediaConnection;
176
248
  this.#onError = options.onError;
177
249
  this.#onRemoteTrack = options.onRemoteTrack;
178
250
  this.#onRtcSummary = options.onRtcSummary;
251
+ this.#recordReconnect = options.recordReconnect;
179
252
  this.#onScreenEnded = options.onScreenEnded;
180
253
  this.#peerConnectionFactory = options.peerConnectionFactory;
181
254
  this.#connection = this.#createPeerConnection(options.bootstrap);
@@ -194,6 +267,19 @@ var CloudflareSFUClient = class {
194
267
  subscribe(listener) {
195
268
  return subscribeSnapshot(this.#snapshotListeners, listener);
196
269
  }
270
+ remotePublicationResumed(publicationId) {
271
+ this.#remotePullRetryAfter.delete(publicationId);
272
+ this.remotePublicationsChanged();
273
+ }
274
+ remotePublicationsChanged() {
275
+ recordReconnect(this.#recordReconnect, "remote_publications_changed");
276
+ if (this.#polling) {
277
+ this.#pollAfterCurrent = true;
278
+ return;
279
+ }
280
+ this.#clearPoll();
281
+ this.#schedulePoll(0);
282
+ }
197
283
  prepareLocalTrack(source, track) {
198
284
  this.#requireActive();
199
285
  validateTrackSource(source, track);
@@ -202,7 +288,7 @@ var CloudflareSFUClient = class {
202
288
  source,
203
289
  track,
204
290
  transceiver: this.#reusableLocalTransceivers.get(source) ?? null,
205
- providerPublicationId: null,
291
+ providerPublicationId: this.#reusableLocalPublicationIds.get(source) ?? null,
206
292
  pendingOperationId: null,
207
293
  pendingTrackName: null,
208
294
  desiredEnabled: false,
@@ -210,6 +296,7 @@ var CloudflareSFUClient = class {
210
296
  endedListener: null
211
297
  };
212
298
  this.#reusableLocalTransceivers.delete(source);
299
+ this.#reusableLocalPublicationIds.delete(source);
213
300
  if (source === "screen") {
214
301
  state.endedListener = () => {
215
302
  if (this.#localTracks.get("screen") === state) this.#invokeListener(() => this.#onScreenEnded?.());
@@ -225,6 +312,7 @@ var CloudflareSFUClient = class {
225
312
  if (!state) return;
226
313
  if (state.enabled) await this.#setPreparedTrackEnabled(state, false);
227
314
  if (state.transceiver) this.#reusableLocalTransceivers.set(source, state.transceiver);
315
+ if (state.providerPublicationId) this.#reusableLocalPublicationIds.set(source, state.providerPublicationId);
228
316
  this.#removeOwnedLocalTrack(state);
229
317
  this.#localTracks.delete(source);
230
318
  this.#publishSnapshot();
@@ -246,30 +334,124 @@ var CloudflareSFUClient = class {
246
334
  if (!this.#started || this.#stopped || this.#polling) return;
247
335
  this.#polling = true;
248
336
  const generation = this.#generation;
337
+ this.#pollFailedPull = false;
249
338
  try {
250
339
  const transport = this.#requireTransport();
251
- const authoritative = await transport.listPublications();
340
+ const authoritative = await traceReconnect(this.#recordReconnect, "list_publications", () => transport.listPublications());
252
341
  this.#requireGeneration(generation);
253
342
  await this.#reconcileRemotePublications(authoritative, generation);
343
+ this.#requireGeneration(generation);
344
+ if (!this.#remotePullIncomplete) this.#pollFailures = 0;
254
345
  } catch (error) {
255
346
  if (generation === this.#generation && !this.#stopped) this.#reportError(error);
256
347
  throw error;
257
348
  } finally {
258
- if (generation === this.#generation) this.#polling = false;
349
+ this.#finishPublicationPoll(generation);
259
350
  }
260
351
  }
352
+ #finishPublicationPoll(generation) {
353
+ if (generation !== this.#generation) return;
354
+ this.#polling = false;
355
+ if (!this.#pollAfterCurrent) return;
356
+ this.#pollAfterCurrent = false;
357
+ this.#clearPoll();
358
+ this.#schedulePoll(0);
359
+ }
261
360
  async setLocalPublicationTarget(target) {
262
361
  const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
263
362
  if (resolved.kind === "result") return resolved.result;
264
363
  const state = resolved.value;
265
- if (state.enabled === target.enabled) return { outcome: "satisfied", errorCode: null };
364
+ if (this.#pausedTarget(target)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
266
365
  try {
366
+ if (target.enabled) await this.#retireUnavailableLocalPublication(state);
367
+ if (this.#pausedTarget(target)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
368
+ if (state.enabled === target.enabled) return { outcome: "satisfied", errorCode: null };
267
369
  await this.#setPreparedTrackEnabled(state, target.enabled, target.operationId);
268
370
  return { outcome: "confirmed", errorCode: null };
269
371
  } catch (error) {
270
372
  if (!this.#stopped) this.#reportError(error);
271
- return { outcome: "retryable_failure", errorCode: error instanceof CloudflareSFUError ? error.code : "media_failed" };
373
+ return mediaTargetFailure(error);
374
+ }
375
+ }
376
+ #pausedTarget(target) {
377
+ return target.enabled && this.#locallyPausedSources.has(target.source);
378
+ }
379
+ setLocalSourceIntent(source, enabled) {
380
+ if (enabled) {
381
+ this.#locallyPausedSources.delete(source);
382
+ return;
383
+ }
384
+ this.#locallyPausedSources.add(source);
385
+ const state = this.#localTracks.get(source);
386
+ if (!state) return;
387
+ state.desiredEnabled = false;
388
+ state.track.enabled = false;
389
+ }
390
+ async closeForcedLocalPublication(source) {
391
+ this.setLocalSourceIntent(source, false);
392
+ await this.#retireLocalPublication(source);
393
+ if (!this.#replaceMediaConnection) return;
394
+ const generation = this.#generation;
395
+ await this.#serializeSDP(async () => {
396
+ const existing = new Set([...this.#localTracks.values()].filter((state) => state.source !== source && state.providerPublicationId !== null));
397
+ const states = await this.#prepareConnectionForPublication([], generation);
398
+ await this.#publishPreparedTracksSerialized(
399
+ states.filter((state) => existing.has(state)),
400
+ generation,
401
+ this.#connection,
402
+ this.#bootstrap.connectionId
403
+ );
404
+ this.#requireGeneration(generation);
405
+ this.#setPhase("live", null);
406
+ });
407
+ }
408
+ async #retireUnavailableLocalPublication(state) {
409
+ const publicationId = state.providerPublicationId;
410
+ if (!publicationId) return;
411
+ const authoritative = await traceReconnect(this.#recordReconnect, "list_publications", () => this.#requireTransport().listPublications());
412
+ if (authoritative.publications.some((publication) => publication.publicationId === publicationId)) return;
413
+ if (state.providerPublicationId === publicationId) await this.#retireLocalPublication(state.source);
414
+ }
415
+ async #retireLocalPublication(source) {
416
+ const state = this.#localTracks.get(source);
417
+ const transceiver = state?.transceiver ?? this.#reusableLocalTransceivers.get(source);
418
+ const publicationId = state?.providerPublicationId ?? this.#reusableLocalPublicationIds.get(source);
419
+ this.#reusableLocalTransceivers.delete(source);
420
+ this.#reusableLocalPublicationIds.delete(source);
421
+ if (state) {
422
+ state.track.enabled = false;
423
+ state.desiredEnabled = false;
424
+ state.enabled = false;
425
+ state.providerPublicationId = null;
426
+ state.transceiver = null;
272
427
  }
428
+ this.#publishSnapshot();
429
+ this.#emitLocal();
430
+ if (!transceiver || !publicationId) return;
431
+ const connection = this.#connection;
432
+ const connectionId = this.#bootstrap.connectionId;
433
+ this.#retiredLocalConnection = connection;
434
+ await this.#serializeSDP(async () => {
435
+ if (connection !== this.#connection) return;
436
+ const mid = requireTransceiverMid(transceiver);
437
+ await this.#boundPeerOperation(transceiver.sender.replaceTrack(null));
438
+ transceiver.stop();
439
+ const offer = await traceReconnect(this.#recordReconnect, "create_offer", () => connection.createOffer());
440
+ await traceReconnect(this.#recordReconnect, "set_local_description", () => connection.setLocalDescription(offer));
441
+ try {
442
+ const response = await this.#requireTransport().closeTracks({
443
+ connectionId,
444
+ ...providerDescription(requireDescription(offer)),
445
+ tracks: [{ mid, source, publicationId }],
446
+ force: false
447
+ });
448
+ await this.#applyProviderDescription(response, connection);
449
+ } catch (error) {
450
+ await this.#rollbackLocalOffer(connection);
451
+ if (!this.#stopped) this.#reportError(error);
452
+ throw error;
453
+ }
454
+ });
273
455
  }
274
456
  observeLocalPublications(listener) {
275
457
  this.#localListeners.add(listener);
@@ -285,26 +467,29 @@ var CloudflareSFUClient = class {
285
467
  this.#requireActive();
286
468
  const options = "connectionId" in input ? { bootstrap: input } : input;
287
469
  validateBootstrap(options.bootstrap);
470
+ recordReconnect(this.#recordReconnect, "media_rebuild_start");
288
471
  const generation = ++this.#generation;
289
472
  const connectionEpoch = ++this.#connectionEpoch;
290
473
  this.#polling = false;
474
+ this.#pollAfterCurrent = false;
291
475
  this.#clearPoll();
476
+ this.#remotePullRetryAfter.clear();
292
477
  this.#disposeConnection(false);
293
478
  this.#reusableLocalTransceivers.clear();
479
+ this.#reusableLocalPublicationIds.clear();
294
480
  this.#clearRemoteTracks();
295
481
  this.#cursor = null;
296
482
  this.#negotiatedGeneration = null;
297
483
  this.#replacementAttemptedGeneration = null;
484
+ if (options.bootstrap.connectionId !== this.#bootstrap.connectionId) this.#started = false;
298
485
  this.#bootstrap = options.bootstrap;
299
486
  if (options.transport) this.#transport = options.transport;
300
- this.#connection = this.#createPeerConnection(options.bootstrap);
301
- this.#observeConnection(this.#connection, generation, connectionEpoch);
487
+ this.#rebuildPeerConnection(options.bootstrap, generation, connectionEpoch);
302
488
  for (const state of this.#localTracks.values()) {
303
489
  state.transceiver = null;
304
490
  state.enabled = false;
305
491
  }
306
492
  const enabled = [...this.#localTracks.values()].filter((state) => state.desiredEnabled && state.track.readyState !== "ended");
307
- this.#setPhase("recovering", null);
308
493
  await this.#activatePreparedTracks(enabled, generation);
309
494
  }
310
495
  async #activatePreparedTracks(states, generation) {
@@ -325,9 +510,12 @@ var CloudflareSFUClient = class {
325
510
  this.#generation++;
326
511
  this.#connectionEpoch++;
327
512
  this.#clearPoll();
513
+ this.#remotePullRetryAfter.clear();
328
514
  this.#polling = false;
515
+ this.#pollAfterCurrent = false;
329
516
  this.#disposeConnection(true);
330
517
  this.#reusableLocalTransceivers.clear();
518
+ this.#reusableLocalPublicationIds.clear();
331
519
  this.#clearRemoteTracks();
332
520
  for (const state of this.#localTracks.values()) this.#removeOwnedLocalTrack(state);
333
521
  this.#localTracks.clear();
@@ -346,14 +534,33 @@ var CloudflareSFUClient = class {
346
534
  return this.#disablePreparedTrack(state);
347
535
  }
348
536
  async #enablePreparedTrack(state, operationId) {
537
+ if (state.providerPublicationId && state.transceiver) {
538
+ state.desiredEnabled = true;
539
+ state.track.enabled = true;
540
+ try {
541
+ await this.#boundPeerOperation(state.transceiver.sender.replaceTrack(state.track));
542
+ } catch (error) {
543
+ state.desiredEnabled = false;
544
+ state.track.enabled = false;
545
+ throw error;
546
+ }
547
+ state.enabled = !this.#locallyPausedSources.has(state.source);
548
+ state.desiredEnabled = state.enabled;
549
+ state.track.enabled = state.enabled;
550
+ if (!state.enabled) await this.#boundPeerOperation(state.transceiver.sender.replaceTrack(null));
551
+ this.#publishSnapshot();
552
+ this.#emitLocal();
553
+ return;
554
+ }
349
555
  if (operationId && state.pendingOperationId !== operationId) {
350
556
  state.pendingOperationId = operationId;
351
557
  state.pendingTrackName = `${state.source}-${operationId}`;
352
558
  }
353
559
  state.desiredEnabled = true;
354
560
  state.track.enabled = true;
561
+ const generation = this.#generation;
355
562
  try {
356
- await this.#publishPreparedTracks([state], this.#generation);
563
+ await this.#publishPreparedTracks([state], generation);
357
564
  } catch (error) {
358
565
  state.desiredEnabled = false;
359
566
  state.enabled = false;
@@ -365,26 +572,21 @@ var CloudflareSFUClient = class {
365
572
  const transceiver = state.transceiver;
366
573
  state.desiredEnabled = false;
367
574
  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
- }
575
+ const detach = transceiver ? this.#boundPeerOperation(transceiver.sender.replaceTrack(null)) : Promise.resolve();
375
576
  state.enabled = false;
376
- state.providerPublicationId = null;
377
577
  state.pendingOperationId = null;
378
578
  state.pendingTrackName = null;
379
579
  this.#publishSnapshot();
380
580
  this.#emitLocal();
581
+ await detach;
381
582
  }
382
583
  async #publishPreparedTracks(states, generation) {
383
584
  if (states.length === 0) return;
384
585
  await this.#serializeSDP(async () => {
385
- await this.#replaceDormantConnectionBeforeNegotiation(generation);
586
+ const pending = await this.#prepareConnectionForPublication(states, generation);
587
+ this.#replacementAttemptedGeneration = null;
386
588
  try {
387
- await this.#publishPreparedTracksSerialized(states, generation, this.#connection, this.#bootstrap.connectionId);
589
+ await this.#publishPreparedTracksSerialized(pending, generation, this.#connection, this.#bootstrap.connectionId);
388
590
  } catch (error) {
389
591
  if (!this.#replaceMediaConnection || !this.#isRetryableConnectionFailure(error) || this.#replacementAttemptedGeneration === generation) throw error;
390
592
  await this.#replaceMediaConnectionForRecovery(generation);
@@ -397,6 +599,25 @@ var CloudflareSFUClient = class {
397
599
  }
398
600
  });
399
601
  }
602
+ async #prepareConnectionForPublication(states, generation) {
603
+ if (this.#retiredLocalConnection !== this.#connection || !this.#replaceMediaConnection) {
604
+ await this.#replaceDormantConnectionBeforeNegotiation(generation);
605
+ return states;
606
+ }
607
+ const authoritative = await traceReconnect(this.#recordReconnect, "list_publications", () => this.#requireTransport().listPublications());
608
+ this.#pauseUnavailableOtherSources(authoritative.publications, states);
609
+ this.#replacementAttemptedGeneration = null;
610
+ await this.#replaceMediaConnectionForRecovery(generation);
611
+ return [...this.#localTracks.values()].filter((state) => (state.desiredEnabled || states.includes(state)) && state.track.readyState !== "ended");
612
+ }
613
+ #pauseUnavailableOtherSources(publications, states) {
614
+ for (const state of this.#localTracks.values()) {
615
+ if (states.includes(state) || !state.providerPublicationId) continue;
616
+ if (publications.some((publication) => publication.publicationId === state.providerPublicationId)) continue;
617
+ state.desiredEnabled = false;
618
+ state.track.enabled = false;
619
+ }
620
+ }
400
621
  async #publishPreparedTracksSerialized(states, generation, connection, connectionId) {
401
622
  this.#requireGeneration(generation);
402
623
  const pendingStates = states.filter((state) => !state.enabled);
@@ -404,7 +625,7 @@ var CloudflareSFUClient = class {
404
625
  const wasLive = connectionIsLive(connection);
405
626
  const publications = [];
406
627
  try {
407
- for (const state of pendingStates) publications.push(await this.#prepareLocalPublication(connection, state));
628
+ for (const state of pendingStates) publications.push(await this.#prepareLocalPublication(connection, state, generation));
408
629
  const response = await this.#negotiateLocalPublications(connection, connectionId, publications, generation);
409
630
  if (!wasLive) await this.#waitForConnection(connection, generation);
410
631
  this.#requireGeneration(generation);
@@ -413,21 +634,56 @@ var CloudflareSFUClient = class {
413
634
  this.#publishSnapshot();
414
635
  this.#emitLocal();
415
636
  } catch (error) {
416
- await this.#rollbackLocalOffer(connection);
417
- await this.#discardLocalPublications(publications);
637
+ if (generation === this.#generation) {
638
+ await this.#rollbackLocalOffer(connection);
639
+ await this.#discardLocalPublications(publications);
640
+ }
418
641
  throw error;
419
642
  }
420
643
  }
421
- async #prepareLocalPublication(connection, state) {
644
+ async #prepareLocalPublication(connection, state, generation) {
422
645
  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);
646
+ const transceiver = state.transceiver ?? this.#addLocalTransceiver(connection, state);
647
+ if (reusedTransceiver) await this.#boundPeerOperation(transceiver.sender.replaceTrack(state.track));
648
+ this.#requireGeneration(generation);
425
649
  state.transceiver = transceiver;
426
650
  return { state, transceiver, trackName: state.pendingTrackName ?? `${state.source}-${globalThis.crypto.randomUUID()}`, reusedTransceiver };
427
651
  }
652
+ #addLocalTransceiver(connection, state) {
653
+ if (state.source !== "camera" || firefoxCameraSimulcastUnsupported()) return connection.addTransceiver(state.track, { direction: "sendonly" });
654
+ const sendEncodings = [
655
+ { rid: "h", scaleResolutionDownBy: 1, maxBitrate: 25e5, scalabilityMode: "L1T1" },
656
+ { rid: "l", scaleResolutionDownBy: 2, maxBitrate: 65e4, scalabilityMode: "L1T1" }
657
+ ];
658
+ try {
659
+ return connection.addTransceiver(state.track, {
660
+ direction: "sendonly",
661
+ sendEncodings
662
+ });
663
+ } catch (error) {
664
+ if (!simulcastUnsupported(error)) throw error;
665
+ return connection.addTransceiver(state.track, { direction: "sendonly" });
666
+ }
667
+ }
428
668
  async #negotiateLocalPublications(connection, connectionId, publications, generation) {
429
- const offer = await connection.createOffer();
430
- await connection.setLocalDescription(offer);
669
+ let offer;
670
+ try {
671
+ offer = await traceReconnect(this.#recordReconnect, "create_offer", () => connection.createOffer());
672
+ await traceReconnect(this.#recordReconnect, "set_local_description", () => connection.setLocalDescription(offer));
673
+ } catch (error) {
674
+ const cameras = publications.filter(({ state, transceiver }) => state.source === "camera" && transceiver.sender.getParameters().encodings.length > 1);
675
+ if (!simulcastUnsupported(error) || cameras.length === 0) throw error;
676
+ await this.#rollbackLocalOffer(connection);
677
+ this.#requireGeneration(generation);
678
+ for (const publication of cameras) {
679
+ await this.#boundPeerOperation(publication.transceiver.sender.replaceTrack(null));
680
+ publication.transceiver.stop();
681
+ publication.transceiver = connection.addTransceiver(publication.state.track, { direction: "sendonly" });
682
+ publication.state.transceiver = publication.transceiver;
683
+ }
684
+ offer = await traceReconnect(this.#recordReconnect, "create_offer", () => connection.createOffer());
685
+ await traceReconnect(this.#recordReconnect, "set_local_description", () => connection.setLocalDescription(offer));
686
+ }
431
687
  const tracks = publications.map(
432
688
  ({ state, transceiver, trackName }) => ({
433
689
  location: "local",
@@ -436,17 +692,20 @@ var CloudflareSFUClient = class {
436
692
  source: state.source
437
693
  })
438
694
  );
439
- const response = await this.#requireTransport().addTracks({ connectionId, sessionDescription: requireDescription(offer), tracks });
695
+ const response = await traceReconnect(this.#recordReconnect, "publish_tracks", () => this.#requireTransport().addTracks({ connectionId, ...providerDescription(requireDescription(offer)), tracks }));
440
696
  this.#requireGeneration(generation);
441
- await connection.setRemoteDescription(requireSFUDescription(response.sessionDescription));
697
+ await this.#applyProviderDescription(response, connection);
442
698
  return response;
443
699
  }
444
700
  #confirmLocalPublications(publications, tracks) {
445
701
  for (const { state, transceiver } of publications) {
446
702
  const authoritative = tracks?.find((track) => track.location === "local" && track.mid === transceiver.mid && track.source === state.source);
447
703
  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;
704
+ if (state.transceiver !== transceiver) continue;
705
+ state.enabled = !this.#locallyPausedSources.has(state.source);
706
+ state.desiredEnabled = state.enabled;
707
+ state.track.enabled = state.enabled;
708
+ if (!state.enabled) void transceiver.sender.replaceTrack(null).catch((error) => this.#reportError(error));
450
709
  state.providerPublicationId = authoritative.publicationId;
451
710
  state.pendingOperationId = null;
452
711
  state.pendingTrackName = null;
@@ -482,9 +741,10 @@ var CloudflareSFUClient = class {
482
741
  const cursor = this.#validatedRemotePublicationCursor(authoritative);
483
742
  if (cursor === null) return;
484
743
  const ordering = comparePublicationCursor(this.#cursor, cursor);
485
- if (ordering !== "newer") return;
744
+ if (ordering === "stale" || ordering === "same" && !this.#remotePullIncomplete) return;
486
745
  const desired = this.#desiredRemotePublications(authoritative, cursor);
487
- const toPull = [...desired].filter(([key, publication]) => this.#remoteTracks.get(key)?.publicationId !== publication.publicationId).map(([, publication]) => publication);
746
+ const toPull = this.#pendingRemotePulls(desired);
747
+ this.#pollFailedPull = true;
488
748
  const pulled = await this.#pullWithRecovery(toPull, cursor, generation);
489
749
  if (pulled === null) return;
490
750
  this.#requireGeneration(generation);
@@ -493,10 +753,24 @@ var CloudflareSFUClient = class {
493
753
  this.#remoteTracks.clear();
494
754
  for (const [key, publication] of next) this.#remoteTracks.set(key, publication);
495
755
  this.#cursor = cursor;
756
+ this.#remotePullIncomplete = [...desired].some(([key, publication]) => next.get(key)?.publicationId !== publication.publicationId);
496
757
  for (const publication of pulled) this.#invokeListener(() => this.#onRemoteTrack?.(publication));
497
758
  this.#publishSnapshot();
498
759
  this.#emitRemote();
499
760
  }
761
+ #pendingRemotePulls(desired) {
762
+ const desiredIds = new Set([...desired.values()].map((publication) => publication.publicationId));
763
+ for (const publicationId of this.#remotePullRetryAfter.keys()) {
764
+ if (!desiredIds.has(publicationId)) this.#remotePullRetryAfter.delete(publicationId);
765
+ }
766
+ const now = Date.now();
767
+ return [...desired].filter(([key, publication]) => {
768
+ if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) return false;
769
+ const remaining = (this.#remotePullRetryAfter.get(publication.publicationId) ?? 0) - now;
770
+ if (remaining > 0) recordReconnect(this.#recordReconnect, "remote_pull_deferred", { remaining_ms: remaining, source: publication.source });
771
+ return remaining <= 0;
772
+ }).map(([, publication]) => publication);
773
+ }
500
774
  #validatedRemotePublicationCursor(authoritative) {
501
775
  if (this.#cursor?.signature === INVALID_PUBLICATION_SIGNATURE && this.#cursor.incarnation === authoritative.incarnation && this.#cursor.sequence === authoritative.sequence) return null;
502
776
  try {
@@ -520,7 +794,7 @@ var CloudflareSFUClient = class {
520
794
  } catch (error) {
521
795
  if (generation !== this.#generation || this.#stopped) throw error;
522
796
  if (!this.#canReplaceConnectionAfterRemotePull(error, generation)) {
523
- this.#observeRemotePublicationCursor(cursor);
797
+ this.#observeRemotePublicationCursor(cursor, retryableRemotePull(error));
524
798
  throw error;
525
799
  }
526
800
  return this.#retryRemotePullOnReplacement(publications, cursor, generation);
@@ -539,7 +813,7 @@ var CloudflareSFUClient = class {
539
813
  return await this.#pull(publications, generation);
540
814
  } catch (error) {
541
815
  if (generation !== this.#generation || this.#stopped) throw error;
542
- this.#observeRemotePublicationCursor(cursor);
816
+ this.#observeRemotePublicationCursor(cursor, retryableRemotePull(error));
543
817
  throw error;
544
818
  }
545
819
  }
@@ -550,7 +824,15 @@ var CloudflareSFUClient = class {
550
824
  const connectionId = this.#bootstrap.connectionId;
551
825
  const requested = publications.map((publication) => {
552
826
  const reference = parseCloudflareSFUPublicationID(publication.publicationId);
553
- return { location: "remote", sessionId: reference.connectionId, trackName: reference.trackName };
827
+ return {
828
+ location: "remote",
829
+ sessionId: reference.connectionId,
830
+ trackName: reference.trackName,
831
+ // h sorts before l: prefer full camera quality, but keep video flowing
832
+ // when the publisher pauses h. Do not silently downgrade a live h layer.
833
+ // https://developers.cloudflare.com/realtime/sfu/features/simulcast/#quality-control
834
+ ...publication.source === "camera" ? { simulcast: { preferredRid: "h", priorityOrdering: "none", ridNotAvailable: "asciibetical" } } : {}
835
+ };
554
836
  });
555
837
  const received = /* @__PURE__ */ new Map();
556
838
  const onTrack = (event) => {
@@ -559,17 +841,20 @@ var CloudflareSFUClient = class {
559
841
  connection.addEventListener("track", onTrack);
560
842
  try {
561
843
  this.#requireGeneration(generation);
562
- const response = await this.#requireTransport().addTracks({ connectionId, tracks: requested });
844
+ const response = await traceReconnect(this.#recordReconnect, "pull_tracks", () => this.#requireTransport().addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }));
563
845
  this.#requireGeneration(generation);
846
+ this.#recordRemotePullErrors(publications, response.trackErrors ?? []);
564
847
  const responseTracks = response.tracks ?? [];
848
+ const matched = matchRemotePullTracks(publications, responseTracks);
849
+ for (const { publication } of matched) this.#remotePullRetryAfter.delete(publication.publicationId);
565
850
  await this.#completeRenegotiation(response, connection, connectionId, generation);
851
+ if (matched.length === 0) return [];
566
852
  await this.#waitForConnection(connection, generation);
567
853
  this.#negotiatedGeneration = generation;
568
- await waitFor(() => responseTracks.every((track) => track.mid !== void 0 && received.has(track.mid)), 5e3);
854
+ await traceReconnect(this.#recordReconnect, "remote_track_arrival", () => waitFor(() => responseTracks.every((track) => track.mid !== void 0 && received.has(track.mid)), 5e3));
569
855
  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);
856
+ return matched.map(({ publication, mid }) => {
857
+ const track = received.get(mid);
573
858
  if (!track) throw new CloudflareSFUError("A negotiated remote track did not arrive", "media_failed");
574
859
  return Object.freeze({ ...publication, track });
575
860
  });
@@ -581,38 +866,151 @@ var CloudflareSFUClient = class {
581
866
  }
582
867
  });
583
868
  }
869
+ #recordRemotePullErrors(publications, failures) {
870
+ const requested = new Map(
871
+ publications.map((publication) => {
872
+ const reference = parseCloudflareSFUPublicationID(publication.publicationId);
873
+ return [`${reference.connectionId}\0${reference.trackName}`, publication];
874
+ })
875
+ );
876
+ for (const failure of failures) {
877
+ const publication = requested.get(`${failure.connectionId}\0${failure.trackName}`);
878
+ if (!publication) throw new CloudflareSFUError("Cloudflare SFU returned an unrequested remote track error", "invalid_publication");
879
+ const delay = 750;
880
+ this.#remotePullRetryAfter.set(publication.publicationId, Date.now() + delay);
881
+ recordReconnect(this.#recordReconnect, "remote_pull_backoff", { delay_ms: delay, retry_kind: failure.code === "empty_track_error" ? "empty_track" : "provider_error", source: publication.source });
882
+ this.#reportError(new CloudflareSFUError(`Cloudflare SFU remote track failed: ${failure.code}`, "media_failed", { providerCode: failure.code }));
883
+ }
884
+ }
584
885
  async #completeRenegotiation(response, connection, connectionId, generation) {
585
886
  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) });
887
+ await this.#applyProviderDescription(response, connection);
888
+ const answer = await traceReconnect(this.#recordReconnect, "create_answer", () => connection.createAnswer());
889
+ await traceReconnect(this.#recordReconnect, "set_local_answer", () => connection.setLocalDescription(answer));
890
+ await traceReconnect(this.#recordReconnect, "renegotiate", () => this.#requireTransport().renegotiate({ connectionId, ...providerDescription(requireDescription(answer)) }));
591
891
  this.#requireGeneration(generation);
592
892
  }
893
+ async #applyProviderDescription(response, connection = this.#connection) {
894
+ await traceReconnect(this.#recordReconnect, "set_remote_description", () => connection.setRemoteDescription(requireSFUDescription(response.sessionDescription)));
895
+ }
593
896
  #serializeSDP(operation) {
594
- const result = this.#sdpTail.then(operation, operation);
897
+ const generation = this.#generation;
898
+ const queuedAt = performance.now();
899
+ let expired = false;
900
+ let timer;
901
+ const timeout = new CloudflareSFUError("Cloudflare SFU negotiation timed out. Try again.", "negotiation_timeout");
902
+ const deadline = new Promise((_resolve, reject) => {
903
+ timer = globalThis.setTimeout(() => {
904
+ expired = true;
905
+ if (generation === this.#generation && !this.#stopped) this.#expireNegotiation(timeout);
906
+ reject(timeout);
907
+ }, NEGOTIATION_TIMEOUT_MS);
908
+ });
909
+ const run = () => {
910
+ if (expired || generation !== this.#generation) throw timeout;
911
+ recordReconnect(this.#recordReconnect, "sdp_queue", { duration_ms: performance.now() - queuedAt });
912
+ return operation();
913
+ };
914
+ const pending = this.#sdpTail.then(run, run);
915
+ const result = Promise.race([pending, deadline]).catch((error) => {
916
+ if (error instanceof CloudflareSFUError && error.code === "signaling_timeout" && generation === this.#generation && !this.#stopped) this.#expireNegotiation(error);
917
+ throw error;
918
+ }).finally(() => {
919
+ if (timer !== void 0) globalThis.clearTimeout(timer);
920
+ });
595
921
  this.#sdpTail = result.then(
596
922
  () => void 0,
597
923
  () => void 0
598
924
  );
599
925
  return result;
600
926
  }
927
+ async #boundPeerOperation(operation) {
928
+ const generation = this.#generation;
929
+ let timer;
930
+ const timeout = new CloudflareSFUError("The browser media operation timed out. Try again.", "negotiation_timeout");
931
+ const deadline = new Promise((_resolve, reject) => {
932
+ timer = globalThis.setTimeout(() => {
933
+ if (generation === this.#generation && !this.#stopped) this.#expireNegotiation(timeout);
934
+ reject(timeout);
935
+ }, PEER_OPERATION_TIMEOUT_MS);
936
+ });
937
+ try {
938
+ await Promise.race([operation, deadline]);
939
+ } finally {
940
+ if (timer !== void 0) globalThis.clearTimeout(timer);
941
+ }
942
+ }
943
+ #expireNegotiation(error) {
944
+ this.#generation++;
945
+ this.#connectionEpoch++;
946
+ this.#clearPoll();
947
+ this.#polling = false;
948
+ this.#disposeConnection(false);
949
+ this.#setFailure(error, "media_failed");
950
+ }
601
951
  #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"
952
+ return traceReconnect(
953
+ this.#recordReconnect,
954
+ "connection_ready",
955
+ () => waitFor(
956
+ () => {
957
+ this.#requireGeneration(generation);
958
+ return connectionIsLive(connection);
959
+ },
960
+ CONNECTION_TIMEOUT_MS,
961
+ "Timed out waiting for the Cloudflare SFU peer connection"
962
+ )
609
963
  );
610
964
  }
965
+ #cameraSimulcastSender(state) {
966
+ if (!state?.enabled) return void 0;
967
+ const sender = state.transceiver?.sender;
968
+ if (!sender?.track || sender.getParameters().encodings.length < 2) return void 0;
969
+ return sender;
970
+ }
971
+ async #applyCameraUplink(sender, report, policy) {
972
+ const parameters = sender.getParameters();
973
+ const low = parameters.encodings.find((encoding) => encoding.rid === "l");
974
+ if (!low) return;
975
+ const active = policy.sample(cameraUplinkBitrate(report.values()), low.active !== false);
976
+ if (active === (low.active !== false)) return;
977
+ low.active = active;
978
+ parameters.degradationPreference = "maintain-framerate";
979
+ await sender.setParameters(parameters);
980
+ }
611
981
  #observeConnection(connection, generation, connectionEpoch) {
982
+ this.#disposeConnectionObservation?.();
612
983
  let disposed = false;
984
+ let cameraTimer;
985
+ const cameraPolicy = new CameraUplinkPolicy();
986
+ let observedCamera;
987
+ const current = () => !disposed && generation === this.#generation && connectionEpoch === this.#connectionEpoch && !this.#stopped && connection.connectionState !== "failed" && connection.connectionState !== "closed";
988
+ const adaptCamera = async () => {
989
+ if (!current()) return;
990
+ try {
991
+ const state = this.#localTracks.get("camera");
992
+ if (state !== observedCamera) {
993
+ cameraPolicy.reset();
994
+ observedCamera = state;
995
+ }
996
+ const sender = this.#cameraSimulcastSender(state);
997
+ if (!sender) {
998
+ cameraPolicy.reset();
999
+ return;
1000
+ }
1001
+ const report = await connection.getStats();
1002
+ if (!current() || this.#cameraSimulcastSender(state) !== sender || connection.signalingState !== "stable") return;
1003
+ await this.#applyCameraUplink(sender, report, cameraPolicy);
1004
+ } catch {
1005
+ cameraPolicy.reset();
1006
+ } finally {
1007
+ if (current()) cameraTimer = globalThis.setTimeout(adaptCamera, 1e3);
1008
+ }
1009
+ };
613
1010
  const dispose = () => {
614
1011
  if (disposed) return;
615
1012
  disposed = true;
1013
+ if (cameraTimer !== void 0) globalThis.clearTimeout(cameraTimer);
616
1014
  connection.removeEventListener("connectionstatechange", capture);
617
1015
  connection.removeEventListener("iceconnectionstatechange", capture);
618
1016
  connection.removeEventListener("signalingstatechange", capture);
@@ -625,6 +1023,7 @@ var CloudflareSFUClient = class {
625
1023
  };
626
1024
  const capture = () => {
627
1025
  if (disposed || generation !== this.#generation || connectionEpoch !== this.#connectionEpoch || this.#stopped) return;
1026
+ recordReconnect(this.#recordReconnect, "rtc_state", { connection_state: connection.connectionState, ice_connection_state: connection.iceConnectionState, signaling_state: connection.signalingState });
628
1027
  observe();
629
1028
  const recorder = this.#onRtcSummary;
630
1029
  if (recorder) {
@@ -641,11 +1040,67 @@ var CloudflareSFUClient = class {
641
1040
  }
642
1041
  if (connection.connectionState === "closed") dispose();
643
1042
  };
1043
+ this.#disposeConnectionObservation = dispose;
1044
+ cameraTimer = globalThis.setTimeout(adaptCamera, 1e3);
1045
+ if (this.#recordReconnect) this.#sampleRecoveryTraffic(connection, generation, connectionEpoch);
644
1046
  connection.addEventListener("connectionstatechange", capture);
645
1047
  connection.addEventListener("iceconnectionstatechange", capture);
646
1048
  connection.addEventListener("signalingstatechange", capture);
647
1049
  capture();
648
1050
  }
1051
+ #sampleRecoveryTraffic(connection, generation, connectionEpoch) {
1052
+ const traffic = /* @__PURE__ */ new Map();
1053
+ let dtlsState;
1054
+ const current = () => generation === this.#generation && connectionEpoch === this.#connectionEpoch && !this.#stopped && connection.connectionState !== "closed" && connection.connectionState !== "failed";
1055
+ const sample = async () => {
1056
+ if (!current()) return;
1057
+ try {
1058
+ const report = await connection.getStats();
1059
+ if (!current()) return;
1060
+ const now = performance.now();
1061
+ report.forEach((entry) => {
1062
+ if (entry.type === "transport") {
1063
+ const nextState = rtcString(entry, "dtlsState");
1064
+ if (nextState !== dtlsState) recordReconnect(this.#recordReconnect, "dtls_state", { dtls_state: nextState ?? "unknown" });
1065
+ dtlsState = nextState;
1066
+ }
1067
+ this.#observeRecoveryTraffic(entry, traffic, now);
1068
+ });
1069
+ } catch {
1070
+ if (current()) recordReconnect(this.#recordReconnect, "rtc_stats_unavailable");
1071
+ }
1072
+ if (current()) globalThis.setTimeout(() => void sample(), 250);
1073
+ };
1074
+ void sample();
1075
+ }
1076
+ #observeRecoveryTraffic(entry, traffic, now) {
1077
+ if (entry.type !== "inbound-rtp" && entry.type !== "outbound-rtp") return;
1078
+ const direction = entry.type === "inbound-rtp" ? "inbound" : "outbound";
1079
+ const kind = rtcString(entry, "kind") ?? "unknown";
1080
+ const previous = traffic.get(entry.id);
1081
+ const next = previous ?? {
1082
+ packets: { count: 0, lastProgress: now, stalled: false },
1083
+ frames: { count: 0, lastProgress: now, stalled: false }
1084
+ };
1085
+ const packets = recoveryTrafficCount(entry, direction, "packet");
1086
+ const frames = recoveryTrafficCount(entry, direction, "frame");
1087
+ this.#observeTrafficCounter(next.packets, { count: packets, now, direction, kind, unit: "packet", existed: previous !== void 0 });
1088
+ this.#observeTrafficCounter(next.frames, { count: frames, now, direction, kind, unit: "frame", existed: previous !== void 0 });
1089
+ traffic.set(entry.id, next);
1090
+ }
1091
+ #observeTrafficCounter(progress, sample) {
1092
+ const attributes = { direction: sample.direction, kind: sample.kind };
1093
+ if (sample.count > progress.count) {
1094
+ if (progress.count === 0) recordReconnect(this.#recordReconnect, `first_${sample.unit}`, attributes);
1095
+ else if (progress.stalled) recordReconnect(this.#recordReconnect, sample.unit === "packet" ? "rtp_resumed" : "frame_resumed", attributes);
1096
+ progress.lastProgress = sample.now;
1097
+ progress.stalled = false;
1098
+ } else if (trafficStalled(progress, sample)) {
1099
+ progress.stalled = true;
1100
+ recordReconnect(this.#recordReconnect, sample.unit === "packet" ? "rtp_stall" : "frame_stall", attributes);
1101
+ }
1102
+ progress.count = sample.count;
1103
+ }
649
1104
  async #replaceDormantConnectionBeforeNegotiation(generation) {
650
1105
  if (!this.#started || this.#negotiatedGeneration === generation || this.#replacementAttemptedGeneration === generation || !this.#replaceMediaConnection) return;
651
1106
  await this.#replaceMediaConnectionForRecovery(generation);
@@ -656,7 +1111,8 @@ var CloudflareSFUClient = class {
656
1111
  throw new CloudflareSFUError("A fresh Cloudflare SFU connection is unavailable", "signaling_failed");
657
1112
  }
658
1113
  this.#replacementAttemptedGeneration = generation;
659
- const bootstrap = await this.#replaceMediaConnection();
1114
+ const replaceMediaConnection = this.#replaceMediaConnection;
1115
+ const bootstrap = await traceReconnect(this.#recordReconnect, "replace_access", replaceMediaConnection);
660
1116
  this.#requireGeneration(generation);
661
1117
  validateBootstrap(bootstrap);
662
1118
  if (bootstrap.connectionId === this.#bootstrap.connectionId) {
@@ -665,25 +1121,27 @@ var CloudflareSFUClient = class {
665
1121
  const connectionEpoch = ++this.#connectionEpoch;
666
1122
  this.#disposeConnection(false);
667
1123
  this.#reusableLocalTransceivers.clear();
1124
+ this.#reusableLocalPublicationIds.clear();
668
1125
  this.#clearRemoteTracks();
669
1126
  this.#cursor = null;
670
1127
  this.#bootstrap = bootstrap;
671
- this.#connection = this.#createPeerConnection(bootstrap);
1128
+ this.#rebuildPeerConnection(bootstrap, generation, connectionEpoch);
1129
+ this.#retiredLocalConnection = null;
672
1130
  this.#negotiatedGeneration = null;
673
- this.#observeConnection(this.#connection, generation, connectionEpoch);
674
1131
  for (const state of this.#localTracks.values()) {
675
1132
  state.transceiver = null;
676
1133
  state.enabled = false;
677
1134
  state.providerPublicationId = null;
678
1135
  }
679
- this.#setPhase("recovering", null);
1136
+ this.remotePublicationsChanged();
680
1137
  }
681
1138
  async #republishPreparedTracksAfterRecovery(generation) {
682
1139
  const enabled = [...this.#localTracks.values()].filter((state) => state.desiredEnabled && state.track.readyState !== "ended");
683
1140
  await this.#publishPreparedTracks(enabled, generation);
684
1141
  }
685
- #observeRemotePublicationCursor(cursor) {
1142
+ #observeRemotePublicationCursor(cursor, pullIncomplete = false) {
686
1143
  this.#cursor = cursor;
1144
+ this.#remotePullIncomplete = pullIncomplete;
687
1145
  this.#publishSnapshot();
688
1146
  }
689
1147
  #quarantineInvalidRemotePublicationCursor(authoritative) {
@@ -697,7 +1155,19 @@ var CloudflareSFUClient = class {
697
1155
  const create = this.#peerConnectionFactory ?? ((configuration) => new RTCPeerConnection(configuration));
698
1156
  return create({ iceServers: [{ urls: bootstrap.stunServer }], bundlePolicy: "max-bundle" });
699
1157
  }
1158
+ #rebuildPeerConnection(bootstrap, generation, connectionEpoch) {
1159
+ try {
1160
+ this.#connection = this.#createPeerConnection(bootstrap);
1161
+ this.#observeConnection(this.#connection, generation, connectionEpoch);
1162
+ } catch (error) {
1163
+ this.#setFailure(error, "media_failed");
1164
+ throw error;
1165
+ }
1166
+ }
700
1167
  #disposeConnection(stopSenders) {
1168
+ if (!stopSenders) this.#setPhase("recovering", null);
1169
+ this.#disposeConnectionObservation?.();
1170
+ this.#disposeConnectionObservation = void 0;
701
1171
  if (stopSenders) {
702
1172
  for (const sender of this.#connection.getSenders()) {
703
1173
  if (sender.track) safeStopTrack(sender.track, this.#reportError.bind(this));
@@ -737,15 +1207,31 @@ var CloudflareSFUClient = class {
737
1207
  if (this.#stopped || !this.#started || this.#pollTimer !== void 0) return;
738
1208
  this.#pollTimer = globalThis.setTimeout(async () => {
739
1209
  this.#pollTimer = void 0;
1210
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") {
1211
+ this.#schedulePoll();
1212
+ return;
1213
+ }
1214
+ let nextDelay = this.#pollIntervalMs;
740
1215
  try {
741
1216
  await this.refreshRemotePublications();
1217
+ if (this.#remotePullIncomplete) nextDelay = this.#incompletePullDelay();
742
1218
  } catch {
1219
+ if (this.#pollFailedPull && this.#remotePullIncomplete) nextDelay = this.#incompletePullDelay();
1220
+ else {
1221
+ this.#pollFailures = Math.min(this.#pollFailures + 1, 6);
1222
+ nextDelay = Math.min(this.#pollIntervalMs, (500 + Math.random() * 500) * 2 ** (this.#pollFailures - 1));
1223
+ }
743
1224
  } finally {
744
- this.#schedulePoll();
1225
+ this.#schedulePoll(nextDelay);
745
1226
  }
746
1227
  }, delayMs);
747
1228
  }
1229
+ #incompletePullDelay() {
1230
+ this.#pollFailures = Math.min(this.#pollFailures + 1, 6);
1231
+ return Math.min(this.#pollIntervalMs, 750 * 2 ** (this.#pollFailures - 1));
1232
+ }
748
1233
  #clearPoll() {
1234
+ this.#pollFailures = 0;
749
1235
  if (this.#pollTimer !== void 0) globalThis.clearTimeout(this.#pollTimer);
750
1236
  this.#pollTimer = void 0;
751
1237
  }
@@ -754,7 +1240,7 @@ var CloudflareSFUClient = class {
754
1240
  participantId: this.#participantId,
755
1241
  source: state.source,
756
1242
  enabled: state.enabled,
757
- publicationId: state.enabled ? state.providerPublicationId : null
1243
+ publicationId: state.providerPublicationId
758
1244
  }));
759
1245
  }
760
1246
  #projectRemotePublications() {
@@ -772,7 +1258,7 @@ var CloudflareSFUClient = class {
772
1258
  (state) => Object.freeze({
773
1259
  source: state.source,
774
1260
  enabled: state.enabled,
775
- publicationId: state.enabled ? state.providerPublicationId : null,
1261
+ publicationId: state.providerPublicationId,
776
1262
  track: state.track
777
1263
  })
778
1264
  ) : EMPTY_LOCAL,
@@ -908,10 +1394,10 @@ function desiredRemotePublications(publications, participantId) {
908
1394
  function reconcileRemoteTracks(desired, pulled, current) {
909
1395
  const pulledByKey = new Map(pulled.map((publication) => [publicationKey(publication), publication]));
910
1396
  return new Map(
911
- [...desired].map(([key, publication]) => {
1397
+ [...desired].flatMap(([key, publication]) => {
912
1398
  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];
1399
+ if (!track || track.publicationId !== publication.publicationId) return [];
1400
+ return [[key, track]];
915
1401
  })
916
1402
  );
917
1403
  }
@@ -973,31 +1459,63 @@ function publicationEqual(left, right) {
973
1459
  function remotePublicationEqual(left, right) {
974
1460
  return right !== void 0 && left.participantId === right.participantId && left.source === right.source && left.publicationId === right.publicationId && left.track === right.track;
975
1461
  }
1462
+ function providerDescription(description) {
1463
+ return { sessionDescription: description };
1464
+ }
1465
+ function retryableRemotePull(error) {
1466
+ return error instanceof CloudflareSFUError && ["signaling_failed", "signaling_timeout", "media_failed", "negotiation_timeout"].includes(error.code);
1467
+ }
1468
+ function firefoxCameraSimulcastUnsupported() {
1469
+ return typeof navigator !== "undefined" && /\bFirefox\/\d+/i.test(navigator.userAgent);
1470
+ }
1471
+ function simulcastUnsupported(error) {
1472
+ return error instanceof Error && ["NotSupportedError", "OperationError", "TypeError"].includes(error.name);
1473
+ }
1474
+ function mediaTargetFailure(error) {
1475
+ const code = error instanceof CloudflareSFUError ? error.code : "media_failed";
1476
+ const ambiguous = code === "signaling_timeout" || code === "negotiation_timeout";
1477
+ return { outcome: ambiguous ? "ambiguous" : "retryable_failure", errorCode: code };
1478
+ }
1479
+ function recoveryTrafficCount(entry, direction, unit) {
1480
+ const property = unit === "packet" ? direction === "inbound" ? "packetsReceived" : "packetsSent" : direction === "inbound" ? "framesDecoded" : "framesEncoded";
1481
+ return rtcNumber(entry, property) ?? 0;
1482
+ }
1483
+ function trafficStalled(progress, sample) {
1484
+ return sample.existed && !progress.stalled && sample.now - progress.lastProgress >= 1e3 && (sample.unit === "packet" || progress.count > 0);
1485
+ }
976
1486
 
977
1487
  // src/media/transport.ts
1488
+ var SIGNALING_TIMEOUT_MS = 8e3;
1489
+ var CREDENTIAL_TIMEOUT_MS = 7e3;
978
1490
  function createCloudflareSFUHTTPTransport(options) {
979
1491
  const fetch = options.fetch ?? globalThis.fetch;
980
1492
  const credential = requireCredential(options);
981
1493
  const base = options.apiBaseURL.replace(/\/$/, "");
982
1494
  const mediaPath = `${base}/v1/tenants/${encodeURIComponent(options.tenantId)}/spaces/${encodeURIComponent(options.spaceId)}/episodes/${encodeURIComponent(options.episodeId)}/participants/${encodeURIComponent(options.participantId)}/media/sfu`;
1495
+ const timeoutMs = options.requestTimeoutMs ?? SIGNALING_TIMEOUT_MS;
1496
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new CloudflareSFUError("The signaling deadline is invalid", "signaling_failed");
983
1497
  const request = async (path, init) => {
984
- const token = await credential();
1498
+ const token = await withDeadline(CREDENTIAL_TIMEOUT_MS, (signal) => credential(signal));
985
1499
  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
1500
+ return withDeadline(timeoutMs, async (signal) => {
1501
+ const response = await fetch(`${mediaPath}/${path}`, {
1502
+ ...init,
1503
+ signal,
1504
+ headers: { ...options.telemetry ? journeyHeaders(options.telemetry) : {}, Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...init?.headers }
994
1505
  });
995
- }
996
- return await response.json();
1506
+ if (!response.ok) {
1507
+ throw new CloudflareSFUError(`Chalk SFU signaling failed with HTTP ${response.status}`, "signaling_failed", {
1508
+ status: response.status,
1509
+ retryableConnection: response.status === 404 || response.status === 410 || response.status >= 500 && response.status < 600
1510
+ });
1511
+ }
1512
+ return await response.json();
1513
+ });
997
1514
  };
998
1515
  return {
999
1516
  addTracks: async (input) => {
1000
- const response = await request("tracks", {
1517
+ const path = input.allowPartialRemoteTracks ? "tracks?allow_partial_remote_tracks=true" : "tracks";
1518
+ const response = await request(path, {
1001
1519
  method: "POST",
1002
1520
  body: JSON.stringify({ connection_id: input.connectionId, session_description: input.sessionDescription, tracks: input.tracks })
1003
1521
  });
@@ -1035,6 +1553,25 @@ function createCloudflareSFUHTTPTransport(options) {
1035
1553
  }
1036
1554
  };
1037
1555
  }
1556
+ async function withDeadline(timeoutMs, operation) {
1557
+ const controller = new AbortController();
1558
+ const timeout = new CloudflareSFUError("Cloudflare SFU signaling timed out. Try again.", "signaling_timeout");
1559
+ let timer;
1560
+ const deadline = new Promise((_resolve, reject) => {
1561
+ timer = globalThis.setTimeout(() => {
1562
+ controller.abort();
1563
+ reject(timeout);
1564
+ }, timeoutMs);
1565
+ });
1566
+ try {
1567
+ return await Promise.race([Promise.resolve().then(() => operation(controller.signal)), deadline]);
1568
+ } catch (error) {
1569
+ if (controller.signal.aborted) throw timeout;
1570
+ throw error;
1571
+ } finally {
1572
+ if (timer !== void 0) globalThis.clearTimeout(timer);
1573
+ }
1574
+ }
1038
1575
  function requireCredential(options) {
1039
1576
  if (options.credential) return options.credential;
1040
1577
  if (options.bearerToken !== void 0) return () => options.bearerToken ?? "";
@@ -1059,12 +1596,14 @@ var CloudflareRTKClient = class {
1059
1596
  #clientFactory;
1060
1597
  #snapshotListeners = /* @__PURE__ */ new Set();
1061
1598
  #sourceOperations = /* @__PURE__ */ new Map();
1599
+ #generationInvalidators = /* @__PURE__ */ new Set();
1062
1600
  #connection = null;
1063
1601
  #localTracks = /* @__PURE__ */ new Map();
1064
1602
  #connectionUnsubscribers = [];
1065
1603
  #participantUnsubscribers = /* @__PURE__ */ new Map();
1066
1604
  #snapshot;
1067
1605
  #stopped = false;
1606
+ #generation = 0;
1068
1607
  #screenDisableRequested = false;
1069
1608
  #initialAuthToken;
1070
1609
  constructor(options) {
@@ -1101,16 +1640,22 @@ var CloudflareRTKClient = class {
1101
1640
  async clearPreparedLocalTrack(source) {
1102
1641
  const state = this.#localTracks.get(source);
1103
1642
  if (!state) return;
1104
- if (state.enabled) {
1105
- await this.#setSourceEnabled(state, false);
1106
- }
1643
+ const connection = this.#connection;
1107
1644
  if (state.endedListener) state.track.removeEventListener("ended", state.endedListener);
1108
1645
  this.#localTracks.delete(source);
1646
+ const actions = connection ? this.#sourceActions(state, connection) : null;
1647
+ const cleanup = actions?.enabled && state.enabled ? this.#queueSourceOperation(source, () => connection === this.#connection ? actions.disable() : Promise.resolve()) : Promise.resolve();
1109
1648
  this.#publishSnapshot();
1649
+ try {
1650
+ await cleanup;
1651
+ } catch (error) {
1652
+ if (!isStaleGeneration(error)) throw error;
1653
+ }
1110
1654
  }
1111
1655
  async start(localMedia) {
1112
1656
  if (this.#snapshot.connection.phase === "live") return;
1113
1657
  this.#requireStartable();
1658
+ const generation = this.#advanceGeneration();
1114
1659
  this.#setPhase("connecting", null);
1115
1660
  try {
1116
1661
  const tracks = localMedia.getTracks().filter((track) => track.kind === "audio" || track.kind === "video");
@@ -1118,38 +1663,60 @@ var CloudflareRTKClient = class {
1118
1663
  const source = track.kind === "audio" ? "microphone" : "camera";
1119
1664
  if (!this.#localTracks.has(source)) this.#localTracks.set(source, { source, track, enabled: false, endedListener: null });
1120
1665
  }
1121
- await this.#openConnection(this.#initialAuthToken);
1122
- for (const state of this.#localTracks.values()) {
1123
- if (state.source === "microphone" || state.source === "camera") await this.#setSourceEnabled(state, true);
1124
- }
1125
- this.#setPhase("live", null);
1666
+ if (!await this.#openConnection(this.#initialAuthToken, generation)) return;
1667
+ await this.#restoreLocalTracks(generation, true);
1126
1668
  } catch (error) {
1127
- this.#setFailure(error);
1128
- this.#reportError(error);
1669
+ this.#failCurrentGeneration(generation, error);
1129
1670
  throw error;
1130
1671
  }
1131
1672
  }
1132
1673
  async setLocalPublicationTarget(target) {
1133
1674
  const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
1134
1675
  if (resolved.kind === "result") return resolved.result;
1135
- const state = resolved.value;
1136
- const previous = this.#sourceOperations.get(target.source) ?? Promise.resolve();
1137
- const operation = previous.catch(() => void 0).then(async () => {
1138
- if (state.enabled === target.enabled) return "satisfied";
1139
- try {
1140
- await this.#setSourceEnabled(state, target.enabled);
1141
- return "confirmed";
1142
- } catch (error) {
1143
- this.#reportError(error);
1144
- return "retryable_failure";
1145
- }
1676
+ const generation = this.#generation;
1677
+ try {
1678
+ return await this.#queueSourceOperation(target.source, () => this.#applyLocalTarget(target, generation));
1679
+ } catch (error) {
1680
+ return { outcome: "retryable_failure", errorCode: error instanceof CloudflareRTKError ? error.code : "media_failed" };
1681
+ }
1682
+ }
1683
+ async #queueSourceOperation(source, apply) {
1684
+ const previous = this.#sourceOperations.get(source) ?? Promise.resolve();
1685
+ const generation = this.#generation;
1686
+ let invalidate = () => {
1687
+ };
1688
+ const retired = new Promise((_resolve, reject) => {
1689
+ invalidate = () => reject(new CloudflareRTKError("The media operation belongs to a retired connection", "stale_generation"));
1690
+ this.#generationInvalidators.add(invalidate);
1146
1691
  });
1147
- this.#sourceOperations.set(target.source, operation);
1692
+ const operation = Promise.race([
1693
+ previous.catch(() => void 0).then(() => {
1694
+ if (!this.#isCurrentGeneration(generation)) throw new CloudflareRTKError("The queued media operation belongs to a retired connection", "stale_generation");
1695
+ return apply();
1696
+ }),
1697
+ retired
1698
+ ]);
1699
+ this.#sourceOperations.set(source, operation);
1148
1700
  try {
1149
- const outcome = await operation;
1150
- return { outcome, errorCode: outcome === "retryable_failure" ? "media_failed" : null };
1701
+ return await operation;
1151
1702
  } finally {
1152
- if (this.#sourceOperations.get(target.source) === operation) this.#sourceOperations.delete(target.source);
1703
+ this.#generationInvalidators.delete(invalidate);
1704
+ if (this.#sourceOperations.get(source) === operation) this.#sourceOperations.delete(source);
1705
+ }
1706
+ }
1707
+ async #applyLocalTarget(target, generation) {
1708
+ if (!this.#isCurrentGeneration(generation)) return { outcome: "retryable_failure", errorCode: "stale_generation" };
1709
+ const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
1710
+ if (resolved.kind === "result") return resolved.result;
1711
+ const state = resolved.value;
1712
+ try {
1713
+ if (!this.#connection) throw new CloudflareRTKError("The RealtimeKit connection is recovering", "stale_generation");
1714
+ if (state.enabled === target.enabled) return { outcome: "satisfied", errorCode: null };
1715
+ await this.#setSourceEnabled(state, target.enabled);
1716
+ return { outcome: "confirmed", errorCode: null };
1717
+ } catch (error) {
1718
+ this.#reportError(error);
1719
+ return { outcome: "retryable_failure", errorCode: error instanceof CloudflareRTKError ? error.code : "media_failed" };
1153
1720
  }
1154
1721
  }
1155
1722
  observeLocalPublications(listener) {
@@ -1161,31 +1728,74 @@ var CloudflareRTKClient = class {
1161
1728
  async restart(input) {
1162
1729
  if (!isRTKAccess(input)) throw new CloudflareRTKError("The RealtimeKit adapter requires a Cloudflare RealtimeKit access grant", "invalid_client");
1163
1730
  if (this.#stopped) throw new CloudflareRTKError("The RealtimeKit media client has stopped", "media_stopped");
1731
+ const generation = this.#advanceGeneration();
1164
1732
  this.#setPhase("recovering", null);
1165
1733
  try {
1166
1734
  await this.#closeConnection();
1167
- await this.#openConnection(input.clientPayload.token);
1168
- for (const state of this.#localTracks.values()) if (state.enabled) await this.#setSourceEnabled(state, true);
1169
- this.#setPhase("live", null);
1735
+ if (!this.#isCurrentGeneration(generation)) return;
1736
+ if (!await this.#openConnection(input.clientPayload.token, generation)) return;
1737
+ await this.#restoreLocalTracks(generation, false);
1170
1738
  } catch (error) {
1171
- this.#setFailure(error);
1172
- this.#reportError(error);
1739
+ this.#failCurrentGeneration(generation, error);
1173
1740
  throw error;
1174
1741
  }
1175
1742
  }
1176
1743
  stop() {
1177
1744
  if (this.#stopped) return;
1178
1745
  this.#stopped = true;
1746
+ this.#advanceGeneration();
1179
1747
  const leave = this.#closeConnection();
1180
1748
  void leave.catch((error) => this.#reportError(error));
1181
1749
  for (const state of this.#localTracks.values()) if (state.endedListener) state.track.removeEventListener("ended", state.endedListener);
1182
1750
  this.#localTracks.clear();
1183
1751
  this.#setPhase("stopped", null);
1184
1752
  }
1185
- async #openConnection(authToken) {
1186
- this.#connection = await this.#clientFactory({ authToken, onError: (error) => this.#reportError(error) });
1187
- this.#bindConnection(this.#connection);
1188
- await this.#connection.join();
1753
+ #isCurrentGeneration(generation) {
1754
+ return !this.#stopped && generation === this.#generation;
1755
+ }
1756
+ #advanceGeneration() {
1757
+ const generation = ++this.#generation;
1758
+ this.#sourceOperations.clear();
1759
+ for (const invalidate of this.#generationInvalidators) invalidate();
1760
+ this.#generationInvalidators.clear();
1761
+ return generation;
1762
+ }
1763
+ async #restoreLocalTracks(generation, initial) {
1764
+ for (const state of this.#localTracks.values()) {
1765
+ if (!this.#isCurrentGeneration(generation)) return;
1766
+ const enable = initial ? state.source !== "screen" : state.enabled;
1767
+ if (enable) {
1768
+ await this.#queueSourceOperation(state.source, async () => {
1769
+ if (!this.#isCurrentGeneration(generation) || this.#localTracks.get(state.source) !== state || !initial && !state.enabled) return;
1770
+ await this.#setSourceEnabled(state, true);
1771
+ }).catch((error) => {
1772
+ if (!this.#isCurrentGeneration(generation)) return;
1773
+ if (isStaleGeneration(error) && this.#localTracks.get(state.source) !== state) return;
1774
+ throw error;
1775
+ });
1776
+ }
1777
+ }
1778
+ if (this.#isCurrentGeneration(generation)) this.#setPhase("live", null);
1779
+ }
1780
+ #failCurrentGeneration(generation, error) {
1781
+ if (!this.#isCurrentGeneration(generation)) return;
1782
+ this.#setFailure(error);
1783
+ this.#reportError(error);
1784
+ }
1785
+ async #openConnection(authToken, generation) {
1786
+ const connection = await this.#clientFactory({ authToken, onError: (error) => this.#reportError(error) });
1787
+ if (!this.#isCurrentGeneration(generation)) {
1788
+ await connection.leave();
1789
+ return false;
1790
+ }
1791
+ this.#connection = connection;
1792
+ this.#bindConnection(connection);
1793
+ await connection.join();
1794
+ if (!this.#isCurrentGeneration(generation)) {
1795
+ await connection.leave();
1796
+ return false;
1797
+ }
1798
+ return true;
1189
1799
  }
1190
1800
  async #closeConnection() {
1191
1801
  const connection = this.#connection;
@@ -1237,22 +1847,44 @@ var CloudflareRTKClient = class {
1237
1847
  }
1238
1848
  async #setSourceEnabled(state, enabled) {
1239
1849
  const connection = this.#connection;
1850
+ const generation = this.#generation;
1240
1851
  if (!connection) throw new CloudflareRTKError("The RealtimeKit connection is not active", "media_stopped");
1241
- if (state.source === "microphone") {
1242
- if (enabled) await connection.self.enableAudio(state.track);
1243
- else await connection.self.disableAudio();
1244
- } else if (state.source === "camera") {
1245
- if (enabled) await connection.self.enableVideo(state.track);
1246
- else await connection.self.disableVideo();
1247
- } else {
1248
- this.#screenDisableRequested = !enabled;
1249
- if (enabled) await connection.self.enableScreenShare();
1250
- else await connection.self.disableScreenShare();
1852
+ const actions = this.#sourceActions(state, connection);
1853
+ if (state.source === "screen") this.#screenDisableRequested = !enabled;
1854
+ await actions[enabled ? "enable" : "disable"]();
1855
+ try {
1856
+ this.#requireCurrentSource(state, connection, generation);
1857
+ } catch (error) {
1858
+ if (enabled)
1859
+ await actions.disable().catch((cause) => {
1860
+ this.#failCurrentGeneration(generation, cause);
1861
+ throw cause;
1862
+ });
1863
+ throw error;
1251
1864
  }
1252
1865
  state.enabled = enabled;
1253
1866
  state.track.enabled = enabled;
1254
1867
  this.#publishSnapshot();
1255
1868
  }
1869
+ #sourceActions(state, connection) {
1870
+ return {
1871
+ microphone: { enabled: connection.self.audioEnabled, enable: () => connection.self.enableAudio(state.track), disable: () => connection.self.disableAudio() },
1872
+ camera: { enabled: connection.self.videoEnabled, enable: () => connection.self.enableVideo(state.track), disable: () => connection.self.disableVideo() },
1873
+ screen: {
1874
+ enabled: connection.self.screenShareEnabled,
1875
+ enable: () => connection.self.enableScreenShare(),
1876
+ disable: () => {
1877
+ if (connection === this.#connection) this.#screenDisableRequested = true;
1878
+ return connection.self.disableScreenShare();
1879
+ }
1880
+ }
1881
+ }[state.source];
1882
+ }
1883
+ #requireCurrentSource(state, connection, generation) {
1884
+ if (!this.#isCurrentGeneration(generation) || connection !== this.#connection || this.#localTracks.get(state.source) !== state) {
1885
+ throw new CloudflareRTKError("The local media target belongs to a replaced connection or source", "stale_generation");
1886
+ }
1887
+ }
1256
1888
  #syncLocalState(source) {
1257
1889
  const state = this.#localTracks.get(source);
1258
1890
  const connection = this.#connection;
@@ -1450,8 +2082,36 @@ function toParticipant(participant) {
1450
2082
  }
1451
2083
  };
1452
2084
  }
2085
+ function isStaleGeneration(error) {
2086
+ return error instanceof CloudflareRTKError && error.code === "stale_generation";
2087
+ }
2088
+
2089
+ // src/media/capture-error.ts
2090
+ function describeMediaCaptureError(cause) {
2091
+ const name = typeof cause === "object" && cause !== null && "name" in cause ? cause.name : void 0;
2092
+ switch (name) {
2093
+ case "NotAllowedError":
2094
+ case "PermissionDeniedError":
2095
+ return { code: "permission_denied", recoverable: true, message: "Camera or microphone permission was denied. Allow access in your device settings and try again." };
2096
+ case "NotFoundError":
2097
+ case "DevicesNotFoundError":
2098
+ 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." };
2099
+ case "NotReadableError":
2100
+ case "TrackStartError":
2101
+ return { code: "device_busy", recoverable: true, message: "The camera or microphone could not start. Close other apps using it and try again." };
2102
+ case "OverconstrainedError":
2103
+ case "ConstraintNotSatisfiedError":
2104
+ return { code: "device_constraint_invalid", recoverable: true, message: "The selected camera or microphone is unavailable. Select another input device and try again." };
2105
+ case "SecurityError":
2106
+ case "NotSupportedError":
2107
+ return { code: "unsupported_environment", recoverable: false, message: "Media capture is unavailable in this environment. Use a supported browser over HTTPS or the native app." };
2108
+ default:
2109
+ return { code: "media_capture_failed", recoverable: true, message: "Media capture failed. Try again or enter with devices off." };
2110
+ }
2111
+ }
1453
2112
 
1454
2113
  export {
2114
+ describeMediaCaptureError,
1455
2115
  CloudflareSFUError,
1456
2116
  parseCloudflareSFUPublicationID,
1457
2117
  requireDescription,