@q9labsai/chalk-client 5.2.4 → 5.2.6

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 (35) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/access/grant.d.ts +2 -1
  3. package/dist/access/manager.d.ts +1 -0
  4. package/dist/{chunk-AZONR3Q3.js → chunk-CJVLTSIO.js} +120 -8
  5. package/dist/{chunk-LDPC2JZB.js → chunk-I7Y4HR2P.js} +711 -194
  6. package/dist/{chunk-SYUGRHZ5.js → chunk-WFRTUBN6.js} +1 -1
  7. package/dist/{chunk-3ASKGMD3.js → chunk-YIDKETOX.js} +410 -232
  8. package/dist/client.d.ts +74 -2
  9. package/dist/connection/dependencies.d.ts +4 -1
  10. package/dist/connection/diagnostics.d.ts +4 -0
  11. package/dist/connection/episode-status.d.ts +7 -0
  12. package/dist/connection/index.d.ts +2 -0
  13. package/dist/connection/lifecycle.d.ts +2 -0
  14. package/dist/connection/production.d.ts +1 -1
  15. package/dist/effect.js +19 -3
  16. package/dist/generated/http-api.d.ts +73 -2
  17. package/dist/generated/schemas.d.ts +126 -4
  18. package/dist/index.js +4 -4
  19. package/dist/media/client.d.ts +4 -3
  20. package/dist/media/failure-diagnostics.d.ts +10 -0
  21. package/dist/media/index.js +1 -1
  22. package/dist/media/plane.d.ts +7 -2
  23. package/dist/media/types.d.ts +4 -2
  24. package/dist/public-invites/index.js +2 -2
  25. package/dist/server/access-refresh.d.cts +1 -1
  26. package/dist/server/access-refresh.d.ts +1 -1
  27. package/dist/server/index.cjs +15 -5
  28. package/dist/server/index.js +15 -5
  29. package/dist/server/types.d.cts +2 -1
  30. package/dist/server/types.d.ts +2 -1
  31. package/dist/space-client/media-projection-monitor.d.ts +26 -0
  32. package/dist/space-client/promise-facade.d.ts +5 -5
  33. package/dist/space-client/types.d.ts +4 -0
  34. package/dist/transport-diagnostics.d.ts +1 -1
  35. package/package.json +1 -1
@@ -176,6 +176,61 @@ function matchRemotePullTracks(publications, tracks) {
176
176
  });
177
177
  }
178
178
 
179
+ // src/media/failure-diagnostics.ts
180
+ var steps = /* @__PURE__ */ new WeakMap();
181
+ var fetchFailures = /* @__PURE__ */ new WeakSet();
182
+ var domErrorKinds = /* @__PURE__ */ new Map([
183
+ ["NotAllowedError", "NotAllowedError"],
184
+ ["NotReadableError", "NotReadableError"],
185
+ ["NotFoundError", "NotFoundError"],
186
+ ["OverconstrainedError", "OverconstrainedError"],
187
+ ["AbortError", "AbortError"],
188
+ ["SecurityError", "SecurityError"],
189
+ ["InvalidStateError", "InvalidStateError"],
190
+ ["OperationError", "OperationError"],
191
+ ["NotSupportedError", "NotSupportedError"],
192
+ ["TimeoutError", "timeout"],
193
+ ["NetworkError", "network"]
194
+ ]);
195
+ function grantedMediaFromStream(stream) {
196
+ const liveTracks = stream.getTracks().filter((track) => track.readyState === "live");
197
+ return {
198
+ granted_audio: liveTracks.some((track) => track.kind === "audio") ? "granted" : "not_granted",
199
+ granted_video: liveTracks.some((track) => track.kind === "video") ? "granted" : "not_granted"
200
+ };
201
+ }
202
+ function markMediaFetchFailure(error) {
203
+ if (typeof error === "object" && error !== null) fetchFailures.add(error);
204
+ }
205
+ function markMediaFailure(error, step) {
206
+ if (typeof error === "object" && error !== null && !steps.has(error)) steps.set(error, step);
207
+ }
208
+ function unwrapMediaFailure(error, step) {
209
+ for (let depth = 0; depth < 8; depth++) {
210
+ if (typeof error !== "object" || error === null) break;
211
+ step = steps.get(error) ?? step;
212
+ if (fetchFailures.has(error)) break;
213
+ if (!("cause" in error) || error.cause === void 0) break;
214
+ error = error.cause;
215
+ }
216
+ return { error, step };
217
+ }
218
+ function classifyMediaFailure(cause, fallbackStep) {
219
+ const { error, step } = unwrapMediaFailure(cause, fallbackStep);
220
+ if (error instanceof CloudflareSFUError) {
221
+ const status = error.options.status;
222
+ return {
223
+ media_error_kind: error.code === "signaling_timeout" || error.code === "negotiation_timeout" ? "timeout" : "CloudflareSFUError",
224
+ media_error_step: step,
225
+ ...status !== void 0 && Number.isInteger(status) && status >= 400 && status <= 599 ? { http_status: status } : {}
226
+ };
227
+ }
228
+ if (typeof DOMException !== "undefined" && error instanceof DOMException) {
229
+ return { media_error_kind: domErrorKinds.get(error.name) ?? "unknown", media_error_step: step };
230
+ }
231
+ return { media_error_kind: error instanceof TypeError && fetchFailures.has(error) ? "network" : "unknown", media_error_step: step };
232
+ }
233
+
179
234
  // src/media/client.ts
180
235
  var EMPTY_LOCAL = Object.freeze([]);
181
236
  var EMPTY_REMOTE = Object.freeze([]);
@@ -183,6 +238,7 @@ var INVALID_PUBLICATION_SIGNATURE = "\0invalid";
183
238
  var CONNECTION_TIMEOUT_MS = 8e3;
184
239
  var NEGOTIATION_TIMEOUT_MS = 35e3;
185
240
  var PEER_OPERATION_TIMEOUT_MS = 8e3;
