@q9labsai/chalk-client 5.2.3 → 5.2.5

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 (37) hide show
  1. package/README.md +1 -1
  2. package/dist/access/grant.d.ts +2 -1
  3. package/dist/access/manager.d.ts +1 -0
  4. package/dist/{chunk-IMMT22US.js → chunk-5YPQNMJE.js} +11 -0
  5. package/dist/{chunk-4ZUN55Z2.js → chunk-BUNSEDXL.js} +840 -185
  6. package/dist/{chunk-DWKTGBHR.js → chunk-FD25AMLG.js} +322 -206
  7. package/dist/{chunk-6LNRDKJF.js → chunk-LKNT6XFD.js} +1 -1
  8. package/dist/{chunk-GA3JDPFI.js → chunk-UMAE5GCV.js} +205 -5
  9. package/dist/client.d.ts +131 -0
  10. package/dist/connection/dependencies.d.ts +10 -1
  11. package/dist/connection/diagnostics.d.ts +4 -0
  12. package/dist/connection/episode-status.d.ts +7 -0
  13. package/dist/connection/index.d.ts +2 -0
  14. package/dist/connection/lifecycle.d.ts +2 -0
  15. package/dist/connection/report.d.ts +21 -0
  16. package/dist/effect.js +30 -4
  17. package/dist/generated/http-api.d.ts +129 -0
  18. package/dist/generated/schemas.d.ts +219 -0
  19. package/dist/index.js +5 -5
  20. package/dist/media/client.d.ts +1 -1
  21. package/dist/media/failure-diagnostics.d.ts +10 -0
  22. package/dist/media/index.js +1 -1
  23. package/dist/media/plane.d.ts +1 -0
  24. package/dist/media/types.d.ts +5 -1
  25. package/dist/public-invites/index.js +2 -2
  26. package/dist/server/access-refresh.d.cts +1 -1
  27. package/dist/server/access-refresh.d.ts +1 -1
  28. package/dist/server/index.cjs +15 -5
  29. package/dist/server/index.js +15 -5
  30. package/dist/server/types.d.cts +2 -1
  31. package/dist/server/types.d.ts +2 -1
  32. package/dist/space-client/media-projection-monitor.d.ts +26 -0
  33. package/dist/space-client/types.d.ts +4 -0
  34. package/dist/sync/index.js +1 -1
  35. package/dist/sync/v1-types.d.ts +3 -0
  36. package/dist/transport-diagnostics.d.ts +3 -0
  37. package/package.json +2 -2
