@q9labsai/chalk-client 5.3.1 → 5.4.0

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.
@@ -10,7 +10,7 @@ import {
10
10
  describeMediaCaptureError,
11
11
  grantedMediaFromStream,
12
12
  markMediaFailure
13
- } from "./chunk-O5JB5XVX.js";
13
+ } from "./chunk-UVWRTXNO.js";
14
14
  import {
15
15
  ChatAttachmentMimeTypeSchema,
16
16
  ChatReactionSetFrameSchema,
@@ -24,7 +24,7 @@ import {
24
24
  formatSyncConnectDiagnostic,
25
25
  scheduleSyncDialDeadline,
26
26
  syncConnectFailureCode
27
- } from "./chunk-FD7ZSDTM.js";
27
+ } from "./chunk-XF567GPH.js";
28
28
  import {
29
29
  withRequestContext
30
30
  } from "./chunk-N4IPKRS6.js";
@@ -2712,6 +2712,7 @@ function bindConnectionMediaClient(client, restart) {
2712
2712
  ...client.remoteParticipantsChanged ? { remoteParticipantsChanged: client.remoteParticipantsChanged.bind(client) } : {},
2713
2713
  ...client.remotePublicationsChanged ? { remotePublicationsChanged: client.remotePublicationsChanged.bind(client) } : {},
2714
2714
  ...client.remotePublicationResumed ? { remotePublicationResumed: client.remotePublicationResumed.bind(client) } : {},
2715
+ ...client.localPublicationWithdrawn ? { localPublicationWithdrawn: client.localPublicationWithdrawn.bind(client) } : {},
2715
2716
  start: client.start.bind(client),
2716
2717
  stop: client.stop.bind(client),
2717
2718
  restart,
@@ -5051,7 +5052,10 @@ var MediaControllerRuntime = class {
5051
5052
  #sources = { microphone: sourceWorker(true), camera: sourceWorker(true), screen: sourceWorker(false) };
5052
5053
  #syncLive = false;
5053
5054
  #disposed = false;
5055
+ /** The wanted-on sources when recovery began, with the intent revision each one had then. */
5054
5056
  #recovering = null;
5057
+ /** The Sync client's last proof of contact when the current recovery began: where its drop is measured from. */
5058
+ #proof = null;
5055
5059
  #pendingSources = /* @__PURE__ */ new Map();
5056
5060
  #pendingCaptures = /* @__PURE__ */ new Set();
5057
5061
  #readinessChecks = /* @__PURE__ */ new Set();
@@ -5537,7 +5541,10 @@ var MediaControllerRuntime = class {
5537
5541
  return state.desired.enabled && state.forcedRevision !== state.desired.revision;
5538
5542
  }
5539
5543
  #resetObservations() {
5540
- if (this.#connection.getSnapshot().state !== "reconnecting") this.#recovering = null;
5544
+ if (this.#connection.getSnapshot().state !== "reconnecting") {
5545
+ this.#recovering = null;
5546
+ this.#proof = null;
5547
+ }
5541
5548
  for (const source of MEDIA_SOURCES) {
5542
5549
  const state = this.#sources[source];
5543
5550
  state.result = { revision: state.desired.revision, status: "confirmed" };
@@ -5629,7 +5636,7 @@ var MediaControllerRuntime = class {
5629
5636
  if (!resumed) return;
5630
5637
  if (this.#ports) {
5631
5638
  const snapshot = this.#ports.sync.getSnapshot();
5632
- this.#pauseRecoveredIntents(this.#ports, snapshot);
5639
+ this.#applyRecoveryRule(this.#ports, snapshot);
5633
5640
  this.#oweOffsSyncStillShows(snapshot);
5634
5641
  }
5635
5642
  this.#resumePendingActions();
@@ -6149,16 +6156,59 @@ var MediaControllerRuntime = class {
6149
6156
  #rememberRecoveryIntents() {
6150
6157
  for (const state of Object.values(this.#sources)) if (state.capturePause === "announced") state.capturePause = "pending";
6151
6158
  if (!this.#ports) return;
6159
+ if (this.#recovering === null) this.#proof = this.#ports.sync.link?.proof() ?? null;
6152
6160
  this.#recovering ??= new Map(MEDIA_SOURCES.filter((source) => this.#sources[source].desired.enabled).map((source) => [source, this.#sources[source].desired.revision]));
6153
6161
  }
6154
- #pauseRecoveredIntents(ports, snapshot) {
6155
- const recovering = this.#recovering;
6162
+ /**
6163
+ * The rule depends on what the connected Sync advertised. Sync's snapshot items carry `off_reason` only when its welcome said
6164
+ * `media_off_reason`, and only then can recovery tell a host stop from a publication that is merely missing. Without it the rule
6165
+ * is the one this SDK had before: a wanted-on source whose own item is missing or not enabled is paused (#597).
6166
+ */
6167
+ #applyRecoveryRule(ports, snapshot) {
6168
+ const recovering = this.#recovering ?? /* @__PURE__ */ new Map();
6169
+ const proof = this.#proof;
6156
6170
  this.#recovering = null;
6157
- for (const [source, revision] of recovering ?? []) {
6158
- const current = snapshot.media?.items.find((item) => item.participantId === snapshot.participantId && item.source === source);
6159
- if (!current?.enabled) this.#pausePublication(ports, source, revision, "sync_recovery_loss");
6171
+ this.#proof = null;
6172
+ if (ports.sync.offReasonNegotiated?.() === true) this.#pauseOnEvidence(ports, snapshot, recovering, ports.sync.link?.longSince(proof) ?? true);
6173
+ else this.#pauseWhenSyncLostIt(ports, snapshot, recovering);
6174
+ }
6175
+ #pauseWhenSyncLostIt(ports, snapshot, recovering) {
6176
+ for (const [source, revision] of recovering) {
6177
+ if (!ownProjection(snapshot, source)?.enabled) this.#pausePublication(ports, source, revision, "sync_recovery_loss");
6160
6178
  }
6161
6179
  }
6180
+ /**
6181
+ * A wanted-on source is paused when Sync's item says the host stopped it after the user's intent (`off_reason: "host"` with a newer
6182
+ * `off_revision`; a replayed stop event reaches `#subscribePauses` the same way). This holds for every source that is wanted on when
6183
+ * the snapshot arrives, including one the user turned on after recovery began, and for every source recovery began with. With no such
6184
+ * evidence the drop decides, whatever Sync lists: the source stays wanted-on after a drop proven short and turns off, like a tap, after
6185
+ * any other. The drop cutoff applies only to the intent the source had when recovery began (`recovering`), so a newer intent from the
6186
+ * user is left alone.
6187
+ */
6188
+ #pauseOnEvidence(ports, snapshot, recovering, longDrop) {
6189
+ for (const source of MEDIA_SOURCES) {
6190
+ const intentRevision = recovering.get(source);
6191
+ if (intentRevision === void 0 && !this.#sources[source].desired.enabled) continue;
6192
+ const item = ownProjection(snapshot, source);
6193
+ const stopRevision = this.#hostStopNewerThanIntent(source, item);
6194
+ if (stopRevision !== null) this.#pauseForHostStop(ports, source, stopRevision);
6195
+ else if (longDrop && intentRevision !== void 0 && this.#sources[source].desired.revision === intentRevision) this.#turnOffLikeATap(source);
6196
+ }
6197
+ }
6198
+ #hostStopNewerThanIntent(source, item) {
6199
+ if (item?.offReason !== "host" || item.offRevision === void 0) return null;
6200
+ return item.offRevision > this.#sources[source].desired.controlRevision ? item.offRevision : null;
6201
+ }
6202
+ #pauseForHostStop(ports, source, stopRevision) {
6203
+ const state = this.#sources[source];
6204
+ state.serverSequence = Math.max(state.serverSequence, stopRevision);
6205
+ this.#pausePublication(ports, source, state.desired.revision, "moderation_pause");
6206
+ }
6207
+ // The intent becomes off exactly as a tap makes it, so the UI shows it off and the user can tap to turn it on.
6208
+ #turnOffLikeATap(source) {
6209
+ this.#recordIntent(source, false);
6210
+ this.#sources[source].offDebt = "owed";
6211
+ }
6162
6212
  #subscribeSync(ports) {
6163
6213
  return ports.sync.subscribe((snapshot) => {
6164
6214
  this.#removeRequestsFromMissingParticipants(snapshot);
@@ -837,6 +837,8 @@ var EMPTY_REMOTE = Object.freeze([]);
837
837
  var EMPTY_RECEIVE_STALLS = Object.freeze([]);
838
838
  var INVALID_PUBLICATION_SIGNATURE = "\0invalid";
839
839
  var CONNECTION_TIMEOUT_MS = 8e3;
840
+ var RECOVERY_BACKOFF_MS = [1e3, 2e3, 4e3, 5e3];
841
+ var RECOVERY_HEALTHY_RESET_MS = 1e4;
840
842
  var NEGOTIATION_TIMEOUT_MS = 35e3;
841
843
  var PEER_OPERATION_TIMEOUT_MS = 8e3;
842
844
  var VIEWER_LAYER_BATCH_SIZE = 16;
@@ -857,6 +859,8 @@ var CloudflareSFUClient = class {
857
859
  #locallyPausedSources = /* @__PURE__ */ new Map();
858
860
  #localTracks = /* @__PURE__ */ new Map();
859
861
  #ownedPublications = /* @__PURE__ */ new Set();
862
+ #localRecoveries = /* @__PURE__ */ new Map();
863
+ #recentRecoveries = /* @__PURE__ */ new Map();
860
864
  #remoteTracks = /* @__PURE__ */ new Map();
861
865
  #bootstrap;
862
866
  #connection;
@@ -865,6 +869,7 @@ var CloudflareSFUClient = class {
865
869
  #retiredLocalConnection = null;
866
870
  #retiredLocalCause = "forced_pause";
867
871
  #replacementCause;
872
+ #replacement = null;
868
873
  #cursor = null;
869
874
  #remotePullIncomplete = false;
870
875
  #remotePullRetries = /* @__PURE__ */ new Map();
@@ -924,6 +929,12 @@ var CloudflareSFUClient = class {
924
929
  this.#remotePullRetries.delete(publicationId);
925
930
  this.remotePublicationsChanged();
926
931
  }
932
+ localPublicationWithdrawn(publicationId) {
933
+ const owned = [...this.#ownedPublications].find((publication) => publication.lifecycle.kind === "confirmed" && publication.lifecycle.handle.publicationId === publicationId && publication.state.providerPublicationId === publicationId);
934
+ const state = owned?.state;
935
+ if (!state || !state.desiredEnabled || this.#localTracks.get(state.source) !== state) return;
936
+ this.#beginLocalRecovery(state, publicationId, false);
937
+ }
927
938
  remoteParticipantsChanged(participantIds) {
928
939
  this.#remoteParticipantIds = new Set(participantIds);
929
940
  this.#remoteParticipantRevision++;
@@ -961,11 +972,13 @@ var CloudflareSFUClient = class {
961
972
  };
962
973
  track.addEventListener("ended", state.endedListener);
963
974
  }
975
+ this.#dropLocalRecovery(source);
964
976
  this.#localTracks.set(source, state);
965
977
  this.#publishSnapshot();
966
978
  this.#emitLocal();
967
979
  }
968
980
  async clearPreparedLocalTrack(source, options = {}) {
981
+ this.#dropLocalRecovery(source);
969
982
  const state = this.#localTracks.get(source);
970
983
  if (!state) return;
971
984
  if (state.clearing) return state.clearing;
@@ -978,6 +991,7 @@ var CloudflareSFUClient = class {
978
991
  }
979
992
  async #clearPreparedTrack(state, stopTrack = true) {
980
993
  const source = state.source;
994
+ this.#dropLocalRecovery(source);
981
995
  if (source === "screen" || source === "screen_audio") {
982
996
  try {
983
997
  this.#pauseLocalSource(source, "capture_cleanup");
@@ -1028,6 +1042,7 @@ var CloudflareSFUClient = class {
1028
1042
  const transport = this.#requireTransport();
1029
1043
  const authoritative = await this.#signal(() => transport.listPublications());
1030
1044
  this.#requireGeneration(generation);
1045
+ this.#republishUnlistedLocalPublications(authoritative);
1031
1046
  await this.#reconcileRemotePublications(authoritative, generation);
1032
1047
  this.#requireGeneration(generation);
1033
1048
  if (!this.#remotePullIncomplete) this.#pollFailures = 0;
@@ -1038,6 +1053,161 @@ var CloudflareSFUClient = class {
1038
1053
  this.#finishPublicationPoll(generation);
1039
1054
  }
1040
1055
  }
1056
+ // The registry drops a publication when listeners keep failing to pull it.
1057
+ // A publisher that still believes it is sending must publish again, so
1058
+ // listeners get a new publication ID instead of retrying a dead one.
1059
+ #republishUnlistedLocalPublications(authoritative) {
1060
+ const unlisted = [...this.#localTracks.values()].filter((state) => state.enabled && state.desiredEnabled && state.providerPublicationId !== null && !authoritative.publications.some((publication) => publication.publicationId === state.providerPublicationId));
1061
+ for (const state of unlisted) this.#beginLocalRecovery(state, state.providerPublicationId, true);
1062
+ }
1063
+ #beginLocalRecovery(state, deadPublicationId, relist) {
1064
+ if (!this.#canBeginLocalRecovery(state)) return;
1065
+ const now = Date.now();
1066
+ const delayMs = this.#recoveryStartDelay(state.source, now);
1067
+ const recovery = { state, deadPublicationId, relist, retiring: false, attempt: 0, timer: void 0, running: false };
1068
+ this.#localRecoveries.set(state.source, recovery);
1069
+ this.#scheduleLocalRecovery(recovery, delayMs);
1070
+ }
1071
+ #canBeginLocalRecovery(state) {
1072
+ return state.enabled && state.desiredEnabled && !this.#stopped && !this.#locallyPausedSources.has(state.source) && !this.#localRecoveries.has(state.source);
1073
+ }
1074
+ #recoveryStartDelay(source, now) {
1075
+ const recent = this.#recentRecoveries.get(source);
1076
+ if (!recent || now - recent.healthySince >= RECOVERY_HEALTHY_RESET_MS) {
1077
+ this.#recentRecoveries.set(source, { count: 0, healthySince: now });
1078
+ return 0;
1079
+ }
1080
+ this.#recentRecoveries.set(source, { count: recent.count + 1, healthySince: now });
1081
+ return recoveryBackoffMs(recent.count);
1082
+ }
1083
+ #scheduleLocalRecovery(recovery, delayMs) {
1084
+ recovery.timer = globalThis.setTimeout(() => {
1085
+ recovery.timer = void 0;
1086
+ if (this.#localRecoveries.get(recovery.state.source) !== recovery) return;
1087
+ void this.#runLocalRecovery(recovery.state.source);
1088
+ }, delayMs);
1089
+ }
1090
+ #canRecover(recovery) {
1091
+ return this.#localRecoveries.get(recovery.state.source) === recovery && !this.#stopped && !this.#locallyPausedSources.has(recovery.state.source) && this.#localTracks.get(recovery.state.source) === recovery.state;
1092
+ }
1093
+ async #runLocalRecovery(source) {
1094
+ const recovery = this.#localRecoveries.get(source);
1095
+ if (!recovery || recovery.running || this.#discardUnrecoverableIntent(recovery)) return;
1096
+ recovery.running = true;
1097
+ try {
1098
+ await this.#attemptLocalRecovery(recovery);
1099
+ } catch (error) {
1100
+ await this.#retryLocalRecovery(recovery, error);
1101
+ } finally {
1102
+ recovery.running = false;
1103
+ }
1104
+ }
1105
+ async #attemptLocalRecovery(recovery) {
1106
+ if (!await this.#relistLocalRecoveryIfNeeded(recovery) || !this.#canContinueLocalRecovery(recovery)) {
1107
+ this.#dropLocalRecovery(recovery.state.source, recovery);
1108
+ return;
1109
+ }
1110
+ this.#retireDeadLocalPublication(recovery);
1111
+ if (await this.#retireLocalRecoveryIfNeeded(recovery)) return;
1112
+ const published = await this.#enablePreparedTrack(recovery.state);
1113
+ if (this.#discardUnrecoverableIntent(recovery)) return;
1114
+ if (!this.#settleLocalRecovery(recovery, published)) this.#scheduleLocalRecoveryRetry(recovery);
1115
+ }
1116
+ async #relistLocalRecoveryIfNeeded(recovery) {
1117
+ const { state, deadPublicationId } = recovery;
1118
+ if (!recovery.relist || deadPublicationId === null) return true;
1119
+ const authoritative = await this.#signal(() => this.#requireTransport().listPublications());
1120
+ if (!this.#canRecover(recovery) || state.providerPublicationId !== deadPublicationId || !state.desiredEnabled) return false;
1121
+ if (authoritative.publications.some((publication) => publication.publicationId === deadPublicationId)) {
1122
+ this.#dropLocalRecovery(state.source, recovery);
1123
+ return false;
1124
+ }
1125
+ recovery.relist = false;
1126
+ return true;
1127
+ }
1128
+ #canContinueLocalRecovery(recovery) {
1129
+ if (!this.#canRecover(recovery) || recovery.retiring) return this.#canRecover(recovery);
1130
+ if (recovery.deadPublicationId !== null && recovery.state.providerPublicationId !== recovery.deadPublicationId) return false;
1131
+ return recovery.state.desiredEnabled;
1132
+ }
1133
+ #retireDeadLocalPublication(recovery) {
1134
+ if (recovery.retiring || recovery.deadPublicationId === null || recovery.state.providerPublicationId !== recovery.deadPublicationId) return;
1135
+ recovery.retiring = true;
1136
+ this.#pauseLocalSource(recovery.state.source, "discovery_absent", { group: false });
1137
+ }
1138
+ async #retireLocalRecoveryIfNeeded(recovery) {
1139
+ if (!this.#hasRetiringLocalPublications(recovery.state.source)) return false;
1140
+ await this.#retireLocalRecoveryPublications(recovery.state.source);
1141
+ return this.#discardUnrecoverableIntent(recovery);
1142
+ }
1143
+ async #retryLocalRecovery(recovery, error) {
1144
+ if (this.#discardUnrecoverableIntent(recovery)) return;
1145
+ this.#reportLocalRecoveryError(error);
1146
+ await this.#replaceConnectionForLocalRecovery(error);
1147
+ this.#scheduleLocalRecoveryRetry(recovery);
1148
+ }
1149
+ #discardUnrecoverableIntent(recovery) {
1150
+ if (this.#canRecover(recovery)) return false;
1151
+ this.#dropLocalRecovery(recovery.state.source, recovery);
1152
+ return true;
1153
+ }
1154
+ #settleLocalRecovery(recovery, published) {
1155
+ const source = recovery.state.source;
1156
+ if (!published && this.#locallyPausedSources.has(source)) {
1157
+ this.#dropLocalRecovery(source, recovery);
1158
+ return true;
1159
+ }
1160
+ const publicationId = recovery.state.providerPublicationId;
1161
+ if (!published || publicationId === null || publicationId === recovery.deadPublicationId) return false;
1162
+ const recent = this.#recentRecoveries.get(source);
1163
+ if (recent) this.#recentRecoveries.set(source, { ...recent, healthySince: Date.now() });
1164
+ this.#dropLocalRecovery(source, recovery);
1165
+ return true;
1166
+ }
1167
+ #reportLocalRecoveryError(error) {
1168
+ if (error instanceof AggregateError) {
1169
+ for (const cause of error.errors) this.#reportError(cause);
1170
+ return;
1171
+ }
1172
+ this.#reportError(error);
1173
+ }
1174
+ async #replaceConnectionForLocalRecovery(error) {
1175
+ const failures = error instanceof AggregateError ? error.errors : [error];
1176
+ if (!this.#replaceMediaConnection || failures.length === 0 || !failures.every((failure) => this.#isRetryableConnectionFailure(failure))) return;
1177
+ const generation = this.#generation;
1178
+ try {
1179
+ await this.#replaceMediaConnectionForRecovery(generation, "local_signaling_failure");
1180
+ } catch {
1181
+ return;
1182
+ }
1183
+ try {
1184
+ await this.#republishPreparedTracksAfterRecovery(generation);
1185
+ } catch (republishError) {
1186
+ this.#reportLocalRecoveryError(republishError);
1187
+ }
1188
+ }
1189
+ #scheduleLocalRecoveryRetry(recovery) {
1190
+ if (!this.#canRecover(recovery)) {
1191
+ this.#dropLocalRecovery(recovery.state.source, recovery);
1192
+ return;
1193
+ }
1194
+ this.#scheduleLocalRecovery(recovery, recoveryBackoffMs(recovery.attempt++));
1195
+ }
1196
+ #hasRetiringLocalPublications(source) {
1197
+ return [...this.#ownedPublications].some((publication) => publication.state.source === source && publication.lifecycle.kind === "retiring" && publication.lifecycle.handle !== null);
1198
+ }
1199
+ #retireLocalRecoveryPublications(source) {
1200
+ return this.#retireOwnedPublications(() => {
1201
+ }, { sources: [source], goneIsComplete: true });
1202
+ }
1203
+ #dropLocalRecovery(source, expected) {
1204
+ for (const item of localSourceGroup(source)) {
1205
+ const recovery = this.#localRecoveries.get(item);
1206
+ if (!recovery || expected && recovery !== expected) continue;
1207
+ if (recovery.timer !== void 0) globalThis.clearTimeout(recovery.timer);
1208
+ this.#localRecoveries.delete(item);
1209
+ }
1210
+ }
1041
1211
  #finishPublicationPoll(generation) {
1042
1212
  if (generation !== this.#generation) return;
1043
1213
  this.#polling = false;
@@ -1053,9 +1223,11 @@ var CloudflareSFUClient = class {
1053
1223
  return this.#enablePublicationTarget({ ...target, source: "screen_audio" });
1054
1224
  }
1055
1225
  async #pausePublicationTarget(target) {
1226
+ this.#dropLocalRecovery(target.source);
1056
1227
  const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
1057
1228
  if (resolved.kind === "result" && resolved.result.outcome !== "satisfied") return resolved.result;
1058
1229
  const outcome = resolved.kind === "state" && resolved.value.enabled ? "confirmed" : "satisfied";
1230
+ this.#dropLocalRecovery(target.source);
1059
1231
  if (target.source !== "screen" && this.#locallyPausedSources.get(target.source) === "capture_cleanup") {
1060
1232
  await this.pauseLocalCapture(target.source);
1061
1233
  return { outcome, errorCode: null };
@@ -1068,6 +1240,25 @@ var CloudflareSFUClient = class {
1068
1240
  return mediaTargetFailure(error);
1069
1241
  }
1070
1242
  }
1243
+ async #waitForReplacementBeforeEnable(state, generation, replacement) {
1244
+ if (replacement?.generation !== generation) return null;
1245
+ state.desiredEnabled = true;
1246
+ await replacement.promise.catch(() => {
1247
+ });
1248
+ if (this.#locallyPausedSources.has(state.source)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
1249
+ if (!state.desiredEnabled) return { outcome: "satisfied", errorCode: null };
1250
+ state.track.enabled = true;
1251
+ return null;
1252
+ }
1253
+ #postReplacementEnableOutcome(state, generation, replacement) {
1254
+ if (this.#locallyPausedSources.has(state.source)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
1255
+ if (replacement?.generation === generation && !state.desiredEnabled) return { outcome: "satisfied", errorCode: null };
1256
+ if (state.enabled) {
1257
+ state.track.enabled = true;
1258
+ return { outcome: "satisfied", errorCode: null };
1259
+ }
1260
+ return null;
1261
+ }
1071
1262
  async #enablePublicationTarget(target) {
1072
1263
  const generation = this.#generation;
1073
1264
  const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
@@ -1075,9 +1266,12 @@ var CloudflareSFUClient = class {
1075
1266
  const state = resolved.value;
1076
1267
  if (this.#locallyPausedSources.has(target.source)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
1077
1268
  try {
1269
+ const replacement = this.#replacement;
1270
+ const replacementOutcome = await this.#waitForReplacementBeforeEnable(state, generation, replacement);
1271
+ if (replacementOutcome) return replacementOutcome;
1078
1272
  await this.#retireUnavailableLocalPublication(state);
1079
- if (this.#locallyPausedSources.has(target.source)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
1080
- if (state.enabled) return { outcome: "satisfied", errorCode: null };
1273
+ const postReplacementOutcome = this.#postReplacementEnableOutcome(state, generation, replacement);
1274
+ if (postReplacementOutcome) return postReplacementOutcome;
1081
1275
  if (!await this.#enablePreparedTrack(state)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
1082
1276
  return { outcome: "confirmed", errorCode: null };
1083
1277
  } catch (error) {
@@ -1086,12 +1280,14 @@ var CloudflareSFUClient = class {
1086
1280
  }
1087
1281
  }
1088
1282
  async pauseLocalCapture(source) {
1283
+ this.#dropLocalRecovery(source);
1089
1284
  this.#locallyPausedSources.set(source, "capture_cleanup");
1090
1285
  const state = this.#localTracks.get(source);
1091
1286
  if (state) await this.#disablePreparedTrack(state);
1092
1287
  }
1093
1288
  setLocalSourceIntent(source, enabled, cause = "forced_pause") {
1094
1289
  const sources = localSourceGroup(source);
1290
+ if (!enabled) this.#dropLocalRecovery(source);
1095
1291
  for (const item of sources) {
1096
1292
  if (enabled) this.#locallyPausedSources.delete(item);
1097
1293
  else this.#locallyPausedSources.set(item, cause);
@@ -1145,13 +1341,16 @@ var CloudflareSFUClient = class {
1145
1341
  if (!publicationId) return;
1146
1342
  const authoritative = await this.#signal(() => this.#requireTransport().listPublications());
1147
1343
  if (authoritative.publications.some((publication) => publication.publicationId === publicationId)) return;
1148
- if (state.providerPublicationId === publicationId) {
1149
- this.#pauseLocalSource(state.source, "discovery_absent");
1150
- await this.#retireOwnedPublications();
1344
+ if (state.providerPublicationId !== publicationId || this.#localRecoveries.has(state.source)) return;
1345
+ if (state.enabled && state.desiredEnabled) {
1346
+ this.#beginLocalRecovery(state, publicationId, true);
1347
+ return;
1151
1348
  }
1349
+ this.#pauseLocalSource(state.source, "discovery_absent");
1350
+ await this.#retireOwnedPublications(void 0, { goneIsComplete: true });
1152
1351
  }
1153
- #pauseLocalSource(source, cause = "forced_pause") {
1154
- const sources = localSourceGroup(source);
1352
+ #pauseLocalSource(source, cause = "forced_pause", options = {}) {
1353
+ const sources = options.group === false ? [source] : localSourceGroup(source);
1155
1354
  for (const publication of this.#ownedPublications) {
1156
1355
  if (!sources.includes(publication.state.source)) continue;
1157
1356
  this.#markPublicationRetiring(publication, cause);
@@ -1164,25 +1363,36 @@ var CloudflareSFUClient = class {
1164
1363
  this.#publishSnapshot();
1165
1364
  this.#emitLocal();
1166
1365
  }
1167
- #retireOwnedPublications(reportFailure = this.#reportError.bind(this)) {
1168
- const publications = [...this.#ownedPublications].filter((item) => item.lifecycle.kind === "retiring" && item.lifecycle.handle);
1169
- const retirement = (async () => {
1170
- const errors = [];
1171
- for (const publication of publications) {
1172
- try {
1173
- await this.#serializeSDP(() => this.#closeOwnedPublication(publication), true);
1174
- } catch (error) {
1175
- errors.push(error);
1176
- if (!this.#stopped) reportFailure(error);
1177
- }
1178
- }
1179
- if (this.#stopped && this.#ownedPublications.size === 0) this.#transport = void 0;
1180
- if (errors.length) throw new AggregateError(errors, "Media retirement failed");
1181
- })();
1366
+ #retireOwnedPublications(reportFailure = this.#reportError.bind(this), options) {
1367
+ const publications = this.#retiringOwnedPublications(options?.sources);
1368
+ const retirement = this.#runOwnedRetirement(publications, reportFailure, options?.goneIsComplete === true);
1182
1369
  void retirement.catch(() => {
1183
1370
  });
1184
1371
  return this.#retirement = retirement;
1185
1372
  }
1373
+ #retiringOwnedPublications(sources) {
1374
+ return [...this.#ownedPublications].filter((item) => item.lifecycle.kind === "retiring" && item.lifecycle.handle && (!sources || sources.includes(item.state.source)));
1375
+ }
1376
+ async #runOwnedRetirement(publications, reportFailure, goneIsComplete) {
1377
+ const errors = [];
1378
+ for (const publication of publications) {
1379
+ try {
1380
+ await this.#serializeSDP(() => this.#closeOwnedPublication(publication), true);
1381
+ } catch (error) {
1382
+ if (goneIsComplete && this.#retireGonePublication(publication, error)) continue;
1383
+ errors.push(error);
1384
+ if (!this.#stopped) reportFailure(error);
1385
+ }
1386
+ }
1387
+ if (this.#stopped && this.#ownedPublications.size === 0) this.#transport = void 0;
1388
+ if (errors.length) throw new AggregateError(errors, "Media retirement failed");
1389
+ }
1390
+ #retireGonePublication(publication, error) {
1391
+ if (!isGonePublicationFailure(error)) return false;
1392
+ publication.lifecycle = { kind: "retired" };
1393
+ this.#ownedPublications.delete(publication);
1394
+ return true;
1395
+ }
1186
1396
  #markPublicationRetiring(publication, cause = "publish_failure") {
1187
1397
  const phase = publication.lifecycle;
1188
1398
  if (phase.kind === "pending" || phase.kind === "confirmed") publication.lifecycle = { kind: "retiring", handle: phase.kind === "confirmed" ? phase.handle : null, detached: false, cause };
@@ -1287,6 +1497,8 @@ var CloudflareSFUClient = class {
1287
1497
  const transport = this.#transport;
1288
1498
  void this.#callStats.flush(async (stats2) => transport?.uploadCallStats?.(stats2, true));
1289
1499
  this.#receiveProgress.expect([], this.#participantId);
1500
+ for (const source of [...this.#localRecoveries.keys()]) this.#dropLocalRecovery(source);
1501
+ this.#recentRecoveries.clear();
1290
1502
  this.#subscribedPublicationIds.clear();
1291
1503
  this.#generation++;
1292
1504
  this.#connectionEpoch++;
@@ -2024,35 +2236,49 @@ var CloudflareSFUClient = class {
2024
2236
  this.#replacementCause = void 0;
2025
2237
  return this.#requireTransport().addTracks({ ...input, replacementCause });
2026
2238
  }
2027
- async #replaceMediaConnectionForRecovery(generation, cause) {
2239
+ #replaceMediaConnectionForRecovery(generation, cause) {
2028
2240
  this.#requireGeneration(generation);
2241
+ if (this.#replacement?.generation === generation) return this.#replacement.promise;
2029
2242
  if (!this.#replaceMediaConnection || this.#replacementAttemptedGeneration === generation) {
2030
- throw new CloudflareSFUError("A fresh Cloudflare SFU connection is unavailable", "signaling_failed");
2243
+ return Promise.reject(new CloudflareSFUError("A fresh Cloudflare SFU connection is unavailable", "signaling_failed"));
2031
2244
  }
2032
2245
  this.#replacementAttemptedGeneration = generation;
2033
2246
  const replaceMediaConnection = this.#replaceMediaConnection;
2034
- const bootstrap = await replaceMediaConnection();
2035
- this.#requireGeneration(generation);
2036
- validateBootstrap(bootstrap);
2037
- if (bootstrap.connectionId === this.#bootstrap.connectionId) {
2038
- throw new CloudflareSFUError("Participant access did not replace the Cloudflare SFU connection", "signaling_failed");
2039
- }
2040
- this.#fenceLocalPublications();
2041
- const connectionEpoch = ++this.#connectionEpoch;
2042
- this.#disposeConnection(false);
2043
- this.#clearRemoteTracks();
2044
- this.#cursor = null;
2045
- this.#bootstrap = bootstrap;
2046
- this.#rebuildPeerConnection(bootstrap, generation, connectionEpoch);
2047
- this.#replacementCause = cause;
2048
- this.#retiredLocalConnection = null;
2049
- this.#negotiatedGeneration = null;
2050
- for (const state of this.#localTracks.values()) {
2051
- state.transceiver = null;
2052
- state.enabled = false;
2053
- state.providerPublicationId = null;
2054
- }
2055
- this.remotePublicationsChanged();
2247
+ const promise = Promise.resolve().then(async () => {
2248
+ const bootstrap = await replaceMediaConnection();
2249
+ this.#requireGeneration(generation);
2250
+ validateBootstrap(bootstrap);
2251
+ if (bootstrap.connectionId === this.#bootstrap.connectionId) {
2252
+ throw new CloudflareSFUError("Participant access did not replace the Cloudflare SFU connection", "signaling_failed");
2253
+ }
2254
+ this.#fenceLocalPublications();
2255
+ const connectionEpoch = ++this.#connectionEpoch;
2256
+ this.#disposeConnection(false);
2257
+ this.#clearRemoteTracks();
2258
+ this.#cursor = null;
2259
+ this.#bootstrap = bootstrap;
2260
+ this.#rebuildPeerConnection(bootstrap, generation, connectionEpoch);
2261
+ this.#replacementCause = cause;
2262
+ this.#retiredLocalConnection = null;
2263
+ this.#negotiatedGeneration = null;
2264
+ for (const state of this.#localTracks.values()) {
2265
+ state.transceiver = null;
2266
+ state.enabled = false;
2267
+ state.providerPublicationId = null;
2268
+ }
2269
+ this.remotePublicationsChanged();
2270
+ });
2271
+ const replacement = { generation, promise };
2272
+ this.#replacement = replacement;
2273
+ void promise.then(
2274
+ () => {
2275
+ if (this.#replacement === replacement) this.#replacement = null;
2276
+ },
2277
+ () => {
2278
+ if (this.#replacement === replacement) this.#replacement = null;
2279
+ }
2280
+ );
2281
+ return promise;
2056
2282
  }
2057
2283
  async #republishPreparedTracksAfterRecovery(generation) {
2058
2284
  const enabled = [...this.#localTracks.values()].filter((state) => state.desiredEnabled && state.track.readyState !== "ended");
@@ -2332,9 +2558,19 @@ async function createLocalOffer(connection) {
2332
2558
  function localSourceGroup(source) {
2333
2559
  return source === "screen" ? [source, "screen_audio"] : [source];
2334
2560
  }
2561
+ function recoveryBackoffMs(attempt) {
2562
+ return RECOVERY_BACKOFF_MS[Math.min(attempt, RECOVERY_BACKOFF_MS.length - 1)] ?? RECOVERY_BACKOFF_MS[RECOVERY_BACKOFF_MS.length - 1];
2563
+ }
2564
+ function isGonePublicationFailure(error) {
2565
+ const failures = error instanceof AggregateError ? error.errors : [error];
2566
+ return failures.length > 0 && failures.every((failure) => failure instanceof CloudflareSFUError && (failure.options.status === 404 || failure.options.status === 410));
2567
+ }
2335
2568
  function isRateLimited(error) {
2336
2569
  return error instanceof CloudflareSFUError && error.options.status === 429;
2337
2570
  }
2571
+ function unwrapAggregateErrors(error) {
2572
+ return error instanceof AggregateError ? error.errors.flatMap(unwrapAggregateErrors) : [error];
2573
+ }
2338
2574
  function retryableRemotePull(error) {
2339
2575
  if (!(error instanceof CloudflareSFUError)) return false;
2340
2576
  if (error.code === "signaling_failed") return error.options.retryableConnection === true || error.options.status === 429;
@@ -2349,7 +2585,9 @@ function simulcastUnsupported(error) {
2349
2585
  function mediaTargetFailure(error) {
2350
2586
  const code = error instanceof CloudflareSFUError ? error.code : "media_failed";
2351
2587
  const ambiguous = code === "signaling_timeout" || code === "negotiation_timeout";
2352
- return { outcome: ambiguous ? "ambiguous" : "retryable_failure", errorCode: code };
2588
+ const failures = unwrapAggregateErrors(error);
2589
+ const terminal = failures.length > 0 && failures.every((failure) => failure instanceof CloudflareSFUError && failure.code === "signaling_failed" && typeof failure.options.status === "number" && failure.options.retryableConnection !== true && !isRateLimited(failure));
2590
+ return { outcome: ambiguous ? "ambiguous" : terminal ? "terminal_failure" : "retryable_failure", errorCode: code };
2353
2591
  }
2354
2592
 
2355
2593
  // src/media/transport.ts