241
+ var CAPTURE_REUSE_WINDOW_MS = 2e4;
186
242
  var CloudflareSFUClient = class {
187
243
  #localListeners = /* @__PURE__ */ new Set();
188
244
  #onError;
@@ -194,19 +250,21 @@ var CloudflareSFUClient = class {
194
250
  #replaceMediaConnection;
195
251
  #remoteListeners = /* @__PURE__ */ new Set();
196
252
  #snapshotListeners = /* @__PURE__ */ new Set();
197
- #locallyPausedSources = /* @__PURE__ */ new Set();
253
+ #failureOwners = /* @__PURE__ */ new WeakMap();
254
+ #locallyPausedSources = /* @__PURE__ */ new Map();
198
255
  #localTracks = /* @__PURE__ */ new Map();
199
- #reusableLocalTransceivers = /* @__PURE__ */ new Map();
200
- #reusableLocalPublicationIds = /* @__PURE__ */ new Map();
256
+ #ownedPublications = /* @__PURE__ */ new Set();
201
257
  #remoteTracks = /* @__PURE__ */ new Map();
202
258
  #bootstrap;
203
259
  #connection;
204
260
  #disposeConnectionObservation;
205
261
  #connectionEpoch = 0;
206
262
  #retiredLocalConnection = null;
263
+ #retiredLocalCause = "forced_pause";
264
+ #replacementCause;
207
265
  #cursor = null;
208
266
  #remotePullIncomplete = false;
209
- #remotePullRetryAfter = /* @__PURE__ */ new Map();
267
+ #remotePullRetries = /* @__PURE__ */ new Map();
210
268
  #remoteParticipantIds = null;
211
269
  #remoteParticipantRevision = 0;
212
270
  #subscribedPublicationIds = /* @__PURE__ */ new Set();
@@ -220,6 +278,7 @@ var CloudflareSFUClient = class {
220
278
  #pollFailedPull = false;
221
279
  #pollTimer;
222
280
  #sdpTail = Promise.resolve();
281
+ #retirement = Promise.resolve();
223
282
  #snapshot;
224
283
  #started = false;
225
284
  #stopped = false;
@@ -252,7 +311,7 @@ var CloudflareSFUClient = class {
252
311
  return subscribeSnapshot(this.#snapshotListeners, listener);
253
312
  }
254
313
  remotePublicationResumed(publicationId) {
255
- this.#remotePullRetryAfter.delete(publicationId);
314
+ this.#remotePullRetries.delete(publicationId);
256
315
  this.remotePublicationsChanged();
257
316
  }
258
317
  remoteParticipantsChanged(participantIds) {
@@ -273,20 +332,17 @@ var CloudflareSFUClient = class {
273
332
  this.#requireActive();
274
333
  validateTrackSource(source, track);
275
334
  if (this.#localTracks.has(source)) throw new CloudflareSFUError(`A ${source} track is already prepared`, "media_failed");
335
+ const reusable = [...this.#ownedPublications].find((item) => item.state.source === source && item.connection === this.#connection && item.lifecycle.kind === "confirmed");
276
336
  const state = {
277
337
  source,
278
338
  track,
279
- transceiver: this.#reusableLocalTransceivers.get(source) ?? null,
280
- providerPublicationId: this.#reusableLocalPublicationIds.get(source) ?? null,
281
- pendingOperationId: null,
282
- pendingTrackName: null,
339
+ transceiver: reusable?.transceiver ?? null,
340
+ providerPublicationId: reusable?.lifecycle.kind === "confirmed" ? reusable.lifecycle.handle.publicationId : null,
283
341
  desiredEnabled: false,
284
342
  enabled: false,
285
343
  endedListener: null,
286
344
  clearing: null
287
345
  };
288
- this.#reusableLocalTransceivers.delete(source);
289
- this.#reusableLocalPublicationIds.delete(source);
290
346
  if (source === "screen") {
291
347
  state.endedListener = () => {
292
348
  if (this.#localTracks.get("screen") === state) this.#invokeListener(() => this.#onScreenEnded?.());
@@ -310,9 +366,10 @@ var CloudflareSFUClient = class {
310
366
  }
311
367
  async #clearPreparedTrack(state, stopTrack = true) {
312
368
  const source = state.source;
313
- if (source === "screen_audio") {
369
+ if (source === "screen" || source === "screen_audio") {
314
370
  try {
315
- await this.#retireLocalPublication(source);
371
+ this.#pauseLocalSource(source, "capture_cleanup");
372
+ await this.#retireOwnedPublications();
316
373
  } finally {
317
374
  this.#removeOwnedLocalTrack(state, stopTrack);
318
375
  if (this.#localTracks.get(source) === state) this.#localTracks.delete(source);
@@ -321,10 +378,8 @@ var CloudflareSFUClient = class {
321
378
  }
322
379
  return;
323
380
  }
324
- if (state.enabled) await this.#setPreparedTrackEnabled(state, false);
381
+ if (state.enabled) await this.#disablePreparedTrack(state);
325
382
  if (this.#localTracks.get(source) !== state) return;
326
- if (state.transceiver) this.#reusableLocalTransceivers.set(source, state.transceiver);
327
- if (state.providerPublicationId) this.#reusableLocalPublicationIds.set(source, state.providerPublicationId);
328
383
  this.#removeOwnedLocalTrack(state, stopTrack);
329
384
  this.#localTracks.delete(source);
330
385
  this.#publishSnapshot();
@@ -349,7 +404,7 @@ var CloudflareSFUClient = class {
349
404
  this.#pollFailedPull = false;
350
405
  try {
351
406
  const transport = this.#requireTransport();
352
- const authoritative = await (() => transport.listPublications())();
407
+ const authoritative = await this.#signal(() => transport.listPublications());
353
408
  this.#requireGeneration(generation);
354
409
  await this.#reconcileRemotePublications(authoritative, generation);
355
410
  this.#requireGeneration(generation);
@@ -370,46 +425,63 @@ var CloudflareSFUClient = class {
370
425
  this.#schedulePoll(0);
371
426
  }
372
427
  async setLocalPublicationTarget(target) {
373
- const result = await this.#setSinglePublicationTarget(target);
428
+ if (!target.enabled) return this.#pausePublicationTarget(target);
429
+ const result = await this.#enablePublicationTarget(target);
374
430
  if (target.source !== "screen" || !this.#localTracks.has("screen_audio") || result.outcome !== "confirmed" && result.outcome !== "satisfied") return result;
375
- return this.#setSinglePublicationTarget({ ...target, source: "screen_audio" });
431
+ return this.#enablePublicationTarget({ ...target, source: "screen_audio" });
376
432
  }
377
- async #setSinglePublicationTarget(target) {
433
+ async #pausePublicationTarget(target) {
434
+ const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
435
+ if (resolved.kind === "result" && resolved.result.outcome !== "satisfied") return resolved.result;
436
+ const outcome = resolved.kind === "state" && resolved.value.enabled ? "confirmed" : "satisfied";
437
+ if (target.source !== "screen" && this.#locallyPausedSources.get(target.source) === "capture_cleanup") {
438
+ await this.pauseLocalCapture(target.source);
439
+ return { outcome, errorCode: null };
440
+ }
441
+ this.#pauseLocalSource(target.source);
442
+ try {
443
+ await this.#retireOwnedPublications();
444
+ return { outcome, errorCode: null };
445
+ } catch (error) {
446
+ return mediaTargetFailure(error);
447
+ }
448
+ }
449
+ async #enablePublicationTarget(target) {
450
+ const generation = this.#generation;
378
451
  const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
379
452
  if (resolved.kind === "result") return resolved.result;
380
453
  const state = resolved.value;
381
- if (this.#pausedTarget(target)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
454
+ if (this.#locallyPausedSources.has(target.source)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
382
455
  try {
383
- if (target.enabled) await this.#retireUnavailableLocalPublication(state);
384
- if (this.#pausedTarget(target)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
385
- if (state.enabled === target.enabled) return { outcome: "satisfied", errorCode: null };
386
- await this.#setPreparedTrackEnabled(state, target.enabled, target.operationId);
456
+ await this.#retireUnavailableLocalPublication(state);
457
+ if (this.#locallyPausedSources.has(target.source)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
458
+ if (state.enabled) return { outcome: "satisfied", errorCode: null };
459
+ if (!await this.#enablePreparedTrack(state)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
387
460
  return { outcome: "confirmed", errorCode: null };
388
461
  } catch (error) {
389
- if (!this.#stopped) this.#reportError(error);
462
+ if (generation === this.#generation && !this.#stopped) this.#reportError(error);
390
463
  return mediaTargetFailure(error);
391
464
  }
392
465
  }
393
- #pausedTarget(target) {
394
- return target.enabled && this.#locallyPausedSources.has(target.source);
395
- }
396
- setLocalSourceIntent(source, enabled) {
397
- if (source === "screen") this.setLocalSourceIntent("screen_audio", enabled);
398
- if (enabled) {
399
- this.#locallyPausedSources.delete(source);
400
- return;
401
- }
402
- this.#locallyPausedSources.add(source);
466
+ async pauseLocalCapture(source) {
467
+ this.#locallyPausedSources.set(source, "capture_cleanup");
403
468
  const state = this.#localTracks.get(source);
404
- if (!state) return;
405
- state.desiredEnabled = false;
406
- state.track.enabled = false;
469
+ if (state) await this.#disablePreparedTrack(state);
407
470
  }
408
- async closeForcedLocalPublication(source) {
409
- this.setLocalSourceIntent(source, false);
410
- await this.#retireLocalPublication(source);
411
- if (source === "screen") await this.#retireLocalPublication("screen_audio");
412
- if (!this.#replaceMediaConnection) return;
471
+ setLocalSourceIntent(source, enabled, cause = "forced_pause") {
472
+ const sources = localSourceGroup(source);
473
+ for (const item of sources) {
474
+ if (enabled) this.#locallyPausedSources.delete(item);
475
+ else this.#locallyPausedSources.set(item, cause);
476
+ }
477
+ if (!enabled) this.#pauseLocalSource(source, cause);
478
+ return enabled ? Promise.resolve() : this.#retireOwnedPublications();
479
+ }
480
+ async closeForcedLocalPublication(source, cause = "forced_pause") {
481
+ const sources = localSourceGroup(source);
482
+ const ownsPublication = [...this.#ownedPublications].some((item) => sources.includes(item.state.source));
483
+ await this.setLocalSourceIntent(source, false, cause);
484
+ if (!ownsPublication || !this.#replaceMediaConnection) return;
413
485
  const generation = this.#generation;
414
486
  await this.#serializeSDP(async () => {
415
487
  const existing = new Set([...this.#localTracks.values()].filter((state) => state.source !== source && state.providerPublicationId !== null));
@@ -423,54 +495,90 @@ var CloudflareSFUClient = class {
423
495
  this.#requireGeneration(generation);
424
496
  this.#setPhase("live", null);
425
497
  });
498
+ await this.#retirement;
426
499
  }
427
500
  async #retireUnavailableLocalPublication(state) {
428
501
  const publicationId = state.providerPublicationId;
429
502
  if (!publicationId) return;
430
- const authoritative = await (() => this.#requireTransport().listPublications())();
503
+ const authoritative = await this.#signal(() => this.#requireTransport().listPublications());
431
504
  if (authoritative.publications.some((publication) => publication.publicationId === publicationId)) return;
432
- if (state.providerPublicationId === publicationId) await this.#retireLocalPublication(state.source);
505
+ if (state.providerPublicationId === publicationId) {
506
+ this.#pauseLocalSource(state.source, "discovery_absent");
507
+ await this.#retireOwnedPublications();
508
+ }
433
509
  }
434
- async #retireLocalPublication(source) {
435
- const state = this.#localTracks.get(source);
436
- const transceiver = state?.transceiver ?? this.#reusableLocalTransceivers.get(source);
437
- const publicationId = state?.providerPublicationId ?? this.#reusableLocalPublicationIds.get(source);
438
- this.#reusableLocalTransceivers.delete(source);
439
- this.#reusableLocalPublicationIds.delete(source);
440
- if (state) {
441
- state.track.enabled = false;
510
+ #pauseLocalSource(source, cause = "forced_pause") {
511
+ const sources = localSourceGroup(source);
512
+ for (const publication of this.#ownedPublications) {
513
+ if (!sources.includes(publication.state.source)) continue;
514
+ this.#markPublicationRetiring(publication, cause);
515
+ }
516
+ for (const state of this.#localTracks.values()) {
517
+ if (!sources.includes(state.source)) continue;
442
518
  state.desiredEnabled = false;
443
- state.enabled = false;
444
- state.providerPublicationId = null;
445
- state.transceiver = null;
519
+ this.#clearLocalPublication(state);
446
520
  }
447
521
  this.#publishSnapshot();
448
522
  this.#emitLocal();
449
- if (!transceiver || !publicationId) return;
450
- const connection = this.#connection;
451
- const connectionId = this.#bootstrap.connectionId;
452
- this.#retiredLocalConnection = connection;
453
- await this.#serializeSDP(async () => {
454
- if (connection !== this.#connection) return;
455
- const mid = requireTransceiverMid(transceiver);
456
- await this.#boundPeerOperation(transceiver.sender.replaceTrack(null));
457
- transceiver.stop();
458
- const offer = await (() => connection.createOffer())();
459
- await (() => connection.setLocalDescription(offer))();
460
- try {
461
- const response = await this.#requireTransport().closeTracks({
462
- connectionId,
463
- ...providerDescription(requireDescription(offer)),
464
- tracks: [{ mid, source, publicationId }],
465
- force: false
466
- });
467
- await this.#applyProviderDescription(response, connection);
468
- } catch (error) {
469
- await this.#rollbackLocalOffer(connection);
470
- if (!this.#stopped) this.#reportError(error);
471
- throw error;
523
+ }
524
+ #retireOwnedPublications(reportFailure = this.#reportError.bind(this)) {
525
+ const publications = [...this.#ownedPublications].filter((item) => item.lifecycle.kind === "retiring" && item.lifecycle.handle);
526
+ const retirement = (async () => {
527
+ const errors = [];
528
+ for (const publication of publications) {
529
+ try {
530
+ await this.#serializeSDP(() => this.#closeOwnedPublication(publication), true);
531
+ } catch (error) {
532
+ errors.push(error);
533
+ if (!this.#stopped) reportFailure(error);
534
+ }
472
535
  }
536
+ if (this.#stopped && this.#ownedPublications.size === 0) this.#transport = void 0;
537
+ if (errors.length) throw new AggregateError(errors, "Media retirement failed");
538
+ })();
539
+ void retirement.catch(() => {
473
540
  });
541
+ return this.#retirement = retirement;
542
+ }
543
+ #markPublicationRetiring(publication, cause = "publish_failure") {
544
+ const phase = publication.lifecycle;
545
+ if (phase.kind === "pending" || phase.kind === "confirmed") publication.lifecycle = { kind: "retiring", handle: phase.kind === "confirmed" ? phase.handle : null, detached: false, cause };
546
+ }
547
+ #markBundledTransportRetiring(publication) {
548
+ const bundleTag = /(?:^|\r?\n)a=group:BUNDLE[ \t]+(\S+)/.exec(publication.connection.localDescription?.sdp ?? "")?.[1];
549
+ if (!bundleTag || publication.transceiver.mid !== null && publication.transceiver.mid === bundleTag) {
550
+ if (this.#retiredLocalConnection === publication.connection) return;
551
+ this.#retiredLocalConnection = publication.connection;
552
+ if (publication.lifecycle.kind === "retiring") this.#retiredLocalCause = publication.lifecycle.cause;
553
+ }
554
+ }
555
+ async #closeOwnedPublication(publication) {
556
+ const phase = publication.lifecycle;
557
+ if (phase.kind !== "retiring" || !phase.handle) return;
558
+ const handle = phase.handle;
559
+ const { connection, connectionId, transceiver, state } = publication;
560
+ this.#markBundledTransportRetiring(publication);
561
+ let closed = connection.connectionState === "closed";
562
+ try {
563
+ if (!phase.detached && !closed) {
564
+ await this.#boundPeerOperation(transceiver.sender.replaceTrack(null));
565
+ closed = connection.connectionState === "closed";
566
+ if (!closed) transceiver.stop();
567
+ }
568
+ phase.detached = true;
569
+ const sessionDescription = closed ? void 0 : await createLocalOffer(connection);
570
+ const response = await this.#signal(() => this.#requireTransport().closeTracks({ connectionId, sessionDescription, tracks: [{ ...handle, source: state.source }], force: closed }), { generation: this.#generation, connectionId });
571
+ publication.lifecycle = { kind: "retired" };
572
+ this.#ownedPublications.delete(publication);
573
+ if (connection.connectionState !== "closed") await this.#applyProviderDescription(response, connection);
574
+ } catch (error) {
575
+ await this.#rollbackLocalOffer(connection);
576
+ throw error;
577
+ }
578
+ }
579
+ #fenceLocalPublications() {
580
+ for (const publication of this.#ownedPublications) publication.lifecycle = { kind: "retired" };
581
+ this.#ownedPublications.clear();
474
582
  }
475
583
  observeLocalPublications(listener) {
476
584
  this.#localListeners.add(listener);
@@ -486,19 +594,23 @@ var CloudflareSFUClient = class {
486
594
  this.#requireActive();
487
595
  const options = "connectionId" in input ? { bootstrap: input } : input;
488
596
  validateBootstrap(options.bootstrap);
597
+ if (options.bootstrap.connectionId !== this.#bootstrap.connectionId) {
598
+ this.#fenceLocalPublications();
599
+ this.#replacementCause = "access_refresh";
600
+ }
489
601
  const generation = ++this.#generation;
490
602
  const connectionEpoch = ++this.#connectionEpoch;
491
603
  this.#polling = false;
492
604
  this.#pollAfterCurrent = false;
493
605
  this.#clearPoll();
494
- this.#remotePullRetryAfter.clear();
606
+ this.#remotePullRetries.clear();
495
607
  this.#disposeConnection(false);
496
- this.#reusableLocalTransceivers.clear();
497
- this.#reusableLocalPublicationIds.clear();
608
+ for (const publication of this.#ownedPublications) this.#markPublicationRetiring(publication);
609
+ void this.#retireOwnedPublications();
498
610
  this.#clearRemoteTracks();
499
611
  this.#cursor = null;
500
612
  this.#negotiatedGeneration = null;
501
- this.#replacementAttemptedGeneration = null;
613
+ this.#replacementAttemptedGeneration = options.bootstrap.connectionId !== this.#bootstrap.connectionId ? generation : null;
502
614
  if (options.bootstrap.connectionId !== this.#bootstrap.connectionId) this.#started = false;
503
615
  this.#bootstrap = options.bootstrap;
504
616
  if (options.transport) this.#transport = options.transport;
@@ -524,22 +636,27 @@ var CloudflareSFUClient = class {
524
636
  }
525
637
  }
526
638
  stop() {
527
- if (this.#stopped) return;
639
+ if (this.#stopped) {
640
+ void this.#retireOwnedPublications();
641
+ return;
642
+ }
528
643
  this.#stopped = true;
529
644
  this.#subscribedPublicationIds.clear();
530
645
  this.#generation++;
531
646
  this.#connectionEpoch++;
532
647
  this.#clearPoll();
533
- this.#remotePullRetryAfter.clear();
648
+ this.#remotePullRetries.clear();
534
649
  this.#polling = false;
535
650
  this.#pollAfterCurrent = false;
536
651
  this.#disposeConnection(true);
537
- this.#reusableLocalTransceivers.clear();
538
- this.#reusableLocalPublicationIds.clear();
652
+ for (const publication of this.#ownedPublications) this.#markPublicationRetiring(publication);
539
653
  this.#clearRemoteTracks();
540
- for (const state of this.#localTracks.values()) this.#removeOwnedLocalTrack(state);
654
+ for (const state of this.#localTracks.values()) {
655
+ state.track.enabled = false;
656
+ this.#removeOwnedLocalTrack(state);
657
+ }
541
658
  this.#localTracks.clear();
542
- this.#transport = void 0;
659
+ void this.#retireOwnedPublications();
543
660
  this.#started = false;
544
661
  this.#cursor = null;
545
662
  this.#publishSnapshot("stopped", null);
@@ -549,94 +666,115 @@ var CloudflareSFUClient = class {
549
666
  this.#remoteListeners.clear();
550
667
  this.#snapshotListeners.clear();
551
668
  }
552
- async #setPreparedTrackEnabled(state, enabled, operationId) {
553
- if (enabled) return this.#enablePreparedTrack(state, operationId);
554
- return this.#disablePreparedTrack(state);
555
- }
556
- async #enablePreparedTrack(state, operationId) {
557
- if (state.providerPublicationId && state.transceiver) {
558
- state.desiredEnabled = true;
559
- state.track.enabled = true;
560
- try {
561
- await this.#boundPeerOperation(state.transceiver.sender.replaceTrack(state.track));
562
- } catch (error) {
563
- state.desiredEnabled = false;
564
- state.track.enabled = false;
565
- throw error;
566
- }
567
- state.enabled = !this.#locallyPausedSources.has(state.source);
568
- state.desiredEnabled = state.enabled;
569
- state.track.enabled = state.enabled;
570
- if (!state.enabled) await this.#boundPeerOperation(state.transceiver.sender.replaceTrack(null));
571
- this.#publishSnapshot();
572
- this.#emitLocal();
573
- return;
574
- }
575
- if (operationId && state.pendingOperationId !== operationId) {
576
- state.pendingOperationId = operationId;
577
- state.pendingTrackName = `${state.source}-${operationId}`;
578
- }
579
- state.desiredEnabled = true;
580
- state.track.enabled = true;
669
+ async #enablePreparedTrack(state) {
581
670
  const generation = this.#generation;
582
671
  try {
672
+ await this.#retireExpiredCapture(state);
673
+ if (this.#locallyPausedSources.has(state.source)) return false;
674
+ state.desiredEnabled = true;
675
+ state.track.enabled = true;
676
+ if (state.providerPublicationId && state.transceiver) return await this.#reattachLocalTrack(state, state.transceiver);
583
677
  await this.#publishPreparedTracks([state], generation);
584
678
  if (this.#remoteRecoveryGeneration !== generation) this.#replacementAttemptedGeneration = null;
679
+ return state.enabled;
585
680
  } catch (error) {
586
- state.desiredEnabled = false;
587
- state.enabled = false;
588
- state.track.enabled = false;
681
+ if (generation === this.#generation) {
682
+ this.#pauseLocalSource(state.source, "enable_failure");
683
+ await this.#retireOwnedPublications().catch(() => {
684
+ });
685
+ }
589
686
  throw error;
590
687
  }
591
688
  }
689
+ async #retireExpiredCapture(state) {
690
+ const publication = [...this.#ownedPublications].find((item) => item.transceiver === state.transceiver && item.lifecycle.kind === "confirmed");
691
+ if (!publication || publication.detachedAt === null || Date.now() - publication.detachedAt < CAPTURE_REUSE_WINDOW_MS) return;
692
+ this.#pauseLocalSource(state.source, "capture_cleanup");
693
+ try {
694
+ await this.#retireOwnedPublications((error) => {
695
+ if (!this.#isExpiredRetiredConnection(error)) this.#reportError(error);
696
+ });
697
+ } catch (error) {
698
+ if (!this.#isExpiredRetiredConnection(error)) throw error;
699
+ this.#fenceLocalPublications();
700
+ }
701
+ }
702
+ #isExpiredRetiredConnection(error) {
703
+ if (!this.#replaceMediaConnection || this.#retiredLocalConnection !== this.#connection) return false;
704
+ if (error instanceof AggregateError) return error.errors.every((cause) => this.#isRetryableConnectionFailure(cause));
705
+ return this.#isRetryableConnectionFailure(error);
706
+ }
707
+ async #reattachLocalTrack(state, transceiver) {
708
+ await this.#boundPeerOperation(transceiver.sender.replaceTrack(state.track));
709
+ const cause = this.#locallyPausedSources.get(state.source);
710
+ state.enabled = cause === void 0;
711
+ state.desiredEnabled = state.enabled;
712
+ state.track.enabled = state.enabled;
713
+ if (cause === "capture_cleanup") await this.#disablePreparedTrack(state);
714
+ else if (cause) {
715
+ this.#pauseLocalSource(state.source, cause);
716
+ await this.#retireOwnedPublications();
717
+ } else {
718
+ for (const publication of this.#ownedPublications) if (publication.transceiver === transceiver) publication.detachedAt = null;
719
+ }
720
+ this.#publishSnapshot();
721
+ this.#emitLocal();
722
+ return state.enabled;
723
+ }
592
724
  async #disablePreparedTrack(state) {
593
725
  const transceiver = state.transceiver;
726
+ for (const publication of this.#ownedPublications) {
727
+ if (publication.transceiver === transceiver && publication.lifecycle.kind === "confirmed") publication.detachedAt ??= Date.now();
728
+ }
594
729
  state.desiredEnabled = false;
595
730
  state.track.enabled = false;
596
731
  const detach = transceiver ? this.#boundPeerOperation(transceiver.sender.replaceTrack(null)) : Promise.resolve();
597
732
  state.enabled = false;
598
- state.pendingOperationId = null;
599
- state.pendingTrackName = null;
600
733
  this.#publishSnapshot();
601
734
  this.#emitLocal();
602
735
  await detach;
603
736
  }
604
737
  async #publishPreparedTracks(states, generation) {
605
738
  if (states.length === 0) return;
606
- await this.#serializeSDP(async () => {
607
- const pending = await this.#prepareConnectionForPublication(states, generation);
608
- try {
609
- await this.#publishPreparedTracksSerialized(pending, generation, this.#connection, this.#bootstrap.connectionId);
610
- } catch (error) {
611
- if (!this.#replaceMediaConnection || !this.#isRetryableConnectionFailure(error) || this.#replacementAttemptedGeneration === generation) throw error;
612
- await this.#replaceMediaConnectionForRecovery(generation);
613
- await this.#publishPreparedTracksSerialized(
614
- [...this.#localTracks.values()].filter((state) => (state.desiredEnabled || states.includes(state)) && state.track.readyState !== "ended"),
615
- generation,
616
- this.#connection,
617
- this.#bootstrap.connectionId
618
- );
619
- }
620
- });
739
+ try {
740
+ await this.#serializeSDP(async () => {
741
+ const pending = await this.#prepareConnectionForPublication(states, generation);
742
+ try {
743
+ await this.#publishPreparedTracksSerialized(pending, generation, this.#connection, this.#bootstrap.connectionId);
744
+ } catch (error) {
745
+ if (!this.#replaceMediaConnection || !this.#isRetryableConnectionFailure(error) || this.#replacementAttemptedGeneration === generation) throw error;
746
+ await this.#replaceMediaConnectionForRecovery(generation, "local_signaling_failure");
747
+ await this.#publishPreparedTracksSerialized(
748
+ [...this.#localTracks.values()].filter((state) => (state.desiredEnabled || states.includes(state)) && state.track.readyState !== "ended"),
749
+ generation,
750
+ this.#connection,
751
+ this.#bootstrap.connectionId
752
+ );
753
+ }
754
+ });
755
+ } finally {
756
+ await this.#retirement.catch(() => {
757
+ });
758
+ }
621
759
  }
622
760
  async #prepareConnectionForPublication(states, generation) {
623
761
  if (this.#retiredLocalConnection !== this.#connection || !this.#replaceMediaConnection) {
624
762
  await this.#replaceDormantConnectionBeforeNegotiation(generation);
625
763
  return states;
626
764
  }
627
- const authoritative = await (() => this.#requireTransport().listPublications())();
765
+ const authoritative = await this.#signal(() => this.#requireTransport().listPublications());
628
766
  this.#pauseUnavailableOtherSources(authoritative.publications, states);
629
767
  this.#replacementAttemptedGeneration = null;
630
- await this.#replaceMediaConnectionForRecovery(generation);
768
+ await this.#replaceMediaConnectionForRecovery(generation, this.#retiredLocalCause);
631
769
  return [...this.#localTracks.values()].filter((state) => (state.desiredEnabled || states.includes(state)) && state.track.readyState !== "ended");
632
770
  }
633
771
  #pauseUnavailableOtherSources(publications, states) {
634
772
  for (const state of this.#localTracks.values()) {
635
773
  if (states.includes(state) || !state.providerPublicationId) continue;
636
774
  if (publications.some((publication) => publication.publicationId === state.providerPublicationId)) continue;
637
- state.desiredEnabled = false;
638
- state.track.enabled = false;
775
+ this.#pauseLocalSource(state.source, "discovery_absent");
639
776
  }
777
+ void this.#retireOwnedPublications();
640
778
  }
641
779
  async #publishPreparedTracksSerialized(states, generation, connection, connectionId) {
642
780
  this.#requireGeneration(generation);
@@ -646,28 +784,30 @@ var CloudflareSFUClient = class {
646
784
  const publications = [];
647
785
  try {
648
786
  for (const state of pendingStates) publications.push(await this.#prepareLocalPublication(connection, state, generation));
649
- const response = await this.#negotiateLocalPublications(connection, connectionId, publications, generation);
787
+ await this.#negotiateLocalPublications(connection, connectionId, publications, generation);
650
788
  if (!wasLive) await this.#waitForConnection(connection, generation);
651
789
  this.#requireGeneration(generation);
652
790
  this.#negotiatedGeneration = generation;
653
- this.#confirmLocalPublications(publications, response.tracks);
791
+ this.#settleLocalPublications(publications);
654
792
  this.#publishSnapshot();
655
793
  this.#emitLocal();
656
794
  } catch (error) {
657
- if (generation === this.#generation) {
658
- await this.#rollbackLocalOffer(connection);
659
- await this.#discardLocalPublications(publications);
660
- }
795
+ await this.#rollbackLocalOffer(connection);
796
+ for (const publication of publications) this.#markPublicationRetiring(publication);
797
+ this.#settleLocalPublications(publications);
661
798
  throw error;
799
+ } finally {
800
+ void this.#retireOwnedPublications();
662
801
  }
663
802
  }
664
803
  async #prepareLocalPublication(connection, state, generation) {
665
- const reusedTransceiver = state.transceiver !== null;
666
804
  const transceiver = state.transceiver ?? this.#addLocalTransceiver(connection, state);
667
- if (reusedTransceiver) await this.#boundPeerOperation(transceiver.sender.replaceTrack(state.track));
805
+ const publication = { state, transceiver, trackName: `${state.source}-${globalThis.crypto.randomUUID()}`, connection, connectionId: this.#bootstrap.connectionId, detachedAt: null, lifecycle: { kind: "pending" } };
806
+ this.#ownedPublications.add(publication);
807
+ if (state.transceiver) await this.#boundPeerOperation(transceiver.sender.replaceTrack(state.track));
668
808
  this.#requireGeneration(generation);
669
809
  state.transceiver = transceiver;
670
- return { state, transceiver, trackName: state.pendingTrackName ?? `${state.source}-${globalThis.crypto.randomUUID()}`, reusedTransceiver };
810
+ return publication;
671
811
  }
672
812
  #addLocalTransceiver(connection, state) {
673
813
  if (state.source !== "camera" || firefoxCameraSimulcastUnsupported()) return connection.addTransceiver(state.track, { direction: "sendonly" });
@@ -686,10 +826,9 @@ var CloudflareSFUClient = class {
686
826
  }
687
827
  }
688
828
  async #negotiateLocalPublications(connection, connectionId, publications, generation) {
689
- let offer;
829
+ let sessionDescription;
690
830
  try {
691
- offer = await (() => connection.createOffer())();
692
- await (() => connection.setLocalDescription(offer))();
831
+ sessionDescription = await createLocalOffer(connection);
693
832
  } catch (error) {
694
833
  const cameras = publications.filter(({ state, transceiver }) => state.source === "camera" && transceiver.sender.getParameters().encodings.length > 1);
695
834
  if (!simulcastUnsupported(error) || cameras.length === 0) throw error;
@@ -701,8 +840,7 @@ var CloudflareSFUClient = class {
701
840
  publication.transceiver = connection.addTransceiver(publication.state.track, { direction: "sendonly" });
702
841
  publication.state.transceiver = publication.transceiver;
703
842
  }
704
- offer = await (() => connection.createOffer())();
705
- await (() => connection.setLocalDescription(offer))();
843
+ sessionDescription = await createLocalOffer(connection);
706
844
  }
707
845
  const tracks = publications.map(
708
846
  ({ state, transceiver, trackName }) => ({
@@ -712,43 +850,44 @@ var CloudflareSFUClient = class {
712
850
  source: state.source
713
851
  })
714
852
  );
715
- const response = await (() => this.#requireTransport().addTracks({ connectionId, ...providerDescription(requireDescription(offer)), tracks }))();
853
+ const response = await this.#signal(() => this.#addTracks({ connectionId, sessionDescription, tracks }), { generation, connectionId });
854
+ this.#recordLocalPublicationHandles(publications, response.tracks);
716
855
  this.#requireGeneration(generation);
717
856
  await this.#applyProviderDescription(response, connection);
718
- return response;
719
857
  }
720
- #confirmLocalPublications(publications, tracks) {
721
- for (const { state, transceiver } of publications) {
858
+ #recordLocalPublicationHandles(publications, tracks) {
859
+ for (const publication of publications) {
860
+ const { state, transceiver, lifecycle } = publication;
861
+ if (lifecycle.kind === "retired") continue;
722
862
  const authoritative = tracks?.find((track) => track.location === "local" && track.mid === transceiver.mid && track.source === state.source);
723
- if (!authoritative?.publicationId) throw new CloudflareSFUError("Chalk did not return an authoritative local publication ID", "invalid_publication");
724
- if (state.transceiver !== transceiver) continue;
725
- state.enabled = !this.#locallyPausedSources.has(state.source);
726
- state.desiredEnabled = state.enabled;
727
- state.track.enabled = state.enabled;
728
- if (!state.enabled) void transceiver.sender.replaceTrack(null).catch((error) => this.#reportError(error));
729
- state.providerPublicationId = authoritative.publicationId;
730
- state.pendingOperationId = null;
731
- state.pendingTrackName = null;
863
+ if (!authoritative?.publicationId) throw new CloudflareSFUError("Missing publication ID", "invalid_publication");
864
+ const handle = { publicationId: authoritative.publicationId, mid: requireTransceiverMid(transceiver) };
865
+ publication.lifecycle = lifecycle.kind === "retiring" ? { ...lifecycle, handle } : { kind: "confirmed", handle };
732
866
  }
733
867
  }
734
- async #discardLocalPublications(publications) {
735
- for (const { state, transceiver, reusedTransceiver } of publications) {
736
- if (reusedTransceiver) {
737
- try {
738
- await transceiver.sender.replaceTrack(null);
739
- } catch (error) {
740
- this.#reportError(error);
741
- }
868
+ #settleLocalPublications(publications) {
869
+ for (const publication of publications) {
870
+ const { state, transceiver, lifecycle } = publication;
871
+ if (lifecycle.kind === "retired") continue;
872
+ if (lifecycle.kind === "confirmed" && state.transceiver === transceiver && !this.#locallyPausedSources.has(state.source)) {
873
+ state.enabled = true;
874
+ state.desiredEnabled = true;
875
+ state.track.enabled = true;
876
+ state.providerPublicationId = lifecycle.handle.publicationId;
742
877
  continue;
743
878
  }
744
- try {
745
- transceiver.stop();
746
- } catch (error) {
747
- this.#reportError(error);
879
+ this.#markPublicationRetiring(publication, this.#locallyPausedSources.get(state.source) ?? "publish_failure");
880
+ if (state.transceiver === transceiver) {
881
+ this.#clearLocalPublication(state);
748
882
  }
749
- if (state.transceiver === transceiver) state.transceiver = null;
750
883
  }
751
884
  }
885
+ #clearLocalPublication(state) {
886
+ state.transceiver = null;
887
+ state.providerPublicationId = null;
888
+ state.enabled = false;
889
+ state.track.enabled = false;
890
+ }
752
891
  async #rollbackLocalOffer(connection) {
753
892
  if (connection.signalingState !== "have-local-offer") return;
754
893
  try {
@@ -784,8 +923,8 @@ var CloudflareSFUClient = class {
784
923
  }
785
924
  #pendingRemotePulls(desired) {
786
925
  const desiredIds = new Set([...desired.values()].map((publication) => publication.publicationId));
787
- for (const publicationId of this.#remotePullRetryAfter.keys()) {
788
- if (!desiredIds.has(publicationId)) this.#remotePullRetryAfter.delete(publicationId);
926
+ for (const publicationId of this.#remotePullRetries.keys()) {
927
+ if (!desiredIds.has(publicationId)) this.#remotePullRetries.delete(publicationId);
789
928
  }
790
929
  for (const publicationId of this.#subscribedPublicationIds) {
791
930
  if (!desiredIds.has(publicationId)) this.#subscribedPublicationIds.delete(publicationId);
@@ -793,7 +932,7 @@ var CloudflareSFUClient = class {
793
932
  const now = Date.now();
794
933
  return [...desired].filter(([key, publication]) => {
795
934
  if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) return false;
796
- const remaining = (this.#remotePullRetryAfter.get(publication.publicationId) ?? 0) - now;
935
+ const remaining = (this.#remotePullRetries.get(publication.publicationId)?.retryAfter ?? 0) - now;
797
936
  return remaining <= 0;
798
937
  }).map(([, publication]) => publication);
799
938
  }
@@ -834,7 +973,8 @@ var CloudflareSFUClient = class {
834
973
  }
835
974
  }
836
975
  #canReplaceConnectionAfterRemotePull(error, generation) {
837
- return this.#isRetryableConnectionFailure(error) && !this.#hasLiveRemoteTracks() && this.#replaceMediaConnection !== void 0 && this.#replacementAttemptedGeneration !== generation;
976
+ const gone = error instanceof CloudflareSFUError && (error.options.status === 404 || error.options.status === 410);
977
+ return this.#isRetryableConnectionFailure(error) && (gone || !this.#hasLiveRemoteTracks()) && this.#replaceMediaConnection !== void 0 && this.#replacementAttemptedGeneration !== generation;
838
978
  }
839
979
  #hasLiveRemoteTracks() {
840
980
  return [...this.#remoteTracks.values()].some((publication) => publication.track.readyState === "live");
@@ -842,7 +982,7 @@ var CloudflareSFUClient = class {
842
982
  async #retryRemotePullOnReplacement(publications, cursor, generation) {
843
983
  this.#remoteRecoveryGeneration = generation;
844
984
  try {
845
- await this.#replaceMediaConnectionForRecovery(generation);
985
+ await this.#replaceMediaConnectionForRecovery(generation, "remote_signaling_failure");
846
986
  await this.#republishPreparedTracksAfterRecovery(generation);
847
987
  return await this.#pull(publications, generation);
848
988
  } catch (error) {
@@ -887,7 +1027,10 @@ var CloudflareSFUClient = class {
887
1027
  }
888
1028
  #deferRemotePulls(publications, error) {
889
1029
  const retryAfter = retryableRemotePull(error) ? Date.now() + 15e3 : Number.POSITIVE_INFINITY;
890
- for (const publication of publications) this.#remotePullRetryAfter.set(publication.publicationId, retryAfter);
1030
+ for (const publication of publications) {
1031
+ const failures = this.#remotePullRetries.get(publication.publicationId)?.failures ?? 0;
1032
+ this.#remotePullRetries.set(publication.publicationId, { retryAfter, failures });
1033
+ }
891
1034
  }
892
1035
  async #pullBatch(publications, generation) {
893
1036
  if (publications.length === 0) return [];
@@ -913,12 +1056,12 @@ var CloudflareSFUClient = class {
913
1056
  connection.addEventListener("track", onTrack);
914
1057
  try {
915
1058
  this.#requireGeneration(generation);
916
- const response = await (() => this.#requireTransport().addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }))();
1059
+ const response = await this.#signal(() => this.#addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }), { generation, connectionId });
917
1060
  this.#requireGeneration(generation);
918
1061
  this.#recordRemotePullErrors(publications, response.trackErrors ?? []);
919
1062
  const responseTracks = response.tracks ?? [];
920
1063
  const matched = matchRemotePullTracks(publications, responseTracks);
921
- for (const { publication } of matched) this.#remotePullRetryAfter.delete(publication.publicationId);
1064
+ for (const { publication } of matched) this.#remotePullRetries.delete(publication.publicationId);
922
1065
  await this.#completeRenegotiation(response, connection, connectionId, generation);
923
1066
  if (matched.length === 0) return [];
924
1067
  await this.#waitForConnection(connection, generation);
@@ -948,8 +1091,10 @@ var CloudflareSFUClient = class {
948
1091
  for (const failure of failures) {
949
1092
  const publication = requested.get(`${failure.connectionId}\0${failure.trackName}`);
950
1093
  if (!publication) throw new CloudflareSFUError("Cloudflare SFU returned an unrequested remote track error", "invalid_publication");
951
- const delay = 15e3;
952
- this.#remotePullRetryAfter.set(publication.publicationId, Date.now() + delay);
1094
+ const previous = this.#remotePullRetries.get(publication.publicationId) ?? { retryAfter: 0, failures: 0 };
1095
+ const failureCount = Math.min(previous.failures + 1, 6);
1096
+ const delay = Math.min(15e3, 750 * 2 ** (failureCount - 1));
1097
+ this.#remotePullRetries.set(publication.publicationId, { retryAfter: Date.now() + delay, failures: failureCount });
953
1098
  this.#reportError(new CloudflareSFUError(`Cloudflare SFU remote track failed: ${failure.code}`, "media_failed", { providerCode: failure.code }));
954
1099
  }
955
1100
  }
@@ -958,13 +1103,13 @@ var CloudflareSFUClient = class {
958
1103
  await this.#applyProviderDescription(response, connection);
959
1104
  const answer = await (() => connection.createAnswer())();
960
1105
  await (() => connection.setLocalDescription(answer))();
961
- await (() => this.#requireTransport().renegotiate({ connectionId, ...providerDescription(requireDescription(answer)) }))();
1106
+ await this.#signal(() => this.#requireTransport().renegotiate({ connectionId, sessionDescription: requireDescription(answer) }), { generation, connectionId });
962
1107
  this.#requireGeneration(generation);
963
1108
  }
964
1109
  async #applyProviderDescription(response, connection = this.#connection) {
965
1110
  await (() => connection.setRemoteDescription(requireSFUDescription(response.sessionDescription)))();
966
1111
  }
967
- #serializeSDP(operation) {
1112
+ #serializeSDP(operation, retirement = false) {
968
1113
  const generation = this.#generation;
969
1114
  let expired = false;
970
1115
  let timer;
@@ -977,7 +1122,7 @@ var CloudflareSFUClient = class {
977
1122
  }, NEGOTIATION_TIMEOUT_MS);
978
1123
  });
979
1124
  const run = () => {
980
- if (expired || generation !== this.#generation) throw timeout;
1125
+ if (expired || !retirement && generation !== this.#generation) throw timeout;
981
1126
  return operation();
982
1127
  };
983
1128
  const pending = this.#sdpTail.then(run, run);
@@ -1100,9 +1245,14 @@ var CloudflareSFUClient = class {
1100
1245
  }
1101
1246
  async #replaceDormantConnectionBeforeNegotiation(generation) {
1102
1247
  if (!this.#started || this.#negotiatedGeneration === generation || this.#replacementAttemptedGeneration === generation || !this.#replaceMediaConnection) return;
1103
- await this.#replaceMediaConnectionForRecovery(generation);
1248
+ await this.#replaceMediaConnectionForRecovery(generation, "dormant_connection");
1249
+ }
1250
+ #addTracks(input) {
1251
+ const replacementCause = this.#replacementCause;
1252
+ this.#replacementCause = void 0;
1253
+ return this.#requireTransport().addTracks({ ...input, replacementCause });
1104
1254
  }
1105
- async #replaceMediaConnectionForRecovery(generation) {
1255
+ async #replaceMediaConnectionForRecovery(generation, cause) {
1106
1256
  this.#requireGeneration(generation);
1107
1257
  if (!this.#replaceMediaConnection || this.#replacementAttemptedGeneration === generation) {
1108
1258
  throw new CloudflareSFUError("A fresh Cloudflare SFU connection is unavailable", "signaling_failed");
@@ -1115,14 +1265,14 @@ var CloudflareSFUClient = class {
1115
1265
  if (bootstrap.connectionId === this.#bootstrap.connectionId) {
1116
1266
  throw new CloudflareSFUError("Participant access did not replace the Cloudflare SFU connection", "signaling_failed");
1117
1267
  }
1268
+ this.#fenceLocalPublications();
1118
1269
  const connectionEpoch = ++this.#connectionEpoch;
1119
1270
  this.#disposeConnection(false);
1120
- this.#reusableLocalTransceivers.clear();
1121
- this.#reusableLocalPublicationIds.clear();
1122
1271
  this.#clearRemoteTracks();
1123
1272
  this.#cursor = null;
1124
1273
  this.#bootstrap = bootstrap;
1125
1274
  this.#rebuildPeerConnection(bootstrap, generation, connectionEpoch);
1275
+ this.#replacementCause = cause;
1126
1276
  this.#retiredLocalConnection = null;
1127
1277
  this.#negotiatedGeneration = null;
1128
1278
  for (const state of this.#localTracks.values()) {
@@ -1190,8 +1340,6 @@ var CloudflareSFUClient = class {
1190
1340
  state.desiredEnabled = false;
1191
1341
  state.transceiver = null;
1192
1342
  state.providerPublicationId = null;
1193
- state.pendingOperationId = null;
1194
- state.pendingTrackName = null;
1195
1343
  state.endedListener = null;
1196
1344
  }
1197
1345
  #clearRemoteTracks() {
@@ -1271,7 +1419,7 @@ var CloudflareSFUClient = class {
1271
1419
  }
1272
1420
  #setFailure(error, fallback) {
1273
1421
  const code = error instanceof CloudflareSFUError ? error.code : fallback;
1274
- this.#publishSnapshot("failed", { code, recoverable: code !== "invalid_bootstrap" && code !== "invalid_target" && code !== "invalid_publication" });
1422
+ this.#publishSnapshot("failed", { code, recoverable: code !== "invalid_bootstrap" && code !== "invalid_target" && code !== "invalid_publication", diagnostic: classifyMediaFailure(error, "peer_connection") });
1275
1423
  this.#reportError(error);
1276
1424
  }
1277
1425
  #emitLocal() {
@@ -1290,19 +1438,29 @@ var CloudflareSFUClient = class {
1290
1438
  }
1291
1439
  }
1292
1440
  #reportError(error) {
1441
+ const owner = error instanceof Error ? this.#failureOwners.get(error) : void 0;
1442
+ if (owner && (owner.generation !== this.#generation || owner.connectionId !== this.#bootstrap.connectionId)) return;
1293
1443
  try {
1294
- this.#onError?.(error);
1444
+ this.#onError?.(error, owner?.connectionId ?? this.#bootstrap.connectionId);
1295
1445
  } catch {
1296
1446
  }
1297
1447
  }
1448
+ async #signal(operation, owner = { generation: this.#generation, connectionId: this.#bootstrap.connectionId }) {
1449
+ try {
1450
+ return await operation();
1451
+ } catch (error) {
1452
+ if (error instanceof Error) this.#failureOwners.set(error, owner);
1453
+ throw error;
1454
+ }
1455
+ }
1298
1456
  #requireGeneration(generation) {
1299
- if (generation !== this.#generation || this.#stopped) throw new CloudflareSFUError("Cloudflare SFU operation belongs to a stale connection generation", "stale_generation");
1457
+ if (generation !== this.#generation || this.#stopped) throw new CloudflareSFUError("Stale media connection", "stale_generation");
1300
1458
  }
1301
1459
  #requireActive() {
1302
- if (this.#stopped) throw new CloudflareSFUError("Cloudflare SFU client is stopped", "media_failed");
1460
+ if (this.#stopped) throw new CloudflareSFUError("SFU client stopped", "media_failed");
1303
1461
  }
1304
1462
  #requireTransport() {
1305
- if (!this.#transport) throw new CloudflareSFUError("Cloudflare SFU signaling transport is unavailable", "signaling_failed");
1463
+ if (!this.#transport) throw new CloudflareSFUError("SFU signaling unavailable", "signaling_failed");
1306
1464
  return this.#transport;
1307
1465
  }
1308
1466
  };
@@ -1391,8 +1549,13 @@ function publicationEqual(left, right) {
1391
1549
  function remotePublicationEqual(left, right) {
1392
1550
  return right !== void 0 && left.participantId === right.participantId && left.source === right.source && left.publicationId === right.publicationId && left.track === right.track;
1393
1551
  }
1394
- function providerDescription(description) {
1395
- return { sessionDescription: description };
1552
+ async function createLocalOffer(connection) {
1553
+ const offer = await connection.createOffer();
1554
+ await connection.setLocalDescription(offer);
1555
+ return requireDescription(offer);
1556
+ }
1557
+ function localSourceGroup(source) {
1558
+ return source === "screen" ? [source, "screen_audio"] : [source];
1396
1559
  }
1397
1560
  function isRateLimited(error) {
1398
1561
  return error instanceof CloudflareSFUError && error.options.status === 429;
@@ -1425,31 +1588,43 @@ function createCloudflareSFUHTTPTransport(options) {
1425
1588
  const timeoutMs = options.requestTimeoutMs ?? SIGNALING_TIMEOUT_MS;
1426
1589
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new CloudflareSFUError("The signaling deadline is invalid", "signaling_failed");
1427
1590
  const request = async (path, init) => {
1428
- const token = await withDeadline(CREDENTIAL_TIMEOUT_MS, (signal) => credential(signal));
1429
- if (!token.trim()) throw new CloudflareSFUError("The media credential provider returned an empty token", "signaling_failed");
1430
- return withDeadline(timeoutMs, async (signal) => {
1431
- const startedAt = performance.now();
1432
- const response = await fetch(`${mediaPath}/${path}`, {
1433
- ...init,
1434
- signal,
1435
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...options.traceparent ? { traceparent: options.traceparent } : {}, ...init?.headers }
1436
- });
1437
- if (!response.ok) {
1438
- try {
1439
- options.onDiagnostic?.({ event: "media_http_error", http_status: response.status, duration_ms: performance.now() - startedAt });
1440
- } catch {
1441
- }
1442
- throw new CloudflareSFUError(`Chalk SFU signaling failed with HTTP ${response.status}`, "signaling_failed", {
1443
- status: response.status,
1444
- retryableConnection: response.status === 404 || response.status === 410 || response.status >= 500 && response.status < 600
1591
+ try {
1592
+ const token = await withDeadline(CREDENTIAL_TIMEOUT_MS, (signal) => credential(signal));
1593
+ if (!token.trim()) throw new CloudflareSFUError("The media credential provider returned an empty token", "signaling_failed");
1594
+ return await withDeadline(timeoutMs, async (signal) => {
1595
+ const startedAt = performance.now();
1596
+ const response = await fetch(`${mediaPath}/${path}`, {
1597
+ ...init,
1598
+ signal,
1599
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...options.traceparent ? { traceparent: options.traceparent } : {}, ...init?.headers }
1600
+ }).catch((error) => {
1601
+ markMediaFetchFailure(error);
1602
+ throw error;
1445
1603
  });
1446
- }
1447
- return await response.json();
1448
- });
1604
+ if (!response.ok) {
1605
+ try {
1606
+ options.onDiagnostic?.({ event: "media_http_error", http_status: response.status, duration_ms: performance.now() - startedAt });
1607
+ } catch {
1608
+ }
1609
+ throw new CloudflareSFUError(`Chalk SFU signaling failed with HTTP ${response.status}`, "signaling_failed", {
1610
+ status: response.status,
1611
+ retryableConnection: response.status === 404 || response.status === 410 || response.status >= 500 && response.status < 600
1612
+ });
1613
+ }
1614
+ return await response.json();
1615
+ });
1616
+ } catch (error) {
1617
+ const step = path === "publications" ? "sfu_publications" : path === "tracks/close" ? "sfu_close_tracks" : path === "renegotiate" ? "sfu_renegotiate" : "sfu_tracks";
1618
+ markMediaFailure(error, step);
1619
+ throw error;
1620
+ }
1449
1621
  };
1450
1622
  return {
1451
1623
  addTracks: async (input) => {
1452
- const path = input.allowPartialRemoteTracks ? "tracks?allow_partial_remote_tracks=true" : "tracks";
1624
+ const query = new URLSearchParams();
1625
+ if (input.allowPartialRemoteTracks) query.set("allow_partial_remote_tracks", "true");
1626
+ if (input.replacementCause) query.set("replacement_cause", input.replacementCause);
1627
+ const path = query.size ? `tracks?${query}` : "tracks";
1453
1628
  const response = await request(path, {
1454
1629
  method: "POST",
1455
1630
  body: JSON.stringify({ connection_id: input.connectionId, session_description: input.sessionDescription, tracks: input.tracks })
@@ -1538,7 +1713,6 @@ function describeMediaCaptureError(cause) {
1538
1713
  }
1539
1714
 
1540
1715
  export {
1541
- describeMediaCaptureError,
1542
1716
  CloudflareSFUError,
1543
1717
  parseCloudflareSFUPublicationID,
1544
1718
  requireDescription,
@@ -1547,6 +1721,10 @@ export {
1547
1721
  comparePublicationCursor,
1548
1722
  publicationKey,
1549
1723
  waitFor,
1724
+ grantedMediaFromStream,
1725
+ markMediaFailure,
1726
+ classifyMediaFailure,
1550
1727
  CloudflareSFUClient,
1551
- createCloudflareSFUHTTPTransport
1728
+ createCloudflareSFUHTTPTransport,
1729
+ describeMediaCaptureError
1552
1730
  };