@@ -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([]);
@@ -194,10 +249,10 @@ var CloudflareSFUClient = class {
194
249
  #replaceMediaConnection;
195
250
  #remoteListeners = /* @__PURE__ */ new Set();
196
251
  #snapshotListeners = /* @__PURE__ */ new Set();
252
+ #failureOwners = /* @__PURE__ */ new WeakMap();
197
253
  #locallyPausedSources = /* @__PURE__ */ new Set();
198
254
  #localTracks = /* @__PURE__ */ new Map();
199
- #reusableLocalTransceivers = /* @__PURE__ */ new Map();
200
- #reusableLocalPublicationIds = /* @__PURE__ */ new Map();
255
+ #ownedPublications = /* @__PURE__ */ new Set();
201
256
  #remoteTracks = /* @__PURE__ */ new Map();
202
257
  #bootstrap;
203
258
  #connection;
@@ -220,6 +275,7 @@ var CloudflareSFUClient = class {
220
275
  #pollFailedPull = false;
221
276
  #pollTimer;
222
277
  #sdpTail = Promise.resolve();
278
+ #retirement = Promise.resolve();
223
279
  #snapshot;
224
280
  #started = false;
225
281
  #stopped = false;
@@ -273,20 +329,17 @@ var CloudflareSFUClient = class {
273
329
  this.#requireActive();
274
330
  validateTrackSource(source, track);
275
331
  if (this.#localTracks.has(source)) throw new CloudflareSFUError(`A ${source} track is already prepared`, "media_failed");
332
+ const reusable = [...this.#ownedPublications].find((item) => item.state.source === source && item.connection === this.#connection && item.lifecycle.kind === "confirmed");
276
333
  const state = {
277
334
  source,
278
335
  track,
279
- transceiver: this.#reusableLocalTransceivers.get(source) ?? null,
280
- providerPublicationId: this.#reusableLocalPublicationIds.get(source) ?? null,
281
- pendingOperationId: null,
282
- pendingTrackName: null,
336
+ transceiver: reusable?.transceiver ?? null,
337
+ providerPublicationId: reusable?.lifecycle.kind === "confirmed" ? reusable.lifecycle.handle.publicationId : null,
283
338
  desiredEnabled: false,
284
339
  enabled: false,
285
340
  endedListener: null,
286
341
  clearing: null
287
342
  };
288
- this.#reusableLocalTransceivers.delete(source);
289
- this.#reusableLocalPublicationIds.delete(source);
290
343
  if (source === "screen") {
291
344
  state.endedListener = () => {
292
345
  if (this.#localTracks.get("screen") === state) this.#invokeListener(() => this.#onScreenEnded?.());
@@ -310,9 +363,10 @@ var CloudflareSFUClient = class {
310
363
  }
311
364
  async #clearPreparedTrack(state, stopTrack = true) {
312
365
  const source = state.source;
313
- if (source === "screen_audio") {
366
+ if (source === "screen" || source === "screen_audio" || state.track.readyState === "ended") {
314
367
  try {
315
- await this.#retireLocalPublication(source);
368
+ this.#pauseLocalSource(source);
369
+ await this.#retireOwnedPublications();
316
370
  } finally {
317
371
  this.#removeOwnedLocalTrack(state, stopTrack);
318
372
  if (this.#localTracks.get(source) === state) this.#localTracks.delete(source);
@@ -321,10 +375,8 @@ var CloudflareSFUClient = class {
321
375
  }
322
376
  return;
323
377
  }
324
- if (state.enabled) await this.#setPreparedTrackEnabled(state, false);
378
+ if (state.enabled) await this.#disablePreparedTrack(state);
325
379
  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
380
  this.#removeOwnedLocalTrack(state, stopTrack);
329
381
  this.#localTracks.delete(source);
330
382
  this.#publishSnapshot();
@@ -349,7 +401,7 @@ var CloudflareSFUClient = class {
349
401
  this.#pollFailedPull = false;
350
402
  try {
351
403
  const transport = this.#requireTransport();
352
- const authoritative = await (() => transport.listPublications())();
404
+ const authoritative = await this.#signal(() => transport.listPublications());
353
405
  this.#requireGeneration(generation);
354
406
  await this.#reconcileRemotePublications(authoritative, generation);
355
407
  this.#requireGeneration(generation);
@@ -370,46 +422,54 @@ var CloudflareSFUClient = class {
370
422
  this.#schedulePoll(0);
371
423
  }
372
424
  async setLocalPublicationTarget(target) {
373
- const result = await this.#setSinglePublicationTarget(target);
425
+ if (!target.enabled) return this.#pausePublicationTarget(target);
426
+ const result = await this.#enablePublicationTarget(target);
374
427
  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" });
428
+ return this.#enablePublicationTarget({ ...target, source: "screen_audio" });
429
+ }
430
+ async #pausePublicationTarget(target) {
431
+ const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
432
+ if (resolved.kind === "result" && resolved.result.outcome !== "satisfied") return resolved.result;
433
+ const outcome = resolved.kind === "state" && resolved.value.enabled ? "confirmed" : "satisfied";
434
+ this.#pauseLocalSource(target.source);
435
+ try {
436
+ await this.#retireOwnedPublications();
437
+ return { outcome, errorCode: null };
438
+ } catch (error) {
439
+ return mediaTargetFailure(error);
440
+ }
376
441
  }
377
- async #setSinglePublicationTarget(target) {
442
+ async #enablePublicationTarget(target) {
443
+ const generation = this.#generation;
378
444
  const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
379
445
  if (resolved.kind === "result") return resolved.result;
380
446
  const state = resolved.value;
381
- if (this.#pausedTarget(target)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
447
+ if (this.#locallyPausedSources.has(target.source)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
382
448
  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);
449
+ await this.#retireUnavailableLocalPublication(state);
450
+ if (this.#locallyPausedSources.has(target.source)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
451
+ if (state.enabled) return { outcome: "satisfied", errorCode: null };
452
+ await this.#enablePreparedTrack(state);
387
453
  return { outcome: "confirmed", errorCode: null };
388
454
  } catch (error) {
389
- if (!this.#stopped) this.#reportError(error);
455
+ if (generation === this.#generation && !this.#stopped) this.#reportError(error);
390
456
  return mediaTargetFailure(error);
391
457
  }
392
458
  }
393
- #pausedTarget(target) {
394
- return target.enabled && this.#locallyPausedSources.has(target.source);
395
- }
396
459
  setLocalSourceIntent(source, enabled) {
397
- if (source === "screen") this.setLocalSourceIntent("screen_audio", enabled);
398
- if (enabled) {
399
- this.#locallyPausedSources.delete(source);
400
- return;
460
+ const sources = localSourceGroup(source);
461
+ for (const item of sources) {
462
+ if (enabled) this.#locallyPausedSources.delete(item);
463
+ else this.#locallyPausedSources.add(item);
401
464
  }
402
- this.#locallyPausedSources.add(source);
403
- const state = this.#localTracks.get(source);
404
- if (!state) return;
405
- state.desiredEnabled = false;
406
- state.track.enabled = false;
465
+ if (!enabled) this.#pauseLocalSource(source);
466
+ return enabled ? Promise.resolve() : this.#retireOwnedPublications();
407
467
  }
408
468
  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;
469
+ const sources = localSourceGroup(source);
470
+ const ownsPublication = [...this.#ownedPublications].some((item) => sources.includes(item.state.source));
471
+ await this.setLocalSourceIntent(source, false);
472
+ if (!ownsPublication || !this.#replaceMediaConnection) return;
413
473
  const generation = this.#generation;
414
474
  await this.#serializeSDP(async () => {
415
475
  const existing = new Set([...this.#localTracks.values()].filter((state) => state.source !== source && state.providerPublicationId !== null));
@@ -423,54 +483,82 @@ var CloudflareSFUClient = class {
423
483
  this.#requireGeneration(generation);
424
484
  this.#setPhase("live", null);
425
485
  });
486
+ await this.#retirement;
426
487
  }
427
488
  async #retireUnavailableLocalPublication(state) {
428
489
  const publicationId = state.providerPublicationId;
429
490
  if (!publicationId) return;
430
- const authoritative = await (() => this.#requireTransport().listPublications())();
491
+ const authoritative = await this.#signal(() => this.#requireTransport().listPublications());
431
492
  if (authoritative.publications.some((publication) => publication.publicationId === publicationId)) return;
432
- if (state.providerPublicationId === publicationId) await this.#retireLocalPublication(state.source);
493
+ if (state.providerPublicationId === publicationId) {
494
+ this.#pauseLocalSource(state.source);
495
+ await this.#retireOwnedPublications();
496
+ }
433
497
  }
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;
498
+ #pauseLocalSource(source) {
499
+ const sources = localSourceGroup(source);
500
+ for (const publication of this.#ownedPublications) {
501
+ if (!sources.includes(publication.state.source)) continue;
502
+ this.#markPublicationRetiring(publication);
503
+ this.#retiredLocalConnection = this.#connection;
504
+ }
505
+ for (const state of this.#localTracks.values()) {
506
+ if (!sources.includes(state.source)) continue;
442
507
  state.desiredEnabled = false;
443
- state.enabled = false;
444
- state.providerPublicationId = null;
445
- state.transceiver = null;
508
+ this.#clearLocalPublication(state);
446
509
  }
447
510
  this.#publishSnapshot();
448
511
  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;
512
+ }
513
+ #retireOwnedPublications() {
514
+ const publications = [...this.#ownedPublications].filter((item) => item.lifecycle.kind === "retiring" && item.lifecycle.handle);
515
+ const retirement = (async () => {
516
+ const errors = [];
517
+ for (const publication of publications) {
518
+ try {
519
+ await this.#serializeSDP(() => this.#closeOwnedPublication(publication), true);
520
+ } catch (error) {
521
+ errors.push(error);
522
+ if (!this.#stopped) this.#reportError(error);
523
+ }
472
524
  }
525
+ if (this.#stopped && this.#ownedPublications.size === 0) this.#transport = void 0;
526
+ if (errors.length) throw new AggregateError(errors, "Media retirement failed");
527
+ })();
528
+ void retirement.catch(() => {
473
529
  });
530
+ return this.#retirement = retirement;
531
+ }
532
+ #markPublicationRetiring(publication) {
533
+ const phase = publication.lifecycle;
534
+ if (phase.kind === "pending" || phase.kind === "confirmed") publication.lifecycle = { kind: "retiring", handle: phase.kind === "confirmed" ? phase.handle : null, detached: false };
535
+ }
536
+ async #closeOwnedPublication(publication) {
537
+ const phase = publication.lifecycle;
538
+ if (phase.kind !== "retiring" || !phase.handle) return;
539
+ const handle = phase.handle;
540
+ const { connection, connectionId, transceiver, state } = publication;
541
+ let closed = connection.connectionState === "closed";
542
+ try {
543
+ if (!phase.detached && !closed) {
544
+ await this.#boundPeerOperation(transceiver.sender.replaceTrack(null));
545
+ closed = connection.connectionState === "closed";
546
+ if (!closed) transceiver.stop();
547
+ }
548
+ phase.detached = true;
549
+ const sessionDescription = closed ? void 0 : await createLocalOffer(connection);
550
+ const response = await this.#signal(() => this.#requireTransport().closeTracks({ connectionId, sessionDescription, tracks: [{ ...handle, source: state.source }], force: closed }), { generation: this.#generation, connectionId });
551
+ publication.lifecycle = { kind: "retired" };
552
+ this.#ownedPublications.delete(publication);
553
+ if (connection.connectionState !== "closed") await this.#applyProviderDescription(response, connection);
554
+ } catch (error) {
555
+ await this.#rollbackLocalOffer(connection);
556
+ throw error;
557
+ }
558
+ }
559
+ #fenceLocalPublications() {
560
+ for (const publication of this.#ownedPublications) publication.lifecycle = { kind: "retired" };
561
+ this.#ownedPublications.clear();
474
562
  }
475
563
  observeLocalPublications(listener) {
476
564
  this.#localListeners.add(listener);
@@ -486,6 +574,7 @@ var CloudflareSFUClient = class {
486
574
  this.#requireActive();
487
575
  const options = "connectionId" in input ? { bootstrap: input } : input;
488
576
  validateBootstrap(options.bootstrap);
577
+ if (options.bootstrap.connectionId !== this.#bootstrap.connectionId) this.#fenceLocalPublications();
489
578
  const generation = ++this.#generation;
490
579
  const connectionEpoch = ++this.#connectionEpoch;
491
580
  this.#polling = false;
@@ -493,12 +582,12 @@ var CloudflareSFUClient = class {
493
582
  this.#clearPoll();
494
583
  this.#remotePullRetryAfter.clear();
495
584
  this.#disposeConnection(false);
496
- this.#reusableLocalTransceivers.clear();
497
- this.#reusableLocalPublicationIds.clear();
585
+ for (const publication of this.#ownedPublications) this.#markPublicationRetiring(publication);
586
+ void this.#retireOwnedPublications();
498
587
  this.#clearRemoteTracks();
499
588
  this.#cursor = null;
500
589
  this.#negotiatedGeneration = null;
501
- this.#replacementAttemptedGeneration = null;
590
+ this.#replacementAttemptedGeneration = options.bootstrap.connectionId !== this.#bootstrap.connectionId ? generation : null;
502
591
  if (options.bootstrap.connectionId !== this.#bootstrap.connectionId) this.#started = false;
503
592
  this.#bootstrap = options.bootstrap;
504
593
  if (options.transport) this.#transport = options.transport;
@@ -524,7 +613,10 @@ var CloudflareSFUClient = class {
524
613
  }
525
614
  }
526
615
  stop() {
527
- if (this.#stopped) return;
616
+ if (this.#stopped) {
617
+ void this.#retireOwnedPublications();
618
+ return;
619
+ }
528
620
  this.#stopped = true;
529
621
  this.#subscribedPublicationIds.clear();
530
622
  this.#generation++;
@@ -534,12 +626,14 @@ var CloudflareSFUClient = class {
534
626
  this.#polling = false;
535
627
  this.#pollAfterCurrent = false;
536
628
  this.#disposeConnection(true);
537
- this.#reusableLocalTransceivers.clear();
538
- this.#reusableLocalPublicationIds.clear();
629
+ for (const publication of this.#ownedPublications) this.#markPublicationRetiring(publication);
539
630
  this.#clearRemoteTracks();
540
- for (const state of this.#localTracks.values()) this.#removeOwnedLocalTrack(state);
631
+ for (const state of this.#localTracks.values()) {
632
+ state.track.enabled = false;
633
+ this.#removeOwnedLocalTrack(state);
634
+ }
541
635
  this.#localTracks.clear();
542
- this.#transport = void 0;
636
+ void this.#retireOwnedPublications();
543
637
  this.#started = false;
544
638
  this.#cursor = null;
545
639
  this.#publishSnapshot("stopped", null);
@@ -549,43 +643,32 @@ var CloudflareSFUClient = class {
549
643
  this.#remoteListeners.clear();
550
644
  this.#snapshotListeners.clear();
551
645
  }
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
- }
646
+ async #enablePreparedTrack(state) {
647
+ const generation = this.#generation;
579
648
  state.desiredEnabled = true;
580
649
  state.track.enabled = true;
581
- const generation = this.#generation;
582
650
  try {
651
+ if (state.providerPublicationId && state.transceiver) {
652
+ await this.#boundPeerOperation(state.transceiver.sender.replaceTrack(state.track));
653
+ state.enabled = !this.#locallyPausedSources.has(state.source);
654
+ state.desiredEnabled = state.enabled;
655
+ state.track.enabled = state.enabled;
656
+ if (!state.enabled) {
657
+ this.#pauseLocalSource(state.source);
658
+ await this.#retireOwnedPublications();
659
+ }
660
+ this.#publishSnapshot();
661
+ this.#emitLocal();
662
+ return;
663
+ }
583
664
  await this.#publishPreparedTracks([state], generation);
584
665
  if (this.#remoteRecoveryGeneration !== generation) this.#replacementAttemptedGeneration = null;
585
666
  } catch (error) {
586
- state.desiredEnabled = false;
587
- state.enabled = false;
588
- state.track.enabled = false;
667
+ if (generation === this.#generation) {
668
+ this.#pauseLocalSource(state.source);
669
+ await this.#retireOwnedPublications().catch(() => {
670
+ });
671
+ }
589
672
  throw error;
590
673
  }
591
674
  }
@@ -595,36 +678,39 @@ var CloudflareSFUClient = class {
595
678
  state.track.enabled = false;
596
679
  const detach = transceiver ? this.#boundPeerOperation(transceiver.sender.replaceTrack(null)) : Promise.resolve();
597
680
  state.enabled = false;
598
- state.pendingOperationId = null;
599
- state.pendingTrackName = null;
600
681
  this.#publishSnapshot();
601
682
  this.#emitLocal();
602
683
  await detach;
603
684
  }
604
685
  async #publishPreparedTracks(states, generation) {
605
686
  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
- });
687
+ try {
688
+ await this.#serializeSDP(async () => {
689
+ const pending = await this.#prepareConnectionForPublication(states, generation);
690
+ try {
691
+ await this.#publishPreparedTracksSerialized(pending, generation, this.#connection, this.#bootstrap.connectionId);
692
+ } catch (error) {
693
+ if (!this.#replaceMediaConnection || !this.#isRetryableConnectionFailure(error) || this.#replacementAttemptedGeneration === generation) throw error;
694
+ await this.#replaceMediaConnectionForRecovery(generation);
695
+ await this.#publishPreparedTracksSerialized(
696
+ [...this.#localTracks.values()].filter((state) => (state.desiredEnabled || states.includes(state)) && state.track.readyState !== "ended"),
697
+ generation,
698
+ this.#connection,
699
+ this.#bootstrap.connectionId
700
+ );
701
+ }
702
+ });
703
+ } finally {
704
+ await this.#retirement.catch(() => {
705
+ });
706
+ }
621
707
  }
622
708
  async #prepareConnectionForPublication(states, generation) {
623
709
  if (this.#retiredLocalConnection !== this.#connection || !this.#replaceMediaConnection) {
624
710
  await this.#replaceDormantConnectionBeforeNegotiation(generation);
625
711
  return states;
626
712
  }
627
- const authoritative = await (() => this.#requireTransport().listPublications())();
713
+ const authoritative = await this.#signal(() => this.#requireTransport().listPublications());
628
714
  this.#pauseUnavailableOtherSources(authoritative.publications, states);
629
715
  this.#replacementAttemptedGeneration = null;
630
716
  await this.#replaceMediaConnectionForRecovery(generation);
@@ -634,9 +720,9 @@ var CloudflareSFUClient = class {
634
720
  for (const state of this.#localTracks.values()) {
635
721
  if (states.includes(state) || !state.providerPublicationId) continue;
636
722
  if (publications.some((publication) => publication.publicationId === state.providerPublicationId)) continue;
637
- state.desiredEnabled = false;
638
- state.track.enabled = false;
723
+ this.#pauseLocalSource(state.source);
639
724
  }
725
+ void this.#retireOwnedPublications();
640
726
  }
641
727
  async #publishPreparedTracksSerialized(states, generation, connection, connectionId) {
642
728
  this.#requireGeneration(generation);
@@ -646,28 +732,30 @@ var CloudflareSFUClient = class {
646
732
  const publications = [];
647
733
  try {
648
734
  for (const state of pendingStates) publications.push(await this.#prepareLocalPublication(connection, state, generation));
649
- const response = await this.#negotiateLocalPublications(connection, connectionId, publications, generation);
735
+ await this.#negotiateLocalPublications(connection, connectionId, publications, generation);
650
736
  if (!wasLive) await this.#waitForConnection(connection, generation);
651
737
  this.#requireGeneration(generation);
652
738
  this.#negotiatedGeneration = generation;
653
- this.#confirmLocalPublications(publications, response.tracks);
739
+ this.#settleLocalPublications(publications);
654
740
  this.#publishSnapshot();
655
741
  this.#emitLocal();
656
742
  } catch (error) {
657
- if (generation === this.#generation) {
658
- await this.#rollbackLocalOffer(connection);
659
- await this.#discardLocalPublications(publications);
660
- }
743
+ await this.#rollbackLocalOffer(connection);
744
+ for (const publication of publications) this.#markPublicationRetiring(publication);
745
+ this.#settleLocalPublications(publications);
661
746
  throw error;
747
+ } finally {
748
+ void this.#retireOwnedPublications();
662
749
  }
663
750
  }
664
751
  async #prepareLocalPublication(connection, state, generation) {
665
- const reusedTransceiver = state.transceiver !== null;
666
752
  const transceiver = state.transceiver ?? this.#addLocalTransceiver(connection, state);
667
- if (reusedTransceiver) await this.#boundPeerOperation(transceiver.sender.replaceTrack(state.track));
753
+ const publication = { state, transceiver, trackName: `${state.source}-${globalThis.crypto.randomUUID()}`, connection, connectionId: this.#bootstrap.connectionId, lifecycle: { kind: "pending" } };
754
+ this.#ownedPublications.add(publication);
755
+ if (state.transceiver) await this.#boundPeerOperation(transceiver.sender.replaceTrack(state.track));
668
756
  this.#requireGeneration(generation);
669
757
  state.transceiver = transceiver;
670
- return { state, transceiver, trackName: state.pendingTrackName ?? `${state.source}-${globalThis.crypto.randomUUID()}`, reusedTransceiver };
758
+ return publication;
671
759
  }
672
760
  #addLocalTransceiver(connection, state) {
673
761
  if (state.source !== "camera" || firefoxCameraSimulcastUnsupported()) return connection.addTransceiver(state.track, { direction: "sendonly" });
@@ -686,10 +774,9 @@ var CloudflareSFUClient = class {
686
774
  }
687
775
  }
688
776
  async #negotiateLocalPublications(connection, connectionId, publications, generation) {
689
- let offer;
777
+ let sessionDescription;
690
778
  try {
691
- offer = await (() => connection.createOffer())();
692
- await (() => connection.setLocalDescription(offer))();
779
+ sessionDescription = await createLocalOffer(connection);
693
780
  } catch (error) {
694
781
  const cameras = publications.filter(({ state, transceiver }) => state.source === "camera" && transceiver.sender.getParameters().encodings.length > 1);
695
782
  if (!simulcastUnsupported(error) || cameras.length === 0) throw error;
@@ -701,8 +788,7 @@ var CloudflareSFUClient = class {
701
788
  publication.transceiver = connection.addTransceiver(publication.state.track, { direction: "sendonly" });
702
789
  publication.state.transceiver = publication.transceiver;
703
790
  }
704
- offer = await (() => connection.createOffer())();
705
- await (() => connection.setLocalDescription(offer))();
791
+ sessionDescription = await createLocalOffer(connection);
706
792
  }
707
793
  const tracks = publications.map(
708
794
  ({ state, transceiver, trackName }) => ({
@@ -712,43 +798,44 @@ var CloudflareSFUClient = class {
712
798
  source: state.source
713
799
  })
714
800
  );
715
- const response = await (() => this.#requireTransport().addTracks({ connectionId, ...providerDescription(requireDescription(offer)), tracks }))();
801
+ const response = await this.#signal(() => this.#requireTransport().addTracks({ connectionId, sessionDescription, tracks }), { generation, connectionId });
802
+ this.#recordLocalPublicationHandles(publications, response.tracks);
716
803
  this.#requireGeneration(generation);
717
804
  await this.#applyProviderDescription(response, connection);
718
- return response;
719
805
  }
720
- #confirmLocalPublications(publications, tracks) {
721
- for (const { state, transceiver } of publications) {
806
+ #recordLocalPublicationHandles(publications, tracks) {
807
+ for (const publication of publications) {
808
+ const { state, transceiver, lifecycle } = publication;
809
+ if (lifecycle.kind === "retired") continue;
722
810
  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;
811
+ if (!authoritative?.publicationId) throw new CloudflareSFUError("Missing publication ID", "invalid_publication");
812
+ const handle = { publicationId: authoritative.publicationId, mid: requireTransceiverMid(transceiver) };
813
+ publication.lifecycle = lifecycle.kind === "retiring" ? { ...lifecycle, handle } : { kind: "confirmed", handle };
732
814
  }
733
815
  }
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
- }
816
+ #settleLocalPublications(publications) {
817
+ for (const publication of publications) {
818
+ const { state, transceiver, lifecycle } = publication;
819
+ if (lifecycle.kind === "retired") continue;
820
+ if (lifecycle.kind === "confirmed" && state.transceiver === transceiver && !this.#locallyPausedSources.has(state.source)) {
821
+ state.enabled = true;
822
+ state.desiredEnabled = true;
823
+ state.track.enabled = true;
824
+ state.providerPublicationId = lifecycle.handle.publicationId;
742
825
  continue;
743
826
  }
744
- try {
745
- transceiver.stop();
746
- } catch (error) {
747
- this.#reportError(error);
827
+ this.#markPublicationRetiring(publication);
828
+ if (state.transceiver === transceiver) {
829
+ this.#clearLocalPublication(state);
748
830
  }
749
- if (state.transceiver === transceiver) state.transceiver = null;
750
831
  }
751
832
  }
833
+ #clearLocalPublication(state) {
834
+ state.transceiver = null;
835
+ state.providerPublicationId = null;
836
+ state.enabled = false;
837
+ state.track.enabled = false;
838
+ }
752
839
  async #rollbackLocalOffer(connection) {
753
840
  if (connection.signalingState !== "have-local-offer") return;
754
841
  try {
@@ -913,7 +1000,7 @@ var CloudflareSFUClient = class {
913
1000
  connection.addEventListener("track", onTrack);
914
1001
  try {
915
1002
  this.#requireGeneration(generation);
916
- const response = await (() => this.#requireTransport().addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }))();
1003
+ const response = await this.#signal(() => this.#requireTransport().addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }), { generation, connectionId });
917
1004
  this.#requireGeneration(generation);
918
1005
  this.#recordRemotePullErrors(publications, response.trackErrors ?? []);
919
1006
  const responseTracks = response.tracks ?? [];
@@ -958,13 +1045,13 @@ var CloudflareSFUClient = class {
958
1045
  await this.#applyProviderDescription(response, connection);
959
1046
  const answer = await (() => connection.createAnswer())();
960
1047
  await (() => connection.setLocalDescription(answer))();
961
- await (() => this.#requireTransport().renegotiate({ connectionId, ...providerDescription(requireDescription(answer)) }))();
1048
+ await this.#signal(() => this.#requireTransport().renegotiate({ connectionId, sessionDescription: requireDescription(answer) }), { generation, connectionId });
962
1049
  this.#requireGeneration(generation);
963
1050
  }
964
1051
  async #applyProviderDescription(response, connection = this.#connection) {
965
1052
  await (() => connection.setRemoteDescription(requireSFUDescription(response.sessionDescription)))();
966
1053
  }
967
- #serializeSDP(operation) {
1054
+ #serializeSDP(operation, retirement = false) {
968
1055
  const generation = this.#generation;
969
1056
  let expired = false;
970
1057
  let timer;
@@ -977,7 +1064,7 @@ var CloudflareSFUClient = class {
977
1064
  }, NEGOTIATION_TIMEOUT_MS);
978
1065
  });
979
1066
  const run = () => {
980
- if (expired || generation !== this.#generation) throw timeout;
1067
+ if (expired || !retirement && generation !== this.#generation) throw timeout;
981
1068
  return operation();
982
1069
  };
983
1070
  const pending = this.#sdpTail.then(run, run);
@@ -1115,10 +1202,9 @@ var CloudflareSFUClient = class {
1115
1202
  if (bootstrap.connectionId === this.#bootstrap.connectionId) {
1116
1203
  throw new CloudflareSFUError("Participant access did not replace the Cloudflare SFU connection", "signaling_failed");
1117
1204
  }
1205
+ this.#fenceLocalPublications();
1118
1206
  const connectionEpoch = ++this.#connectionEpoch;
1119
1207
  this.#disposeConnection(false);
1120
- this.#reusableLocalTransceivers.clear();
1121
- this.#reusableLocalPublicationIds.clear();
1122
1208
  this.#clearRemoteTracks();
1123
1209
  this.#cursor = null;
1124
1210
  this.#bootstrap = bootstrap;
@@ -1190,8 +1276,6 @@ var CloudflareSFUClient = class {
1190
1276
  state.desiredEnabled = false;
1191
1277
  state.transceiver = null;
1192
1278
  state.providerPublicationId = null;
1193
- state.pendingOperationId = null;
1194
- state.pendingTrackName = null;
1195
1279
  state.endedListener = null;
1196
1280
  }
1197
1281
  #clearRemoteTracks() {
@@ -1271,7 +1355,7 @@ var CloudflareSFUClient = class {
1271
1355
  }
1272
1356
  #setFailure(error, fallback) {
1273
1357
  const code = error instanceof CloudflareSFUError ? error.code : fallback;
1274
- this.#publishSnapshot("failed", { code, recoverable: code !== "invalid_bootstrap" && code !== "invalid_target" && code !== "invalid_publication" });
1358
+ this.#publishSnapshot("failed", { code, recoverable: code !== "invalid_bootstrap" && code !== "invalid_target" && code !== "invalid_publication", diagnostic: classifyMediaFailure(error, "peer_connection") });
1275
1359
  this.#reportError(error);
1276
1360
  }
1277
1361
  #emitLocal() {
@@ -1290,19 +1374,29 @@ var CloudflareSFUClient = class {
1290
1374
  }
1291
1375
  }
1292
1376
  #reportError(error) {
1377
+ const owner = error instanceof Error ? this.#failureOwners.get(error) : void 0;
1378
+ if (owner && (owner.generation !== this.#generation || owner.connectionId !== this.#bootstrap.connectionId)) return;
1293
1379
  try {
1294
- this.#onError?.(error);
1380
+ this.#onError?.(error, owner?.connectionId ?? this.#bootstrap.connectionId);
1295
1381
  } catch {
1296
1382
  }
1297
1383
  }
1384
+ async #signal(operation, owner = { generation: this.#generation, connectionId: this.#bootstrap.connectionId }) {
1385
+ try {
1386
+ return await operation();
1387
+ } catch (error) {
1388
+ if (error instanceof Error) this.#failureOwners.set(error, owner);
1389
+ throw error;
1390
+ }
1391
+ }
1298
1392
  #requireGeneration(generation) {
1299
- if (generation !== this.#generation || this.#stopped) throw new CloudflareSFUError("Cloudflare SFU operation belongs to a stale connection generation", "stale_generation");
1393
+ if (generation !== this.#generation || this.#stopped) throw new CloudflareSFUError("Stale media connection", "stale_generation");
1300
1394
  }
1301
1395
  #requireActive() {
1302
- if (this.#stopped) throw new CloudflareSFUError("Cloudflare SFU client is stopped", "media_failed");
1396
+ if (this.#stopped) throw new CloudflareSFUError("SFU client stopped", "media_failed");
1303
1397
  }
1304
1398
  #requireTransport() {
1305
- if (!this.#transport) throw new CloudflareSFUError("Cloudflare SFU signaling transport is unavailable", "signaling_failed");
1399
+ if (!this.#transport) throw new CloudflareSFUError("SFU signaling unavailable", "signaling_failed");
1306
1400
  return this.#transport;
1307
1401
  }
1308
1402
  };
@@ -1391,8 +1485,13 @@ function publicationEqual(left, right) {
1391
1485
  function remotePublicationEqual(left, right) {
1392
1486
  return right !== void 0 && left.participantId === right.participantId && left.source === right.source && left.publicationId === right.publicationId && left.track === right.track;
1393
1487
  }
1394
- function providerDescription(description) {
1395
- return { sessionDescription: description };
1488
+ async function createLocalOffer(connection) {
1489
+ const offer = await connection.createOffer();
1490
+ await connection.setLocalDescription(offer);
1491
+ return requireDescription(offer);
1492
+ }
1493
+ function localSourceGroup(source) {
1494
+ return source === "screen" ? [source, "screen_audio"] : [source];
1396
1495
  }
1397
1496
  function isRateLimited(error) {
1398
1497
  return error instanceof CloudflareSFUError && error.options.status === 429;
@@ -1425,22 +1524,36 @@ function createCloudflareSFUHTTPTransport(options) {
1425
1524
  const timeoutMs = options.requestTimeoutMs ?? SIGNALING_TIMEOUT_MS;
1426
1525
  if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new CloudflareSFUError("The signaling deadline is invalid", "signaling_failed");
1427
1526
  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 response = await fetch(`${mediaPath}/${path}`, {
1432
- ...init,
1433
- signal,
1434
- headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...init?.headers }
1435
- });
1436
- if (!response.ok) {
1437
- throw new CloudflareSFUError(`Chalk SFU signaling failed with HTTP ${response.status}`, "signaling_failed", {
1438
- status: response.status,
1439
- retryableConnection: response.status === 404 || response.status === 410 || response.status >= 500 && response.status < 600
1527
+ try {
1528
+ const token = await withDeadline(CREDENTIAL_TIMEOUT_MS, (signal) => credential(signal));
1529
+ if (!token.trim()) throw new CloudflareSFUError("The media credential provider returned an empty token", "signaling_failed");
1530
+ return await withDeadline(timeoutMs, async (signal) => {
1531
+ const startedAt = performance.now();
1532
+ const response = await fetch(`${mediaPath}/${path}`, {
1533
+ ...init,
1534
+ signal,
1535
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...options.traceparent ? { traceparent: options.traceparent } : {}, ...init?.headers }
1536
+ }).catch((error) => {
1537
+ markMediaFetchFailure(error);
1538
+ throw error;
1440
1539
  });
1441
- }
1442
- return await response.json();
1443
- });
1540
+ if (!response.ok) {
1541
+ try {
1542
+ options.onDiagnostic?.({ event: "media_http_error", http_status: response.status, duration_ms: performance.now() - startedAt });
1543
+ } catch {
1544
+ }
1545
+ throw new CloudflareSFUError(`Chalk SFU signaling failed with HTTP ${response.status}`, "signaling_failed", {
1546
+ status: response.status,
1547
+ retryableConnection: response.status === 404 || response.status === 410 || response.status >= 500 && response.status < 600
1548
+ });
1549
+ }
1550
+ return await response.json();
1551
+ });
1552
+ } catch (error) {
1553
+ const step = path === "publications" ? "sfu_publications" : path === "tracks/close" ? "sfu_close_tracks" : path === "renegotiate" ? "sfu_renegotiate" : "sfu_tracks";
1554
+ markMediaFailure(error, step);
1555
+ throw error;
1556
+ }
1444
1557
  };
1445
1558
  return {
1446
1559
  addTracks: async (input) => {
@@ -1533,7 +1646,6 @@ function describeMediaCaptureError(cause) {
1533
1646
  }
1534
1647
 
1535
1648
  export {
1536
- describeMediaCaptureError,
1537
1649
  CloudflareSFUError,
1538
1650
  parseCloudflareSFUPublicationID,
1539
1651
  requireDescription,
@@ -1542,6 +1654,10 @@ export {
1542
1654
  comparePublicationCursor,
1543
1655
  publicationKey,
1544
1656
  waitFor,
1657
+ grantedMediaFromStream,
1658
+ markMediaFailure,
1659
+ classifyMediaFailure,
1545
1660
  CloudflareSFUClient,
1546
- createCloudflareSFUHTTPTransport
1661
+ createCloudflareSFUHTTPTransport,
1662
+ describeMediaCaptureError
1547
1663
  };