@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.
- package/dist/{chunk-2CEMOTZF.js → chunk-TA2BJXI7.js} +59 -9
- package/dist/{chunk-O5JB5XVX.js → chunk-UVWRTXNO.js} +285 -47
- package/dist/{chunk-FD7ZSDTM.js → chunk-XF567GPH.js} +127 -8
- package/dist/connection/dependencies.d.ts +5 -0
- package/dist/connection/production.d.ts +1 -1
- package/dist/effect.js +3 -3
- package/dist/generated/sync.d.ts +38 -0
- package/dist/index.js +3 -3
- package/dist/media/client.d.ts +1 -0
- package/dist/media/index.js +1 -1
- package/dist/media/plane.d.ts +1 -0
- package/dist/sync/index.js +1 -1
- package/dist/sync/link-watch.d.ts +67 -0
- package/dist/sync/v1-client.d.ts +5 -0
- package/dist/sync/v1-types.d.ts +12 -1
- package/package.json +1 -1
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
describeMediaCaptureError,
|
|
11
11
|
grantedMediaFromStream,
|
|
12
12
|
markMediaFailure
|
|
13
|
-
} from "./chunk-
|
|
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-
|
|
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")
|
|
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.#
|
|
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
|
-
|
|
6155
|
-
|
|
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
|
-
|
|
6158
|
-
|
|
6159
|
-
|
|
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
|
-
|
|
1080
|
-
if (
|
|
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
|
|
1149
|
-
|
|
1150
|
-
|
|
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 =
|
|
1169
|
-
const retirement = (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
state.
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
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
|
-
|
|
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
|