@strevokit/meeting-sdk 0.6.0 → 0.8.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/CHANGELOG.md +28 -0
- package/README.md +45 -13
- package/dist/index.d.ts +20 -2
- package/dist/index.js +1210 -92
- package/dist/index.js.map +4 -4
- package/docs/api-reference.md +19 -1
- package/docs/backend-api.md +370 -0
- package/docs/examples/backend-integration.md +103 -0
- package/docs/examples/vanilla.html +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
// src/internal/env.ts
|
|
2
|
+
function subscribeNetworkChange(callback) {
|
|
3
|
+
const scope = globalThis;
|
|
4
|
+
const targets = [];
|
|
5
|
+
if (typeof scope.addEventListener === "function") {
|
|
6
|
+
targets.push({ target: globalThis, type: "online" });
|
|
7
|
+
}
|
|
8
|
+
const connection = scope.navigator?.connection;
|
|
9
|
+
if (connection && typeof connection.addEventListener === "function" && typeof connection.removeEventListener === "function") {
|
|
10
|
+
targets.push({ target: connection, type: "change" });
|
|
11
|
+
}
|
|
12
|
+
for (const { target, type } of targets) target.addEventListener(type, callback);
|
|
13
|
+
return () => {
|
|
14
|
+
for (const { target, type } of targets) target.removeEventListener(type, callback);
|
|
15
|
+
};
|
|
16
|
+
}
|
|
2
17
|
var override;
|
|
3
18
|
function defaultEnvironment() {
|
|
4
19
|
const scope = globalThis;
|
|
@@ -9,7 +24,8 @@ function defaultEnvironment() {
|
|
|
9
24
|
MediaStream: typeof scope.MediaStream === "function" ? scope.MediaStream : void 0,
|
|
10
25
|
setTimeout: (callback, ms) => globalThis.setTimeout(callback, ms),
|
|
11
26
|
clearTimeout: (handle) => globalThis.clearTimeout(handle),
|
|
12
|
-
now: () => Date.now()
|
|
27
|
+
now: () => Date.now(),
|
|
28
|
+
onNetworkChange: subscribeNetworkChange
|
|
13
29
|
};
|
|
14
30
|
}
|
|
15
31
|
function currentEnvironment() {
|
|
@@ -37,6 +53,9 @@ var MAX_ICE_SERVER_URLS = 4;
|
|
|
37
53
|
var MAX_ICE_SERVER_URL_LENGTH = 256;
|
|
38
54
|
var MAX_ICE_SERVER_USERNAME_LENGTH = 256;
|
|
39
55
|
var MAX_ICE_SERVER_CREDENTIAL_LENGTH = 256;
|
|
56
|
+
var MAX_QUALITY_CODEC_LENGTH = 64;
|
|
57
|
+
var MAX_QUALITY_CANDIDATE_TYPE_LENGTH = 32;
|
|
58
|
+
var MAX_QUALITY_FRAME_DIMENSION = 16384;
|
|
40
59
|
|
|
41
60
|
// ../signaling-protocol/dist/rejection.js
|
|
42
61
|
var REJECTION_CODES = Object.freeze([
|
|
@@ -185,6 +204,7 @@ var CATALOG = {
|
|
|
185
204
|
"webrtc.connected": { family: "webrtc", direction: "client_to_server" },
|
|
186
205
|
"webrtc.disconnected": { family: "webrtc", direction: "client_to_server" },
|
|
187
206
|
"webrtc.failed": { family: "webrtc", direction: "client_to_server" },
|
|
207
|
+
"webrtc.quality_sample": { family: "webrtc", direction: "client_to_server" },
|
|
188
208
|
"webrtc.ice_restart": { family: "webrtc", direction: "both" },
|
|
189
209
|
"webrtc.ice_restart_result": { family: "webrtc", direction: "client_to_server" },
|
|
190
210
|
"media.camera.started": { family: "media", direction: "both" },
|
|
@@ -417,6 +437,99 @@ function sessionWelcome(p) {
|
|
|
417
437
|
}
|
|
418
438
|
return value;
|
|
419
439
|
}
|
|
440
|
+
function optionalNonNegativeNumber(obj, key, path, value) {
|
|
441
|
+
if (!hasOwn(obj, key))
|
|
442
|
+
return;
|
|
443
|
+
const raw = obj[key];
|
|
444
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
445
|
+
fail("wrong_type", path);
|
|
446
|
+
if (raw < 0)
|
|
447
|
+
fail("out_of_range", path);
|
|
448
|
+
value[key] = raw;
|
|
449
|
+
}
|
|
450
|
+
function optionalUnitInterval(obj, key, path, value) {
|
|
451
|
+
if (!hasOwn(obj, key))
|
|
452
|
+
return;
|
|
453
|
+
const raw = obj[key];
|
|
454
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
455
|
+
fail("wrong_type", path);
|
|
456
|
+
if (raw < 0 || raw > 1)
|
|
457
|
+
fail("out_of_range", path);
|
|
458
|
+
value[key] = raw;
|
|
459
|
+
}
|
|
460
|
+
function optionalNonNegativeInt(obj, key, path, value) {
|
|
461
|
+
if (!hasOwn(obj, key))
|
|
462
|
+
return;
|
|
463
|
+
const raw = obj[key];
|
|
464
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
465
|
+
fail("wrong_type", path);
|
|
466
|
+
if (!Number.isInteger(raw) || raw < 0)
|
|
467
|
+
fail("out_of_range", path);
|
|
468
|
+
value[key] = raw;
|
|
469
|
+
}
|
|
470
|
+
function optionalPositiveInt(obj, key, path, max, value) {
|
|
471
|
+
if (!hasOwn(obj, key))
|
|
472
|
+
return;
|
|
473
|
+
const raw = obj[key];
|
|
474
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
475
|
+
fail("wrong_type", path);
|
|
476
|
+
if (!Number.isInteger(raw) || raw < 1 || raw > max)
|
|
477
|
+
fail("out_of_range", path);
|
|
478
|
+
value[key] = raw;
|
|
479
|
+
}
|
|
480
|
+
function looksLikeSdpOrCandidateDump(value) {
|
|
481
|
+
if (value.includes("\r\n") || value.includes("\n"))
|
|
482
|
+
return true;
|
|
483
|
+
if (/^v=0/i.test(value))
|
|
484
|
+
return true;
|
|
485
|
+
if (/^candidate:/i.test(value))
|
|
486
|
+
return true;
|
|
487
|
+
if (/\bIN IP[46]\b/i.test(value))
|
|
488
|
+
return true;
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
function optionalShortToken(obj, key, path, maxLength, value) {
|
|
492
|
+
if (!hasOwn(obj, key))
|
|
493
|
+
return;
|
|
494
|
+
const raw = obj[key];
|
|
495
|
+
if (typeof raw !== "string")
|
|
496
|
+
fail("wrong_type", path);
|
|
497
|
+
if (raw.length === 0 || raw.length > maxLength)
|
|
498
|
+
fail(raw.length === 0 ? "invalid_format" : "too_long", path);
|
|
499
|
+
if (looksLikeSdpOrCandidateDump(raw))
|
|
500
|
+
fail("invalid_format", path);
|
|
501
|
+
if (!/^[a-zA-Z0-9._+/-]+$/.test(raw))
|
|
502
|
+
fail("invalid_format", path);
|
|
503
|
+
value[key] = raw;
|
|
504
|
+
}
|
|
505
|
+
var qualitySample = (p, ctx) => {
|
|
506
|
+
const value = {
|
|
507
|
+
...peer(p, ctx),
|
|
508
|
+
connectionState: reasonCode(p, "connectionState", "payload.connectionState"),
|
|
509
|
+
sampledAt: timestamp(p, "sampledAt", "payload.sampledAt")
|
|
510
|
+
};
|
|
511
|
+
optionalNonNegativeNumber(p, "rttMs", "payload.rttMs", value);
|
|
512
|
+
optionalNonNegativeNumber(p, "jitterMs", "payload.jitterMs", value);
|
|
513
|
+
optionalUnitInterval(p, "packetLossRatio", "payload.packetLossRatio", value);
|
|
514
|
+
optionalNonNegativeNumber(p, "sendBitrateBps", "payload.sendBitrateBps", value);
|
|
515
|
+
optionalNonNegativeNumber(p, "receiveBitrateBps", "payload.receiveBitrateBps", value);
|
|
516
|
+
optionalShortToken(p, "iceLocalCandidateType", "payload.iceLocalCandidateType", MAX_QUALITY_CANDIDATE_TYPE_LENGTH, value);
|
|
517
|
+
optionalShortToken(p, "iceRemoteCandidateType", "payload.iceRemoteCandidateType", MAX_QUALITY_CANDIDATE_TYPE_LENGTH, value);
|
|
518
|
+
for (const key of ["path", "localPath", "remotePath"]) {
|
|
519
|
+
if (hasOwn(p, key))
|
|
520
|
+
value[key] = reasonCode(p, key, `payload.${key}`);
|
|
521
|
+
}
|
|
522
|
+
optionalNonNegativeInt(p, "framesDropped", "payload.framesDropped", value);
|
|
523
|
+
optionalPositiveInt(p, "frameWidth", "payload.frameWidth", MAX_QUALITY_FRAME_DIMENSION, value);
|
|
524
|
+
optionalPositiveInt(p, "frameHeight", "payload.frameHeight", MAX_QUALITY_FRAME_DIMENSION, value);
|
|
525
|
+
optionalShortToken(p, "codec", "payload.codec", MAX_QUALITY_CODEC_LENGTH, value);
|
|
526
|
+
if (hasOwn(p, "incomplete")) {
|
|
527
|
+
if (typeof p.incomplete !== "boolean")
|
|
528
|
+
fail("wrong_type", "payload.incomplete");
|
|
529
|
+
value.incomplete = p.incomplete;
|
|
530
|
+
}
|
|
531
|
+
return value;
|
|
532
|
+
};
|
|
420
533
|
var VALIDATORS = {
|
|
421
534
|
"participant.join": empty,
|
|
422
535
|
"participant.joined": (p) => optionalDisplayName(p, { role: role(p) }),
|
|
@@ -442,6 +555,7 @@ var VALIDATORS = {
|
|
|
442
555
|
...peer(p, ctx),
|
|
443
556
|
reason: reasonCode(p, "reason", "payload.reason")
|
|
444
557
|
}),
|
|
558
|
+
"webrtc.quality_sample": qualitySample,
|
|
445
559
|
"webrtc.ice_restart": (p, ctx) => ({
|
|
446
560
|
targetParticipantId: participantRef(p, "targetParticipantId", "payload.targetParticipantId", ctx, false)
|
|
447
561
|
}),
|
|
@@ -948,10 +1062,14 @@ var LocalMediaController = class {
|
|
|
948
1062
|
throw mapped;
|
|
949
1063
|
}
|
|
950
1064
|
}
|
|
951
|
-
/**
|
|
1065
|
+
/**
|
|
1066
|
+
* A capture the browser or device ended. A live camera or unmuted microphone continues on another listed
|
|
1067
|
+
* device of that kind when one can be opened. Otherwise the kind turns off and is announced. Screen share
|
|
1068
|
+
* always turns off. A muted microphone is dropped and is not replaced.
|
|
1069
|
+
*/
|
|
952
1070
|
#watchEnded(kind, stream) {
|
|
953
1071
|
const onEnded = () => {
|
|
954
|
-
if (this.#streams[kind] !== stream) return;
|
|
1072
|
+
if (this.#released || this.#streams[kind] !== stream) return;
|
|
955
1073
|
if (!this.#state[kind]) {
|
|
956
1074
|
if (kind === "microphone") {
|
|
957
1075
|
this.#streams.microphone = null;
|
|
@@ -959,11 +1077,78 @@ var LocalMediaController = class {
|
|
|
959
1077
|
}
|
|
960
1078
|
return;
|
|
961
1079
|
}
|
|
962
|
-
|
|
963
|
-
|
|
1080
|
+
if (kind === "screen") {
|
|
1081
|
+
this.#log.info("screen share ended by the browser", { kind });
|
|
1082
|
+
this.set(kind, false).catch(() => void 0);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
const generation = this.#generation;
|
|
1086
|
+
const scheduled = this.#tails[kind].then(() => this.#continueOnAnotherDevice(kind, stream, generation));
|
|
1087
|
+
this.#tails[kind] = scheduled.then(
|
|
1088
|
+
() => void 0,
|
|
1089
|
+
() => void 0
|
|
1090
|
+
);
|
|
964
1091
|
};
|
|
965
1092
|
for (const track of stream.getTracks()) track.addEventListener("ended", onEnded, { once: true });
|
|
966
1093
|
}
|
|
1094
|
+
/** The capture that just ended is still the one in use, and the meeting has not been released. */
|
|
1095
|
+
#stillCurrent(kind, stream, generation) {
|
|
1096
|
+
return !this.#released && this.#generation === generation && this.#state[kind] && this.#streams[kind] === stream;
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Opens the first remaining device of `kind` and installs it the same way as a live switch. When none can be
|
|
1100
|
+
* opened, turns `kind` off. A release or a newer capture during the attempt stops the new stream and emits nothing.
|
|
1101
|
+
*/
|
|
1102
|
+
async #continueOnAnotherDevice(kind, endedStream, generation) {
|
|
1103
|
+
if (!this.#stillCurrent(kind, endedStream, generation)) return;
|
|
1104
|
+
const endedId = this.#devices[kind] ?? deviceOf(endedStream, void 0);
|
|
1105
|
+
const candidates = await this.#otherDevices(kind, endedId);
|
|
1106
|
+
if (!this.#stillCurrent(kind, endedStream, generation)) return;
|
|
1107
|
+
for (const deviceId of candidates) {
|
|
1108
|
+
if (!this.#stillCurrent(kind, endedStream, generation)) return;
|
|
1109
|
+
let stream;
|
|
1110
|
+
try {
|
|
1111
|
+
stream = await this.#capture(kind, deviceId);
|
|
1112
|
+
} catch {
|
|
1113
|
+
continue;
|
|
1114
|
+
}
|
|
1115
|
+
if (!this.#stillCurrent(kind, endedStream, generation)) {
|
|
1116
|
+
stopTracks(stream);
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
this.#streams[kind] = stream;
|
|
1120
|
+
this.#devices[kind] = deviceOf(stream, deviceId);
|
|
1121
|
+
this.#watchEnded(kind, stream);
|
|
1122
|
+
this.#hooks.changed(kind, true, stream);
|
|
1123
|
+
stopTracks(endedStream);
|
|
1124
|
+
this.#log.info("capture continued on another device", { kind, reason: "capture_fallback" });
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
if (!this.#stillCurrent(kind, endedStream, generation)) return;
|
|
1128
|
+
this.#log.info("capture ended", { kind });
|
|
1129
|
+
try {
|
|
1130
|
+
this.#turnOff(kind, true);
|
|
1131
|
+
} catch {
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
/** Listed devices of `kind`, excluding the one that just ended and any empty id. */
|
|
1135
|
+
async #otherDevices(kind, endedId) {
|
|
1136
|
+
const devices = this.#env.mediaDevices;
|
|
1137
|
+
if (typeof devices?.enumerateDevices !== "function") return [];
|
|
1138
|
+
let listed;
|
|
1139
|
+
try {
|
|
1140
|
+
listed = await devices.enumerateDevices();
|
|
1141
|
+
} catch {
|
|
1142
|
+
return [];
|
|
1143
|
+
}
|
|
1144
|
+
const wanted = kind === "camera" ? "videoinput" : "audioinput";
|
|
1145
|
+
const ids = [];
|
|
1146
|
+
for (const device of listed) {
|
|
1147
|
+
if (device.kind !== wanted || !device.deviceId || device.deviceId === endedId || ids.includes(device.deviceId)) continue;
|
|
1148
|
+
ids.push(device.deviceId);
|
|
1149
|
+
}
|
|
1150
|
+
return ids;
|
|
1151
|
+
}
|
|
967
1152
|
};
|
|
968
1153
|
|
|
969
1154
|
// src/internal/log.ts
|
|
@@ -980,7 +1165,17 @@ var ALLOWED_KEYS = /* @__PURE__ */ new Set([
|
|
|
980
1165
|
"attempt",
|
|
981
1166
|
"path",
|
|
982
1167
|
"localPath",
|
|
983
|
-
"remotePath"
|
|
1168
|
+
"remotePath",
|
|
1169
|
+
"trigger",
|
|
1170
|
+
"elapsedMs",
|
|
1171
|
+
"requestId",
|
|
1172
|
+
"correlationId",
|
|
1173
|
+
"rttMs",
|
|
1174
|
+
"jitterMs",
|
|
1175
|
+
"packetLossRatio",
|
|
1176
|
+
"sendBitrateBps",
|
|
1177
|
+
"receiveBitrateBps",
|
|
1178
|
+
"incomplete"
|
|
984
1179
|
]);
|
|
985
1180
|
var JWT_SHAPE = /[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*/;
|
|
986
1181
|
var NEGOTIATION_CONTENT = /sdp|candidate/i;
|
|
@@ -1062,6 +1257,233 @@ var SignalingOnlyMediaDelivery = class {
|
|
|
1062
1257
|
}
|
|
1063
1258
|
};
|
|
1064
1259
|
|
|
1260
|
+
// src/internal/reconnect/backoff.ts
|
|
1261
|
+
var RECONNECT_BACKOFF_BASE_MS = 500;
|
|
1262
|
+
var RECONNECT_BACKOFF_FACTOR = 2;
|
|
1263
|
+
var RECONNECT_BACKOFF_CAP_MS = 8e3;
|
|
1264
|
+
var RECONNECT_BACKOFF_JITTER = 0.2;
|
|
1265
|
+
function reconnectBackoffMs(attempt, clock = Math) {
|
|
1266
|
+
const n = Math.max(1, Math.floor(attempt));
|
|
1267
|
+
const raw = Math.min(RECONNECT_BACKOFF_CAP_MS, RECONNECT_BACKOFF_BASE_MS * RECONNECT_BACKOFF_FACTOR ** (n - 1));
|
|
1268
|
+
const jitter = 1 + (clock.random() * 2 - 1) * RECONNECT_BACKOFF_JITTER;
|
|
1269
|
+
return Math.max(0, Math.round(raw * jitter));
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// src/internal/reconnect/recoveryWindow.ts
|
|
1273
|
+
var DEFAULT_RECONNECT_WINDOW_MS = 12e4;
|
|
1274
|
+
var MIN_RECONNECT_WINDOW_MS = 1e3;
|
|
1275
|
+
var MAX_RECONNECT_WINDOW_MS = 6e5;
|
|
1276
|
+
function clampReconnectWindowMs(value) {
|
|
1277
|
+
if (value === void 0) return DEFAULT_RECONNECT_WINDOW_MS;
|
|
1278
|
+
if (typeof value !== "number" || !Number.isInteger(value) || !Number.isFinite(value)) {
|
|
1279
|
+
return DEFAULT_RECONNECT_WINDOW_MS;
|
|
1280
|
+
}
|
|
1281
|
+
if (value < MIN_RECONNECT_WINDOW_MS) return MIN_RECONNECT_WINDOW_MS;
|
|
1282
|
+
if (value > MAX_RECONNECT_WINDOW_MS) return MAX_RECONNECT_WINDOW_MS;
|
|
1283
|
+
return value;
|
|
1284
|
+
}
|
|
1285
|
+
function markRecoveryWindowStart(state, nowMs) {
|
|
1286
|
+
if (state.windowStartedAtMs === void 0) state.windowStartedAtMs = nowMs;
|
|
1287
|
+
}
|
|
1288
|
+
function resetRecoveryWindow(state) {
|
|
1289
|
+
state.windowStartedAtMs = void 0;
|
|
1290
|
+
}
|
|
1291
|
+
function isRecoveryWindowExhausted(state, nowMs) {
|
|
1292
|
+
if (state.windowStartedAtMs === void 0) return false;
|
|
1293
|
+
return nowMs - state.windowStartedAtMs >= state.windowMs;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// src/internal/reconnect/reasons.ts
|
|
1297
|
+
var REASON_CLASSES = /* @__PURE__ */ new Set([
|
|
1298
|
+
"token_refresh_failed",
|
|
1299
|
+
"admission_refused",
|
|
1300
|
+
"recovery_window_exhausted",
|
|
1301
|
+
"meeting_ended",
|
|
1302
|
+
"session_replaced",
|
|
1303
|
+
"left",
|
|
1304
|
+
"hard_failure"
|
|
1305
|
+
]);
|
|
1306
|
+
var TRIGGERS = /* @__PURE__ */ new Set(["meeting_service", "media", "network", "visibility"]);
|
|
1307
|
+
var FORBIDDEN_FIELD_KEYS = /* @__PURE__ */ new Set([
|
|
1308
|
+
"token",
|
|
1309
|
+
"joinToken",
|
|
1310
|
+
"join_token",
|
|
1311
|
+
"authorization",
|
|
1312
|
+
"Authorization",
|
|
1313
|
+
"password",
|
|
1314
|
+
"credential",
|
|
1315
|
+
"sdp",
|
|
1316
|
+
"candidate",
|
|
1317
|
+
"ice"
|
|
1318
|
+
]);
|
|
1319
|
+
function isReconnectReasonClass(value) {
|
|
1320
|
+
return REASON_CLASSES.has(value);
|
|
1321
|
+
}
|
|
1322
|
+
function isReconnectTrigger(value) {
|
|
1323
|
+
return TRIGGERS.has(value);
|
|
1324
|
+
}
|
|
1325
|
+
function reconnectLogFields(input) {
|
|
1326
|
+
const out = {};
|
|
1327
|
+
const attempt = Math.max(1, Math.floor(input.attempt));
|
|
1328
|
+
out.attempt = attempt;
|
|
1329
|
+
if (input.trigger !== void 0 && isReconnectTrigger(input.trigger)) out.trigger = input.trigger;
|
|
1330
|
+
if (typeof input.elapsedMs === "number" && Number.isFinite(input.elapsedMs) && input.elapsedMs >= 0) {
|
|
1331
|
+
out.elapsedMs = Math.floor(input.elapsedMs);
|
|
1332
|
+
}
|
|
1333
|
+
if (input.reason !== void 0 && isReconnectReasonClass(input.reason)) out.reason = input.reason;
|
|
1334
|
+
for (const key of ["meetingId", "participantId", "requestId", "correlationId"]) {
|
|
1335
|
+
const value = input[key];
|
|
1336
|
+
if (typeof value === "string" && value.length > 0) out[key] = value;
|
|
1337
|
+
}
|
|
1338
|
+
for (const key of Object.keys(input)) {
|
|
1339
|
+
if (FORBIDDEN_FIELD_KEYS.has(key)) {
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
return out;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// src/internal/reconnect/reconnectController.ts
|
|
1346
|
+
var ReconnectController = class {
|
|
1347
|
+
#env;
|
|
1348
|
+
#logger;
|
|
1349
|
+
#open;
|
|
1350
|
+
#onAbandoned;
|
|
1351
|
+
#clock;
|
|
1352
|
+
#window;
|
|
1353
|
+
#active = false;
|
|
1354
|
+
#attempt = 0;
|
|
1355
|
+
#trigger = "meeting_service";
|
|
1356
|
+
#timer;
|
|
1357
|
+
#generation = 0;
|
|
1358
|
+
#inFlight = false;
|
|
1359
|
+
constructor(deps) {
|
|
1360
|
+
this.#env = deps.env;
|
|
1361
|
+
this.#logger = deps.logger;
|
|
1362
|
+
this.#open = deps.open;
|
|
1363
|
+
this.#onAbandoned = deps.onAbandoned;
|
|
1364
|
+
this.#clock = { random: deps.random ?? (() => Math.random()) };
|
|
1365
|
+
this.#window = { windowMs: deps.windowMs, windowStartedAtMs: void 0 };
|
|
1366
|
+
}
|
|
1367
|
+
get active() {
|
|
1368
|
+
return this.#active;
|
|
1369
|
+
}
|
|
1370
|
+
get attempt() {
|
|
1371
|
+
return this.#attempt;
|
|
1372
|
+
}
|
|
1373
|
+
/**
|
|
1374
|
+
* Starts (or continues) a single active reconnect chain.
|
|
1375
|
+
* Overlapping starts while already active are ignored.
|
|
1376
|
+
*/
|
|
1377
|
+
start(trigger = "meeting_service") {
|
|
1378
|
+
if (this.#active) return;
|
|
1379
|
+
this.#active = true;
|
|
1380
|
+
this.#trigger = trigger;
|
|
1381
|
+
this.#attempt = 0;
|
|
1382
|
+
markRecoveryWindowStart(this.#window, this.#env.now());
|
|
1383
|
+
this.#scheduleNext(0);
|
|
1384
|
+
}
|
|
1385
|
+
/** Cancels further opens and clears timers. Safe to call when idle. */
|
|
1386
|
+
cancel(reason = "left") {
|
|
1387
|
+
if (!this.#active && this.#timer === void 0) return;
|
|
1388
|
+
const attempt = Math.max(1, this.#attempt);
|
|
1389
|
+
const started = this.#window.windowStartedAtMs;
|
|
1390
|
+
const elapsedMs = started === void 0 ? 0 : Math.max(0, this.#env.now() - started);
|
|
1391
|
+
this.#clearTimer();
|
|
1392
|
+
this.#generation += 1;
|
|
1393
|
+
this.#active = false;
|
|
1394
|
+
this.#inFlight = false;
|
|
1395
|
+
resetRecoveryWindow(this.#window);
|
|
1396
|
+
this.#log("info", "reconnect cancelled", {
|
|
1397
|
+
attempt,
|
|
1398
|
+
elapsedMs,
|
|
1399
|
+
reason,
|
|
1400
|
+
trigger: this.#trigger
|
|
1401
|
+
});
|
|
1402
|
+
}
|
|
1403
|
+
#scheduleNext(delayMs) {
|
|
1404
|
+
this.#clearTimer();
|
|
1405
|
+
const generation = this.#generation;
|
|
1406
|
+
const run = () => {
|
|
1407
|
+
if (generation !== this.#generation) return;
|
|
1408
|
+
void this.#runAttempt();
|
|
1409
|
+
};
|
|
1410
|
+
if (delayMs <= 0) {
|
|
1411
|
+
queueMicrotask(run);
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
this.#timer = this.#env.setTimeout(run, delayMs);
|
|
1415
|
+
}
|
|
1416
|
+
async #runAttempt() {
|
|
1417
|
+
if (!this.#active || this.#inFlight) return;
|
|
1418
|
+
const now = this.#env.now();
|
|
1419
|
+
if (isRecoveryWindowExhausted(this.#window, now)) {
|
|
1420
|
+
this.#abandon("recovery_window_exhausted");
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
this.#attempt += 1;
|
|
1424
|
+
const attempt = this.#attempt;
|
|
1425
|
+
const generation = this.#generation;
|
|
1426
|
+
this.#inFlight = true;
|
|
1427
|
+
this.#log("info", "reconnect attempt started", { attempt, trigger: this.#trigger });
|
|
1428
|
+
let result = "failed";
|
|
1429
|
+
try {
|
|
1430
|
+
result = await this.#open(attempt);
|
|
1431
|
+
} catch {
|
|
1432
|
+
result = "failed";
|
|
1433
|
+
}
|
|
1434
|
+
if (generation !== this.#generation) return;
|
|
1435
|
+
this.#inFlight = false;
|
|
1436
|
+
const started = this.#window.windowStartedAtMs;
|
|
1437
|
+
const elapsedMs = started === void 0 ? 0 : Math.max(0, this.#env.now() - started);
|
|
1438
|
+
if (result === "recovered") {
|
|
1439
|
+
this.#active = false;
|
|
1440
|
+
resetRecoveryWindow(this.#window);
|
|
1441
|
+
this.#log("info", "reconnect succeeded", { attempt, elapsedMs, trigger: this.#trigger });
|
|
1442
|
+
return;
|
|
1443
|
+
}
|
|
1444
|
+
if (result === "cancelled") {
|
|
1445
|
+
this.cancel("left");
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
if (result === "failed") {
|
|
1449
|
+
this.#abandon("hard_failure");
|
|
1450
|
+
return;
|
|
1451
|
+
}
|
|
1452
|
+
if (isRecoveryWindowExhausted(this.#window, this.#env.now())) {
|
|
1453
|
+
this.#abandon("recovery_window_exhausted");
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
const delay = reconnectBackoffMs(attempt + 1, this.#clock);
|
|
1457
|
+
this.#scheduleNext(delay);
|
|
1458
|
+
}
|
|
1459
|
+
#abandon(reason) {
|
|
1460
|
+
const attempt = Math.max(1, this.#attempt);
|
|
1461
|
+
const started = this.#window.windowStartedAtMs;
|
|
1462
|
+
const elapsedMs = started === void 0 ? 0 : Math.max(0, this.#env.now() - started);
|
|
1463
|
+
this.#clearTimer();
|
|
1464
|
+
this.#generation += 1;
|
|
1465
|
+
this.#active = false;
|
|
1466
|
+
this.#inFlight = false;
|
|
1467
|
+
resetRecoveryWindow(this.#window);
|
|
1468
|
+
this.#log("warn", "reconnect failed", { attempt, elapsedMs, reason, trigger: this.#trigger });
|
|
1469
|
+
this.#onAbandoned?.(reason);
|
|
1470
|
+
}
|
|
1471
|
+
#clearTimer() {
|
|
1472
|
+
if (this.#timer !== void 0) this.#env.clearTimeout(this.#timer);
|
|
1473
|
+
this.#timer = void 0;
|
|
1474
|
+
}
|
|
1475
|
+
#log(level, message, fields) {
|
|
1476
|
+
const logger = this.#logger;
|
|
1477
|
+
if (!logger) return;
|
|
1478
|
+
const method = logger[level];
|
|
1479
|
+
if (typeof method !== "function") return;
|
|
1480
|
+
try {
|
|
1481
|
+
method.call(logger, message, reconnectLogFields(fields));
|
|
1482
|
+
} catch {
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
};
|
|
1486
|
+
|
|
1065
1487
|
// src/internal/roster.ts
|
|
1066
1488
|
function snapshot(entry) {
|
|
1067
1489
|
const media = Object.freeze({ ...entry.media });
|
|
@@ -1131,6 +1553,11 @@ var Roster = class {
|
|
|
1131
1553
|
this.#entries.clear();
|
|
1132
1554
|
this.#cache = void 0;
|
|
1133
1555
|
}
|
|
1556
|
+
/** Clears participants and returns to the uncommitted (joining) buffer state. */
|
|
1557
|
+
reset() {
|
|
1558
|
+
this.clear();
|
|
1559
|
+
this.#committed = false;
|
|
1560
|
+
}
|
|
1134
1561
|
};
|
|
1135
1562
|
|
|
1136
1563
|
// src/internal/signalingSession.ts
|
|
@@ -1155,6 +1582,7 @@ var PEER_OUTBOUND_TYPES = Object.freeze([
|
|
|
1155
1582
|
"webrtc.connected",
|
|
1156
1583
|
"webrtc.disconnected",
|
|
1157
1584
|
"webrtc.failed",
|
|
1585
|
+
"webrtc.quality_sample",
|
|
1158
1586
|
"webrtc.ice_restart",
|
|
1159
1587
|
"webrtc.ice_restart_result"
|
|
1160
1588
|
]);
|
|
@@ -1294,6 +1722,49 @@ var SignalingSession = class {
|
|
|
1294
1722
|
}
|
|
1295
1723
|
};
|
|
1296
1724
|
|
|
1725
|
+
// src/internal/token.ts
|
|
1726
|
+
var TOKEN_EXPIRY_SKEW_MS = 3e4;
|
|
1727
|
+
var BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
1728
|
+
function decodeSegment(segment) {
|
|
1729
|
+
if (!BASE64URL.test(segment)) throw new MeetingError("join_token_invalid");
|
|
1730
|
+
const padded = segment.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(segment.length / 4) * 4, "=");
|
|
1731
|
+
const binary = atob(padded);
|
|
1732
|
+
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
1733
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
1734
|
+
}
|
|
1735
|
+
function decodeClaims(token) {
|
|
1736
|
+
const segments = token.split(".");
|
|
1737
|
+
if (segments.length !== 3) throw new MeetingError("join_token_invalid");
|
|
1738
|
+
let claims;
|
|
1739
|
+
try {
|
|
1740
|
+
claims = decodeSegment(segments[1] ?? "");
|
|
1741
|
+
} catch {
|
|
1742
|
+
throw new MeetingError("join_token_invalid");
|
|
1743
|
+
}
|
|
1744
|
+
if (typeof claims !== "object" || claims === null || Array.isArray(claims)) {
|
|
1745
|
+
throw new MeetingError("join_token_invalid");
|
|
1746
|
+
}
|
|
1747
|
+
return claims;
|
|
1748
|
+
}
|
|
1749
|
+
function decodeAddressing(token) {
|
|
1750
|
+
const claims = decodeClaims(token);
|
|
1751
|
+
const { meeting_id: meetingId, participant_id: participantId } = claims;
|
|
1752
|
+
if (!isCanonicalUlid(meetingId) || !isCanonicalUlid(participantId)) {
|
|
1753
|
+
throw new MeetingError("join_token_invalid");
|
|
1754
|
+
}
|
|
1755
|
+
return { meetingId, participantId };
|
|
1756
|
+
}
|
|
1757
|
+
function isJoinTokenUsable(token, nowMs, skewMs = TOKEN_EXPIRY_SKEW_MS) {
|
|
1758
|
+
try {
|
|
1759
|
+
const claims = decodeClaims(token);
|
|
1760
|
+
const exp = claims.exp;
|
|
1761
|
+
if (typeof exp !== "number" || !Number.isFinite(exp)) return false;
|
|
1762
|
+
return exp * 1e3 > nowMs + skewMs;
|
|
1763
|
+
} catch {
|
|
1764
|
+
return false;
|
|
1765
|
+
}
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1297
1768
|
// src/internal/engine.ts
|
|
1298
1769
|
var LEAVE_FALLBACK_MS = 5e3;
|
|
1299
1770
|
var RESTRICTED_PENDING_MS = 2e3;
|
|
@@ -1319,6 +1790,9 @@ var MeetingEngineImpl = class {
|
|
|
1319
1790
|
#addressing;
|
|
1320
1791
|
#env;
|
|
1321
1792
|
#createDelivery;
|
|
1793
|
+
#getJoinToken;
|
|
1794
|
+
#reconnectWindowMs;
|
|
1795
|
+
#logger;
|
|
1322
1796
|
#delivery = new SignalingOnlyMediaDelivery();
|
|
1323
1797
|
#log;
|
|
1324
1798
|
#emitter;
|
|
@@ -1337,6 +1811,12 @@ var MeetingEngineImpl = class {
|
|
|
1337
1811
|
#endMeeting;
|
|
1338
1812
|
#endMeetingTimer;
|
|
1339
1813
|
#pendingRestricted;
|
|
1814
|
+
#reconnect;
|
|
1815
|
+
#reconnectOpen;
|
|
1816
|
+
#reconnectOutcome;
|
|
1817
|
+
#preRecoveryParticipants;
|
|
1818
|
+
#pendingAbandonReason;
|
|
1819
|
+
#onVisibility;
|
|
1340
1820
|
constructor(config) {
|
|
1341
1821
|
this.#token = config.token;
|
|
1342
1822
|
this.#realtimeUrl = config.realtimeUrl;
|
|
@@ -1344,6 +1824,9 @@ var MeetingEngineImpl = class {
|
|
|
1344
1824
|
this.#addressing = config.addressing;
|
|
1345
1825
|
this.#env = config.env;
|
|
1346
1826
|
this.#createDelivery = config.createMediaDelivery;
|
|
1827
|
+
this.#getJoinToken = config.getJoinToken;
|
|
1828
|
+
this.#reconnectWindowMs = config.reconnectWindowMs ?? 12e4;
|
|
1829
|
+
this.#logger = config.logger;
|
|
1347
1830
|
this.#log = new Log(config.logger);
|
|
1348
1831
|
this.#emitter = new Emitter(this.#log);
|
|
1349
1832
|
this.#media = new LocalMediaController(
|
|
@@ -1357,6 +1840,7 @@ var MeetingEngineImpl = class {
|
|
|
1357
1840
|
},
|
|
1358
1841
|
this.#log
|
|
1359
1842
|
);
|
|
1843
|
+
this.#installVisibilityListener();
|
|
1360
1844
|
}
|
|
1361
1845
|
get state() {
|
|
1362
1846
|
return this.#state;
|
|
@@ -1383,27 +1867,13 @@ var MeetingEngineImpl = class {
|
|
|
1383
1867
|
if (this.#state === "closed") return Promise.reject(new MeetingError("engine_closed"));
|
|
1384
1868
|
if (this.#state !== "idle") return Promise.reject(new MeetingError("already_joined"));
|
|
1385
1869
|
const token = this.#token;
|
|
1386
|
-
this.#token = void 0;
|
|
1387
1870
|
if (!this.#env.WebSocket || token === void 0) return Promise.reject(new MeetingError("unsupported_environment"));
|
|
1388
1871
|
const join = deferred();
|
|
1389
1872
|
this.#join = join;
|
|
1390
1873
|
this.#setState("connecting");
|
|
1391
1874
|
if (!this.#is("connecting")) return join.promise;
|
|
1392
1875
|
this.#joinTimer = this.#env.setTimeout(() => this.#failJoin(new MeetingError("join_timeout")), this.#joinTimeoutMs);
|
|
1393
|
-
|
|
1394
|
-
env: this.#env,
|
|
1395
|
-
realtimeUrl: this.#realtimeUrl,
|
|
1396
|
-
token,
|
|
1397
|
-
addressing: this.#addressing,
|
|
1398
|
-
log: this.#log,
|
|
1399
|
-
callbacks: {
|
|
1400
|
-
onMessage: (message) => this.#onMessage(message),
|
|
1401
|
-
onProtocolError: () => this.#onProtocolError(),
|
|
1402
|
-
onClose: (code) => this.#onClose(session, code)
|
|
1403
|
-
}
|
|
1404
|
-
});
|
|
1405
|
-
this.#session = session;
|
|
1406
|
-
session.open();
|
|
1876
|
+
this.#openSession(token);
|
|
1407
1877
|
return join.promise;
|
|
1408
1878
|
}
|
|
1409
1879
|
leave() {
|
|
@@ -1417,6 +1887,10 @@ var MeetingEngineImpl = class {
|
|
|
1417
1887
|
case "joining":
|
|
1418
1888
|
this.#failJoin(new MeetingError("join_cancelled"));
|
|
1419
1889
|
return Promise.resolve();
|
|
1890
|
+
case "reconnecting":
|
|
1891
|
+
this.#cancelReconnect("left");
|
|
1892
|
+
this.#finish("left");
|
|
1893
|
+
return Promise.resolve();
|
|
1420
1894
|
case "joined":
|
|
1421
1895
|
break;
|
|
1422
1896
|
}
|
|
@@ -1476,9 +1950,11 @@ var MeetingEngineImpl = class {
|
|
|
1476
1950
|
dispose() {
|
|
1477
1951
|
if (this.#disposed) return;
|
|
1478
1952
|
this.#disposed = true;
|
|
1953
|
+
this.#removeVisibilityListener();
|
|
1479
1954
|
this.#emitter.clear();
|
|
1480
1955
|
const end = this.#endMeeting;
|
|
1481
1956
|
this.#endMeeting = void 0;
|
|
1957
|
+
this.#cancelReconnect("hard_failure");
|
|
1482
1958
|
this.#teardown();
|
|
1483
1959
|
this.#state = "closed";
|
|
1484
1960
|
const join = this.#join;
|
|
@@ -1529,34 +2005,45 @@ var MeetingEngineImpl = class {
|
|
|
1529
2005
|
const isSelf = from === this.#addressing.participantId;
|
|
1530
2006
|
switch (message.type) {
|
|
1531
2007
|
case "session.authenticated": {
|
|
1532
|
-
if (state
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
2008
|
+
if (state === "connecting") {
|
|
2009
|
+
this.#role = message.payload.role;
|
|
2010
|
+
this.#setState("joining");
|
|
2011
|
+
if (!this.#is("joining")) return;
|
|
2012
|
+
if (!this.#session?.send("participant.join")) this.#failJoin(new MeetingError("connection_failed"));
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
if (state === "reconnecting") {
|
|
2016
|
+
this.#role = message.payload.role;
|
|
2017
|
+
if (!this.#session?.send("participant.join")) this.#failReconnectOpen(new MeetingError("connection_failed"));
|
|
2018
|
+
}
|
|
1537
2019
|
return;
|
|
1538
2020
|
}
|
|
1539
2021
|
case "session.welcome": {
|
|
1540
|
-
if (state
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
2022
|
+
if (state === "joining") {
|
|
2023
|
+
this.#clearTimer("join");
|
|
2024
|
+
this.#roster.commit();
|
|
2025
|
+
const payload = message.payload;
|
|
2026
|
+
const self = {
|
|
2027
|
+
id: this.#addressing.participantId,
|
|
2028
|
+
role: payload.role,
|
|
2029
|
+
joinedAt: payload.joinedAt
|
|
2030
|
+
};
|
|
2031
|
+
if (payload.displayName !== void 0) self.displayName = payload.displayName;
|
|
2032
|
+
this.#self = Object.freeze(self);
|
|
2033
|
+
this.#role = payload.role;
|
|
2034
|
+
const join = this.#join;
|
|
2035
|
+
this.#join = void 0;
|
|
2036
|
+
this.#setState("joined");
|
|
2037
|
+
this.#emit("joined", { self: this.#self, participants: this.#roster.list() });
|
|
2038
|
+
join?.resolve();
|
|
2039
|
+
if (this.#is("joined")) {
|
|
2040
|
+
const expiresAt = payload.iceServersExpireAt !== void 0 ? Date.parse(payload.iceServersExpireAt) : NaN;
|
|
2041
|
+
this.#startDelivery(payload.mediaMode, payload.iceServers ?? [], Number.isFinite(expiresAt) ? expiresAt : null);
|
|
2042
|
+
}
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
if (state === "reconnecting") {
|
|
2046
|
+
this.#completeReconnectWelcome(message);
|
|
1560
2047
|
}
|
|
1561
2048
|
return;
|
|
1562
2049
|
}
|
|
@@ -1572,6 +2059,7 @@ var MeetingEngineImpl = class {
|
|
|
1572
2059
|
case "participant.joined": {
|
|
1573
2060
|
if (isSelf || state === "connecting") return;
|
|
1574
2061
|
const result = this.#roster.upsertJoined(from, message.payload.role, message.payload.displayName);
|
|
2062
|
+
if (state === "reconnecting") return;
|
|
1575
2063
|
const participant = this.#roster.get(from);
|
|
1576
2064
|
if (result === "added" && participant) {
|
|
1577
2065
|
this.#emit("participant_joined", { participant });
|
|
@@ -1581,7 +2069,7 @@ var MeetingEngineImpl = class {
|
|
|
1581
2069
|
return;
|
|
1582
2070
|
}
|
|
1583
2071
|
case "participant.left": {
|
|
1584
|
-
if (isSelf || state === "connecting") return;
|
|
2072
|
+
if (isSelf || state === "connecting" || state === "reconnecting") return;
|
|
1585
2073
|
if (this.#roster.remove(from) && this.#roster.committed) {
|
|
1586
2074
|
this.#delivery.peerLeft(from);
|
|
1587
2075
|
this.#emit("participant_left", { participantId: from, reason: mapLeftReason(message.payload.reason) });
|
|
@@ -1590,6 +2078,11 @@ var MeetingEngineImpl = class {
|
|
|
1590
2078
|
}
|
|
1591
2079
|
case "participant.reconnected": {
|
|
1592
2080
|
if (isSelf || !this.#roster.committed) return;
|
|
2081
|
+
if (!this.#roster.get(from)) {
|
|
2082
|
+
this.#roster.upsertJoined(from, "participant");
|
|
2083
|
+
const restored = this.#roster.get(from);
|
|
2084
|
+
if (restored && this.#is("joined")) this.#delivery.peerPresent(from, restored.media);
|
|
2085
|
+
}
|
|
1593
2086
|
this.#delivery.peerReconnected(from);
|
|
1594
2087
|
this.#emit("participant_reconnected", { participantId: from });
|
|
1595
2088
|
return;
|
|
@@ -1612,20 +2105,23 @@ var MeetingEngineImpl = class {
|
|
|
1612
2105
|
case "meeting.terminated": {
|
|
1613
2106
|
this.#log.info("meeting ended", { messageType: message.type });
|
|
1614
2107
|
if (state === "connecting" || state === "joining") this.#failJoin(new MeetingError("meeting_ended"));
|
|
1615
|
-
else
|
|
2108
|
+
else {
|
|
2109
|
+
this.#cancelReconnect("meeting_ended");
|
|
2110
|
+
this.#finish("meeting_ended");
|
|
2111
|
+
}
|
|
1616
2112
|
return;
|
|
1617
2113
|
}
|
|
1618
2114
|
case "session.rejected": {
|
|
1619
2115
|
const payload = message.payload;
|
|
1620
2116
|
const isEndMeetingRejection = payload.ref_type === "meeting.end" || payload.refType === "meeting.end" || this.#endMeeting !== void 0;
|
|
1621
2117
|
if (this.#endMeeting && isEndMeetingRejection) {
|
|
1622
|
-
const
|
|
2118
|
+
const pending = this.#endMeeting;
|
|
1623
2119
|
this.#clearEndMeetingTimer();
|
|
1624
2120
|
this.#endMeeting = void 0;
|
|
1625
2121
|
let mappedCode = "service_unavailable";
|
|
1626
2122
|
if (payload.code === "role_not_allowed") mappedCode = "not_permitted";
|
|
1627
2123
|
else if (payload.code === "rate_limited") mappedCode = "rate_limited";
|
|
1628
|
-
|
|
2124
|
+
pending.reject(new MeetingError(mappedCode));
|
|
1629
2125
|
return;
|
|
1630
2126
|
}
|
|
1631
2127
|
this.#onRejected(payload.code);
|
|
@@ -1681,6 +2177,13 @@ var MeetingEngineImpl = class {
|
|
|
1681
2177
|
this.#failJoin(error);
|
|
1682
2178
|
return;
|
|
1683
2179
|
}
|
|
2180
|
+
if (this.#state === "reconnecting" && this.#reconnectOpen) {
|
|
2181
|
+
const session = this.#session;
|
|
2182
|
+
this.#session = void 0;
|
|
2183
|
+
session?.close();
|
|
2184
|
+
this.#failReconnectOpen(error, error.retryable);
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
1684
2187
|
if (code === "role_not_allowed") {
|
|
1685
2188
|
const pending = this.#pendingRestricted;
|
|
1686
2189
|
this.#clearPendingRestricted();
|
|
@@ -1706,9 +2209,35 @@ var MeetingEngineImpl = class {
|
|
|
1706
2209
|
}
|
|
1707
2210
|
case "joined": {
|
|
1708
2211
|
const { reason, error } = mapClose(code, "after_joined");
|
|
2212
|
+
if (reason === "left" || reason === "meeting_ended" || reason === "session_replaced") {
|
|
2213
|
+
this.#finish(reason, error);
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
if (this.#isRecoverableClose(reason, error)) {
|
|
2217
|
+
this.#beginRecovery("meeting_service");
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
1709
2220
|
this.#finish(reason, error);
|
|
1710
2221
|
return;
|
|
1711
2222
|
}
|
|
2223
|
+
case "reconnecting": {
|
|
2224
|
+
const { reason, error } = mapClose(code, "after_joined");
|
|
2225
|
+
if (reason === "meeting_ended" || reason === "session_replaced") {
|
|
2226
|
+
this.#cancelReconnect(reason === "meeting_ended" ? "meeting_ended" : "session_replaced");
|
|
2227
|
+
this.#finish(reason, error);
|
|
2228
|
+
return;
|
|
2229
|
+
}
|
|
2230
|
+
if (reason === "left") {
|
|
2231
|
+
this.#cancelReconnect("left");
|
|
2232
|
+
this.#finish("left");
|
|
2233
|
+
return;
|
|
2234
|
+
}
|
|
2235
|
+
const tokenFailure = error?.code === "join_token_expired" || error?.code === "join_token_invalid";
|
|
2236
|
+
if (tokenFailure) this.#token = void 0;
|
|
2237
|
+
const retry = tokenFailure || this.#isRecoverableClose(reason, error);
|
|
2238
|
+
this.#failReconnectOpen(error ?? new MeetingError("connection_lost"), retry);
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
1712
2241
|
case "leaving":
|
|
1713
2242
|
this.#finish("left");
|
|
1714
2243
|
return;
|
|
@@ -1730,6 +2259,9 @@ var MeetingEngineImpl = class {
|
|
|
1730
2259
|
if (this.#state === "closed") return;
|
|
1731
2260
|
const end = this.#endMeeting;
|
|
1732
2261
|
this.#endMeeting = void 0;
|
|
2262
|
+
this.#cancelReconnect(
|
|
2263
|
+
reason === "meeting_ended" ? "meeting_ended" : reason === "session_replaced" ? "session_replaced" : reason === "left" ? "left" : "hard_failure"
|
|
2264
|
+
);
|
|
1733
2265
|
this.#teardown();
|
|
1734
2266
|
this.#setState("closed");
|
|
1735
2267
|
this.#emit("closed", error ? { reason, error } : { reason });
|
|
@@ -1750,6 +2282,222 @@ var MeetingEngineImpl = class {
|
|
|
1750
2282
|
this.#session = void 0;
|
|
1751
2283
|
session?.close();
|
|
1752
2284
|
this.#roster.clear();
|
|
2285
|
+
this.#reconnectOpen = void 0;
|
|
2286
|
+
this.#reconnectOutcome = void 0;
|
|
2287
|
+
this.#preRecoveryParticipants = void 0;
|
|
2288
|
+
}
|
|
2289
|
+
#isRecoverableClose(reason, error) {
|
|
2290
|
+
if (reason === "connection_lost") return true;
|
|
2291
|
+
if (reason === "error" && error?.code === "service_unavailable") return true;
|
|
2292
|
+
return false;
|
|
2293
|
+
}
|
|
2294
|
+
#beginRecovery(trigger) {
|
|
2295
|
+
if (this.#state !== "joined" && this.#state !== "reconnecting") return;
|
|
2296
|
+
if (this.#endMeeting) {
|
|
2297
|
+
const end = this.#endMeeting;
|
|
2298
|
+
this.#endMeeting = void 0;
|
|
2299
|
+
this.#clearEndMeetingTimer();
|
|
2300
|
+
end.reject(new MeetingError("connection_lost"));
|
|
2301
|
+
}
|
|
2302
|
+
if (this.#state === "joined") this.#preRecoveryParticipants = this.#roster.list();
|
|
2303
|
+
this.#pauseDeliveryForRecovery();
|
|
2304
|
+
this.#setState("reconnecting");
|
|
2305
|
+
if (!this.#is("reconnecting")) return;
|
|
2306
|
+
if (!this.#reconnect) {
|
|
2307
|
+
this.#reconnect = new ReconnectController({
|
|
2308
|
+
env: this.#env,
|
|
2309
|
+
logger: this.#logger,
|
|
2310
|
+
windowMs: this.#reconnectWindowMs,
|
|
2311
|
+
open: () => this.#reconnectOpenAttempt(),
|
|
2312
|
+
onAbandoned: (reason) => this.#onReconnectAbandoned(reason)
|
|
2313
|
+
});
|
|
2314
|
+
}
|
|
2315
|
+
this.#reconnect.start(trigger);
|
|
2316
|
+
}
|
|
2317
|
+
#pauseDeliveryForRecovery() {
|
|
2318
|
+
const delivery = this.#delivery;
|
|
2319
|
+
for (const participant of this.#roster.list()) {
|
|
2320
|
+
const media = delivery.remoteMedia(participant.id);
|
|
2321
|
+
for (const kind of MEDIA_KINDS) {
|
|
2322
|
+
if (media[kind]) this.#emit("remote_media_changed", { participantId: participant.id, kind, stream: null });
|
|
2323
|
+
}
|
|
2324
|
+
if (delivery.connectionStatus(participant.id) !== null) {
|
|
2325
|
+
this.#emit("media_connection_changed", { participantId: participant.id, status: null });
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
delivery.close();
|
|
2329
|
+
this.#delivery = new SignalingOnlyMediaDelivery();
|
|
2330
|
+
}
|
|
2331
|
+
async #reconnectOpenAttempt() {
|
|
2332
|
+
if (this.#state !== "reconnecting" || this.#disposed) return "cancelled";
|
|
2333
|
+
let token;
|
|
2334
|
+
try {
|
|
2335
|
+
token = await this.#resolveReconnectToken();
|
|
2336
|
+
} catch (error) {
|
|
2337
|
+
const meetingError = error instanceof MeetingError ? error : new MeetingError("join_token_invalid");
|
|
2338
|
+
this.#log.warn("reconnect token refresh failed", { code: meetingError.code });
|
|
2339
|
+
this.#pendingAbandonReason = "token_refresh_failed";
|
|
2340
|
+
return "failed";
|
|
2341
|
+
}
|
|
2342
|
+
this.#roster.reset();
|
|
2343
|
+
this.#reconnectOutcome = void 0;
|
|
2344
|
+
const open = deferred();
|
|
2345
|
+
this.#reconnectOpen = open;
|
|
2346
|
+
const attemptTimer = this.#env.setTimeout(() => {
|
|
2347
|
+
if (this.#reconnectOpen !== open) return;
|
|
2348
|
+
const session = this.#session;
|
|
2349
|
+
this.#session = void 0;
|
|
2350
|
+
session?.close();
|
|
2351
|
+
this.#failReconnectOpen(new MeetingError("join_timeout"), true);
|
|
2352
|
+
}, this.#joinTimeoutMs);
|
|
2353
|
+
this.#openSession(token);
|
|
2354
|
+
try {
|
|
2355
|
+
await open.promise;
|
|
2356
|
+
} catch {
|
|
2357
|
+
} finally {
|
|
2358
|
+
this.#env.clearTimeout(attemptTimer);
|
|
2359
|
+
}
|
|
2360
|
+
if (this.#state !== "reconnecting" && this.#state !== "joined") {
|
|
2361
|
+
return this.#reconnectOutcome ?? "cancelled";
|
|
2362
|
+
}
|
|
2363
|
+
return this.#reconnectOutcome ?? "failed";
|
|
2364
|
+
}
|
|
2365
|
+
async #resolveReconnectToken() {
|
|
2366
|
+
const current = this.#token;
|
|
2367
|
+
if (current !== void 0 && isJoinTokenUsable(current, this.#env.now())) return current;
|
|
2368
|
+
const refresh = this.#getJoinToken;
|
|
2369
|
+
if (!refresh) throw new MeetingError("join_token_expired");
|
|
2370
|
+
const next = await refresh();
|
|
2371
|
+
if (typeof next !== "string" || next.length === 0) throw new MeetingError("join_token_invalid");
|
|
2372
|
+
const addressing = decodeAddressing(next);
|
|
2373
|
+
if (addressing.meetingId !== this.#addressing.meetingId || addressing.participantId !== this.#addressing.participantId) {
|
|
2374
|
+
throw new MeetingError("join_token_invalid");
|
|
2375
|
+
}
|
|
2376
|
+
this.#token = next;
|
|
2377
|
+
return next;
|
|
2378
|
+
}
|
|
2379
|
+
#openSession(token) {
|
|
2380
|
+
const session = new SignalingSession({
|
|
2381
|
+
env: this.#env,
|
|
2382
|
+
realtimeUrl: this.#realtimeUrl,
|
|
2383
|
+
token,
|
|
2384
|
+
addressing: this.#addressing,
|
|
2385
|
+
log: this.#log,
|
|
2386
|
+
callbacks: {
|
|
2387
|
+
onMessage: (message) => this.#onMessage(message),
|
|
2388
|
+
onProtocolError: () => this.#onProtocolError(),
|
|
2389
|
+
onClose: (code) => this.#onClose(session, code)
|
|
2390
|
+
}
|
|
2391
|
+
});
|
|
2392
|
+
this.#session = session;
|
|
2393
|
+
session.open();
|
|
2394
|
+
}
|
|
2395
|
+
#completeReconnectWelcome(message) {
|
|
2396
|
+
const payload = message.payload;
|
|
2397
|
+
if (message.participantId !== this.#addressing.participantId) {
|
|
2398
|
+
this.#failReconnectOpen(new MeetingError("protocol_error"));
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
this.#roster.commit();
|
|
2402
|
+
const self = {
|
|
2403
|
+
id: this.#addressing.participantId,
|
|
2404
|
+
role: payload.role,
|
|
2405
|
+
joinedAt: payload.joinedAt
|
|
2406
|
+
};
|
|
2407
|
+
if (payload.displayName !== void 0) self.displayName = payload.displayName;
|
|
2408
|
+
this.#self = Object.freeze(self);
|
|
2409
|
+
this.#role = payload.role;
|
|
2410
|
+
this.#reconnectOutcome = "recovered";
|
|
2411
|
+
const open = this.#reconnectOpen;
|
|
2412
|
+
this.#reconnectOpen = void 0;
|
|
2413
|
+
this.#setState("joined");
|
|
2414
|
+
open?.resolve();
|
|
2415
|
+
if (!this.#is("joined")) {
|
|
2416
|
+
this.#preRecoveryParticipants = void 0;
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
this.#emitRecoveryRosterDiff();
|
|
2420
|
+
if (!this.#is("joined")) return;
|
|
2421
|
+
const expiresAt = payload.iceServersExpireAt !== void 0 ? Date.parse(payload.iceServersExpireAt) : NaN;
|
|
2422
|
+
this.#startDelivery(payload.mediaMode, payload.iceServers ?? [], Number.isFinite(expiresAt) ? expiresAt : null);
|
|
2423
|
+
}
|
|
2424
|
+
/** Announce peers who joined or left during the outage; leave survivors quiet (no join/leave flash). */
|
|
2425
|
+
#emitRecoveryRosterDiff() {
|
|
2426
|
+
const previous = this.#preRecoveryParticipants ?? [];
|
|
2427
|
+
this.#preRecoveryParticipants = void 0;
|
|
2428
|
+
const previousIds = new Set(previous.map((participant) => participant.id));
|
|
2429
|
+
const next = this.#roster.list();
|
|
2430
|
+
const nextIds = new Set(next.map((participant) => participant.id));
|
|
2431
|
+
for (const id of previousIds) {
|
|
2432
|
+
if (!nextIds.has(id)) this.#emit("participant_left", { participantId: id, reason: "disconnected" });
|
|
2433
|
+
}
|
|
2434
|
+
for (const participant of next) {
|
|
2435
|
+
if (!previousIds.has(participant.id)) this.#emit("participant_joined", { participant });
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
#failReconnectOpen(error, retry = false) {
|
|
2439
|
+
const open = this.#reconnectOpen;
|
|
2440
|
+
if (!open) return;
|
|
2441
|
+
this.#reconnectOpen = void 0;
|
|
2442
|
+
this.#reconnectOutcome = retry ? "retry" : "failed";
|
|
2443
|
+
if (!retry) this.#log.warn("reconnect open failed", { code: error.code });
|
|
2444
|
+
open.resolve();
|
|
2445
|
+
}
|
|
2446
|
+
#onReconnectAbandoned(reason) {
|
|
2447
|
+
if (this.#state === "closed") return;
|
|
2448
|
+
const actual = this.#pendingAbandonReason ?? reason;
|
|
2449
|
+
this.#pendingAbandonReason = void 0;
|
|
2450
|
+
this.#preRecoveryParticipants = void 0;
|
|
2451
|
+
this.#reconnect = void 0;
|
|
2452
|
+
if (actual === "meeting_ended") {
|
|
2453
|
+
this.#finish("meeting_ended");
|
|
2454
|
+
return;
|
|
2455
|
+
}
|
|
2456
|
+
if (actual === "session_replaced") {
|
|
2457
|
+
this.#finish("session_replaced", new MeetingError("session_replaced"));
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
if (actual === "left") {
|
|
2461
|
+
this.#finish("left");
|
|
2462
|
+
return;
|
|
2463
|
+
}
|
|
2464
|
+
const error = actual === "token_refresh_failed" ? new MeetingError("join_token_expired") : new MeetingError("connection_lost");
|
|
2465
|
+
this.#finish("connection_lost", error);
|
|
2466
|
+
}
|
|
2467
|
+
#cancelReconnect(reason) {
|
|
2468
|
+
const controller = this.#reconnect;
|
|
2469
|
+
this.#reconnect = void 0;
|
|
2470
|
+
this.#preRecoveryParticipants = void 0;
|
|
2471
|
+
if (this.#reconnectOpen) {
|
|
2472
|
+
this.#reconnectOutcome = "cancelled";
|
|
2473
|
+
const open = this.#reconnectOpen;
|
|
2474
|
+
this.#reconnectOpen = void 0;
|
|
2475
|
+
open.resolve();
|
|
2476
|
+
}
|
|
2477
|
+
controller?.cancel(reason);
|
|
2478
|
+
}
|
|
2479
|
+
#installVisibilityListener() {
|
|
2480
|
+
const doc = globalThis.document;
|
|
2481
|
+
if (!doc || typeof doc.addEventListener !== "function") return;
|
|
2482
|
+
this.#onVisibility = () => {
|
|
2483
|
+
if (doc.visibilityState !== "visible" || this.#disposed) return;
|
|
2484
|
+
if (this.#state === "reconnecting") {
|
|
2485
|
+
if (this.#reconnect && !this.#reconnect.active) this.#reconnect.start("visibility");
|
|
2486
|
+
return;
|
|
2487
|
+
}
|
|
2488
|
+
if (this.#state === "joined" && this.#session === void 0) {
|
|
2489
|
+
this.#beginRecovery("visibility");
|
|
2490
|
+
}
|
|
2491
|
+
};
|
|
2492
|
+
doc.addEventListener("visibilitychange", this.#onVisibility);
|
|
2493
|
+
}
|
|
2494
|
+
#removeVisibilityListener() {
|
|
2495
|
+
const doc = globalThis.document;
|
|
2496
|
+
const handler = this.#onVisibility;
|
|
2497
|
+
this.#onVisibility = void 0;
|
|
2498
|
+
if (doc && handler && typeof doc.removeEventListener === "function") {
|
|
2499
|
+
doc.removeEventListener("visibilitychange", handler);
|
|
2500
|
+
}
|
|
1753
2501
|
}
|
|
1754
2502
|
#clearEndMeetingTimer() {
|
|
1755
2503
|
if (this.#endMeetingTimer !== void 0) this.#env.clearTimeout(this.#endMeetingTimer);
|
|
@@ -1778,13 +2526,17 @@ var MeetingEngineImpl = class {
|
|
|
1778
2526
|
};
|
|
1779
2527
|
|
|
1780
2528
|
// src/internal/selectedPair.ts
|
|
2529
|
+
function selectedCandidatePairReport(reports) {
|
|
2530
|
+
const byId = new Map(reports.map((report) => [report.id, report]));
|
|
2531
|
+
const selectedId = reports.find((r) => r.type === "transport" && r.selectedCandidatePairId)?.selectedCandidatePairId;
|
|
2532
|
+
return (selectedId ? byId.get(selectedId) : void 0) ?? reports.find((r) => r.type === "candidate-pair" && r.state === "succeeded" && r.nominated === true);
|
|
2533
|
+
}
|
|
1781
2534
|
async function selectedPair(pc) {
|
|
1782
2535
|
const reports = await statsOf(pc);
|
|
1783
2536
|
if (!reports) return null;
|
|
1784
|
-
const
|
|
1785
|
-
const selectedId = reports.find((r) => r.type === "transport" && r.selectedCandidatePairId)?.selectedCandidatePairId;
|
|
1786
|
-
const pair = (selectedId ? byId.get(selectedId) : void 0) ?? reports.find((r) => r.type === "candidate-pair" && r.state === "succeeded" && r.nominated === true);
|
|
2537
|
+
const pair = selectedCandidatePairReport(reports);
|
|
1787
2538
|
if (!pair?.localCandidateId || !pair.remoteCandidateId) return null;
|
|
2539
|
+
const byId = new Map(reports.map((report) => [report.id, report]));
|
|
1788
2540
|
const localType = byId.get(pair.localCandidateId)?.candidateType;
|
|
1789
2541
|
const remoteType = byId.get(pair.remoteCandidateId)?.candidateType;
|
|
1790
2542
|
if (!localType || !remoteType) return null;
|
|
@@ -1817,6 +2569,179 @@ function kindOf(candidateType) {
|
|
|
1817
2569
|
return candidateType === "relay" ? "relayed" : "direct";
|
|
1818
2570
|
}
|
|
1819
2571
|
|
|
2572
|
+
// src/internal/qualitySample.ts
|
|
2573
|
+
var QUALITY_SAMPLE_INTERVAL_MS = 5e3;
|
|
2574
|
+
function finiteNumber(value) {
|
|
2575
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
2576
|
+
}
|
|
2577
|
+
function nonNegative(value) {
|
|
2578
|
+
return value !== void 0 && value >= 0 ? value : void 0;
|
|
2579
|
+
}
|
|
2580
|
+
async function collectReports(pc) {
|
|
2581
|
+
try {
|
|
2582
|
+
const collection = await pc.getStats();
|
|
2583
|
+
const reports = [];
|
|
2584
|
+
collection.forEach((report) => reports.push(report));
|
|
2585
|
+
return reports;
|
|
2586
|
+
} catch {
|
|
2587
|
+
return null;
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
function pickRttMs(reports) {
|
|
2591
|
+
const pair = selectedCandidatePairReport(reports);
|
|
2592
|
+
if (pair) {
|
|
2593
|
+
const seconds = finiteNumber(pair.currentRoundTripTime);
|
|
2594
|
+
if (seconds !== void 0) return nonNegative(seconds * 1e3);
|
|
2595
|
+
}
|
|
2596
|
+
for (const report of reports) {
|
|
2597
|
+
if (report.type === "remote-inbound-rtp") {
|
|
2598
|
+
const seconds = finiteNumber(report.roundTripTime);
|
|
2599
|
+
if (seconds !== void 0) return nonNegative(seconds * 1e3);
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
return void 0;
|
|
2603
|
+
}
|
|
2604
|
+
function pickJitterMs(reports) {
|
|
2605
|
+
for (const report of reports) {
|
|
2606
|
+
if (report.type === "inbound-rtp") {
|
|
2607
|
+
const seconds = finiteNumber(report.jitter);
|
|
2608
|
+
if (seconds !== void 0) return nonNegative(seconds * 1e3);
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
return void 0;
|
|
2612
|
+
}
|
|
2613
|
+
function pickPacketLossRatio(reports) {
|
|
2614
|
+
let lost = 0;
|
|
2615
|
+
let received = 0;
|
|
2616
|
+
let saw = false;
|
|
2617
|
+
for (const report of reports) {
|
|
2618
|
+
if (report.type !== "inbound-rtp") continue;
|
|
2619
|
+
const packetsLost = finiteNumber(report.packetsLost);
|
|
2620
|
+
const packetsReceived = finiteNumber(report.packetsReceived);
|
|
2621
|
+
if (packetsLost === void 0 && packetsReceived === void 0) continue;
|
|
2622
|
+
saw = true;
|
|
2623
|
+
lost += Math.max(0, packetsLost ?? 0);
|
|
2624
|
+
received += Math.max(0, packetsReceived ?? 0);
|
|
2625
|
+
}
|
|
2626
|
+
if (!saw) return void 0;
|
|
2627
|
+
const total = lost + received;
|
|
2628
|
+
if (total <= 0) return 0;
|
|
2629
|
+
return Math.min(1, lost / total);
|
|
2630
|
+
}
|
|
2631
|
+
function sumBytes(reports, type, key) {
|
|
2632
|
+
let total = 0;
|
|
2633
|
+
let saw = false;
|
|
2634
|
+
for (const report of reports) {
|
|
2635
|
+
if (report.type !== type) continue;
|
|
2636
|
+
const value = finiteNumber(report[key]);
|
|
2637
|
+
if (value === void 0) continue;
|
|
2638
|
+
saw = true;
|
|
2639
|
+
total += Math.max(0, value);
|
|
2640
|
+
}
|
|
2641
|
+
return saw ? total : void 0;
|
|
2642
|
+
}
|
|
2643
|
+
function bitrateBps(current, previous, elapsedMs) {
|
|
2644
|
+
if (current === void 0 || previous === void 0 || elapsedMs <= 0) return void 0;
|
|
2645
|
+
const delta = current - previous;
|
|
2646
|
+
if (delta < 0) return void 0;
|
|
2647
|
+
return delta * 8 * 1e3 / elapsedMs;
|
|
2648
|
+
}
|
|
2649
|
+
function pickFrames(reports) {
|
|
2650
|
+
let framesDropped;
|
|
2651
|
+
let frameWidth;
|
|
2652
|
+
let frameHeight;
|
|
2653
|
+
for (const report of reports) {
|
|
2654
|
+
if (report.type !== "inbound-rtp") continue;
|
|
2655
|
+
const dropped = finiteNumber(report.framesDropped);
|
|
2656
|
+
if (dropped !== void 0 && dropped >= 0) framesDropped = (framesDropped ?? 0) + dropped;
|
|
2657
|
+
const width = finiteNumber(report.frameWidth);
|
|
2658
|
+
const height = finiteNumber(report.frameHeight);
|
|
2659
|
+
if (width !== void 0 && width > 0) frameWidth = width;
|
|
2660
|
+
if (height !== void 0 && height > 0) frameHeight = height;
|
|
2661
|
+
}
|
|
2662
|
+
return {
|
|
2663
|
+
...framesDropped !== void 0 ? { framesDropped } : {},
|
|
2664
|
+
...frameWidth !== void 0 ? { frameWidth } : {},
|
|
2665
|
+
...frameHeight !== void 0 ? { frameHeight } : {}
|
|
2666
|
+
};
|
|
2667
|
+
}
|
|
2668
|
+
function isQualityCodecToken(value) {
|
|
2669
|
+
if (value.length === 0 || value.length > MAX_QUALITY_CODEC_LENGTH) return false;
|
|
2670
|
+
if (value.includes("\r\n") || value.includes("\n")) return false;
|
|
2671
|
+
if (/^v=0/i.test(value) || /^candidate:/i.test(value) || /\bIN IP[46]\b/i.test(value)) return false;
|
|
2672
|
+
return /^[a-zA-Z0-9._+/-]+$/.test(value);
|
|
2673
|
+
}
|
|
2674
|
+
function pickCodec(reports) {
|
|
2675
|
+
const byId = new Map(reports.map((report) => [report.id, report]));
|
|
2676
|
+
for (const report of reports) {
|
|
2677
|
+
if (report.type !== "inbound-rtp" && report.type !== "outbound-rtp") continue;
|
|
2678
|
+
const codecId = report.codecId;
|
|
2679
|
+
if (!codecId) continue;
|
|
2680
|
+
const mime = byId.get(codecId)?.mimeType;
|
|
2681
|
+
if (typeof mime === "string" && isQualityCodecToken(mime)) return mime;
|
|
2682
|
+
}
|
|
2683
|
+
for (const report of reports) {
|
|
2684
|
+
if (report.type === "codec" && typeof report.mimeType === "string" && isQualityCodecToken(report.mimeType)) {
|
|
2685
|
+
return report.mimeType;
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
return void 0;
|
|
2689
|
+
}
|
|
2690
|
+
async function buildQualitySample(pc, options) {
|
|
2691
|
+
const sampledAt = new Date(options.nowMs).toISOString();
|
|
2692
|
+
const base = {
|
|
2693
|
+
connectionState: options.connectionState,
|
|
2694
|
+
sampledAt
|
|
2695
|
+
};
|
|
2696
|
+
const reports = await collectReports(pc);
|
|
2697
|
+
if (!reports) {
|
|
2698
|
+
return { sample: { ...base, incomplete: true }, bytes: options.previous ?? null };
|
|
2699
|
+
}
|
|
2700
|
+
const rttMs = pickRttMs(reports);
|
|
2701
|
+
const jitterMs = pickJitterMs(reports);
|
|
2702
|
+
const packetLossRatio = pickPacketLossRatio(reports);
|
|
2703
|
+
const bytesSent = sumBytes(reports, "outbound-rtp", "bytesSent");
|
|
2704
|
+
const bytesReceived = sumBytes(reports, "inbound-rtp", "bytesReceived");
|
|
2705
|
+
const elapsedMs = options.previous ? Math.max(0, options.nowMs - options.previous.atMs) : 0;
|
|
2706
|
+
const sendBitrateBps = bitrateBps(bytesSent, options.previous?.sent, elapsedMs);
|
|
2707
|
+
const receiveBitrateBps = bitrateBps(bytesReceived, options.previous?.received, elapsedMs);
|
|
2708
|
+
const frames = pickFrames(reports);
|
|
2709
|
+
const codec = pickCodec(reports);
|
|
2710
|
+
let iceLocalCandidateType;
|
|
2711
|
+
let iceRemoteCandidateType;
|
|
2712
|
+
let pathFields = {};
|
|
2713
|
+
try {
|
|
2714
|
+
const pair = await selectedPair(pc);
|
|
2715
|
+
if (pair) {
|
|
2716
|
+
iceLocalCandidateType = pair.localType;
|
|
2717
|
+
iceRemoteCandidateType = pair.remoteType;
|
|
2718
|
+
pathFields = pathReport(pair);
|
|
2719
|
+
}
|
|
2720
|
+
} catch {
|
|
2721
|
+
}
|
|
2722
|
+
const hasCore = rttMs !== void 0 && jitterMs !== void 0 && packetLossRatio !== void 0 && sendBitrateBps !== void 0 && receiveBitrateBps !== void 0 && iceLocalCandidateType !== void 0;
|
|
2723
|
+
const sample = {
|
|
2724
|
+
...base,
|
|
2725
|
+
...rttMs !== void 0 ? { rttMs } : {},
|
|
2726
|
+
...jitterMs !== void 0 ? { jitterMs } : {},
|
|
2727
|
+
...packetLossRatio !== void 0 ? { packetLossRatio } : {},
|
|
2728
|
+
...sendBitrateBps !== void 0 ? { sendBitrateBps } : {},
|
|
2729
|
+
...receiveBitrateBps !== void 0 ? { receiveBitrateBps } : {},
|
|
2730
|
+
...iceLocalCandidateType ? { iceLocalCandidateType } : {},
|
|
2731
|
+
...iceRemoteCandidateType ? { iceRemoteCandidateType } : {},
|
|
2732
|
+
...pathFields,
|
|
2733
|
+
...frames,
|
|
2734
|
+
...codec ? { codec } : {},
|
|
2735
|
+
...!hasCore ? { incomplete: true } : {}
|
|
2736
|
+
};
|
|
2737
|
+
const bytes = bytesSent !== void 0 || bytesReceived !== void 0 ? {
|
|
2738
|
+
sent: bytesSent ?? options.previous?.sent ?? 0,
|
|
2739
|
+
received: bytesReceived ?? options.previous?.received ?? 0,
|
|
2740
|
+
atMs: options.nowMs
|
|
2741
|
+
} : options.previous ?? null;
|
|
2742
|
+
return { sample, bytes };
|
|
2743
|
+
}
|
|
2744
|
+
|
|
1820
2745
|
// src/internal/peerLink.ts
|
|
1821
2746
|
var MAX_QUEUED_CANDIDATES = 64;
|
|
1822
2747
|
var CONNECTION_TIMEOUT_MS = 3e4;
|
|
@@ -1825,6 +2750,7 @@ var SWITCH_RETRY_DELAYS_MS = [5e3, 1e4, 2e4];
|
|
|
1825
2750
|
var SWITCH_DEADLINE_MARGIN_MS = 5e3;
|
|
1826
2751
|
var SWITCH_POLL_MS = 1e3;
|
|
1827
2752
|
var SWITCH_INTERRUPTION_HOLD_MS = 5e3;
|
|
2753
|
+
var INTERRUPTION_RESTART_HOLD_MS = 3e3;
|
|
1828
2754
|
var PATH_RECHECK_DELAYS_MS = [2e3, 1e4];
|
|
1829
2755
|
var PATH_RECHECK_INTERVAL_MS = 3e4;
|
|
1830
2756
|
function copyServers(iceServers2) {
|
|
@@ -1877,8 +2803,15 @@ var PeerLink = class {
|
|
|
1877
2803
|
#switch = null;
|
|
1878
2804
|
#restarting = false;
|
|
1879
2805
|
#interruptionHold;
|
|
2806
|
+
#restartHold;
|
|
2807
|
+
/** Local candidates gathered while an offer is being created; sent after it so the other side sees it first. */
|
|
2808
|
+
#heldLocal = null;
|
|
2809
|
+
#remoteOrigin;
|
|
1880
2810
|
#lastReport = null;
|
|
1881
2811
|
#recheckTimer;
|
|
2812
|
+
#qualityTimer;
|
|
2813
|
+
#qualityBytes = null;
|
|
2814
|
+
#qualityRunning = false;
|
|
1882
2815
|
/** Throws when the connection cannot be created; the owner treats that as a negotiation failure. */
|
|
1883
2816
|
constructor(options) {
|
|
1884
2817
|
this.peerId = options.peerId;
|
|
@@ -1926,10 +2859,10 @@ var PeerLink = class {
|
|
|
1926
2859
|
this.#bindStreams();
|
|
1927
2860
|
await this.#attachAll();
|
|
1928
2861
|
const offer = await this.#pc.createOffer();
|
|
2862
|
+
this.#heldLocal = [];
|
|
1929
2863
|
await this.#pc.setLocalDescription(offer);
|
|
1930
2864
|
if (this.#closed) return;
|
|
1931
|
-
|
|
1932
|
-
this.#callbacks.sendOffer({ targetParticipantId: this.peerId, sdp: sdp2 });
|
|
2865
|
+
this.#sendOffer(this.#pc.localDescription?.sdp ?? offer.sdp ?? "");
|
|
1933
2866
|
});
|
|
1934
2867
|
}
|
|
1935
2868
|
/**
|
|
@@ -1946,6 +2879,7 @@ var PeerLink = class {
|
|
|
1946
2879
|
return;
|
|
1947
2880
|
}
|
|
1948
2881
|
this.#offerHandled = true;
|
|
2882
|
+
this.#remoteOrigin = sessionOrigin(sdp2);
|
|
1949
2883
|
this.#run(async () => {
|
|
1950
2884
|
await this.#pc.setRemoteDescription({ type: "offer", sdp: sdp2 });
|
|
1951
2885
|
this.#remoteDescriptionSet = true;
|
|
@@ -1966,6 +2900,14 @@ var PeerLink = class {
|
|
|
1966
2900
|
await this.#flushCandidates();
|
|
1967
2901
|
});
|
|
1968
2902
|
}
|
|
2903
|
+
/**
|
|
2904
|
+
* Answerer: whether `sdp` comes from a different connection than the one this link answered first, as when the
|
|
2905
|
+
* other side replaced its connection. Such an offer needs a fresh link; a restart keeps the same origin.
|
|
2906
|
+
*/
|
|
2907
|
+
startsNewSession(sdp2) {
|
|
2908
|
+
const origin = sessionOrigin(sdp2);
|
|
2909
|
+
return this.#offerHandled && this.#remoteOrigin !== void 0 && origin !== void 0 && origin !== this.#remoteOrigin;
|
|
2910
|
+
}
|
|
1969
2911
|
/** Offerer: applies the answer while an offer is outstanding; otherwise ignored. */
|
|
1970
2912
|
acceptAnswer(sdp2) {
|
|
1971
2913
|
if (this.role !== "offerer") {
|
|
@@ -1988,9 +2930,11 @@ var PeerLink = class {
|
|
|
1988
2930
|
await this.#pc.setRemoteDescription({ type: "answer", sdp: sdp2 });
|
|
1989
2931
|
} catch {
|
|
1990
2932
|
this.#restartFailed();
|
|
2933
|
+
this.#rescheduleInterruptionRestartIfNeeded();
|
|
1991
2934
|
return;
|
|
1992
2935
|
}
|
|
1993
2936
|
await this.#flushCandidates();
|
|
2937
|
+
this.#rescheduleInterruptionRestartIfNeeded();
|
|
1994
2938
|
});
|
|
1995
2939
|
}
|
|
1996
2940
|
/** Replaces the server list for future candidate gathering. The current connection is untouched. */
|
|
@@ -2033,22 +2977,32 @@ var PeerLink = class {
|
|
|
2033
2977
|
* another; a restart that already finished may predate the other side's fresh servers, so it does not count.
|
|
2034
2978
|
*/
|
|
2035
2979
|
restart(reason) {
|
|
2036
|
-
if (this.role !== "offerer" || this.#closed || this.#restarting) return;
|
|
2980
|
+
if (this.role !== "offerer" || this.#closed || this.#restarting) return false;
|
|
2037
2981
|
this.#restarting = true;
|
|
2038
2982
|
this.#log.debug("media connection restart", { participantId: this.peerId, reason });
|
|
2039
2983
|
this.#chain = this.#chain.then(async () => {
|
|
2040
2984
|
if (this.#closed) return;
|
|
2041
2985
|
try {
|
|
2042
2986
|
const offer = await this.#pc.createOffer({ iceRestart: true });
|
|
2987
|
+
this.#heldLocal = [];
|
|
2043
2988
|
await this.#pc.setLocalDescription(offer);
|
|
2044
2989
|
if (this.#closed) return;
|
|
2045
|
-
|
|
2046
|
-
this.#callbacks.sendOffer({ targetParticipantId: this.peerId, sdp: sdp2 });
|
|
2990
|
+
this.#sendOffer(this.#pc.localDescription?.sdp ?? offer.sdp ?? "");
|
|
2047
2991
|
} catch {
|
|
2992
|
+
this.#heldLocal = null;
|
|
2048
2993
|
this.#restarting = false;
|
|
2049
2994
|
this.#restartFailed();
|
|
2995
|
+
this.#rescheduleInterruptionRestartIfNeeded();
|
|
2050
2996
|
}
|
|
2051
2997
|
});
|
|
2998
|
+
return true;
|
|
2999
|
+
}
|
|
3000
|
+
/** The browser regained connectivity or changed networks: an offerer restarts an interrupted connection now. */
|
|
3001
|
+
networkChanged() {
|
|
3002
|
+
if (this.#closed || this.#status !== "interrupted" || this.role !== "offerer") return;
|
|
3003
|
+
this.#clearRestartHold();
|
|
3004
|
+
if (this.restart("network")) this.#callbacks.recoveryStarted("network");
|
|
3005
|
+
else this.#scheduleInterruptionRestart();
|
|
2052
3006
|
}
|
|
2053
3007
|
addRemoteCandidate(candidate) {
|
|
2054
3008
|
if (this.#closed) return;
|
|
@@ -2085,7 +3039,9 @@ var PeerLink = class {
|
|
|
2085
3039
|
this.#clearTimer();
|
|
2086
3040
|
this.#endSwitch();
|
|
2087
3041
|
this.#clearInterruptionHold();
|
|
3042
|
+
this.#clearRestartHold();
|
|
2088
3043
|
this.#clearRecheck();
|
|
3044
|
+
this.#stopQualitySampler();
|
|
2089
3045
|
this.#pc.onicecandidate = null;
|
|
2090
3046
|
this.#pc.onconnectionstatechange = null;
|
|
2091
3047
|
this.#pc.oniceconnectionstatechange = null;
|
|
@@ -2179,6 +3135,7 @@ var PeerLink = class {
|
|
|
2179
3135
|
if (this.#switch !== current || this.#closed) return;
|
|
2180
3136
|
this.#clearSwitchTimers(current);
|
|
2181
3137
|
this.#restarting = false;
|
|
3138
|
+
this.#rescheduleInterruptionRestartIfNeeded();
|
|
2182
3139
|
this.#log.debug("media path switch attempt failed", { participantId: this.peerId, attempt: current.attempt });
|
|
2183
3140
|
this.#callbacks.switchResult("failed");
|
|
2184
3141
|
if (this.#switch !== current || this.#closed) return;
|
|
@@ -2211,6 +3168,25 @@ var PeerLink = class {
|
|
|
2211
3168
|
if (this.#interruptionHold !== void 0) this.#env.clearTimeout(this.#interruptionHold);
|
|
2212
3169
|
this.#interruptionHold = void 0;
|
|
2213
3170
|
}
|
|
3171
|
+
#clearRestartHold() {
|
|
3172
|
+
if (this.#restartHold !== void 0) this.#env.clearTimeout(this.#restartHold);
|
|
3173
|
+
this.#restartHold = void 0;
|
|
3174
|
+
}
|
|
3175
|
+
/** When a concurrent restart ends and the link is still interrupted, arm another recovery. */
|
|
3176
|
+
#rescheduleInterruptionRestartIfNeeded() {
|
|
3177
|
+
if (this.#closed || this.#status !== "interrupted") return;
|
|
3178
|
+
this.#scheduleInterruptionRestart();
|
|
3179
|
+
}
|
|
3180
|
+
/** Offerer: restarts the connection when an interruption outlasts the hold, without waiting for it to fail. */
|
|
3181
|
+
#scheduleInterruptionRestart() {
|
|
3182
|
+
if (this.role !== "offerer" || this.#restartHold !== void 0) return;
|
|
3183
|
+
this.#restartHold = this.#env.setTimeout(() => {
|
|
3184
|
+
this.#restartHold = void 0;
|
|
3185
|
+
if (this.#closed || this.#status !== "interrupted" || coarseState(this.#pc) === "connected") return;
|
|
3186
|
+
if (this.restart("interruption")) this.#callbacks.recoveryStarted("media");
|
|
3187
|
+
else this.#scheduleInterruptionRestart();
|
|
3188
|
+
}, INTERRUPTION_RESTART_HOLD_MS);
|
|
3189
|
+
}
|
|
2214
3190
|
#bindStreams() {
|
|
2215
3191
|
const Stream = this.#env.MediaStream;
|
|
2216
3192
|
if (!Stream) return;
|
|
@@ -2239,6 +3215,13 @@ var PeerLink = class {
|
|
|
2239
3215
|
this.#log.debug("late candidate dropped", { participantId: this.peerId, reason: "rejected" });
|
|
2240
3216
|
}
|
|
2241
3217
|
}
|
|
3218
|
+
/** Sends the offer, then the candidates gathered while it was being created. */
|
|
3219
|
+
#sendOffer(sdp2) {
|
|
3220
|
+
this.#callbacks.sendOffer({ targetParticipantId: this.peerId, sdp: sdp2 });
|
|
3221
|
+
const held = this.#heldLocal;
|
|
3222
|
+
this.#heldLocal = null;
|
|
3223
|
+
for (const candidate of held ?? []) this.#callbacks.sendCandidate({ targetParticipantId: this.peerId, candidate });
|
|
3224
|
+
}
|
|
2242
3225
|
#onLocalCandidate(candidate) {
|
|
2243
3226
|
if (this.#closed || !candidate || candidate.candidate === "") return;
|
|
2244
3227
|
const payload = {
|
|
@@ -2247,7 +3230,8 @@ var PeerLink = class {
|
|
|
2247
3230
|
sdpMLineIndex: candidate.sdpMLineIndex ?? null
|
|
2248
3231
|
};
|
|
2249
3232
|
if (typeof candidate.usernameFragment === "string") payload.usernameFragment = candidate.usernameFragment;
|
|
2250
|
-
this.#
|
|
3233
|
+
if (this.#heldLocal) this.#heldLocal.push(payload);
|
|
3234
|
+
else this.#callbacks.sendCandidate({ targetParticipantId: this.peerId, candidate: payload });
|
|
2251
3235
|
}
|
|
2252
3236
|
#onStateChange() {
|
|
2253
3237
|
if (this.#closed || this.#status === "failed") return;
|
|
@@ -2259,6 +3243,7 @@ var PeerLink = class {
|
|
|
2259
3243
|
this.#clearTimer();
|
|
2260
3244
|
this.#clearInterruptionHold();
|
|
2261
3245
|
if (this.#setStatus("connected")) void this.#reportPath();
|
|
3246
|
+
this.#startQualitySampler();
|
|
2262
3247
|
return;
|
|
2263
3248
|
case "disconnected":
|
|
2264
3249
|
if (!this.#everConnected) return;
|
|
@@ -2270,6 +3255,7 @@ var PeerLink = class {
|
|
|
2270
3255
|
return;
|
|
2271
3256
|
}
|
|
2272
3257
|
this.#setStatus("interrupted");
|
|
3258
|
+
this.#startQualitySampler();
|
|
2273
3259
|
return;
|
|
2274
3260
|
case "failed":
|
|
2275
3261
|
this.#fail("connection_failed");
|
|
@@ -2278,10 +3264,62 @@ var PeerLink = class {
|
|
|
2278
3264
|
return;
|
|
2279
3265
|
}
|
|
2280
3266
|
}
|
|
3267
|
+
#qualityConnectionState() {
|
|
3268
|
+
if (this.#status === "interrupted") return "interrupted";
|
|
3269
|
+
if (this.#status === "failed") return "failed";
|
|
3270
|
+
if (this.#status === "connected") return "connected";
|
|
3271
|
+
return "connecting";
|
|
3272
|
+
}
|
|
3273
|
+
#startQualitySampler() {
|
|
3274
|
+
if (this.#closed || this.#qualityRunning) return;
|
|
3275
|
+
this.#qualityRunning = true;
|
|
3276
|
+
this.#scheduleQualitySample();
|
|
3277
|
+
}
|
|
3278
|
+
#stopQualitySampler() {
|
|
3279
|
+
this.#qualityRunning = false;
|
|
3280
|
+
if (this.#qualityTimer !== void 0) this.#env.clearTimeout(this.#qualityTimer);
|
|
3281
|
+
this.#qualityTimer = void 0;
|
|
3282
|
+
this.#qualityBytes = null;
|
|
3283
|
+
}
|
|
3284
|
+
#scheduleQualitySample() {
|
|
3285
|
+
if (!this.#qualityRunning || this.#closed) return;
|
|
3286
|
+
this.#qualityTimer = this.#env.setTimeout(() => {
|
|
3287
|
+
this.#qualityTimer = void 0;
|
|
3288
|
+
void this.#emitQualitySample();
|
|
3289
|
+
}, QUALITY_SAMPLE_INTERVAL_MS);
|
|
3290
|
+
}
|
|
3291
|
+
async #emitQualitySample() {
|
|
3292
|
+
if (!this.#qualityRunning || this.#closed) return;
|
|
3293
|
+
try {
|
|
3294
|
+
const result = await buildQualitySample(this.#pc, {
|
|
3295
|
+
connectionState: this.#qualityConnectionState(),
|
|
3296
|
+
nowMs: this.#env.now(),
|
|
3297
|
+
previous: this.#qualityBytes
|
|
3298
|
+
});
|
|
3299
|
+
if (this.#closed || !this.#qualityRunning) return;
|
|
3300
|
+
this.#qualityBytes = result.bytes;
|
|
3301
|
+
this.#log.debug("webrtc quality sample", {
|
|
3302
|
+
participantId: this.peerId,
|
|
3303
|
+
state: result.sample.connectionState,
|
|
3304
|
+
...result.sample.rttMs !== void 0 ? { rttMs: result.sample.rttMs } : {},
|
|
3305
|
+
...result.sample.jitterMs !== void 0 ? { jitterMs: result.sample.jitterMs } : {},
|
|
3306
|
+
...result.sample.packetLossRatio !== void 0 ? { packetLossRatio: result.sample.packetLossRatio } : {},
|
|
3307
|
+
...result.sample.sendBitrateBps !== void 0 ? { sendBitrateBps: result.sample.sendBitrateBps } : {},
|
|
3308
|
+
...result.sample.receiveBitrateBps !== void 0 ? { receiveBitrateBps: result.sample.receiveBitrateBps } : {},
|
|
3309
|
+
...result.sample.path ? { path: result.sample.path } : {},
|
|
3310
|
+
...result.sample.incomplete ? { incomplete: true } : {}
|
|
3311
|
+
});
|
|
3312
|
+
this.#callbacks.qualitySample(result.sample);
|
|
3313
|
+
} catch {
|
|
3314
|
+
}
|
|
3315
|
+
if (!this.#closed && this.#qualityRunning) this.#scheduleQualitySample();
|
|
3316
|
+
}
|
|
2281
3317
|
/** True when the status changed. */
|
|
2282
3318
|
#setStatus(status, reason) {
|
|
2283
3319
|
if (this.#status === status) return false;
|
|
2284
3320
|
this.#status = status;
|
|
3321
|
+
if (status === "interrupted") this.#scheduleInterruptionRestart();
|
|
3322
|
+
else this.#clearRestartHold();
|
|
2285
3323
|
this.#callbacks.statusChanged(status, reason);
|
|
2286
3324
|
return true;
|
|
2287
3325
|
}
|
|
@@ -2337,6 +3375,12 @@ var PeerLink = class {
|
|
|
2337
3375
|
this.#timer = void 0;
|
|
2338
3376
|
}
|
|
2339
3377
|
};
|
|
3378
|
+
function sessionOrigin(sdp2) {
|
|
3379
|
+
for (const line of sdp2.split(/\r?\n/)) {
|
|
3380
|
+
if (line.startsWith("o=")) return line.split(" ")[1];
|
|
3381
|
+
}
|
|
3382
|
+
return void 0;
|
|
3383
|
+
}
|
|
2340
3384
|
|
|
2341
3385
|
// src/internal/remoteMedia.ts
|
|
2342
3386
|
var KINDS2 = ["camera", "microphone", "screen"];
|
|
@@ -2375,6 +3419,9 @@ var PeerMediaDelivery = class {
|
|
|
2375
3419
|
#env;
|
|
2376
3420
|
#log;
|
|
2377
3421
|
#timeoutMs;
|
|
3422
|
+
#recoveryWindowMs;
|
|
3423
|
+
#episode = null;
|
|
3424
|
+
#unsubscribeNetwork;
|
|
2378
3425
|
#context;
|
|
2379
3426
|
/** The latest server list; links created later use it. */
|
|
2380
3427
|
#iceServers = [];
|
|
@@ -2395,6 +3442,7 @@ var PeerMediaDelivery = class {
|
|
|
2395
3442
|
this.#env = options.env;
|
|
2396
3443
|
this.#log = options.log;
|
|
2397
3444
|
this.#timeoutMs = options.connectionTimeoutMs ?? CONNECTION_TIMEOUT_MS;
|
|
3445
|
+
this.#recoveryWindowMs = options.recoveryWindowMs ?? DEFAULT_RECONNECT_WINDOW_MS;
|
|
2398
3446
|
}
|
|
2399
3447
|
attach(context) {
|
|
2400
3448
|
if (this.#closed || this.#context) return;
|
|
@@ -2405,7 +3453,9 @@ var PeerMediaDelivery = class {
|
|
|
2405
3453
|
this.#inert = true;
|
|
2406
3454
|
this.#log.warn("media connections unsupported");
|
|
2407
3455
|
this.#emit("error", { error: new MeetingError("unsupported_environment") });
|
|
3456
|
+
return;
|
|
2408
3457
|
}
|
|
3458
|
+
this.#unsubscribeNetwork = this.#env.onNetworkChange?.(() => this.#link?.networkChanged());
|
|
2409
3459
|
}
|
|
2410
3460
|
peersPresent(entries) {
|
|
2411
3461
|
if (!this.#active()) return;
|
|
@@ -2513,6 +3563,9 @@ var PeerMediaDelivery = class {
|
|
|
2513
3563
|
close() {
|
|
2514
3564
|
if (this.#closed) return;
|
|
2515
3565
|
this.#closed = true;
|
|
3566
|
+
this.#unsubscribeNetwork?.();
|
|
3567
|
+
this.#unsubscribeNetwork = void 0;
|
|
3568
|
+
this.#episode = null;
|
|
2516
3569
|
const link = this.#link;
|
|
2517
3570
|
this.#link = null;
|
|
2518
3571
|
link?.close();
|
|
@@ -2571,10 +3624,19 @@ var PeerMediaDelivery = class {
|
|
|
2571
3624
|
this.#log.debug("frame ignored", { messageType: "webrtc.offer", reason: "offerer" });
|
|
2572
3625
|
return;
|
|
2573
3626
|
}
|
|
2574
|
-
|
|
3627
|
+
let link = this.#link;
|
|
3628
|
+
if (link?.startsNewSession(sdp2)) {
|
|
3629
|
+
const interrupted = link.everConnected;
|
|
3630
|
+
this.#link = null;
|
|
3631
|
+
link.close();
|
|
3632
|
+
if (interrupted) this.#openEpisode("media").replaced = true;
|
|
3633
|
+
link = this.#createLink(from, interrupted ? "interrupted" : "connecting");
|
|
3634
|
+
this.#refreshRemote(from);
|
|
3635
|
+
}
|
|
3636
|
+
link ??= this.#createLink(from);
|
|
2575
3637
|
link?.acceptOffer(sdp2);
|
|
2576
3638
|
}
|
|
2577
|
-
#createLink(peerId) {
|
|
3639
|
+
#createLink(peerId, initialStatus = "connecting") {
|
|
2578
3640
|
const context = this.#context;
|
|
2579
3641
|
if (!context) return null;
|
|
2580
3642
|
let link;
|
|
@@ -2604,10 +3666,20 @@ var PeerMediaDelivery = class {
|
|
|
2604
3666
|
if (this.#link === link) context.send("webrtc.connected", { peerParticipantId: peerId, ...report });
|
|
2605
3667
|
},
|
|
2606
3668
|
requestRestart: () => this.#link === link && this.#serverSupportsRestart && context.send("webrtc.ice_restart", { targetParticipantId: peerId }),
|
|
3669
|
+
recoveryStarted: (trigger) => {
|
|
3670
|
+
if (this.#link === link) this.#recoveryStarted(trigger);
|
|
3671
|
+
},
|
|
2607
3672
|
switchResult: (outcome) => {
|
|
2608
3673
|
if (this.#link === link && this.#serverSupportsRestart) {
|
|
2609
3674
|
context.send("webrtc.ice_restart_result", { peerParticipantId: peerId, outcome });
|
|
2610
3675
|
}
|
|
3676
|
+
},
|
|
3677
|
+
qualitySample: (sample) => {
|
|
3678
|
+
if (this.#link !== link) return;
|
|
3679
|
+
try {
|
|
3680
|
+
context.send("webrtc.quality_sample", { peerParticipantId: peerId, ...sample });
|
|
3681
|
+
} catch {
|
|
3682
|
+
}
|
|
2611
3683
|
}
|
|
2612
3684
|
}
|
|
2613
3685
|
});
|
|
@@ -2617,7 +3689,7 @@ var PeerMediaDelivery = class {
|
|
|
2617
3689
|
return null;
|
|
2618
3690
|
}
|
|
2619
3691
|
this.#link = link;
|
|
2620
|
-
this.#setStatus(peerId,
|
|
3692
|
+
this.#setStatus(peerId, initialStatus);
|
|
2621
3693
|
const early = this.#early.get(peerId);
|
|
2622
3694
|
if (early) {
|
|
2623
3695
|
this.#dropEarly(peerId);
|
|
@@ -2629,23 +3701,89 @@ var PeerMediaDelivery = class {
|
|
|
2629
3701
|
const peerParticipantId = link.peerId;
|
|
2630
3702
|
switch (status) {
|
|
2631
3703
|
case "connected":
|
|
3704
|
+
this.#recovered(peerParticipantId);
|
|
2632
3705
|
this.#setStatus(peerParticipantId, "connected");
|
|
2633
3706
|
this.#refreshRemote(peerParticipantId);
|
|
2634
3707
|
return;
|
|
2635
3708
|
case "interrupted":
|
|
3709
|
+
this.#openEpisode("media");
|
|
2636
3710
|
this.#context?.send("webrtc.disconnected", { peerParticipantId });
|
|
2637
3711
|
this.#setStatus(peerParticipantId, "interrupted");
|
|
2638
3712
|
return;
|
|
2639
3713
|
case "failed":
|
|
2640
3714
|
this.#link = null;
|
|
2641
3715
|
link.close();
|
|
3716
|
+
if (this.#replaceFailedLink(link)) return;
|
|
3717
|
+
this.#giveUp(peerParticipantId);
|
|
2642
3718
|
this.#reportFailure(peerParticipantId, reason ?? "connection_failed");
|
|
2643
3719
|
return;
|
|
2644
3720
|
default:
|
|
2645
3721
|
return;
|
|
2646
3722
|
}
|
|
2647
3723
|
}
|
|
2648
|
-
|
|
3724
|
+
#openEpisode(trigger) {
|
|
3725
|
+
this.#episode ??= { startedAt: this.#env.now(), attempts: 0, trigger, replaced: false };
|
|
3726
|
+
return this.#episode;
|
|
3727
|
+
}
|
|
3728
|
+
#recoveryStarted(trigger) {
|
|
3729
|
+
const episode = this.#openEpisode(trigger);
|
|
3730
|
+
episode.attempts += 1;
|
|
3731
|
+
episode.trigger = trigger;
|
|
3732
|
+
this.#log.info("reconnect attempt started", reconnectLogFields({ attempt: episode.attempts, trigger }));
|
|
3733
|
+
}
|
|
3734
|
+
/** The connection is carrying media again after a drop. */
|
|
3735
|
+
#recovered(peerParticipantId) {
|
|
3736
|
+
const episode = this.#episode;
|
|
3737
|
+
if (!episode) return;
|
|
3738
|
+
this.#episode = null;
|
|
3739
|
+
this.#log.info(
|
|
3740
|
+
"reconnect succeeded",
|
|
3741
|
+
reconnectLogFields({
|
|
3742
|
+
attempt: Math.max(1, episode.attempts),
|
|
3743
|
+
trigger: episode.trigger,
|
|
3744
|
+
elapsedMs: Math.max(0, this.#env.now() - episode.startedAt),
|
|
3745
|
+
participantId: peerParticipantId
|
|
3746
|
+
})
|
|
3747
|
+
);
|
|
3748
|
+
}
|
|
3749
|
+
/** Recovery is over without media coming back. */
|
|
3750
|
+
#giveUp(peerParticipantId) {
|
|
3751
|
+
const episode = this.#episode;
|
|
3752
|
+
if (!episode) return;
|
|
3753
|
+
this.#episode = null;
|
|
3754
|
+
const elapsedMs = Math.max(0, this.#env.now() - episode.startedAt);
|
|
3755
|
+
this.#log.warn(
|
|
3756
|
+
"reconnect failed",
|
|
3757
|
+
reconnectLogFields({
|
|
3758
|
+
attempt: Math.max(1, episode.attempts),
|
|
3759
|
+
elapsedMs,
|
|
3760
|
+
reason: elapsedMs >= this.#recoveryWindowMs ? "recovery_window_exhausted" : "hard_failure",
|
|
3761
|
+
participantId: peerParticipantId
|
|
3762
|
+
})
|
|
3763
|
+
);
|
|
3764
|
+
}
|
|
3765
|
+
/**
|
|
3766
|
+
* A connection that carried media and then failed is replaced once, within the recovery window, before media is
|
|
3767
|
+
* given up. The meeting connection is untouched. False when no replacement is allowed or it could not be made.
|
|
3768
|
+
*/
|
|
3769
|
+
#replaceFailedLink(failed) {
|
|
3770
|
+
const peerId = failed.peerId;
|
|
3771
|
+
if (!failed.everConnected || peerId !== this.#activeId || !this.#present.has(peerId)) return false;
|
|
3772
|
+
const episode = this.#openEpisode("media");
|
|
3773
|
+
if (episode.replaced || this.#env.now() - episode.startedAt >= this.#recoveryWindowMs) return false;
|
|
3774
|
+
episode.replaced = true;
|
|
3775
|
+
episode.attempts += 1;
|
|
3776
|
+
this.#log.info("reconnect attempt started", reconnectLogFields({ attempt: episode.attempts, trigger: episode.trigger }));
|
|
3777
|
+
const next = this.#createLink(peerId, "interrupted");
|
|
3778
|
+
if (!next) {
|
|
3779
|
+
this.#episode = null;
|
|
3780
|
+
return true;
|
|
3781
|
+
}
|
|
3782
|
+
this.#refreshRemote(peerId);
|
|
3783
|
+
next.start();
|
|
3784
|
+
return true;
|
|
3785
|
+
}
|
|
3786
|
+
/** Reports a failed link. The pair stays as it is; nothing more is tried until the peer reconnects or rejoins. */
|
|
2649
3787
|
#reportFailure(peerParticipantId, reason) {
|
|
2650
3788
|
this.#log.warn("media connection failed", { participantId: peerParticipantId, reason });
|
|
2651
3789
|
this.#context?.send("webrtc.failed", { peerParticipantId, reason });
|
|
@@ -2658,6 +3796,7 @@ var PeerMediaDelivery = class {
|
|
|
2658
3796
|
const link = this.#link;
|
|
2659
3797
|
if (!link) return;
|
|
2660
3798
|
this.#link = null;
|
|
3799
|
+
this.#episode = null;
|
|
2661
3800
|
link.close();
|
|
2662
3801
|
this.#refreshRemote(link.peerId);
|
|
2663
3802
|
}
|
|
@@ -2734,34 +3873,6 @@ var PeerMediaDelivery = class {
|
|
|
2734
3873
|
}
|
|
2735
3874
|
};
|
|
2736
3875
|
|
|
2737
|
-
// src/internal/token.ts
|
|
2738
|
-
var BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
2739
|
-
function decodeSegment(segment) {
|
|
2740
|
-
if (!BASE64URL.test(segment)) throw new MeetingError("join_token_invalid");
|
|
2741
|
-
const padded = segment.replace(/-/g, "+").replace(/_/g, "/").padEnd(Math.ceil(segment.length / 4) * 4, "=");
|
|
2742
|
-
const binary = atob(padded);
|
|
2743
|
-
const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
|
2744
|
-
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
2745
|
-
}
|
|
2746
|
-
function decodeAddressing(token) {
|
|
2747
|
-
const segments = token.split(".");
|
|
2748
|
-
if (segments.length !== 3) throw new MeetingError("join_token_invalid");
|
|
2749
|
-
let claims;
|
|
2750
|
-
try {
|
|
2751
|
-
claims = decodeSegment(segments[1] ?? "");
|
|
2752
|
-
} catch {
|
|
2753
|
-
throw new MeetingError("join_token_invalid");
|
|
2754
|
-
}
|
|
2755
|
-
if (typeof claims !== "object" || claims === null || Array.isArray(claims)) {
|
|
2756
|
-
throw new MeetingError("join_token_invalid");
|
|
2757
|
-
}
|
|
2758
|
-
const { meeting_id: meetingId, participant_id: participantId } = claims;
|
|
2759
|
-
if (!isCanonicalUlid(meetingId) || !isCanonicalUlid(participantId)) {
|
|
2760
|
-
throw new MeetingError("join_token_invalid");
|
|
2761
|
-
}
|
|
2762
|
-
return { meetingId, participantId };
|
|
2763
|
-
}
|
|
2764
|
-
|
|
2765
3876
|
// src/createMeetingEngine.ts
|
|
2766
3877
|
var DEFAULT_JOIN_TIMEOUT_MS = 15e3;
|
|
2767
3878
|
var MIN_JOIN_TIMEOUT_MS = 1e3;
|
|
@@ -2800,6 +3911,11 @@ function createMeetingEngine(options) {
|
|
|
2800
3911
|
const env = currentEnvironment();
|
|
2801
3912
|
if (!env.WebSocket) throw new MeetingError("unsupported_environment");
|
|
2802
3913
|
const addressing = decodeAddressing(token);
|
|
3914
|
+
const getJoinToken = options.getJoinToken;
|
|
3915
|
+
if (getJoinToken !== void 0 && typeof getJoinToken !== "function") {
|
|
3916
|
+
throw new TypeError("getJoinToken must be a function when provided.");
|
|
3917
|
+
}
|
|
3918
|
+
const reconnectWindowMs = clampReconnectWindowMs(options.reconnectWindowMs);
|
|
2803
3919
|
return new MeetingEngineImpl({
|
|
2804
3920
|
token,
|
|
2805
3921
|
realtimeUrl,
|
|
@@ -2807,7 +3923,9 @@ function createMeetingEngine(options) {
|
|
|
2807
3923
|
logger: options.logger,
|
|
2808
3924
|
addressing,
|
|
2809
3925
|
env,
|
|
2810
|
-
|
|
3926
|
+
getJoinToken: typeof getJoinToken === "function" ? getJoinToken : void 0,
|
|
3927
|
+
reconnectWindowMs,
|
|
3928
|
+
createMediaDelivery: (mediaMode, deps) => mediaMode === "p2p" ? new PeerMediaDelivery({ ...deps, recoveryWindowMs: reconnectWindowMs }) : new SignalingOnlyMediaDelivery()
|
|
2811
3929
|
});
|
|
2812
3930
|
}
|
|
2813
3931
|
export {
|