@q9labsai/chalk-client 4.1.14 → 4.1.16
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/README.md +56 -2
- package/dist/access/grant.d.ts +1 -0
- package/dist/access/grant.d.ts.map +1 -1
- package/dist/access/manager.d.ts.map +1 -1
- package/dist/{chunk-JCNUF3DP.js → chunk-BD7FRQ67.js} +2082 -328
- package/dist/{chunk-2NUTY2VP.js → chunk-E4OK5K6N.js} +7 -3
- package/dist/{chunk-NKOKY2AE.js → chunk-EWTITNJ3.js} +15 -101
- package/dist/chunk-HW5TTZL2.js +99 -0
- package/dist/{chunk-HE6CLPZM.js → chunk-M3S7HNQF.js} +1663 -516
- package/dist/{chunk-S5OUHFGC.js → chunk-O7WTAXAY.js} +670 -73
- package/dist/{chunk-PWUU6T3R.js → chunk-OKIL2ELF.js} +1185 -874
- package/dist/chunk-RCDXIDCN.js +33 -0
- package/dist/client.d.ts +1420 -463
- package/dist/client.d.ts.map +1 -1
- package/dist/connection/dependencies.d.ts +10 -0
- package/dist/connection/dependencies.d.ts.map +1 -1
- package/dist/connection/index.d.ts +2 -0
- package/dist/connection/index.d.ts.map +1 -1
- package/dist/connection/lifecycle.d.ts.map +1 -1
- package/dist/connection/production.d.ts +4 -1
- package/dist/connection/production.d.ts.map +1 -1
- package/dist/connection/types.d.ts +2 -2
- package/dist/connection/types.d.ts.map +1 -1
- package/dist/effect.js +343 -130
- package/dist/feedback/collector.d.ts +14 -0
- package/dist/feedback/collector.d.ts.map +1 -0
- package/dist/feedback/context.d.ts +38 -0
- package/dist/feedback/context.d.ts.map +1 -0
- package/dist/feedback/controller.d.ts +4 -0
- package/dist/feedback/controller.d.ts.map +1 -0
- package/dist/feedback/index.d.ts +7 -0
- package/dist/feedback/index.d.ts.map +1 -0
- package/dist/feedback/transport.d.ts +29 -0
- package/dist/feedback/transport.d.ts.map +1 -0
- package/dist/feedback/types.d.ts +202 -0
- package/dist/feedback/types.d.ts.map +1 -0
- package/dist/feedback/validation.d.ts +30 -0
- package/dist/feedback/validation.d.ts.map +1 -0
- package/dist/generated/http-api.d.ts +1409 -462
- package/dist/generated/http-api.d.ts.map +1 -1
- package/dist/generated/schemas.d.ts +2613 -858
- package/dist/generated/schemas.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +130 -15
- package/dist/media/camera-uplink.d.ts +13 -0
- package/dist/media/camera-uplink.d.ts.map +1 -0
- package/dist/media/capture-error.d.ts +27 -0
- package/dist/media/capture-error.d.ts.map +1 -0
- package/dist/media/client.d.ts +4 -0
- package/dist/media/client.d.ts.map +1 -1
- package/dist/media/index.d.ts +1 -0
- package/dist/media/index.d.ts.map +1 -1
- package/dist/media/index.js +5 -1
- package/dist/media/plane.d.ts +4 -0
- package/dist/media/plane.d.ts.map +1 -1
- package/dist/media/remote-track-response.d.ts +6 -0
- package/dist/media/remote-track-response.d.ts.map +1 -0
- package/dist/media/transport.d.ts.map +1 -1
- package/dist/media/types.d.ts +20 -2
- package/dist/media/types.d.ts.map +1 -1
- package/dist/public-invites/client.d.ts.map +1 -1
- package/dist/public-invites/index.js +4 -3
- package/dist/public-invites/types.d.ts +4 -2
- package/dist/public-invites/types.d.ts.map +1 -1
- package/dist/server/access-refresh.d.cts +8 -0
- package/dist/server/access-refresh.d.ts +9 -0
- package/dist/server/access-refresh.d.ts.map +1 -0
- package/dist/server/client.d.ts.map +1 -1
- package/dist/server/index.cjs +189 -20
- package/dist/server/index.d.cts +2 -1
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +187 -19
- package/dist/server/types.d.cts +74 -0
- package/dist/server/types.d.ts +74 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/space-client/chat-controller.d.ts +3 -0
- package/dist/space-client/chat-controller.d.ts.map +1 -1
- package/dist/space-client/controller-effects.d.ts +6 -0
- package/dist/space-client/controller-effects.d.ts.map +1 -1
- package/dist/space-client/core.d.ts +12 -1
- package/dist/space-client/core.d.ts.map +1 -1
- package/dist/space-client/core.test.helpers.d.ts +1 -0
- package/dist/space-client/core.test.helpers.d.ts.map +1 -1
- package/dist/space-client/effect.d.ts +5 -0
- package/dist/space-client/effect.d.ts.map +1 -1
- package/dist/space-client/episode-diagnostic-runtime.d.ts +3 -0
- package/dist/space-client/episode-diagnostic-runtime.d.ts.map +1 -1
- package/dist/space-client/errors.d.ts.map +1 -1
- package/dist/space-client/index.d.ts +4 -1
- package/dist/space-client/index.d.ts.map +1 -1
- package/dist/space-client/media-controller.d.ts.map +1 -1
- package/dist/space-client/offline-actions-storage.d.ts +66 -0
- package/dist/space-client/offline-actions-storage.d.ts.map +1 -0
- package/dist/space-client/offline-actions.d.ts +36 -0
- package/dist/space-client/offline-actions.d.ts.map +1 -0
- package/dist/space-client/participants-controller.d.ts +2 -1
- package/dist/space-client/participants-controller.d.ts.map +1 -1
- package/dist/space-client/recording-controller.d.ts +20 -0
- package/dist/space-client/recording-controller.d.ts.map +1 -0
- package/dist/space-client/reload-rejoin.d.ts +24 -0
- package/dist/space-client/reload-rejoin.d.ts.map +1 -0
- package/dist/space-client/space-client.d.ts.map +1 -1
- package/dist/space-client/store.d.ts +3 -1
- package/dist/space-client/store.d.ts.map +1 -1
- package/dist/space-client/types.d.ts +40 -4
- package/dist/space-client/types.d.ts.map +1 -1
- package/dist/sync/index.js +2 -1
- package/dist/sync/v1-client.d.ts +1 -5
- package/dist/sync/v1-client.d.ts.map +1 -1
- package/dist/sync/v1-collaboration-state.d.ts +65 -0
- package/dist/sync/v1-collaboration-state.d.ts.map +1 -0
- package/dist/sync/v1-command-scheduler.d.ts +54 -0
- package/dist/sync/v1-command-scheduler.d.ts.map +1 -0
- package/dist/sync/v1-deferred.d.ts +8 -0
- package/dist/sync/v1-deferred.d.ts.map +1 -0
- package/dist/sync/v1-error.d.ts +5 -0
- package/dist/sync/v1-error.d.ts.map +1 -0
- package/dist/sync/v1-frame-signature.d.ts +2 -0
- package/dist/sync/v1-frame-signature.d.ts.map +1 -0
- package/dist/sync/v1-live-target-coordinator.d.ts +32 -0
- package/dist/sync/v1-live-target-coordinator.d.ts.map +1 -0
- package/dist/sync/v1-types.d.ts +3 -0
- package/dist/sync/v1-types.d.ts.map +1 -1
- package/dist/telemetry/index.js +9 -7
- package/dist/telemetry/reconnect.d.ts +7 -0
- package/dist/telemetry/reconnect.d.ts.map +1 -0
- package/dist/telemetry/trace.d.ts +1 -1
- package/dist/telemetry/trace.d.ts.map +1 -1
- package/dist/whiteboard/v1-client.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -1,3 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
journeyHeaders
|
|
3
|
+
} from "./chunk-HW5TTZL2.js";
|
|
4
|
+
import {
|
|
5
|
+
recordReconnect,
|
|
6
|
+
traceReconnect
|
|
7
|
+
} from "./chunk-RCDXIDCN.js";
|
|
8
|
+
|
|
9
|
+
// src/media/camera-uplink.ts
|
|
10
|
+
var CameraUplinkPolicy = class {
|
|
11
|
+
#healthySamples = 0;
|
|
12
|
+
sample(bitrate, lowActive) {
|
|
13
|
+
if (bitrate === void 0 || !Number.isFinite(bitrate) || bitrate <= 0) {
|
|
14
|
+
this.reset();
|
|
15
|
+
return lowActive;
|
|
16
|
+
}
|
|
17
|
+
if (bitrate < 15e5) {
|
|
18
|
+
this.reset();
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
if (lowActive) {
|
|
22
|
+
this.reset();
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
if (bitrate < 2e6) {
|
|
26
|
+
this.reset();
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
return ++this.#healthySamples >= 5;
|
|
30
|
+
}
|
|
31
|
+
reset() {
|
|
32
|
+
this.#healthySamples = 0;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
function cameraUplinkBitrate(stats) {
|
|
36
|
+
for (const stat of stats) {
|
|
37
|
+
if (stat.type === "candidate-pair" && stat.state === "succeeded" && (stat.nominated || stat.selected)) return stat.availableOutgoingBitrate;
|
|
38
|
+
}
|
|
39
|
+
return void 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
1
42
|
// src/media/observers.ts
|
|
2
43
|
function subscribeSnapshot(listeners, listener) {
|
|
3
44
|
listeners.add(listener);
|
|
@@ -132,14 +173,37 @@ async function waitFor(predicate, timeoutMs, timeoutMessage = "Timed out waiting
|
|
|
132
173
|
}
|
|
133
174
|
}
|
|
134
175
|
|
|
176
|
+
// src/media/remote-track-response.ts
|
|
177
|
+
function matchRemotePullTracks(publications, tracks) {
|
|
178
|
+
const requested = new Map(
|
|
179
|
+
publications.map((publication) => {
|
|
180
|
+
const reference = parseCloudflareSFUPublicationID(publication.publicationId);
|
|
181
|
+
return [`${reference.connectionId}\0${reference.trackName}`, publication];
|
|
182
|
+
})
|
|
183
|
+
);
|
|
184
|
+
const mids = /* @__PURE__ */ new Set();
|
|
185
|
+
return tracks.map(({ location, sessionId: connectionId, trackName, mid }) => {
|
|
186
|
+
const key = `${connectionId}\0${trackName}`;
|
|
187
|
+
const publication = requested.get(key);
|
|
188
|
+
if (location !== "remote" || !publication || !mid || mids.has(mid)) throw new CloudflareSFUError("Cloudflare SFU returned an invalid remote track identity", "invalid_publication");
|
|
189
|
+
requested.delete(key);
|
|
190
|
+
mids.add(mid);
|
|
191
|
+
return { publication, mid };
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
135
195
|
// src/media/client.ts
|
|
136
196
|
var EMPTY_LOCAL = Object.freeze([]);
|
|
137
197
|
var EMPTY_REMOTE = Object.freeze([]);
|
|
198
|
+
var INVALID_PUBLICATION_SIGNATURE = "\0invalid";
|
|
138
199
|
var CONNECTION_TIMEOUT_MS = 8e3;
|
|
200
|
+
var NEGOTIATION_TIMEOUT_MS = 35e3;
|
|
201
|
+
var PEER_OPERATION_TIMEOUT_MS = 8e3;
|
|
139
202
|
var CloudflareSFUClient = class {
|
|
140
203
|
#localListeners = /* @__PURE__ */ new Set();
|
|
141
204
|
#onError;
|
|
142
205
|
#onRemoteTrack;
|
|
206
|
+
#recordReconnect;
|
|
143
207
|
#onRtcSummary;
|
|
144
208
|
#onScreenEnded;
|
|
145
209
|
#participantId;
|
|
@@ -148,17 +212,24 @@ var CloudflareSFUClient = class {
|
|
|
148
212
|
#replaceMediaConnection;
|
|
149
213
|
#remoteListeners = /* @__PURE__ */ new Set();
|
|
150
214
|
#snapshotListeners = /* @__PURE__ */ new Set();
|
|
215
|
+
#locallyPausedSources = /* @__PURE__ */ new Set();
|
|
151
216
|
#localTracks = /* @__PURE__ */ new Map();
|
|
152
217
|
#reusableLocalTransceivers = /* @__PURE__ */ new Map();
|
|
218
|
+
#reusableLocalPublicationIds = /* @__PURE__ */ new Map();
|
|
153
219
|
#remoteTracks = /* @__PURE__ */ new Map();
|
|
154
220
|
#bootstrap;
|
|
155
221
|
#connection;
|
|
222
|
+
#disposeConnectionObservation;
|
|
156
223
|
#connectionEpoch = 0;
|
|
224
|
+
#retiredLocalConnection = null;
|
|
157
225
|
#cursor = null;
|
|
226
|
+
#remotePullIncomplete = false;
|
|
227
|
+
#remotePullRetryAfter = /* @__PURE__ */ new Map();
|
|
158
228
|
#generation = 0;
|
|
159
229
|
#negotiatedGeneration = null;
|
|
160
230
|
#replacementAttemptedGeneration = null;
|
|
161
231
|
#polling = false;
|
|
232
|
+
#pollAfterCurrent = false;
|
|
162
233
|
#pollTimer;
|
|
163
234
|
#sdpTail = Promise.resolve();
|
|
164
235
|
#snapshot;
|
|
@@ -170,11 +241,12 @@ var CloudflareSFUClient = class {
|
|
|
170
241
|
this.#participantId = options.participantId;
|
|
171
242
|
this.#bootstrap = options.bootstrap;
|
|
172
243
|
this.#transport = options.transport;
|
|
173
|
-
this.#pollIntervalMs = options.pollIntervalMs ??
|
|
244
|
+
this.#pollIntervalMs = options.pollIntervalMs ?? 15e3;
|
|
174
245
|
this.#replaceMediaConnection = options.replaceMediaConnection;
|
|
175
246
|
this.#onError = options.onError;
|
|
176
247
|
this.#onRemoteTrack = options.onRemoteTrack;
|
|
177
248
|
this.#onRtcSummary = options.onRtcSummary;
|
|
249
|
+
this.#recordReconnect = options.recordReconnect;
|
|
178
250
|
this.#onScreenEnded = options.onScreenEnded;
|
|
179
251
|
this.#peerConnectionFactory = options.peerConnectionFactory;
|
|
180
252
|
this.#connection = this.#createPeerConnection(options.bootstrap);
|
|
@@ -193,6 +265,19 @@ var CloudflareSFUClient = class {
|
|
|
193
265
|
subscribe(listener) {
|
|
194
266
|
return subscribeSnapshot(this.#snapshotListeners, listener);
|
|
195
267
|
}
|
|
268
|
+
remotePublicationResumed(publicationId) {
|
|
269
|
+
this.#remotePullRetryAfter.delete(publicationId);
|
|
270
|
+
this.remotePublicationsChanged();
|
|
271
|
+
}
|
|
272
|
+
remotePublicationsChanged() {
|
|
273
|
+
recordReconnect(this.#recordReconnect, "remote_publications_changed");
|
|
274
|
+
if (this.#polling) {
|
|
275
|
+
this.#pollAfterCurrent = true;
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
this.#clearPoll();
|
|
279
|
+
this.#schedulePoll(0);
|
|
280
|
+
}
|
|
196
281
|
prepareLocalTrack(source, track) {
|
|
197
282
|
this.#requireActive();
|
|
198
283
|
validateTrackSource(source, track);
|
|
@@ -201,7 +286,7 @@ var CloudflareSFUClient = class {
|
|
|
201
286
|
source,
|
|
202
287
|
track,
|
|
203
288
|
transceiver: this.#reusableLocalTransceivers.get(source) ?? null,
|
|
204
|
-
providerPublicationId: null,
|
|
289
|
+
providerPublicationId: this.#reusableLocalPublicationIds.get(source) ?? null,
|
|
205
290
|
pendingOperationId: null,
|
|
206
291
|
pendingTrackName: null,
|
|
207
292
|
desiredEnabled: false,
|
|
@@ -209,6 +294,7 @@ var CloudflareSFUClient = class {
|
|
|
209
294
|
endedListener: null
|
|
210
295
|
};
|
|
211
296
|
this.#reusableLocalTransceivers.delete(source);
|
|
297
|
+
this.#reusableLocalPublicationIds.delete(source);
|
|
212
298
|
if (source === "screen") {
|
|
213
299
|
state.endedListener = () => {
|
|
214
300
|
if (this.#localTracks.get("screen") === state) this.#invokeListener(() => this.#onScreenEnded?.());
|
|
@@ -224,6 +310,7 @@ var CloudflareSFUClient = class {
|
|
|
224
310
|
if (!state) return;
|
|
225
311
|
if (state.enabled) await this.#setPreparedTrackEnabled(state, false);
|
|
226
312
|
if (state.transceiver) this.#reusableLocalTransceivers.set(source, state.transceiver);
|
|
313
|
+
if (state.providerPublicationId) this.#reusableLocalPublicationIds.set(source, state.providerPublicationId);
|
|
227
314
|
this.#removeOwnedLocalTrack(state);
|
|
228
315
|
this.#localTracks.delete(source);
|
|
229
316
|
this.#publishSnapshot();
|
|
@@ -247,28 +334,118 @@ var CloudflareSFUClient = class {
|
|
|
247
334
|
const generation = this.#generation;
|
|
248
335
|
try {
|
|
249
336
|
const transport = this.#requireTransport();
|
|
250
|
-
const authoritative = await transport.listPublications();
|
|
337
|
+
const authoritative = await traceReconnect(this.#recordReconnect, "list_publications", () => transport.listPublications());
|
|
251
338
|
this.#requireGeneration(generation);
|
|
252
339
|
await this.#reconcileRemotePublications(authoritative, generation);
|
|
253
340
|
} catch (error) {
|
|
254
341
|
if (generation === this.#generation && !this.#stopped) this.#reportError(error);
|
|
255
342
|
throw error;
|
|
256
343
|
} finally {
|
|
257
|
-
if (generation === this.#generation)
|
|
344
|
+
if (generation === this.#generation) {
|
|
345
|
+
this.#polling = false;
|
|
346
|
+
if (this.#pollAfterCurrent) {
|
|
347
|
+
this.#pollAfterCurrent = false;
|
|
348
|
+
this.#clearPoll();
|
|
349
|
+
this.#schedulePoll(0);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
258
352
|
}
|
|
259
353
|
}
|
|
260
354
|
async setLocalPublicationTarget(target) {
|
|
261
355
|
const resolved = resolveMediaTarget(this.#participantId, this.#stopped, this.#localTracks, target);
|
|
262
356
|
if (resolved.kind === "result") return resolved.result;
|
|
263
357
|
const state = resolved.value;
|
|
264
|
-
if (
|
|
358
|
+
if (this.#pausedTarget(target)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
|
|
265
359
|
try {
|
|
360
|
+
if (target.enabled) await this.#retireUnavailableLocalPublication(state);
|
|
361
|
+
if (this.#pausedTarget(target)) return { outcome: "terminal_failure", errorCode: "local_source_paused" };
|
|
362
|
+
if (state.enabled === target.enabled) return { outcome: "satisfied", errorCode: null };
|
|
266
363
|
await this.#setPreparedTrackEnabled(state, target.enabled, target.operationId);
|
|
267
364
|
return { outcome: "confirmed", errorCode: null };
|
|
268
365
|
} catch (error) {
|
|
269
366
|
if (!this.#stopped) this.#reportError(error);
|
|
270
|
-
return
|
|
367
|
+
return mediaTargetFailure(error);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
#pausedTarget(target) {
|
|
371
|
+
return target.enabled && this.#locallyPausedSources.has(target.source);
|
|
372
|
+
}
|
|
373
|
+
setLocalSourceIntent(source, enabled) {
|
|
374
|
+
if (enabled) {
|
|
375
|
+
this.#locallyPausedSources.delete(source);
|
|
376
|
+
return;
|
|
271
377
|
}
|
|
378
|
+
this.#locallyPausedSources.add(source);
|
|
379
|
+
const state = this.#localTracks.get(source);
|
|
380
|
+
if (!state) return;
|
|
381
|
+
state.desiredEnabled = false;
|
|
382
|
+
state.track.enabled = false;
|
|
383
|
+
}
|
|
384
|
+
async closeForcedLocalPublication(source) {
|
|
385
|
+
this.setLocalSourceIntent(source, false);
|
|
386
|
+
await this.#retireLocalPublication(source);
|
|
387
|
+
if (!this.#replaceMediaConnection) return;
|
|
388
|
+
const generation = this.#generation;
|
|
389
|
+
await this.#serializeSDP(async () => {
|
|
390
|
+
const existing = new Set([...this.#localTracks.values()].filter((state) => state.source !== source && state.providerPublicationId !== null));
|
|
391
|
+
const states = await this.#prepareConnectionForPublication([], generation);
|
|
392
|
+
await this.#publishPreparedTracksSerialized(
|
|
393
|
+
states.filter((state) => existing.has(state)),
|
|
394
|
+
generation,
|
|
395
|
+
this.#connection,
|
|
396
|
+
this.#bootstrap.connectionId
|
|
397
|
+
);
|
|
398
|
+
this.#requireGeneration(generation);
|
|
399
|
+
this.#setPhase("live", null);
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
async #retireUnavailableLocalPublication(state) {
|
|
403
|
+
const publicationId = state.providerPublicationId;
|
|
404
|
+
if (!publicationId) return;
|
|
405
|
+
const authoritative = await traceReconnect(this.#recordReconnect, "list_publications", () => this.#requireTransport().listPublications());
|
|
406
|
+
if (authoritative.publications.some((publication) => publication.publicationId === publicationId)) return;
|
|
407
|
+
if (state.providerPublicationId === publicationId) await this.#retireLocalPublication(state.source);
|
|
408
|
+
}
|
|
409
|
+
async #retireLocalPublication(source) {
|
|
410
|
+
const state = this.#localTracks.get(source);
|
|
411
|
+
const transceiver = state?.transceiver ?? this.#reusableLocalTransceivers.get(source);
|
|
412
|
+
const publicationId = state?.providerPublicationId ?? this.#reusableLocalPublicationIds.get(source);
|
|
413
|
+
this.#reusableLocalTransceivers.delete(source);
|
|
414
|
+
this.#reusableLocalPublicationIds.delete(source);
|
|
415
|
+
if (state) {
|
|
416
|
+
state.track.enabled = false;
|
|
417
|
+
state.desiredEnabled = false;
|
|
418
|
+
state.enabled = false;
|
|
419
|
+
state.providerPublicationId = null;
|
|
420
|
+
state.transceiver = null;
|
|
421
|
+
}
|
|
422
|
+
this.#publishSnapshot();
|
|
423
|
+
this.#emitLocal();
|
|
424
|
+
if (!transceiver || !publicationId) return;
|
|
425
|
+
const connection = this.#connection;
|
|
426
|
+
const connectionId = this.#bootstrap.connectionId;
|
|
427
|
+
this.#retiredLocalConnection = connection;
|
|
428
|
+
await this.#serializeSDP(async () => {
|
|
429
|
+
if (connection !== this.#connection) return;
|
|
430
|
+
const mid = requireTransceiverMid(transceiver);
|
|
431
|
+
await this.#boundPeerOperation(transceiver.sender.replaceTrack(null));
|
|
432
|
+
transceiver.stop();
|
|
433
|
+
const offer = await traceReconnect(this.#recordReconnect, "create_offer", () => connection.createOffer());
|
|
434
|
+
await traceReconnect(this.#recordReconnect, "set_local_description", () => connection.setLocalDescription(offer));
|
|
435
|
+
try {
|
|
436
|
+
const response = await this.#requireTransport().closeTracks({
|
|
437
|
+
connectionId,
|
|
438
|
+
...providerDescription(requireDescription(offer)),
|
|
439
|
+
tracks: [{ mid, source, publicationId }],
|
|
440
|
+
force: false
|
|
441
|
+
});
|
|
442
|
+
await this.#applyProviderDescription(response, connection);
|
|
443
|
+
} catch (error) {
|
|
444
|
+
await this.#rollbackLocalOffer(connection);
|
|
445
|
+
if (!this.#stopped) this.#reportError(error);
|
|
446
|
+
throw error;
|
|
447
|
+
}
|
|
448
|
+
});
|
|
272
449
|
}
|
|
273
450
|
observeLocalPublications(listener) {
|
|
274
451
|
this.#localListeners.add(listener);
|
|
@@ -284,16 +461,21 @@ var CloudflareSFUClient = class {
|
|
|
284
461
|
this.#requireActive();
|
|
285
462
|
const options = "connectionId" in input ? { bootstrap: input } : input;
|
|
286
463
|
validateBootstrap(options.bootstrap);
|
|
464
|
+
recordReconnect(this.#recordReconnect, "media_rebuild_start");
|
|
287
465
|
const generation = ++this.#generation;
|
|
288
466
|
const connectionEpoch = ++this.#connectionEpoch;
|
|
289
467
|
this.#polling = false;
|
|
468
|
+
this.#pollAfterCurrent = false;
|
|
290
469
|
this.#clearPoll();
|
|
470
|
+
this.#remotePullRetryAfter.clear();
|
|
291
471
|
this.#disposeConnection(false);
|
|
292
472
|
this.#reusableLocalTransceivers.clear();
|
|
473
|
+
this.#reusableLocalPublicationIds.clear();
|
|
293
474
|
this.#clearRemoteTracks();
|
|
294
475
|
this.#cursor = null;
|
|
295
476
|
this.#negotiatedGeneration = null;
|
|
296
477
|
this.#replacementAttemptedGeneration = null;
|
|
478
|
+
if (options.bootstrap.connectionId !== this.#bootstrap.connectionId) this.#started = false;
|
|
297
479
|
this.#bootstrap = options.bootstrap;
|
|
298
480
|
if (options.transport) this.#transport = options.transport;
|
|
299
481
|
this.#connection = this.#createPeerConnection(options.bootstrap);
|
|
@@ -324,9 +506,12 @@ var CloudflareSFUClient = class {
|
|
|
324
506
|
this.#generation++;
|
|
325
507
|
this.#connectionEpoch++;
|
|
326
508
|
this.#clearPoll();
|
|
509
|
+
this.#remotePullRetryAfter.clear();
|
|
327
510
|
this.#polling = false;
|
|
511
|
+
this.#pollAfterCurrent = false;
|
|
328
512
|
this.#disposeConnection(true);
|
|
329
513
|
this.#reusableLocalTransceivers.clear();
|
|
514
|
+
this.#reusableLocalPublicationIds.clear();
|
|
330
515
|
this.#clearRemoteTracks();
|
|
331
516
|
for (const state of this.#localTracks.values()) this.#removeOwnedLocalTrack(state);
|
|
332
517
|
this.#localTracks.clear();
|
|
@@ -345,14 +530,33 @@ var CloudflareSFUClient = class {
|
|
|
345
530
|
return this.#disablePreparedTrack(state);
|
|
346
531
|
}
|
|
347
532
|
async #enablePreparedTrack(state, operationId) {
|
|
533
|
+
if (state.providerPublicationId && state.transceiver) {
|
|
534
|
+
state.desiredEnabled = true;
|
|
535
|
+
state.track.enabled = true;
|
|
536
|
+
try {
|
|
537
|
+
await this.#boundPeerOperation(state.transceiver.sender.replaceTrack(state.track));
|
|
538
|
+
} catch (error) {
|
|
539
|
+
state.desiredEnabled = false;
|
|
540
|
+
state.track.enabled = false;
|
|
541
|
+
throw error;
|
|
542
|
+
}
|
|
543
|
+
state.enabled = !this.#locallyPausedSources.has(state.source);
|
|
544
|
+
state.desiredEnabled = state.enabled;
|
|
545
|
+
state.track.enabled = state.enabled;
|
|
546
|
+
if (!state.enabled) await this.#boundPeerOperation(state.transceiver.sender.replaceTrack(null));
|
|
547
|
+
this.#publishSnapshot();
|
|
548
|
+
this.#emitLocal();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
348
551
|
if (operationId && state.pendingOperationId !== operationId) {
|
|
349
552
|
state.pendingOperationId = operationId;
|
|
350
553
|
state.pendingTrackName = `${state.source}-${operationId}`;
|
|
351
554
|
}
|
|
352
555
|
state.desiredEnabled = true;
|
|
353
556
|
state.track.enabled = true;
|
|
557
|
+
const generation = this.#generation;
|
|
354
558
|
try {
|
|
355
|
-
await this.#publishPreparedTracks([state],
|
|
559
|
+
await this.#publishPreparedTracks([state], generation);
|
|
356
560
|
} catch (error) {
|
|
357
561
|
state.desiredEnabled = false;
|
|
358
562
|
state.enabled = false;
|
|
@@ -364,26 +568,21 @@ var CloudflareSFUClient = class {
|
|
|
364
568
|
const transceiver = state.transceiver;
|
|
365
569
|
state.desiredEnabled = false;
|
|
366
570
|
state.track.enabled = false;
|
|
367
|
-
|
|
368
|
-
if (transceiver) await transceiver.sender.replaceTrack(null);
|
|
369
|
-
} catch (error) {
|
|
370
|
-
state.desiredEnabled = true;
|
|
371
|
-
state.track.enabled = true;
|
|
372
|
-
throw error;
|
|
373
|
-
}
|
|
571
|
+
const detach = transceiver ? this.#boundPeerOperation(transceiver.sender.replaceTrack(null)) : Promise.resolve();
|
|
374
572
|
state.enabled = false;
|
|
375
|
-
state.providerPublicationId = null;
|
|
376
573
|
state.pendingOperationId = null;
|
|
377
574
|
state.pendingTrackName = null;
|
|
378
575
|
this.#publishSnapshot();
|
|
379
576
|
this.#emitLocal();
|
|
577
|
+
await detach;
|
|
380
578
|
}
|
|
381
579
|
async #publishPreparedTracks(states, generation) {
|
|
382
580
|
if (states.length === 0) return;
|
|
383
581
|
await this.#serializeSDP(async () => {
|
|
384
|
-
await this.#
|
|
582
|
+
const pending = await this.#prepareConnectionForPublication(states, generation);
|
|
583
|
+
this.#replacementAttemptedGeneration = null;
|
|
385
584
|
try {
|
|
386
|
-
await this.#publishPreparedTracksSerialized(
|
|
585
|
+
await this.#publishPreparedTracksSerialized(pending, generation, this.#connection, this.#bootstrap.connectionId);
|
|
387
586
|
} catch (error) {
|
|
388
587
|
if (!this.#replaceMediaConnection || !this.#isRetryableConnectionFailure(error) || this.#replacementAttemptedGeneration === generation) throw error;
|
|
389
588
|
await this.#replaceMediaConnectionForRecovery(generation);
|
|
@@ -396,6 +595,25 @@ var CloudflareSFUClient = class {
|
|
|
396
595
|
}
|
|
397
596
|
});
|
|
398
597
|
}
|
|
598
|
+
async #prepareConnectionForPublication(states, generation) {
|
|
599
|
+
if (this.#retiredLocalConnection !== this.#connection || !this.#replaceMediaConnection) {
|
|
600
|
+
await this.#replaceDormantConnectionBeforeNegotiation(generation);
|
|
601
|
+
return states;
|
|
602
|
+
}
|
|
603
|
+
const authoritative = await traceReconnect(this.#recordReconnect, "list_publications", () => this.#requireTransport().listPublications());
|
|
604
|
+
this.#pauseUnavailableOtherSources(authoritative.publications, states);
|
|
605
|
+
this.#replacementAttemptedGeneration = null;
|
|
606
|
+
await this.#replaceMediaConnectionForRecovery(generation);
|
|
607
|
+
return [...this.#localTracks.values()].filter((state) => (state.desiredEnabled || states.includes(state)) && state.track.readyState !== "ended");
|
|
608
|
+
}
|
|
609
|
+
#pauseUnavailableOtherSources(publications, states) {
|
|
610
|
+
for (const state of this.#localTracks.values()) {
|
|
611
|
+
if (states.includes(state) || !state.providerPublicationId) continue;
|
|
612
|
+
if (publications.some((publication) => publication.publicationId === state.providerPublicationId)) continue;
|
|
613
|
+
state.desiredEnabled = false;
|
|
614
|
+
state.track.enabled = false;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
399
617
|
async #publishPreparedTracksSerialized(states, generation, connection, connectionId) {
|
|
400
618
|
this.#requireGeneration(generation);
|
|
401
619
|
const pendingStates = states.filter((state) => !state.enabled);
|
|
@@ -403,7 +621,7 @@ var CloudflareSFUClient = class {
|
|
|
403
621
|
const wasLive = connectionIsLive(connection);
|
|
404
622
|
const publications = [];
|
|
405
623
|
try {
|
|
406
|
-
for (const state of pendingStates) publications.push(await this.#prepareLocalPublication(connection, state));
|
|
624
|
+
for (const state of pendingStates) publications.push(await this.#prepareLocalPublication(connection, state, generation));
|
|
407
625
|
const response = await this.#negotiateLocalPublications(connection, connectionId, publications, generation);
|
|
408
626
|
if (!wasLive) await this.#waitForConnection(connection, generation);
|
|
409
627
|
this.#requireGeneration(generation);
|
|
@@ -412,21 +630,56 @@ var CloudflareSFUClient = class {
|
|
|
412
630
|
this.#publishSnapshot();
|
|
413
631
|
this.#emitLocal();
|
|
414
632
|
} catch (error) {
|
|
415
|
-
|
|
416
|
-
|
|
633
|
+
if (generation === this.#generation) {
|
|
634
|
+
await this.#rollbackLocalOffer(connection);
|
|
635
|
+
await this.#discardLocalPublications(publications);
|
|
636
|
+
}
|
|
417
637
|
throw error;
|
|
418
638
|
}
|
|
419
639
|
}
|
|
420
|
-
async #prepareLocalPublication(connection, state) {
|
|
640
|
+
async #prepareLocalPublication(connection, state, generation) {
|
|
421
641
|
const reusedTransceiver = state.transceiver !== null;
|
|
422
|
-
const transceiver = state.transceiver ?? connection
|
|
423
|
-
if (reusedTransceiver) await transceiver.sender.replaceTrack(state.track);
|
|
642
|
+
const transceiver = state.transceiver ?? this.#addLocalTransceiver(connection, state);
|
|
643
|
+
if (reusedTransceiver) await this.#boundPeerOperation(transceiver.sender.replaceTrack(state.track));
|
|
644
|
+
this.#requireGeneration(generation);
|
|
424
645
|
state.transceiver = transceiver;
|
|
425
646
|
return { state, transceiver, trackName: state.pendingTrackName ?? `${state.source}-${globalThis.crypto.randomUUID()}`, reusedTransceiver };
|
|
426
647
|
}
|
|
648
|
+
#addLocalTransceiver(connection, state) {
|
|
649
|
+
if (state.source !== "camera" || firefoxCameraSimulcastUnsupported()) return connection.addTransceiver(state.track, { direction: "sendonly" });
|
|
650
|
+
const sendEncodings = [
|
|
651
|
+
{ rid: "h", scaleResolutionDownBy: 1, maxBitrate: 25e5, scalabilityMode: "L1T1" },
|
|
652
|
+
{ rid: "l", scaleResolutionDownBy: 2, maxBitrate: 65e4, scalabilityMode: "L1T1" }
|
|
653
|
+
];
|
|
654
|
+
try {
|
|
655
|
+
return connection.addTransceiver(state.track, {
|
|
656
|
+
direction: "sendonly",
|
|
657
|
+
sendEncodings
|
|
658
|
+
});
|
|
659
|
+
} catch (error) {
|
|
660
|
+
if (!simulcastUnsupported(error)) throw error;
|
|
661
|
+
return connection.addTransceiver(state.track, { direction: "sendonly" });
|
|
662
|
+
}
|
|
663
|
+
}
|
|
427
664
|
async #negotiateLocalPublications(connection, connectionId, publications, generation) {
|
|
428
|
-
|
|
429
|
-
|
|
665
|
+
let offer;
|
|
666
|
+
try {
|
|
667
|
+
offer = await traceReconnect(this.#recordReconnect, "create_offer", () => connection.createOffer());
|
|
668
|
+
await traceReconnect(this.#recordReconnect, "set_local_description", () => connection.setLocalDescription(offer));
|
|
669
|
+
} catch (error) {
|
|
670
|
+
const cameras = publications.filter(({ state, transceiver }) => state.source === "camera" && transceiver.sender.getParameters().encodings.length > 1);
|
|
671
|
+
if (!simulcastUnsupported(error) || cameras.length === 0) throw error;
|
|
672
|
+
await this.#rollbackLocalOffer(connection);
|
|
673
|
+
this.#requireGeneration(generation);
|
|
674
|
+
for (const publication of cameras) {
|
|
675
|
+
await this.#boundPeerOperation(publication.transceiver.sender.replaceTrack(null));
|
|
676
|
+
publication.transceiver.stop();
|
|
677
|
+
publication.transceiver = connection.addTransceiver(publication.state.track, { direction: "sendonly" });
|
|
678
|
+
publication.state.transceiver = publication.transceiver;
|
|
679
|
+
}
|
|
680
|
+
offer = await traceReconnect(this.#recordReconnect, "create_offer", () => connection.createOffer());
|
|
681
|
+
await traceReconnect(this.#recordReconnect, "set_local_description", () => connection.setLocalDescription(offer));
|
|
682
|
+
}
|
|
430
683
|
const tracks = publications.map(
|
|
431
684
|
({ state, transceiver, trackName }) => ({
|
|
432
685
|
location: "local",
|
|
@@ -435,17 +688,20 @@ var CloudflareSFUClient = class {
|
|
|
435
688
|
source: state.source
|
|
436
689
|
})
|
|
437
690
|
);
|
|
438
|
-
const response = await this.#requireTransport().addTracks({ connectionId,
|
|
691
|
+
const response = await traceReconnect(this.#recordReconnect, "publish_tracks", () => this.#requireTransport().addTracks({ connectionId, ...providerDescription(requireDescription(offer)), tracks }));
|
|
439
692
|
this.#requireGeneration(generation);
|
|
440
|
-
await
|
|
693
|
+
await this.#applyProviderDescription(response, connection);
|
|
441
694
|
return response;
|
|
442
695
|
}
|
|
443
696
|
#confirmLocalPublications(publications, tracks) {
|
|
444
697
|
for (const { state, transceiver } of publications) {
|
|
445
698
|
const authoritative = tracks?.find((track) => track.location === "local" && track.mid === transceiver.mid && track.source === state.source);
|
|
446
699
|
if (!authoritative?.publicationId) throw new CloudflareSFUError("Chalk did not return an authoritative local publication ID", "invalid_publication");
|
|
447
|
-
state.
|
|
448
|
-
state.enabled =
|
|
700
|
+
if (state.transceiver !== transceiver) continue;
|
|
701
|
+
state.enabled = !this.#locallyPausedSources.has(state.source);
|
|
702
|
+
state.desiredEnabled = state.enabled;
|
|
703
|
+
state.track.enabled = state.enabled;
|
|
704
|
+
if (!state.enabled) void transceiver.sender.replaceTrack(null).catch((error) => this.#reportError(error));
|
|
449
705
|
state.providerPublicationId = authoritative.publicationId;
|
|
450
706
|
state.pendingOperationId = null;
|
|
451
707
|
state.pendingTrackName = null;
|
|
@@ -478,12 +734,13 @@ var CloudflareSFUClient = class {
|
|
|
478
734
|
}
|
|
479
735
|
}
|
|
480
736
|
async #reconcileRemotePublications(authoritative, generation) {
|
|
481
|
-
const cursor =
|
|
737
|
+
const cursor = this.#validatedRemotePublicationCursor(authoritative);
|
|
738
|
+
if (cursor === null) return;
|
|
482
739
|
const ordering = comparePublicationCursor(this.#cursor, cursor);
|
|
483
|
-
if (ordering
|
|
484
|
-
const desired = desiredRemotePublications(authoritative
|
|
485
|
-
const toPull =
|
|
486
|
-
const pulled = await this.#
|
|
740
|
+
if (ordering === "stale" || ordering === "same" && !this.#remotePullIncomplete) return;
|
|
741
|
+
const desired = this.#desiredRemotePublications(authoritative, cursor);
|
|
742
|
+
const toPull = this.#pendingRemotePulls(desired);
|
|
743
|
+
const pulled = await this.#pullWithRecovery(toPull, cursor, generation);
|
|
487
744
|
if (pulled === null) return;
|
|
488
745
|
this.#requireGeneration(generation);
|
|
489
746
|
const next = reconcileRemoteTracks(desired, pulled, this.#remoteTracks);
|
|
@@ -491,10 +748,70 @@ var CloudflareSFUClient = class {
|
|
|
491
748
|
this.#remoteTracks.clear();
|
|
492
749
|
for (const [key, publication] of next) this.#remoteTracks.set(key, publication);
|
|
493
750
|
this.#cursor = cursor;
|
|
751
|
+
this.#remotePullIncomplete = [...desired].some(([key, publication]) => next.get(key)?.publicationId !== publication.publicationId);
|
|
494
752
|
for (const publication of pulled) this.#invokeListener(() => this.#onRemoteTrack?.(publication));
|
|
495
753
|
this.#publishSnapshot();
|
|
496
754
|
this.#emitRemote();
|
|
497
755
|
}
|
|
756
|
+
#pendingRemotePulls(desired) {
|
|
757
|
+
const desiredIds = new Set([...desired.values()].map((publication) => publication.publicationId));
|
|
758
|
+
for (const publicationId of this.#remotePullRetryAfter.keys()) {
|
|
759
|
+
if (!desiredIds.has(publicationId)) this.#remotePullRetryAfter.delete(publicationId);
|
|
760
|
+
}
|
|
761
|
+
const now = Date.now();
|
|
762
|
+
return [...desired].filter(([key, publication]) => {
|
|
763
|
+
if (this.#remoteTracks.get(key)?.publicationId === publication.publicationId) return false;
|
|
764
|
+
const remaining = (this.#remotePullRetryAfter.get(publication.publicationId) ?? 0) - now;
|
|
765
|
+
if (remaining > 0) recordReconnect(this.#recordReconnect, "remote_pull_deferred", { remaining_ms: remaining, source: publication.source });
|
|
766
|
+
return remaining <= 0;
|
|
767
|
+
}).map(([, publication]) => publication);
|
|
768
|
+
}
|
|
769
|
+
#validatedRemotePublicationCursor(authoritative) {
|
|
770
|
+
if (this.#cursor?.signature === INVALID_PUBLICATION_SIGNATURE && this.#cursor.incarnation === authoritative.incarnation && this.#cursor.sequence === authoritative.sequence) return null;
|
|
771
|
+
try {
|
|
772
|
+
return validatePublicationSnapshot(authoritative);
|
|
773
|
+
} catch (error) {
|
|
774
|
+
this.#quarantineInvalidRemotePublicationCursor(authoritative);
|
|
775
|
+
throw error;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
#desiredRemotePublications(authoritative, cursor) {
|
|
779
|
+
try {
|
|
780
|
+
return desiredRemotePublications(authoritative.publications, this.#participantId);
|
|
781
|
+
} catch (error) {
|
|
782
|
+
this.#observeRemotePublicationCursor(cursor);
|
|
783
|
+
throw error;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
async #pullWithRecovery(publications, cursor, generation) {
|
|
787
|
+
try {
|
|
788
|
+
return await this.#pull(publications, generation);
|
|
789
|
+
} catch (error) {
|
|
790
|
+
if (generation !== this.#generation || this.#stopped) throw error;
|
|
791
|
+
if (!this.#canReplaceConnectionAfterRemotePull(error, generation)) {
|
|
792
|
+
this.#observeRemotePublicationCursor(cursor, retryableRemotePull(error));
|
|
793
|
+
throw error;
|
|
794
|
+
}
|
|
795
|
+
return this.#retryRemotePullOnReplacement(publications, cursor, generation);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
#canReplaceConnectionAfterRemotePull(error, generation) {
|
|
799
|
+
return error instanceof CloudflareSFUError && error.code === "signaling_failed" && !this.#hasLiveRemoteTracks() && this.#replaceMediaConnection !== void 0 && this.#replacementAttemptedGeneration !== generation;
|
|
800
|
+
}
|
|
801
|
+
#hasLiveRemoteTracks() {
|
|
802
|
+
return [...this.#remoteTracks.values()].some((publication) => publication.track.readyState === "live");
|
|
803
|
+
}
|
|
804
|
+
async #retryRemotePullOnReplacement(publications, cursor, generation) {
|
|
805
|
+
try {
|
|
806
|
+
await this.#replaceMediaConnectionForRecovery(generation);
|
|
807
|
+
await this.#republishPreparedTracksAfterRecovery(generation);
|
|
808
|
+
return await this.#pull(publications, generation);
|
|
809
|
+
} catch (error) {
|
|
810
|
+
if (generation !== this.#generation || this.#stopped) throw error;
|
|
811
|
+
this.#observeRemotePublicationCursor(cursor, retryableRemotePull(error));
|
|
812
|
+
throw error;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
498
815
|
async #pull(publications, generation) {
|
|
499
816
|
if (publications.length === 0) return [];
|
|
500
817
|
return this.#serializeSDP(async () => {
|
|
@@ -502,7 +819,15 @@ var CloudflareSFUClient = class {
|
|
|
502
819
|
const connectionId = this.#bootstrap.connectionId;
|
|
503
820
|
const requested = publications.map((publication) => {
|
|
504
821
|
const reference = parseCloudflareSFUPublicationID(publication.publicationId);
|
|
505
|
-
return {
|
|
822
|
+
return {
|
|
823
|
+
location: "remote",
|
|
824
|
+
sessionId: reference.connectionId,
|
|
825
|
+
trackName: reference.trackName,
|
|
826
|
+
// h sorts before l: prefer full camera quality, but keep video flowing
|
|
827
|
+
// when the publisher pauses h. Do not silently downgrade a live h layer.
|
|
828
|
+
// https://developers.cloudflare.com/realtime/sfu/features/simulcast/#quality-control
|
|
829
|
+
...publication.source === "camera" ? { simulcast: { preferredRid: "h", priorityOrdering: "none", ridNotAvailable: "asciibetical" } } : {}
|
|
830
|
+
};
|
|
506
831
|
});
|
|
507
832
|
const received = /* @__PURE__ */ new Map();
|
|
508
833
|
const onTrack = (event) => {
|
|
@@ -511,17 +836,20 @@ var CloudflareSFUClient = class {
|
|
|
511
836
|
connection.addEventListener("track", onTrack);
|
|
512
837
|
try {
|
|
513
838
|
this.#requireGeneration(generation);
|
|
514
|
-
const response = await this.#requireTransport().addTracks({ connectionId, tracks: requested });
|
|
839
|
+
const response = await traceReconnect(this.#recordReconnect, "pull_tracks", () => this.#requireTransport().addTracks({ connectionId, tracks: requested, allowPartialRemoteTracks: true }));
|
|
515
840
|
this.#requireGeneration(generation);
|
|
841
|
+
this.#recordRemotePullErrors(publications, response.trackErrors ?? []);
|
|
516
842
|
const responseTracks = response.tracks ?? [];
|
|
843
|
+
const matched = matchRemotePullTracks(publications, responseTracks);
|
|
844
|
+
for (const { publication } of matched) this.#remotePullRetryAfter.delete(publication.publicationId);
|
|
517
845
|
await this.#completeRenegotiation(response, connection, connectionId, generation);
|
|
846
|
+
if (matched.length === 0) return [];
|
|
518
847
|
await this.#waitForConnection(connection, generation);
|
|
519
848
|
this.#negotiatedGeneration = generation;
|
|
520
|
-
await waitFor(() => responseTracks.every((track) => track.mid !== void 0 && received.has(track.mid)), 5e3);
|
|
849
|
+
await traceReconnect(this.#recordReconnect, "remote_track_arrival", () => waitFor(() => responseTracks.every((track) => track.mid !== void 0 && received.has(track.mid)), 5e3));
|
|
521
850
|
this.#requireGeneration(generation);
|
|
522
|
-
return
|
|
523
|
-
const
|
|
524
|
-
const track = responseTrack?.mid === void 0 ? void 0 : received.get(responseTrack.mid);
|
|
851
|
+
return matched.map(({ publication, mid }) => {
|
|
852
|
+
const track = received.get(mid);
|
|
525
853
|
if (!track) throw new CloudflareSFUError("A negotiated remote track did not arrive", "media_failed");
|
|
526
854
|
return Object.freeze({ ...publication, track });
|
|
527
855
|
});
|
|
@@ -533,38 +861,151 @@ var CloudflareSFUClient = class {
|
|
|
533
861
|
}
|
|
534
862
|
});
|
|
535
863
|
}
|
|
864
|
+
#recordRemotePullErrors(publications, failures) {
|
|
865
|
+
const requested = new Map(
|
|
866
|
+
publications.map((publication) => {
|
|
867
|
+
const reference = parseCloudflareSFUPublicationID(publication.publicationId);
|
|
868
|
+
return [`${reference.connectionId}\0${reference.trackName}`, publication];
|
|
869
|
+
})
|
|
870
|
+
);
|
|
871
|
+
for (const failure of failures) {
|
|
872
|
+
const publication = requested.get(`${failure.connectionId}\0${failure.trackName}`);
|
|
873
|
+
if (!publication) throw new CloudflareSFUError("Cloudflare SFU returned an unrequested remote track error", "invalid_publication");
|
|
874
|
+
const delay = 750;
|
|
875
|
+
this.#remotePullRetryAfter.set(publication.publicationId, Date.now() + delay);
|
|
876
|
+
recordReconnect(this.#recordReconnect, "remote_pull_backoff", { delay_ms: delay, retry_kind: failure.code === "empty_track_error" ? "empty_track" : "provider_error", source: publication.source });
|
|
877
|
+
this.#reportError(new CloudflareSFUError(`Cloudflare SFU remote track failed: ${failure.code}`, "media_failed", { providerCode: failure.code }));
|
|
878
|
+
}
|
|
879
|
+
}
|
|
536
880
|
async #completeRenegotiation(response, connection, connectionId, generation) {
|
|
537
881
|
if (!response.requiresImmediateRenegotiation) return;
|
|
538
|
-
|
|
539
|
-
await connection.
|
|
540
|
-
|
|
541
|
-
await
|
|
542
|
-
await this.#requireTransport().renegotiate({ connectionId, sessionDescription: requireDescription(answer) });
|
|
882
|
+
await this.#applyProviderDescription(response, connection);
|
|
883
|
+
const answer = await traceReconnect(this.#recordReconnect, "create_answer", () => connection.createAnswer());
|
|
884
|
+
await traceReconnect(this.#recordReconnect, "set_local_answer", () => connection.setLocalDescription(answer));
|
|
885
|
+
await traceReconnect(this.#recordReconnect, "renegotiate", () => this.#requireTransport().renegotiate({ connectionId, ...providerDescription(requireDescription(answer)) }));
|
|
543
886
|
this.#requireGeneration(generation);
|
|
544
887
|
}
|
|
888
|
+
async #applyProviderDescription(response, connection = this.#connection) {
|
|
889
|
+
await traceReconnect(this.#recordReconnect, "set_remote_description", () => connection.setRemoteDescription(requireSFUDescription(response.sessionDescription)));
|
|
890
|
+
}
|
|
545
891
|
#serializeSDP(operation) {
|
|
546
|
-
const
|
|
892
|
+
const generation = this.#generation;
|
|
893
|
+
const queuedAt = performance.now();
|
|
894
|
+
let expired = false;
|
|
895
|
+
let timer;
|
|
896
|
+
const timeout = new CloudflareSFUError("Cloudflare SFU negotiation timed out. Try again.", "negotiation_timeout");
|
|
897
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
898
|
+
timer = globalThis.setTimeout(() => {
|
|
899
|
+
expired = true;
|
|
900
|
+
if (generation === this.#generation && !this.#stopped) this.#expireNegotiation(timeout);
|
|
901
|
+
reject(timeout);
|
|
902
|
+
}, NEGOTIATION_TIMEOUT_MS);
|
|
903
|
+
});
|
|
904
|
+
const run = () => {
|
|
905
|
+
if (expired || generation !== this.#generation) throw timeout;
|
|
906
|
+
recordReconnect(this.#recordReconnect, "sdp_queue", { duration_ms: performance.now() - queuedAt });
|
|
907
|
+
return operation();
|
|
908
|
+
};
|
|
909
|
+
const pending = this.#sdpTail.then(run, run);
|
|
910
|
+
const result = Promise.race([pending, deadline]).catch((error) => {
|
|
911
|
+
if (error instanceof CloudflareSFUError && error.code === "signaling_timeout" && generation === this.#generation && !this.#stopped) this.#expireNegotiation(error);
|
|
912
|
+
throw error;
|
|
913
|
+
}).finally(() => {
|
|
914
|
+
if (timer !== void 0) globalThis.clearTimeout(timer);
|
|
915
|
+
});
|
|
547
916
|
this.#sdpTail = result.then(
|
|
548
917
|
() => void 0,
|
|
549
918
|
() => void 0
|
|
550
919
|
);
|
|
551
920
|
return result;
|
|
552
921
|
}
|
|
922
|
+
async #boundPeerOperation(operation) {
|
|
923
|
+
const generation = this.#generation;
|
|
924
|
+
let timer;
|
|
925
|
+
const timeout = new CloudflareSFUError("The browser media operation timed out. Try again.", "negotiation_timeout");
|
|
926
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
927
|
+
timer = globalThis.setTimeout(() => {
|
|
928
|
+
if (generation === this.#generation && !this.#stopped) this.#expireNegotiation(timeout);
|
|
929
|
+
reject(timeout);
|
|
930
|
+
}, PEER_OPERATION_TIMEOUT_MS);
|
|
931
|
+
});
|
|
932
|
+
try {
|
|
933
|
+
await Promise.race([operation, deadline]);
|
|
934
|
+
} finally {
|
|
935
|
+
if (timer !== void 0) globalThis.clearTimeout(timer);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
#expireNegotiation(error) {
|
|
939
|
+
this.#generation++;
|
|
940
|
+
this.#connectionEpoch++;
|
|
941
|
+
this.#clearPoll();
|
|
942
|
+
this.#polling = false;
|
|
943
|
+
this.#disposeConnection(false);
|
|
944
|
+
this.#setFailure(error, "media_failed");
|
|
945
|
+
}
|
|
553
946
|
#waitForConnection(connection, generation) {
|
|
554
|
-
return
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
947
|
+
return traceReconnect(
|
|
948
|
+
this.#recordReconnect,
|
|
949
|
+
"connection_ready",
|
|
950
|
+
() => waitFor(
|
|
951
|
+
() => {
|
|
952
|
+
this.#requireGeneration(generation);
|
|
953
|
+
return connectionIsLive(connection);
|
|
954
|
+
},
|
|
955
|
+
CONNECTION_TIMEOUT_MS,
|
|
956
|
+
"Timed out waiting for the Cloudflare SFU peer connection"
|
|
957
|
+
)
|
|
561
958
|
);
|
|
562
959
|
}
|
|
960
|
+
#cameraSimulcastSender(state) {
|
|
961
|
+
if (!state?.enabled) return void 0;
|
|
962
|
+
const sender = state.transceiver?.sender;
|
|
963
|
+
if (!sender?.track || sender.getParameters().encodings.length < 2) return void 0;
|
|
964
|
+
return sender;
|
|
965
|
+
}
|
|
966
|
+
async #applyCameraUplink(sender, report, policy) {
|
|
967
|
+
const parameters = sender.getParameters();
|
|
968
|
+
const low = parameters.encodings.find((encoding) => encoding.rid === "l");
|
|
969
|
+
if (!low) return;
|
|
970
|
+
const active = policy.sample(cameraUplinkBitrate(report.values()), low.active !== false);
|
|
971
|
+
if (active === (low.active !== false)) return;
|
|
972
|
+
low.active = active;
|
|
973
|
+
parameters.degradationPreference = "maintain-framerate";
|
|
974
|
+
await sender.setParameters(parameters);
|
|
975
|
+
}
|
|
563
976
|
#observeConnection(connection, generation, connectionEpoch) {
|
|
977
|
+
this.#disposeConnectionObservation?.();
|
|
564
978
|
let disposed = false;
|
|
979
|
+
let cameraTimer;
|
|
980
|
+
const cameraPolicy = new CameraUplinkPolicy();
|
|
981
|
+
let observedCamera;
|
|
982
|
+
const current = () => !disposed && generation === this.#generation && connectionEpoch === this.#connectionEpoch && !this.#stopped && connection.connectionState !== "failed" && connection.connectionState !== "closed";
|
|
983
|
+
const adaptCamera = async () => {
|
|
984
|
+
if (!current()) return;
|
|
985
|
+
try {
|
|
986
|
+
const state = this.#localTracks.get("camera");
|
|
987
|
+
if (state !== observedCamera) {
|
|
988
|
+
cameraPolicy.reset();
|
|
989
|
+
observedCamera = state;
|
|
990
|
+
}
|
|
991
|
+
const sender = this.#cameraSimulcastSender(state);
|
|
992
|
+
if (!sender) {
|
|
993
|
+
cameraPolicy.reset();
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
const report = await connection.getStats();
|
|
997
|
+
if (!current() || this.#cameraSimulcastSender(state) !== sender || connection.signalingState !== "stable") return;
|
|
998
|
+
await this.#applyCameraUplink(sender, report, cameraPolicy);
|
|
999
|
+
} catch {
|
|
1000
|
+
cameraPolicy.reset();
|
|
1001
|
+
} finally {
|
|
1002
|
+
if (current()) cameraTimer = globalThis.setTimeout(adaptCamera, 1e3);
|
|
1003
|
+
}
|
|
1004
|
+
};
|
|
565
1005
|
const dispose = () => {
|
|
566
1006
|
if (disposed) return;
|
|
567
1007
|
disposed = true;
|
|
1008
|
+
if (cameraTimer !== void 0) globalThis.clearTimeout(cameraTimer);
|
|
568
1009
|
connection.removeEventListener("connectionstatechange", capture);
|
|
569
1010
|
connection.removeEventListener("iceconnectionstatechange", capture);
|
|
570
1011
|
connection.removeEventListener("signalingstatechange", capture);
|
|
@@ -577,6 +1018,7 @@ var CloudflareSFUClient = class {
|
|
|
577
1018
|
};
|
|
578
1019
|
const capture = () => {
|
|
579
1020
|
if (disposed || generation !== this.#generation || connectionEpoch !== this.#connectionEpoch || this.#stopped) return;
|
|
1021
|
+
recordReconnect(this.#recordReconnect, "rtc_state", { connection_state: connection.connectionState, ice_connection_state: connection.iceConnectionState, signaling_state: connection.signalingState });
|
|
580
1022
|
observe();
|
|
581
1023
|
const recorder = this.#onRtcSummary;
|
|
582
1024
|
if (recorder) {
|
|
@@ -593,11 +1035,67 @@ var CloudflareSFUClient = class {
|
|
|
593
1035
|
}
|
|
594
1036
|
if (connection.connectionState === "closed") dispose();
|
|
595
1037
|
};
|
|
1038
|
+
this.#disposeConnectionObservation = dispose;
|
|
1039
|
+
cameraTimer = globalThis.setTimeout(adaptCamera, 1e3);
|
|
1040
|
+
if (this.#recordReconnect) this.#sampleRecoveryTraffic(connection, generation, connectionEpoch);
|
|
596
1041
|
connection.addEventListener("connectionstatechange", capture);
|
|
597
1042
|
connection.addEventListener("iceconnectionstatechange", capture);
|
|
598
1043
|
connection.addEventListener("signalingstatechange", capture);
|
|
599
1044
|
capture();
|
|
600
1045
|
}
|
|
1046
|
+
#sampleRecoveryTraffic(connection, generation, connectionEpoch) {
|
|
1047
|
+
const traffic = /* @__PURE__ */ new Map();
|
|
1048
|
+
let dtlsState;
|
|
1049
|
+
const current = () => generation === this.#generation && connectionEpoch === this.#connectionEpoch && !this.#stopped && connection.connectionState !== "closed" && connection.connectionState !== "failed";
|
|
1050
|
+
const sample = async () => {
|
|
1051
|
+
if (!current()) return;
|
|
1052
|
+
try {
|
|
1053
|
+
const report = await connection.getStats();
|
|
1054
|
+
if (!current()) return;
|
|
1055
|
+
const now = performance.now();
|
|
1056
|
+
report.forEach((entry) => {
|
|
1057
|
+
if (entry.type === "transport") {
|
|
1058
|
+
const nextState = rtcString(entry, "dtlsState");
|
|
1059
|
+
if (nextState !== dtlsState) recordReconnect(this.#recordReconnect, "dtls_state", { dtls_state: nextState ?? "unknown" });
|
|
1060
|
+
dtlsState = nextState;
|
|
1061
|
+
}
|
|
1062
|
+
this.#observeRecoveryTraffic(entry, traffic, now);
|
|
1063
|
+
});
|
|
1064
|
+
} catch {
|
|
1065
|
+
if (current()) recordReconnect(this.#recordReconnect, "rtc_stats_unavailable");
|
|
1066
|
+
}
|
|
1067
|
+
if (current()) globalThis.setTimeout(() => void sample(), 250);
|
|
1068
|
+
};
|
|
1069
|
+
void sample();
|
|
1070
|
+
}
|
|
1071
|
+
#observeRecoveryTraffic(entry, traffic, now) {
|
|
1072
|
+
if (entry.type !== "inbound-rtp" && entry.type !== "outbound-rtp") return;
|
|
1073
|
+
const direction = entry.type === "inbound-rtp" ? "inbound" : "outbound";
|
|
1074
|
+
const kind = rtcString(entry, "kind") ?? "unknown";
|
|
1075
|
+
const previous = traffic.get(entry.id);
|
|
1076
|
+
const next = previous ?? {
|
|
1077
|
+
packets: { count: 0, lastProgress: now, stalled: false },
|
|
1078
|
+
frames: { count: 0, lastProgress: now, stalled: false }
|
|
1079
|
+
};
|
|
1080
|
+
const packets = recoveryTrafficCount(entry, direction, "packet");
|
|
1081
|
+
const frames = recoveryTrafficCount(entry, direction, "frame");
|
|
1082
|
+
this.#observeTrafficCounter(next.packets, { count: packets, now, direction, kind, unit: "packet", existed: previous !== void 0 });
|
|
1083
|
+
this.#observeTrafficCounter(next.frames, { count: frames, now, direction, kind, unit: "frame", existed: previous !== void 0 });
|
|
1084
|
+
traffic.set(entry.id, next);
|
|
1085
|
+
}
|
|
1086
|
+
#observeTrafficCounter(progress, sample) {
|
|
1087
|
+
const attributes = { direction: sample.direction, kind: sample.kind };
|
|
1088
|
+
if (sample.count > progress.count) {
|
|
1089
|
+
if (progress.count === 0) recordReconnect(this.#recordReconnect, `first_${sample.unit}`, attributes);
|
|
1090
|
+
else if (progress.stalled) recordReconnect(this.#recordReconnect, sample.unit === "packet" ? "rtp_resumed" : "frame_resumed", attributes);
|
|
1091
|
+
progress.lastProgress = sample.now;
|
|
1092
|
+
progress.stalled = false;
|
|
1093
|
+
} else if (trafficStalled(progress, sample)) {
|
|
1094
|
+
progress.stalled = true;
|
|
1095
|
+
recordReconnect(this.#recordReconnect, sample.unit === "packet" ? "rtp_stall" : "frame_stall", attributes);
|
|
1096
|
+
}
|
|
1097
|
+
progress.count = sample.count;
|
|
1098
|
+
}
|
|
601
1099
|
async #replaceDormantConnectionBeforeNegotiation(generation) {
|
|
602
1100
|
if (!this.#started || this.#negotiatedGeneration === generation || this.#replacementAttemptedGeneration === generation || !this.#replaceMediaConnection) return;
|
|
603
1101
|
await this.#replaceMediaConnectionForRecovery(generation);
|
|
@@ -608,7 +1106,8 @@ var CloudflareSFUClient = class {
|
|
|
608
1106
|
throw new CloudflareSFUError("A fresh Cloudflare SFU connection is unavailable", "signaling_failed");
|
|
609
1107
|
}
|
|
610
1108
|
this.#replacementAttemptedGeneration = generation;
|
|
611
|
-
const
|
|
1109
|
+
const replaceMediaConnection = this.#replaceMediaConnection;
|
|
1110
|
+
const bootstrap = await traceReconnect(this.#recordReconnect, "replace_access", replaceMediaConnection);
|
|
612
1111
|
this.#requireGeneration(generation);
|
|
613
1112
|
validateBootstrap(bootstrap);
|
|
614
1113
|
if (bootstrap.connectionId === this.#bootstrap.connectionId) {
|
|
@@ -617,10 +1116,12 @@ var CloudflareSFUClient = class {
|
|
|
617
1116
|
const connectionEpoch = ++this.#connectionEpoch;
|
|
618
1117
|
this.#disposeConnection(false);
|
|
619
1118
|
this.#reusableLocalTransceivers.clear();
|
|
1119
|
+
this.#reusableLocalPublicationIds.clear();
|
|
620
1120
|
this.#clearRemoteTracks();
|
|
621
1121
|
this.#cursor = null;
|
|
622
1122
|
this.#bootstrap = bootstrap;
|
|
623
1123
|
this.#connection = this.#createPeerConnection(bootstrap);
|
|
1124
|
+
this.#retiredLocalConnection = null;
|
|
624
1125
|
this.#negotiatedGeneration = null;
|
|
625
1126
|
this.#observeConnection(this.#connection, generation, connectionEpoch);
|
|
626
1127
|
for (const state of this.#localTracks.values()) {
|
|
@@ -629,6 +1130,20 @@ var CloudflareSFUClient = class {
|
|
|
629
1130
|
state.providerPublicationId = null;
|
|
630
1131
|
}
|
|
631
1132
|
this.#setPhase("recovering", null);
|
|
1133
|
+
this.remotePublicationsChanged();
|
|
1134
|
+
}
|
|
1135
|
+
async #republishPreparedTracksAfterRecovery(generation) {
|
|
1136
|
+
const enabled = [...this.#localTracks.values()].filter((state) => state.desiredEnabled && state.track.readyState !== "ended");
|
|
1137
|
+
await this.#publishPreparedTracks(enabled, generation);
|
|
1138
|
+
}
|
|
1139
|
+
#observeRemotePublicationCursor(cursor, pullIncomplete = false) {
|
|
1140
|
+
this.#cursor = cursor;
|
|
1141
|
+
this.#remotePullIncomplete = pullIncomplete;
|
|
1142
|
+
this.#publishSnapshot();
|
|
1143
|
+
}
|
|
1144
|
+
#quarantineInvalidRemotePublicationCursor(authoritative) {
|
|
1145
|
+
if (!Number.isSafeInteger(authoritative.incarnation) || authoritative.incarnation < 0 || !Number.isSafeInteger(authoritative.sequence) || authoritative.sequence < 0) return;
|
|
1146
|
+
this.#observeRemotePublicationCursor({ incarnation: authoritative.incarnation, sequence: authoritative.sequence, signature: INVALID_PUBLICATION_SIGNATURE });
|
|
632
1147
|
}
|
|
633
1148
|
#isRetryableConnectionFailure(error) {
|
|
634
1149
|
return error instanceof CloudflareSFUError && error.code === "signaling_failed" && error.options.retryableConnection === true;
|
|
@@ -638,6 +1153,8 @@ var CloudflareSFUClient = class {
|
|
|
638
1153
|
return create({ iceServers: [{ urls: bootstrap.stunServer }], bundlePolicy: "max-bundle" });
|
|
639
1154
|
}
|
|
640
1155
|
#disposeConnection(stopSenders) {
|
|
1156
|
+
this.#disposeConnectionObservation?.();
|
|
1157
|
+
this.#disposeConnectionObservation = void 0;
|
|
641
1158
|
if (stopSenders) {
|
|
642
1159
|
for (const sender of this.#connection.getSenders()) {
|
|
643
1160
|
if (sender.track) safeStopTrack(sender.track, this.#reportError.bind(this));
|
|
@@ -677,11 +1194,15 @@ var CloudflareSFUClient = class {
|
|
|
677
1194
|
if (this.#stopped || !this.#started || this.#pollTimer !== void 0) return;
|
|
678
1195
|
this.#pollTimer = globalThis.setTimeout(async () => {
|
|
679
1196
|
this.#pollTimer = void 0;
|
|
1197
|
+
if (typeof document !== "undefined" && document.visibilityState === "hidden") {
|
|
1198
|
+
this.#schedulePoll();
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
680
1201
|
try {
|
|
681
1202
|
await this.refreshRemotePublications();
|
|
682
1203
|
} catch {
|
|
683
1204
|
} finally {
|
|
684
|
-
this.#schedulePoll();
|
|
1205
|
+
this.#schedulePoll(this.#remotePullIncomplete ? 750 : this.#pollIntervalMs);
|
|
685
1206
|
}
|
|
686
1207
|
}, delayMs);
|
|
687
1208
|
}
|
|
@@ -694,7 +1215,7 @@ var CloudflareSFUClient = class {
|
|
|
694
1215
|
participantId: this.#participantId,
|
|
695
1216
|
source: state.source,
|
|
696
1217
|
enabled: state.enabled,
|
|
697
|
-
publicationId: state.
|
|
1218
|
+
publicationId: state.providerPublicationId
|
|
698
1219
|
}));
|
|
699
1220
|
}
|
|
700
1221
|
#projectRemotePublications() {
|
|
@@ -712,7 +1233,7 @@ var CloudflareSFUClient = class {
|
|
|
712
1233
|
(state) => Object.freeze({
|
|
713
1234
|
source: state.source,
|
|
714
1235
|
enabled: state.enabled,
|
|
715
|
-
publicationId: state.
|
|
1236
|
+
publicationId: state.providerPublicationId,
|
|
716
1237
|
track: state.track
|
|
717
1238
|
})
|
|
718
1239
|
) : EMPTY_LOCAL,
|
|
@@ -848,10 +1369,10 @@ function desiredRemotePublications(publications, participantId) {
|
|
|
848
1369
|
function reconcileRemoteTracks(desired, pulled, current) {
|
|
849
1370
|
const pulledByKey = new Map(pulled.map((publication) => [publicationKey(publication), publication]));
|
|
850
1371
|
return new Map(
|
|
851
|
-
[...desired].
|
|
1372
|
+
[...desired].flatMap(([key, publication]) => {
|
|
852
1373
|
const track = pulledByKey.get(key) ?? current.get(key);
|
|
853
|
-
if (!track || track.publicationId !== publication.publicationId)
|
|
854
|
-
return [key, track];
|
|
1374
|
+
if (!track || track.publicationId !== publication.publicationId) return [];
|
|
1375
|
+
return [[key, track]];
|
|
855
1376
|
})
|
|
856
1377
|
);
|
|
857
1378
|
}
|
|
@@ -913,31 +1434,63 @@ function publicationEqual(left, right) {
|
|
|
913
1434
|
function remotePublicationEqual(left, right) {
|
|
914
1435
|
return right !== void 0 && left.participantId === right.participantId && left.source === right.source && left.publicationId === right.publicationId && left.track === right.track;
|
|
915
1436
|
}
|
|
1437
|
+
function providerDescription(description) {
|
|
1438
|
+
return { sessionDescription: description };
|
|
1439
|
+
}
|
|
1440
|
+
function retryableRemotePull(error) {
|
|
1441
|
+
return error instanceof CloudflareSFUError && ["signaling_failed", "signaling_timeout", "media_failed", "negotiation_timeout"].includes(error.code);
|
|
1442
|
+
}
|
|
1443
|
+
function firefoxCameraSimulcastUnsupported() {
|
|
1444
|
+
return typeof navigator !== "undefined" && /\bFirefox\/\d+/i.test(navigator.userAgent);
|
|
1445
|
+
}
|
|
1446
|
+
function simulcastUnsupported(error) {
|
|
1447
|
+
return error instanceof Error && ["NotSupportedError", "OperationError", "TypeError"].includes(error.name);
|
|
1448
|
+
}
|
|
1449
|
+
function mediaTargetFailure(error) {
|
|
1450
|
+
const code = error instanceof CloudflareSFUError ? error.code : "media_failed";
|
|
1451
|
+
const ambiguous = code === "signaling_timeout" || code === "negotiation_timeout";
|
|
1452
|
+
return { outcome: ambiguous ? "ambiguous" : "retryable_failure", errorCode: code };
|
|
1453
|
+
}
|
|
1454
|
+
function recoveryTrafficCount(entry, direction, unit) {
|
|
1455
|
+
const property = unit === "packet" ? direction === "inbound" ? "packetsReceived" : "packetsSent" : direction === "inbound" ? "framesDecoded" : "framesEncoded";
|
|
1456
|
+
return rtcNumber(entry, property) ?? 0;
|
|
1457
|
+
}
|
|
1458
|
+
function trafficStalled(progress, sample) {
|
|
1459
|
+
return sample.existed && !progress.stalled && sample.now - progress.lastProgress >= 1e3 && (sample.unit === "packet" || progress.count > 0);
|
|
1460
|
+
}
|
|
916
1461
|
|
|
917
1462
|
// src/media/transport.ts
|
|
1463
|
+
var SIGNALING_TIMEOUT_MS = 8e3;
|
|
1464
|
+
var CREDENTIAL_TIMEOUT_MS = 7e3;
|
|
918
1465
|
function createCloudflareSFUHTTPTransport(options) {
|
|
919
1466
|
const fetch = options.fetch ?? globalThis.fetch;
|
|
920
1467
|
const credential = requireCredential(options);
|
|
921
1468
|
const base = options.apiBaseURL.replace(/\/$/, "");
|
|
922
1469
|
const mediaPath = `${base}/v1/tenants/${encodeURIComponent(options.tenantId)}/spaces/${encodeURIComponent(options.spaceId)}/episodes/${encodeURIComponent(options.episodeId)}/participants/${encodeURIComponent(options.participantId)}/media/sfu`;
|
|
1470
|
+
const timeoutMs = options.requestTimeoutMs ?? SIGNALING_TIMEOUT_MS;
|
|
1471
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new CloudflareSFUError("The signaling deadline is invalid", "signaling_failed");
|
|
923
1472
|
const request = async (path, init) => {
|
|
924
|
-
const token = await credential();
|
|
1473
|
+
const token = await withDeadline(CREDENTIAL_TIMEOUT_MS, (signal) => credential(signal));
|
|
925
1474
|
if (!token.trim()) throw new CloudflareSFUError("The media credential provider returned an empty token", "signaling_failed");
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
throw new CloudflareSFUError(`Chalk SFU signaling failed with HTTP ${response.status}`, "signaling_failed", {
|
|
932
|
-
status: response.status,
|
|
933
|
-
retryableConnection: response.status === 404 || response.status === 410 || response.status >= 500 && response.status < 600
|
|
1475
|
+
return withDeadline(timeoutMs, async (signal) => {
|
|
1476
|
+
const response = await fetch(`${mediaPath}/${path}`, {
|
|
1477
|
+
...init,
|
|
1478
|
+
signal,
|
|
1479
|
+
headers: { ...options.telemetry ? journeyHeaders(options.telemetry) : {}, Authorization: `Bearer ${token}`, "Content-Type": "application/json", ...init?.headers }
|
|
934
1480
|
});
|
|
935
|
-
|
|
936
|
-
|
|
1481
|
+
if (!response.ok) {
|
|
1482
|
+
throw new CloudflareSFUError(`Chalk SFU signaling failed with HTTP ${response.status}`, "signaling_failed", {
|
|
1483
|
+
status: response.status,
|
|
1484
|
+
retryableConnection: response.status === 404 || response.status === 410 || response.status >= 500 && response.status < 600
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
return await response.json();
|
|
1488
|
+
});
|
|
937
1489
|
};
|
|
938
1490
|
return {
|
|
939
1491
|
addTracks: async (input) => {
|
|
940
|
-
const
|
|
1492
|
+
const path = input.allowPartialRemoteTracks ? "tracks?allow_partial_remote_tracks=true" : "tracks";
|
|
1493
|
+
const response = await request(path, {
|
|
941
1494
|
method: "POST",
|
|
942
1495
|
body: JSON.stringify({ connection_id: input.connectionId, session_description: input.sessionDescription, tracks: input.tracks })
|
|
943
1496
|
});
|
|
@@ -975,6 +1528,25 @@ function createCloudflareSFUHTTPTransport(options) {
|
|
|
975
1528
|
}
|
|
976
1529
|
};
|
|
977
1530
|
}
|
|
1531
|
+
async function withDeadline(timeoutMs, operation) {
|
|
1532
|
+
const controller = new AbortController();
|
|
1533
|
+
const timeout = new CloudflareSFUError("Cloudflare SFU signaling timed out. Try again.", "signaling_timeout");
|
|
1534
|
+
let timer;
|
|
1535
|
+
const deadline = new Promise((_resolve, reject) => {
|
|
1536
|
+
timer = globalThis.setTimeout(() => {
|
|
1537
|
+
controller.abort();
|
|
1538
|
+
reject(timeout);
|
|
1539
|
+
}, timeoutMs);
|
|
1540
|
+
});
|
|
1541
|
+
try {
|
|
1542
|
+
return await Promise.race([Promise.resolve().then(() => operation(controller.signal)), deadline]);
|
|
1543
|
+
} catch (error) {
|
|
1544
|
+
if (controller.signal.aborted) throw timeout;
|
|
1545
|
+
throw error;
|
|
1546
|
+
} finally {
|
|
1547
|
+
if (timer !== void 0) globalThis.clearTimeout(timer);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
978
1550
|
function requireCredential(options) {
|
|
979
1551
|
if (options.credential) return options.credential;
|
|
980
1552
|
if (options.bearerToken !== void 0) return () => options.bearerToken ?? "";
|
|
@@ -1391,6 +1963,30 @@ function toParticipant(participant) {
|
|
|
1391
1963
|
};
|
|
1392
1964
|
}
|
|
1393
1965
|
|
|
1966
|
+
// src/media/capture-error.ts
|
|
1967
|
+
function describeMediaCaptureError(cause) {
|
|
1968
|
+
const name = typeof cause === "object" && cause !== null && "name" in cause ? cause.name : void 0;
|
|
1969
|
+
switch (name) {
|
|
1970
|
+
case "NotAllowedError":
|
|
1971
|
+
case "PermissionDeniedError":
|
|
1972
|
+
return { code: "permission_denied", recoverable: true, message: "Camera or microphone permission was denied. Allow access in your device settings and try again." };
|
|
1973
|
+
case "NotFoundError":
|
|
1974
|
+
case "DevicesNotFoundError":
|
|
1975
|
+
return { code: "device_not_found", recoverable: true, message: "No matching camera or microphone was found. Connect an input device or enter with that device off." };
|
|
1976
|
+
case "NotReadableError":
|
|
1977
|
+
case "TrackStartError":
|
|
1978
|
+
return { code: "device_busy", recoverable: true, message: "The camera or microphone could not start. Close other apps using it and try again." };
|
|
1979
|
+
case "OverconstrainedError":
|
|
1980
|
+
case "ConstraintNotSatisfiedError":
|
|
1981
|
+
return { code: "device_constraint_invalid", recoverable: true, message: "The selected camera or microphone is unavailable. Select another input device and try again." };
|
|
1982
|
+
case "SecurityError":
|
|
1983
|
+
case "NotSupportedError":
|
|
1984
|
+
return { code: "unsupported_environment", recoverable: false, message: "Media capture is unavailable in this environment. Use a supported browser over HTTPS or the native app." };
|
|
1985
|
+
default:
|
|
1986
|
+
return { code: "media_capture_failed", recoverable: true, message: "Media capture failed. Try again or enter with devices off." };
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1394
1990
|
export {
|
|
1395
1991
|
CloudflareSFUError,
|
|
1396
1992
|
parseCloudflareSFUPublicationID,
|
|
@@ -1403,5 +1999,6 @@ export {
|
|
|
1403
1999
|
CloudflareSFUClient,
|
|
1404
2000
|
createCloudflareSFUHTTPTransport,
|
|
1405
2001
|
CloudflareRTKError,
|
|
1406
|
-
CloudflareRTKClient
|
|
2002
|
+
CloudflareRTKClient,
|
|
2003
|
+
describeMediaCaptureError
|
|
1407
2004
|
};
|