@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/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
- /** A capture the browser or device ended (unplugged, revoked, "Stop sharing") turns that kind off and is announced. */
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
- this.#log.info(kind === "screen" ? "screen share ended by the browser" : "capture ended", { kind });
963
- this.set(kind, false).catch(() => void 0);
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
- const session = new SignalingSession({
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 !== "connecting") return;
1533
- this.#role = message.payload.role;
1534
- this.#setState("joining");
1535
- if (!this.#is("joining")) return;
1536
- if (!this.#session?.send("participant.join")) this.#failJoin(new MeetingError("connection_failed"));
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 !== "joining") return;
1541
- this.#clearTimer("join");
1542
- this.#roster.commit();
1543
- const payload = message.payload;
1544
- const self = {
1545
- id: this.#addressing.participantId,
1546
- role: payload.role,
1547
- joinedAt: payload.joinedAt
1548
- };
1549
- if (payload.displayName !== void 0) self.displayName = payload.displayName;
1550
- this.#self = Object.freeze(self);
1551
- this.#role = payload.role;
1552
- const join = this.#join;
1553
- this.#join = void 0;
1554
- this.#setState("joined");
1555
- this.#emit("joined", { self: this.#self, participants: this.#roster.list() });
1556
- join?.resolve();
1557
- if (this.#is("joined")) {
1558
- const expiresAt = payload.iceServersExpireAt !== void 0 ? Date.parse(payload.iceServersExpireAt) : NaN;
1559
- this.#startDelivery(payload.mediaMode, payload.iceServers ?? [], Number.isFinite(expiresAt) ? expiresAt : null);
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 this.#finish("meeting_ended");
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 deferred2 = this.#endMeeting;
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
- deferred2.reject(new MeetingError(mappedCode));
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 byId = new Map(reports.map((report) => [report.id, report]));
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
- const sdp2 = this.#pc.localDescription?.sdp ?? offer.sdp ?? "";
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
- const sdp2 = this.#pc.localDescription?.sdp ?? offer.sdp ?? "";
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.#callbacks.sendCandidate({ targetParticipantId: this.peerId, candidate: payload });
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
- const link = this.#link ?? this.#createLink(from);
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, "connecting");
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
- /** Reports a failed link. The pair stays as it is; nothing is retried until the peer reconnects or rejoins. */
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
- createMediaDelivery: (mediaMode, deps) => mediaMode === "p2p" ? new PeerMediaDelivery(deps) : new SignalingOnlyMediaDelivery()
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 {