livekit-client 2.22.0 → 2.22.1

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.
Files changed (75) hide show
  1. package/dist/livekit-client.e2ee.worker.js +1 -1
  2. package/dist/livekit-client.e2ee.worker.js.map +1 -1
  3. package/dist/livekit-client.e2ee.worker.mjs +187 -1
  4. package/dist/livekit-client.e2ee.worker.mjs.map +1 -1
  5. package/dist/livekit-client.esm.mjs +1764 -121
  6. package/dist/livekit-client.esm.mjs.map +1 -1
  7. package/dist/livekit-client.fm.worker.js +1 -1
  8. package/dist/livekit-client.fm.worker.js.map +1 -1
  9. package/dist/livekit-client.fm.worker.mjs +187 -1
  10. package/dist/livekit-client.fm.worker.mjs.map +1 -1
  11. package/dist/livekit-client.umd.js +1 -1
  12. package/dist/livekit-client.umd.js.map +1 -1
  13. package/dist/src/api/SignalClient.d.ts +25 -1
  14. package/dist/src/api/SignalClient.d.ts.map +1 -1
  15. package/dist/src/api/SignalClientStateMachine.d.ts +85 -0
  16. package/dist/src/api/SignalClientStateMachine.d.ts.map +1 -0
  17. package/dist/src/api/WebSocketStream.d.ts.map +1 -1
  18. package/dist/src/api/utils.d.ts.map +1 -1
  19. package/dist/src/index.d.ts +3 -2
  20. package/dist/src/index.d.ts.map +1 -1
  21. package/dist/src/logger.d.ts +2 -1
  22. package/dist/src/logger.d.ts.map +1 -1
  23. package/dist/src/options.d.ts +22 -0
  24. package/dist/src/options.d.ts.map +1 -1
  25. package/dist/src/room/PCTransport.d.ts +2 -1
  26. package/dist/src/room/PCTransport.d.ts.map +1 -1
  27. package/dist/src/room/RTCEngine.d.ts +7 -2
  28. package/dist/src/room/RTCEngine.d.ts.map +1 -1
  29. package/dist/src/room/Room.d.ts +8 -1
  30. package/dist/src/room/Room.d.ts.map +1 -1
  31. package/dist/src/room/events.d.ts +1 -1
  32. package/dist/src/room/statsSummary.d.ts +13 -0
  33. package/dist/src/room/statsSummary.d.ts.map +1 -0
  34. package/dist/src/room/token-source/utils.d.ts.map +1 -1
  35. package/dist/src/room/track/LocalAudioTrack.d.ts.map +1 -1
  36. package/dist/src/room/track/Track.d.ts +10 -0
  37. package/dist/src/room/track/Track.d.ts.map +1 -1
  38. package/dist/src/utils/machineInspector.d.ts +54 -0
  39. package/dist/src/utils/machineInspector.d.ts.map +1 -0
  40. package/dist/ts4.2/api/SignalClient.d.ts +25 -1
  41. package/dist/ts4.2/api/SignalClientStateMachine.d.ts +85 -0
  42. package/dist/ts4.2/index.d.ts +3 -2
  43. package/dist/ts4.2/logger.d.ts +2 -1
  44. package/dist/ts4.2/options.d.ts +22 -0
  45. package/dist/ts4.2/room/PCTransport.d.ts +2 -1
  46. package/dist/ts4.2/room/RTCEngine.d.ts +7 -2
  47. package/dist/ts4.2/room/Room.d.ts +8 -1
  48. package/dist/ts4.2/room/events.d.ts +1 -1
  49. package/dist/ts4.2/room/statsSummary.d.ts +13 -0
  50. package/dist/ts4.2/room/track/Track.d.ts +10 -0
  51. package/dist/ts4.2/utils/machineInspector.d.ts +54 -0
  52. package/package.json +7 -1
  53. package/src/api/SignalClient.test.ts +320 -8
  54. package/src/api/SignalClient.ts +260 -82
  55. package/src/api/SignalClientStateMachine.test.ts +472 -0
  56. package/src/api/SignalClientStateMachine.ts +180 -0
  57. package/src/api/WebSocketStream.ts +19 -3
  58. package/src/api/utils.test.ts +20 -1
  59. package/src/api/utils.ts +5 -0
  60. package/src/index.ts +5 -0
  61. package/src/logger.ts +1 -0
  62. package/src/options.ts +24 -0
  63. package/src/room/PCTransport.ts +2 -1
  64. package/src/room/RTCEngine.ts +16 -7
  65. package/src/room/Room.ts +70 -5
  66. package/src/room/events.ts +1 -1
  67. package/src/room/statsSummary.ts +187 -0
  68. package/src/room/token-source/test-tokens.ts +20 -0
  69. package/src/room/token-source/utils.test.ts +27 -0
  70. package/src/room/token-source/utils.ts +12 -5
  71. package/src/room/track/LocalAudioTrack.ts +9 -3
  72. package/src/room/track/Track.ts +27 -0
  73. package/src/room/utils.test.ts +24 -1
  74. package/src/room/utils.ts +1 -1
  75. package/src/utils/machineInspector.ts +90 -0
@@ -946,6 +946,9 @@ function _defineProperty(e, r, t) {
946
946
  writable: true
947
947
  }) : e[r] = t, e;
948
948
  }
949
+ function _iterableToArray(r) {
950
+ if ("undefined" != typeof Symbol && null != r[Symbol.iterator] || null != r["@@iterator"]) return Array.from(r);
951
+ }
949
952
  function _iterableToArrayLimit(r, l) {
950
953
  var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"];
951
954
  if (null != t) {
@@ -976,9 +979,53 @@ function _iterableToArrayLimit(r, l) {
976
979
  function _nonIterableRest() {
977
980
  throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.");
978
981
  }
982
+ function ownKeys(e, r) {
983
+ var t = Object.keys(e);
984
+ if (Object.getOwnPropertySymbols) {
985
+ var o = Object.getOwnPropertySymbols(e);
986
+ r && (o = o.filter(function (r) {
987
+ return Object.getOwnPropertyDescriptor(e, r).enumerable;
988
+ })), t.push.apply(t, o);
989
+ }
990
+ return t;
991
+ }
992
+ function _objectSpread2(e) {
993
+ for (var r = 1; r < arguments.length; r++) {
994
+ var t = null != arguments[r] ? arguments[r] : {};
995
+ r % 2 ? ownKeys(Object(t), true).forEach(function (r) {
996
+ _defineProperty(e, r, t[r]);
997
+ }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) {
998
+ Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r));
999
+ });
1000
+ }
1001
+ return e;
1002
+ }
1003
+ function _objectWithoutProperties(e, t) {
1004
+ if (null == e) return {};
1005
+ var o,
1006
+ r,
1007
+ i = _objectWithoutPropertiesLoose(e, t);
1008
+ if (Object.getOwnPropertySymbols) {
1009
+ var n = Object.getOwnPropertySymbols(e);
1010
+ for (r = 0; r < n.length; r++) o = n[r], -1 === t.indexOf(o) && {}.propertyIsEnumerable.call(e, o) && (i[o] = e[o]);
1011
+ }
1012
+ return i;
1013
+ }
1014
+ function _objectWithoutPropertiesLoose(r, e) {
1015
+ if (null == r) return {};
1016
+ var t = {};
1017
+ for (var n in r) if ({}.hasOwnProperty.call(r, n)) {
1018
+ if (-1 !== e.indexOf(n)) continue;
1019
+ t[n] = r[n];
1020
+ }
1021
+ return t;
1022
+ }
979
1023
  function _slicedToArray(r, e) {
980
1024
  return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest();
981
1025
  }
1026
+ function _toArray(r) {
1027
+ return _arrayWithHoles(r) || _iterableToArray(r) || _unsupportedIterableToArray(r) || _nonIterableRest();
1028
+ }
982
1029
  function _toPrimitive(t, r) {
983
1030
  if ("object" != typeof t || !t) return t;
984
1031
  var e = t[Symbol.toPrimitive];
@@ -8505,6 +8552,7 @@ var LoggerNames;
8505
8552
  LoggerNames["DataTracks"] = "livekit-data-tracks";
8506
8553
  LoggerNames["Region"] = "livekit-region";
8507
8554
  LoggerNames["ICE"] = "livekit-ice";
8555
+ LoggerNames["Stats"] = "livekit-stats";
8508
8556
  })(LoggerNames || (LoggerNames = {}));
8509
8557
  let livekitLogger = loglevelExports.getLogger(LoggerNames.Default);
8510
8558
  const livekitLoggers = Object.values(LoggerNames).map(name => loglevelExports.getLogger(name));
@@ -12160,7 +12208,7 @@ function getMatch(exp, ua) {
12160
12208
  }
12161
12209
  function getOSVersion(ua) {
12162
12210
  return ua.includes('mac os') ? getMatch(/\(.+?(\d+_\d+(:?_\d+)?)/, ua, 1).replace(/_/g, '.') : undefined;
12163
- }var version$1 = "2.22.0";const version = version$1;
12211
+ }var version$1 = "2.22.1";const version = version$1;
12164
12212
  const protocolVersion = 17;
12165
12213
  /** Initial client protocol. */
12166
12214
  const CLIENT_PROTOCOL_DEFAULT = 0;
@@ -12446,7 +12494,7 @@ var RoomEvent;
12446
12494
  * When a [[RemoteParticipant]] leaves *after* the local
12447
12495
  * participant has joined.
12448
12496
  *
12449
- * args: ([[RemoteParticipant]])
12497
+ * args: ([[RemoteParticipant]], [[DisconnectReason]] | undefined)
12450
12498
  */
12451
12499
  RoomEvent["ParticipantDisconnected"] = "participantDisconnected";
12452
12500
  /**
@@ -13014,7 +13062,171 @@ var TrackEvent;
13014
13062
  * @internal
13015
13063
  */
13016
13064
  TrackEvent["PreConnectBufferFlushed"] = "preConnectBufferFlushed";
13017
- })(TrackEvent || (TrackEvent = {}));function cloneDeep(value) {
13065
+ })(TrackEvent || (TrackEvent = {}));function compact(summary) {
13066
+ const compacted = {};
13067
+ for (const _ref of Object.entries(summary)) {
13068
+ var _ref2 = _slicedToArray(_ref, 2);
13069
+ const key = _ref2[0];
13070
+ const value = _ref2[1];
13071
+ if (value !== undefined) {
13072
+ compacted[key] = value;
13073
+ }
13074
+ }
13075
+ return compacted;
13076
+ }
13077
+ function resolution(width, height) {
13078
+ return width && height ? "".concat(width, "x").concat(height) : undefined;
13079
+ }
13080
+ /** keeps the derived durations readable; reported values are logged as they are */
13081
+ function round(seconds) {
13082
+ return Math.round(seconds * 10000) / 10000;
13083
+ }
13084
+ /**
13085
+ * Average time media spent in the jitter buffer, in s. Both counters are
13086
+ * cumulative, so this is the average over the lifetime of the stream.
13087
+ */
13088
+ function jitterBuffer(stat) {
13089
+ const delay = stat.jitterBufferDelay;
13090
+ const emitted = stat.jitterBufferEmittedCount;
13091
+ return delay !== undefined && emitted ? round(delay / emitted) : undefined;
13092
+ }
13093
+ /**
13094
+ * Playout delay in s. Video receive reports it directly, the audio path keeps it
13095
+ * in the linked `media-playout` stats summed over the samples played out. Pairs
13096
+ * with `RemoteTrack.setPlayoutDelay`.
13097
+ */
13098
+ function playoutDelay(stat, playout) {
13099
+ if (stat.playoutDelay !== undefined) {
13100
+ return round(stat.playoutDelay);
13101
+ }
13102
+ const total = playout === null || playout === void 0 ? void 0 : playout.totalPlayoutDelay;
13103
+ const samples = playout === null || playout === void 0 ? void 0 : playout.totalSamplesCount;
13104
+ return total !== undefined && samples ? round(total / samples) : undefined;
13105
+ }
13106
+ /**
13107
+ * Picks the interesting fields out of a `getStats()` report and groups them by
13108
+ * RTP stream, so a stats dump can be read without unfolding the raw report.
13109
+ */
13110
+ function summarizeStatsReport(report) {
13111
+ var _a, _b;
13112
+ const byId = new Map();
13113
+ const candidatePairs = [];
13114
+ const inbound = [];
13115
+ const outbound = [];
13116
+ let transport;
13117
+ report.forEach(stat => byId.set(stat.id, stat));
13118
+ const codecOf = stat => {
13119
+ var _a;
13120
+ return stat.codecId ? (_a = byId.get(stat.codecId)) === null || _a === void 0 ? void 0 : _a.mimeType : undefined;
13121
+ };
13122
+ const relatedOf = (stat, key) => stat[key] ? byId.get(stat[key]) : undefined;
13123
+ report.forEach(stat => {
13124
+ switch (stat.type) {
13125
+ case 'inbound-rtp':
13126
+ {
13127
+ const playout = relatedOf(stat, 'playoutId');
13128
+ inbound.push(compact({
13129
+ kind: stat.kind,
13130
+ ssrc: stat.ssrc,
13131
+ mid: stat.mid,
13132
+ // matches `streamTrackID` in the track's own log context
13133
+ trackId: stat.trackIdentifier,
13134
+ codec: codecOf(stat),
13135
+ decoder: stat.decoderImplementation,
13136
+ resolution: resolution(stat.frameWidth, stat.frameHeight),
13137
+ fps: stat.framesPerSecond,
13138
+ bytesReceived: stat.bytesReceived,
13139
+ packetsReceived: stat.packetsReceived,
13140
+ packetsLost: stat.packetsLost,
13141
+ packetsDiscarded: stat.packetsDiscarded,
13142
+ // frames received without frames decoded is a decode failure
13143
+ framesReceived: stat.framesReceived,
13144
+ framesDecoded: stat.framesDecoded,
13145
+ framesDropped: stat.framesDropped,
13146
+ keyFramesDecoded: stat.keyFramesDecoded,
13147
+ freezeCount: stat.freezeCount,
13148
+ totalFreezesDuration: stat.totalFreezesDuration,
13149
+ pauseCount: stat.pauseCount,
13150
+ nackCount: stat.nackCount,
13151
+ pliCount: stat.pliCount,
13152
+ firCount: stat.firCount,
13153
+ jitter: stat.jitter,
13154
+ jitterBuffer: jitterBuffer(stat),
13155
+ playoutDelay: playoutDelay(stat, playout),
13156
+ audioLevel: stat.audioLevel,
13157
+ totalSamplesReceived: stat.totalSamplesReceived,
13158
+ concealedSamples: stat.concealedSamples
13159
+ }));
13160
+ break;
13161
+ }
13162
+ case 'outbound-rtp':
13163
+ {
13164
+ const remote = relatedOf(stat, 'remoteId');
13165
+ const source = relatedOf(stat, 'mediaSourceId');
13166
+ outbound.push(compact({
13167
+ kind: stat.kind,
13168
+ ssrc: stat.ssrc,
13169
+ mid: stat.mid,
13170
+ rid: stat.rid,
13171
+ trackId: source === null || source === void 0 ? void 0 : source.trackIdentifier,
13172
+ active: stat.active,
13173
+ codec: codecOf(stat),
13174
+ encoder: stat.encoderImplementation,
13175
+ resolution: resolution(stat.frameWidth, stat.frameHeight),
13176
+ fps: stat.framesPerSecond,
13177
+ // what the source produces, to tell a stalled capture from a stalled encoder
13178
+ captureResolution: resolution(source === null || source === void 0 ? void 0 : source.width, source === null || source === void 0 ? void 0 : source.height),
13179
+ captureFps: source === null || source === void 0 ? void 0 : source.framesPerSecond,
13180
+ audioLevel: source === null || source === void 0 ? void 0 : source.audioLevel,
13181
+ targetBitrate: stat.targetBitrate,
13182
+ bytesSent: stat.bytesSent,
13183
+ packetsSent: stat.packetsSent,
13184
+ retransmittedPacketsSent: stat.retransmittedPacketsSent,
13185
+ framesEncoded: stat.framesEncoded,
13186
+ keyFramesEncoded: stat.keyFramesEncoded,
13187
+ limitedBy: stat.qualityLimitationReason === 'none' ? undefined : stat.qualityLimitationReason,
13188
+ nackCount: stat.nackCount,
13189
+ pliCount: stat.pliCount,
13190
+ firCount: stat.firCount,
13191
+ // loss, jitter and RTT are only known from what the remote reports
13192
+ remotePacketsLost: remote === null || remote === void 0 ? void 0 : remote.packetsLost,
13193
+ remoteFractionLost: remote === null || remote === void 0 ? void 0 : remote.fractionLost,
13194
+ remoteJitter: remote === null || remote === void 0 ? void 0 : remote.jitter,
13195
+ remoteRoundTripTime: remote === null || remote === void 0 ? void 0 : remote.roundTripTime
13196
+ }));
13197
+ break;
13198
+ }
13199
+ case 'transport':
13200
+ transport = stat;
13201
+ break;
13202
+ case 'candidate-pair':
13203
+ candidatePairs.push(stat);
13204
+ break;
13205
+ }
13206
+ });
13207
+ const selectedPairId = transport === null || transport === void 0 ? void 0 : transport.selectedCandidatePairId;
13208
+ const pair = (_b = (_a = selectedPairId ? byId.get(selectedPairId) : undefined) !== null && _a !== void 0 ? _a : candidatePairs.find(candidate => candidate.selected)) !== null && _b !== void 0 ? _b : candidatePairs.find(candidate => candidate.nominated);
13209
+ const local = (pair === null || pair === void 0 ? void 0 : pair.localCandidateId) ? byId.get(pair.localCandidateId) : undefined;
13210
+ const remote = (pair === null || pair === void 0 ? void 0 : pair.remoteCandidateId) ? byId.get(pair.remoteCandidateId) : undefined;
13211
+ const connection = compact({
13212
+ ice: transport === null || transport === void 0 ? void 0 : transport.iceState,
13213
+ dtls: transport === null || transport === void 0 ? void 0 : transport.dtlsState,
13214
+ route: local && remote ? "".concat(local.candidateType, "/").concat(local.protocol, " -> ").concat(remote.candidateType) : undefined,
13215
+ network: local === null || local === void 0 ? void 0 : local.networkType,
13216
+ currentRoundTripTime: pair === null || pair === void 0 ? void 0 : pair.currentRoundTripTime,
13217
+ // the send bandwidth estimate; no RTP stream reports it
13218
+ availableOutgoingBitrate: pair === null || pair === void 0 ? void 0 : pair.availableOutgoingBitrate,
13219
+ availableIncomingBitrate: pair === null || pair === void 0 ? void 0 : pair.availableIncomingBitrate,
13220
+ bytesSent: pair === null || pair === void 0 ? void 0 : pair.bytesSent,
13221
+ bytesReceived: pair === null || pair === void 0 ? void 0 : pair.bytesReceived,
13222
+ candidatePairChanges: transport === null || transport === void 0 ? void 0 : transport.selectedCandidatePairChanges
13223
+ });
13224
+ return {
13225
+ connection: Object.keys(connection).length > 0 ? connection : undefined,
13226
+ outbound: outbound.length > 0 ? outbound : undefined,
13227
+ inbound: inbound.length > 0 ? inbound : undefined
13228
+ };
13229
+ }function cloneDeep$1(value) {
13018
13230
  if (typeof value === 'undefined') {
13019
13231
  return value;
13020
13232
  }
@@ -13439,7 +13651,7 @@ function extractProcessorsFromOptions(options) {
13439
13651
  return {
13440
13652
  audioProcessor,
13441
13653
  videoProcessor,
13442
- optionsWithoutProcessor: cloneDeep(newOptions)
13654
+ optionsWithoutProcessor: cloneDeep$1(newOptions)
13443
13655
  };
13444
13656
  }
13445
13657
  function getTrackSourceFromProto(source) {
@@ -13496,6 +13708,7 @@ class Track extends eventsExports.EventEmitter {
13496
13708
  this._streamState = Track.StreamState.Active;
13497
13709
  this.isInBackground = false;
13498
13710
  this._currentBitrate = 0;
13711
+ this.finalStatsLogged = false;
13499
13712
  this.log = livekitLogger;
13500
13713
  this.appVisibilityChangedListener = () => {
13501
13714
  if (this.backgroundTimeout) {
@@ -13644,6 +13857,24 @@ class Track extends eventsExports.EventEmitter {
13644
13857
  cancelAnimationFrame(this.timeSyncHandle);
13645
13858
  this.timeSyncHandle = undefined;
13646
13859
  }
13860
+ this.logFinalStats();
13861
+ }
13862
+ /**
13863
+ * Dumps the raw stats of the track as it ends, once: a track that stops
13864
+ * between two of the room's stats dumps is gone by the time the next one runs.
13865
+ */
13866
+ logFinalStats() {
13867
+ if (this.finalStatsLogged) {
13868
+ return;
13869
+ }
13870
+ this.finalStatsLogged = true;
13871
+ this.getRTCStatsReport().then(report => {
13872
+ if (report) {
13873
+ this.log.info('final track stats', summarizeStatsReport(report));
13874
+ }
13875
+ }).catch(error => this.log.debug('could not collect final track stats', {
13876
+ error
13877
+ }));
13647
13878
  }
13648
13879
  /** @internal */
13649
13880
  updateLoggerOptions(loggerOptions) {
@@ -13869,7 +14100,7 @@ function supportsAddTrack() {
13869
14100
  return 'addTrack' in RTCPeerConnection.prototype;
13870
14101
  }
13871
14102
  function supportsAdaptiveStream() {
13872
- return typeof ResizeObserver !== undefined && typeof IntersectionObserver !== undefined;
14103
+ return typeof ResizeObserver !== 'undefined' && typeof IntersectionObserver !== 'undefined';
13873
14104
  }
13874
14105
  function supportsDynacast() {
13875
14106
  return supportsTransceiver();
@@ -13927,6 +14158,22 @@ function supportsVP9() {
13927
14158
  }
13928
14159
  return hasVP9;
13929
14160
  }
14161
+ function supportsH265() {
14162
+ if (!('getCapabilities' in RTCRtpSender)) {
14163
+ return false;
14164
+ }
14165
+ const capabilities = RTCRtpSender.getCapabilities('video');
14166
+ let hasH265 = false;
14167
+ if (capabilities) {
14168
+ for (const codec of capabilities.codecs) {
14169
+ if (codec.mimeType.toLowerCase() === 'video/h265') {
14170
+ hasH265 = true;
14171
+ break;
14172
+ }
14173
+ }
14174
+ }
14175
+ return hasH265;
14176
+ }
13930
14177
  function isSVCCodec(codec) {
13931
14178
  return codec === 'av1' || codec === 'vp9';
13932
14179
  }
@@ -14560,6 +14807,11 @@ function parseSignalResponse(value) {
14560
14807
  }
14561
14808
  function getAbortReasonAsString(signal) {
14562
14809
  let defaultMessage = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'Unknown reason';
14810
+ // the connect timeout hands this an Error rather than a signal, and its message is the only
14811
+ // description of what went wrong — without this a timeout reports itself as a generic abort
14812
+ if (signal instanceof Error) {
14813
+ return signal.message;
14814
+ }
14563
14815
  if (!(signal instanceof AbortSignal)) {
14564
14816
  return defaultMessage;
14565
14817
  }
@@ -16620,6 +16872,1140 @@ class AsyncQueue {
16620
16872
  snapshot() {
16621
16873
  return Array.from(this.pendingTasks.values());
16622
16874
  }
16875
+ }/**
16876
+ * Opt-in registry that lets a development tool observe the state machines driving the connection
16877
+ * layer.
16878
+ *
16879
+ * The machines are private to the objects that own them and, more importantly, do not outlive them:
16880
+ * a full reconnect replaces the whole engine along with its `SignalClient`, so anything watching a
16881
+ * single machine reference goes blind exactly when the interesting part starts. Machines therefore
16882
+ * announce themselves here as they are constructed, and a subscriber sees the whole succession.
16883
+ *
16884
+ * Nothing is recorded until {@link enableMachineInspector} is called, which no shipping code does —
16885
+ * with the registry off, announcing is a comparison and a return.
16886
+ */
16887
+ /** Kept small: enough for a panel opened mid-session to see how the connection got where it is. */
16888
+ /** Announces a machine to whatever is watching. A no-op unless the inspector was enabled. */
16889
+ function announceMachine(label, machine) {
16890
+ {
16891
+ return;
16892
+ }
16893
+ }const _excluded = ["client"];
16894
+ //#region src/emitter.ts
16895
+ /**
16896
+ * Minimal typed event emitter used internally by Fsm and BehavioralFsm.
16897
+ *
16898
+ * Two listener categories exist at runtime: named listeners (keyed by event
16899
+ * name) and wildcard listeners (keyed as `"*"`). The wildcard fires on EVERY
16900
+ * emit, regardless of event name, and its callback receives both the event
16901
+ * name and the data. Named listeners receive only the data.
16902
+ *
16903
+ * @typeParam TEventMap - Record mapping event names to their payload types.
16904
+ * Constrains `on()` and `emit()` to matching event/payload pairs.
16905
+ */
16906
+ var Emitter = class Emitter {
16907
+ constructor() {
16908
+ _defineProperty(this, "listeners", /* @__PURE__ */new Map());
16909
+ }
16910
+ on(event, cb) {
16911
+ let set = this.listeners.get(event);
16912
+ if (!set) {
16913
+ set = /* @__PURE__ */new Set();
16914
+ this.listeners.set(event, set);
16915
+ }
16916
+ set.add(cb);
16917
+ return {
16918
+ off: () => {
16919
+ set.delete(cb);
16920
+ }
16921
+ };
16922
+ }
16923
+ /**
16924
+ * Emit a named event, notifying wildcard listeners first, then named listeners.
16925
+ *
16926
+ * Wildcard listeners receive `(eventName, data)`; named listeners receive
16927
+ * only `data`. The firing order (wildcards before named) is intentional —
16928
+ * it lets relay listeners (like Fsm's bfsm proxy) observe all events
16929
+ * before specific subscribers react.
16930
+ */
16931
+ emit(event, data) {
16932
+ const wildcards = this.listeners.get("*");
16933
+ if (wildcards) for (const cb of wildcards) cb(event, data);
16934
+ const named = this.listeners.get(event);
16935
+ if (named) for (const cb of named) cb(data);
16936
+ }
16937
+ /**
16938
+ * Remove all listeners (named and wildcard).
16939
+ *
16940
+ * Existing `Subscription` objects remain valid — their `off()` closures
16941
+ * hold a reference to the now-empty Set, so calling them is harmless.
16942
+ * Called by `dispose()` to prevent memory retention after an FSM shuts down.
16943
+ */
16944
+ clear() {
16945
+ this.listeners.clear();
16946
+ }
16947
+ };
16948
+
16949
+ //#endregion
16950
+ //#region src/json-safe.ts
16951
+ /**
16952
+ * Thrown internally when the walk hits a value that can't survive a
16953
+ * serialization boundary. Callers catch this to attach FSM/input context
16954
+ * before re-throwing a fully descriptive error — see `path`/`label` below.
16955
+ */
16956
+ var NonSerializableValueError = class extends Error {
16957
+ constructor(path, label) {
16958
+ super("non-serializable value at ".concat(path, " (").concat(label, ")"));
16959
+ this.path = path;
16960
+ this.label = label;
16961
+ }
16962
+ };
16963
+ /**
16964
+ * Deep-clones `value`, throwing `NonSerializableValueError` the moment it
16965
+ * finds anything that isn't `null`, a boolean, a finite number, a string, a
16966
+ * plain array, or a plain object. `rootPath` seeds the path used in error
16967
+ * messages — e.g. `"args"` so a nested failure reads as `args[1].onComplete`.
16968
+ *
16969
+ * Cloning (rather than just validating) is what keeps a returned snapshot
16970
+ * from aliasing live FSM state: mutating the snapshot afterward — or the
16971
+ * original value passed into a deferred `handle()` call — can't reach back
16972
+ * into the FSM's internal deferred queue.
16973
+ */
16974
+ const cloneJsonSafe = (value, rootPath) => {
16975
+ return cloneNode(value, rootPath, /* @__PURE__ */new Set());
16976
+ };
16977
+ const cloneNode = (value, path, ancestors) => {
16978
+ if (value === null) return null;
16979
+ switch (typeof value) {
16980
+ case "string":
16981
+ case "boolean":
16982
+ return value;
16983
+ case "number":
16984
+ if (!Number.isFinite(value)) throw new NonSerializableValueError(path, describeNonFiniteNumber(value));
16985
+ return value;
16986
+ case "object":
16987
+ return cloneObject(value, path, ancestors);
16988
+ default:
16989
+ throw new NonSerializableValueError(path, typeof value);
16990
+ }
16991
+ };
16992
+ const cloneObject = (obj, path, ancestors) => {
16993
+ var _obj$constructor$name, _obj$constructor;
16994
+ if (ancestors.has(obj)) throw new NonSerializableValueError(path, "circular reference");
16995
+ if (Array.isArray(obj)) {
16996
+ if (obj.length !== Object.keys(obj).length) throw new NonSerializableValueError(path, "sparse array or array with non-index properties");
16997
+ ancestors.add(obj);
16998
+ const cloned = obj.map((item, i) => cloneNode(item, "".concat(path, "[").concat(i, "]"), ancestors));
16999
+ ancestors.delete(obj);
17000
+ return cloned;
17001
+ }
17002
+ const proto = Object.getPrototypeOf(obj);
17003
+ if (proto !== Object.prototype && proto !== null) throw new NonSerializableValueError(path, (_obj$constructor$name = (_obj$constructor = obj.constructor) === null || _obj$constructor === void 0 ? void 0 : _obj$constructor.name) !== null && _obj$constructor$name !== void 0 ? _obj$constructor$name : "object");
17004
+ ancestors.add(obj);
17005
+ const cloned = {};
17006
+ for (const key of Object.keys(obj)) {
17007
+ const value = cloneNode(obj[key], "".concat(path, ".").concat(key), ancestors);
17008
+ Object.defineProperty(cloned, key, {
17009
+ value,
17010
+ enumerable: true,
17011
+ writable: true,
17012
+ configurable: true
17013
+ });
17014
+ }
17015
+ ancestors.delete(obj);
17016
+ return cloned;
17017
+ };
17018
+ const describeNonFiniteNumber = n => {
17019
+ if (Number.isNaN(n)) return "NaN";
17020
+ return n > 0 ? "Infinity" : "-Infinity";
17021
+ };
17022
+ /**
17023
+ * Deep-clones `value` WITHOUT validating it — unlike `cloneJsonSafe`, this
17024
+ * never throws. Plain objects/arrays are cloned recursively (so a caller
17025
+ * can't alias state back into whatever holds the result); anything else
17026
+ * (functions, `Date`/`Map`/class instances, symbols, non-finite numbers,
17027
+ * etc.) is passed through by reference as-is.
17028
+ *
17029
+ * This is `rehydrate()`'s side of the aliasing guarantee: `dehydrate()`
17030
+ * validates-and-clones on the way OUT (via `cloneJsonSafe`), but `rehydrate()`
17031
+ * trusts the snapshot it's given is already valid data — re-validating on
17032
+ * the way IN would be a redundant, unwanted asymmetry (see the build plan's
17033
+ * disclosed known gaps). We still need the clone so mutating the caller's
17034
+ * snapshot object after `rehydrate()` returns can't reach into the live
17035
+ * FSM's internal deferred queue.
17036
+ *
17037
+ * Cycle-safe: a value that contains itself is returned as-is (by reference)
17038
+ * rather than cloned infinitely — there's no validation step here to make
17039
+ * that throw, so silently keeping the shared reference is the only option
17040
+ * that doesn't hang.
17041
+ */
17042
+ const cloneDeep = function (value) {
17043
+ let ancestors = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : /* @__PURE__ */new Set();
17044
+ if (value === null || typeof value !== "object") return value;
17045
+ if (ancestors.has(value)) return value;
17046
+ if (Array.isArray(value)) {
17047
+ ancestors.add(value);
17048
+ const cloned = value.map(item => cloneDeep(item, ancestors));
17049
+ ancestors.delete(value);
17050
+ return cloned;
17051
+ }
17052
+ const proto = Object.getPrototypeOf(value);
17053
+ if (proto !== Object.prototype && proto !== null) return value;
17054
+ ancestors.add(value);
17055
+ const cloned = {};
17056
+ for (const key of Object.keys(value)) {
17057
+ const clonedValue = cloneDeep(value[key], ancestors);
17058
+ Object.defineProperty(cloned, key, {
17059
+ value: clonedValue,
17060
+ enumerable: true,
17061
+ writable: true,
17062
+ configurable: true
17063
+ });
17064
+ }
17065
+ ancestors.delete(value);
17066
+ return cloned;
17067
+ };
17068
+
17069
+ //#endregion
17070
+ //#region src/types.ts
17071
+ /**
17072
+ * Symbol used as a property key to identify machina FSM instances at runtime.
17073
+ * Each class stamps itself with a MachinaType value so the ChildLink adapter
17074
+ * can dispatch handle()/canHandle()/reset() correctly without circular imports.
17075
+ */
17076
+ const MACHINA_TYPE = Symbol("machina.type");
17077
+
17078
+ //#endregion
17079
+ //#region src/behavioral-fsm.ts
17080
+ const MAX_TRANSITION_DEPTH = 20;
17081
+ /**
17082
+ * Defines FSM behavior (states + transitions) while tracking per-client state
17083
+ * in a `WeakMap`. A single `BehavioralFsm` instance can drive any number of
17084
+ * independent client objects simultaneously — each gets its own state,
17085
+ * deferred queue, and lifecycle.
17086
+ *
17087
+ * Prefer `createBehavioralFsm()` over constructing this directly — the factory
17088
+ * infers all generic parameters from the config object.
17089
+ *
17090
+ * All public methods silently no-op after `dispose()` is called.
17091
+ *
17092
+ * @typeParam TClient - The client object type. Must be an object (non-primitive)
17093
+ * so it can serve as a WeakMap key.
17094
+ * @typeParam TStateNames - String literal union of valid state names.
17095
+ * @typeParam TInputNames - String literal union of valid input names.
17096
+ * @typeParam TBubbles - String literal union of inputs this FSM declares via
17097
+ * `bubbles`. Type-only — carried so `BubblesOfInstance` can extract it from
17098
+ * a constructed instance; nothing at runtime reads this generic.
17099
+ */
17100
+ var BehavioralFsm = class BehavioralFsm {
17101
+ constructor(config) {
17102
+ _defineProperty(this, "id", void 0);
17103
+ _defineProperty(this, "initialState", void 0);
17104
+ _defineProperty(this, MACHINA_TYPE, "BehavioralFsm");
17105
+ _defineProperty(this, "states", void 0);
17106
+ _defineProperty(this, "emitter", new Emitter());
17107
+ _defineProperty(this, "clients", /* @__PURE__ */new WeakMap());
17108
+ _defineProperty(this, "knownClients", /* @__PURE__ */new Set());
17109
+ _defineProperty(this, "childSubscriptions", []);
17110
+ _defineProperty(this, "disposed", false);
17111
+ _defineProperty(this, "transitionDepth", 0);
17112
+ this.id = config.id;
17113
+ this.initialState = config.initialState;
17114
+ this.states = config.states;
17115
+ this.wrapChildLinks();
17116
+ this.setupChildSubscriptions();
17117
+ }
17118
+ /**
17119
+ * Dispatch an input to the given client's current state handler.
17120
+ *
17121
+ * Delegation order: if the current state has a `_child` FSM that can
17122
+ * handle the input, it is dispatched there. If the child emits `nohandler`,
17123
+ * the input bubbles up to this FSM's local handler. If no handler exists
17124
+ * here either, `nohandler` is emitted on this FSM's emitter.
17125
+ *
17126
+ * No-ops silently when disposed.
17127
+ */
17128
+ handle(client, inputName) {
17129
+ var _this$states$meta$sta;
17130
+ if (this.disposed) return;
17131
+ const meta = this.getOrCreateClientMeta(client);
17132
+ for (var _len = arguments.length, args = new Array(_len > 2 ? _len - 2 : 0), _key = 2; _key < _len; _key++) {
17133
+ args[_key - 2] = arguments[_key];
17134
+ }
17135
+ meta.currentActionArgs = args;
17136
+ const childLink = (_this$states$meta$sta = this.states[meta.state]) === null || _this$states$meta$sta === void 0 ? void 0 : _this$states$meta$sta._child;
17137
+ if (childLink) {
17138
+ if (childLink.canHandle(client, inputName)) {
17139
+ try {
17140
+ childLink.handle(client, inputName, ...args);
17141
+ } finally {
17142
+ meta.currentActionArgs = void 0;
17143
+ }
17144
+ return;
17145
+ }
17146
+ }
17147
+ this.handleLocally(client, inputName, args, meta);
17148
+ }
17149
+ /**
17150
+ * Returns true if the client's current state has a handler for `inputName`
17151
+ * (or a catch-all `"*"` handler), or if the current state's `_child` chain
17152
+ * can handle it — the check recurses to the same depth `handle()`'s
17153
+ * delegation can actually reach, so a grandchild-only input answers true
17154
+ * from the root. Does NOT initialize the client — no `_onEnter`, no
17155
+ * events, no side effects. Unseen clients are treated as if they were
17156
+ * already in `initialState`. Returns false when disposed.
17157
+ */
17158
+ canHandle(client, inputName) {
17159
+ var _this$clients$get$sta, _this$clients$get, _stateObj$inputName;
17160
+ if (this.disposed) return false;
17161
+ const state = (_this$clients$get$sta = (_this$clients$get = this.clients.get(client)) === null || _this$clients$get === void 0 ? void 0 : _this$clients$get.state) !== null && _this$clients$get$sta !== void 0 ? _this$clients$get$sta : this.initialState;
17162
+ const stateObj = this.states[state];
17163
+ if ((_stateObj$inputName = stateObj === null || stateObj === void 0 ? void 0 : stateObj[inputName]) !== null && _stateObj$inputName !== void 0 ? _stateObj$inputName : stateObj === null || stateObj === void 0 ? void 0 : stateObj["*"]) return true;
17164
+ const childLink = stateObj === null || stateObj === void 0 ? void 0 : stateObj._child;
17165
+ return childLink ? childLink.canHandle(client, inputName) : false;
17166
+ }
17167
+ /**
17168
+ * Transition the client back to `initialState`, firing `_onEnter` and
17169
+ * lifecycle events as if entering it fresh. No-ops when disposed.
17170
+ */
17171
+ reset(client) {
17172
+ if (this.disposed) return;
17173
+ this.transition(client, this.initialState);
17174
+ }
17175
+ /**
17176
+ * Returns the client's current state, or `undefined` if the client has
17177
+ * never been initialized (i.e. `handle()`, `transition()`, or `reset()`
17178
+ * have never been called for it). Does NOT trigger initialization.
17179
+ */
17180
+ currentState(client) {
17181
+ var _this$clients$get2;
17182
+ return (_this$clients$get2 = this.clients.get(client)) === null || _this$clients$get2 === void 0 ? void 0 : _this$clients$get2.state;
17183
+ }
17184
+ /**
17185
+ * Directly transition `client` to `toState`, running the full lifecycle:
17186
+ * `_onExit` for the current state → `transitioning` event → update state →
17187
+ * `_onEnter` for new state → `transitioned` event → child reset → deferred
17188
+ * queue replay → bounce (if `_onEnter` returned a state name).
17189
+ *
17190
+ * Same-state transitions are silently ignored. Transitions to unknown state
17191
+ * names emit `invalidstate` instead of throwing. Throws if the transition
17192
+ * depth exceeds `MAX_TRANSITION_DEPTH` (likely an `_onEnter` → transition loop).
17193
+ *
17194
+ * No-ops when disposed.
17195
+ */
17196
+ transition(client, toState) {
17197
+ if (this.disposed) return;
17198
+ const meta = this.getOrCreateClientMeta(client);
17199
+ const fromState = meta.state;
17200
+ if (toState === fromState) return;
17201
+ if (!Object.hasOwn(this.states, toState)) {
17202
+ this.emitter.emit("invalidstate", {
17203
+ stateName: toState,
17204
+ client
17205
+ });
17206
+ return;
17207
+ }
17208
+ this.transitionDepth++;
17209
+ if (this.transitionDepth > MAX_TRANSITION_DEPTH) {
17210
+ this.transitionDepth = 0;
17211
+ throw new Error("Max transition depth (".concat(MAX_TRANSITION_DEPTH, ") exceeded in FSM \"").concat(this.id, "\". Likely an infinite _onEnter \u2192 transition loop."));
17212
+ }
17213
+ try {
17214
+ const curStateObj = this.states[fromState];
17215
+ const newStateObj = this.states[toState];
17216
+ if (curStateObj !== null && curStateObj !== void 0 && curStateObj._onExit && typeof curStateObj._onExit === "function") {
17217
+ const exitArgs = this.buildHandlerArgs(client, "", meta);
17218
+ curStateObj._onExit(exitArgs);
17219
+ }
17220
+ meta.state = toState;
17221
+ const payload = {
17222
+ fromState,
17223
+ toState,
17224
+ client
17225
+ };
17226
+ this.emitter.emit("transitioning", payload);
17227
+ let bounceTarget = void 0;
17228
+ if (newStateObj !== null && newStateObj !== void 0 && newStateObj._onEnter && typeof newStateObj._onEnter === "function") {
17229
+ const enterArgs = this.buildHandlerArgs(client, "", meta);
17230
+ bounceTarget = newStateObj._onEnter(enterArgs);
17231
+ }
17232
+ this.emitter.emit("transitioned", payload);
17233
+ const childLink = newStateObj === null || newStateObj === void 0 ? void 0 : newStateObj._child;
17234
+ if (childLink) childLink.reset(client);
17235
+ this.processQueue(client, meta);
17236
+ if (typeof bounceTarget === "string" && meta.state === toState) this.transition(client, bounceTarget);
17237
+ } finally {
17238
+ this.transitionDepth--;
17239
+ }
17240
+ }
17241
+ /**
17242
+ * Returns the client's state as a dot-delimited path including any active
17243
+ * child FSM states (e.g. `"active.connecting.retrying"`). Returns just the
17244
+ * current state name when no child is active. Returns `""` for clients that
17245
+ * have never been initialized (unlike `currentState()` which returns `undefined`).
17246
+ */
17247
+ compositeState(client) {
17248
+ var _this$states$meta$sta2;
17249
+ const meta = this.clients.get(client);
17250
+ if (!meta) return "";
17251
+ const childLink = (_this$states$meta$sta2 = this.states[meta.state]) === null || _this$states$meta$sta2 === void 0 ? void 0 : _this$states$meta$sta2._child;
17252
+ if (childLink) {
17253
+ const childComposite = childLink.compositeState(client);
17254
+ if (childComposite) return "".concat(meta.state, ".").concat(childComposite);
17255
+ }
17256
+ return meta.state;
17257
+ }
17258
+ rehydrate(client, input) {
17259
+ if (this.disposed) return;
17260
+ if (typeof input === "string") {
17261
+ this.rehydrateCompositePath(client, input);
17262
+ return;
17263
+ }
17264
+ const writes = this.planSnapshotWrites(client, input);
17265
+ for (const write of writes) write();
17266
+ }
17267
+ /**
17268
+ * Snapshot everything machina tracks for `client`: current state, pending
17269
+ * deferred inputs, and — recursively — the same for every `_child` that has
17270
+ * ever seen this client, active or not. Feed the result to the object form
17271
+ * of `rehydrate()` to restore it later, deferrals included.
17272
+ *
17273
+ * Returns `undefined` for a client this FSM has never seen (mirrors
17274
+ * `currentState()`) — the call does NOT trigger initialization.
17275
+ *
17276
+ * Throws if any deferred input's args contain a non-serializable value
17277
+ * (function, undefined, symbol, bigint, non-finite number, Date/Map/class
17278
+ * instance, or a circular reference) — naming the input, its `until` target
17279
+ * if any, the FSM id, and the exact value path. Throws for an Fsm child
17280
+ * that's on `client`'s active path *relative to the true root* (consistent
17281
+ * with `rehydrate()`'s conditional throw) — an Fsm owns its own context, so
17282
+ * there's nothing per-client to snapshot. An Fsm child declared at a state
17283
+ * `client` never visited, OR nested under a `BehavioralFsm` child that is
17284
+ * itself off-path from the root, is skipped rather than throwing — neither
17285
+ * has any per-client state to lose, so one Fsm child anywhere in the
17286
+ * hierarchy doesn't disable `dehydrate()` for clients that never reach that
17287
+ * branch, no matter how deeply nested the Fsm child is.
17288
+ *
17289
+ * Meant for clients at rest between `handle()` calls — `currentActionArgs`
17290
+ * (the in-flight args mid-handler) has no meaning here and is excluded.
17291
+ *
17292
+ * @param isOnActivePath - @internal Whether this FSM itself is currently
17293
+ * reachable from the true root's active path. Defaults to `true` for the
17294
+ * public entry point (this FSM IS the root from its own perspective); the
17295
+ * `ChildLink` adapter passes `false` down when recursing into a nested
17296
+ * `BehavioralFsm` child that is itself off-path, so that child's own
17297
+ * Fsm-child checks don't recompute reachability from its dormant local
17298
+ * state alone.
17299
+ */
17300
+ dehydrate(client) {
17301
+ let isOnActivePath = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true;
17302
+ const meta = this.clients.get(client);
17303
+ if (!meta) return;
17304
+ const snapshot = {
17305
+ state: meta.state,
17306
+ deferred: meta.deferredQueue.map(item => this.snapshotDeferredInput(item))
17307
+ };
17308
+ const children = this.collectChildSnapshots(client, meta.state, isOnActivePath);
17309
+ if (children) snapshot.children = children;
17310
+ return snapshot;
17311
+ }
17312
+ /**
17313
+ * @internal
17314
+ * Validates `snapshot` against this level's state graph and recurses into
17315
+ * every declared child, returning write thunks to run only once the WHOLE
17316
+ * tree — every level — validates successfully. Nothing is written here.
17317
+ * Called by the object-form of `rehydrate()` and, recursively, by ChildLink
17318
+ * so a nested BehavioralFsm participates in the same validate-then-write
17319
+ * pass. Not part of the public persistence API — call `rehydrate()` instead.
17320
+ */
17321
+ planSnapshotWrites(client, snapshot) {
17322
+ if (this.disposed) return [];
17323
+ const state = snapshot.state,
17324
+ deferred = snapshot.deferred,
17325
+ children = snapshot.children;
17326
+ if (!Object.hasOwn(this.states, state)) throw new Error("rehydrate: unknown state \"".concat(state, "\" in FSM \"").concat(this.id, "\". Valid states: ").concat(Object.keys(this.states).join(", ")));
17327
+ const writes = [];
17328
+ if (children) for (const stateName of Object.keys(children)) {
17329
+ var _this$states$stateNam;
17330
+ if (!Object.hasOwn(this.states, stateName)) throw new Error("rehydrate: unknown state \"".concat(stateName, "\" in FSM \"").concat(this.id, "\" referenced by snapshot.children."));
17331
+ const childLink = (_this$states$stateNam = this.states[stateName]) === null || _this$states$stateNam === void 0 ? void 0 : _this$states$stateNam._child;
17332
+ if (!childLink) throw new Error("rehydrate: state \"".concat(stateName, "\" in FSM \"").concat(this.id, "\" has no _child, but the snapshot has a children[\"").concat(stateName, "\"] entry."));
17333
+ writes.push(...childLink.planRehydrate(client, children[stateName]));
17334
+ }
17335
+ const deferredQueue = deferred.map(item => _objectSpread2({
17336
+ inputName: item.inputName,
17337
+ args: cloneDeep(item.args)
17338
+ }, item.untilState !== void 0 ? {
17339
+ untilState: item.untilState
17340
+ } : {}));
17341
+ writes.push(() => {
17342
+ if (!this.clients.has(client)) this.knownClients.add(new WeakRef(client));
17343
+ this.clients.set(client, {
17344
+ state,
17345
+ deferredQueue
17346
+ });
17347
+ });
17348
+ return writes;
17349
+ }
17350
+ rehydrateCompositePath(client, compositeState) {
17351
+ const _compositeState$split = compositeState.split("."),
17352
+ _compositeState$split2 = _toArray(_compositeState$split),
17353
+ state = _compositeState$split2[0],
17354
+ rest = _arrayLikeToArray(_compositeState$split2).slice(1);
17355
+ if (!Object.hasOwn(this.states, state)) throw new Error("rehydrate: unknown state \"".concat(state, "\" in FSM \"").concat(this.id, "\". Valid states: ").concat(Object.keys(this.states).join(", ")));
17356
+ if (rest.length > 0) {
17357
+ var _this$states$state;
17358
+ const childPath = rest.join(".");
17359
+ const childLink = (_this$states$state = this.states[state]) === null || _this$states$state === void 0 ? void 0 : _this$states$state._child;
17360
+ if (!childLink) throw new Error("rehydrate: state \"".concat(state, "\" in FSM \"").concat(this.id, "\" has no _child, but composite path \"").concat(compositeState, "\" requires one."));
17361
+ childLink.rehydrate(client, childPath);
17362
+ }
17363
+ if (!this.clients.has(client)) this.knownClients.add(new WeakRef(client));
17364
+ this.clients.set(client, {
17365
+ state,
17366
+ deferredQueue: []
17367
+ });
17368
+ }
17369
+ /**
17370
+ * Walks every declared state's `_child`, dehydrating each one that has ever
17371
+ * seen `client`. The same child instance can be declared under multiple
17372
+ * state names (shared child) — it's dehydrated once and the result is
17373
+ * reused under every declaring state name, matching how the engine already
17374
+ * treats shared children elsewhere (dispose, event subscriptions).
17375
+ *
17376
+ * Declaring names are grouped by `childLink.instance` (the actual FSM
17377
+ * instance, not the `ChildLink` wrapper — `wrapChildLinks()` mints a fresh
17378
+ * wrapper per declaring state, so grouping by wrapper would never hit for a
17379
+ * shared child) BEFORE any `onPath` is computed or any child is dehydrated.
17380
+ * A shared child's `onPath` is the OR of `stateName === activeState` across
17381
+ * every declaring name in its group: at most one declaring name can ever
17382
+ * equal `activeState`, so this combined flag is correct and, critically,
17383
+ * independent of `Object.keys(this.states)` iteration order — computing
17384
+ * `onPath` per-declaring-name and caching whichever one happened to run
17385
+ * first would silently launder an on-path client's Fsm-child throw through
17386
+ * an unrelated off-path declaring name.
17387
+ *
17388
+ * An off-path Fsm child (declared only at states other than `activeState`,
17389
+ * OR nested anywhere under a `BehavioralFsm` child that is itself off-path
17390
+ * relative to the true root) is skipped entirely rather than dehydrated:
17391
+ * Fsm state isn't tracked per-client to begin with, so an off-path Fsm
17392
+ * child has nothing to lose by being skipped — unlike a BehavioralFsm
17393
+ * child's off-path meta, which is real per-client data. `isOnActivePath`
17394
+ * is the inherited "am I even reachable from the root" flag; combining it
17395
+ * with the group's `stateNames.includes(activeState)` check (rather than
17396
+ * using that check alone) is what keeps a nested Fsm grandchild from
17397
+ * throwing when its immediate BehavioralFsm parent is itself off-path —
17398
+ * the parent's own dormant `activeState` is irrelevant once the parent
17399
+ * isn't reachable. This keeps one Fsm child anywhere in the hierarchy from
17400
+ * disabling `dehydrate()` for every client, only for clients actually on
17401
+ * that branch.
17402
+ */
17403
+ collectChildSnapshots(client, activeState, isOnActivePath) {
17404
+ const declaringNamesByInstance = /* @__PURE__ */new Map();
17405
+ for (const stateName of Object.keys(this.states)) {
17406
+ var _this$states$stateNam2;
17407
+ const childLink = (_this$states$stateNam2 = this.states[stateName]) === null || _this$states$stateNam2 === void 0 ? void 0 : _this$states$stateNam2._child;
17408
+ if (!childLink) continue;
17409
+ const entry = declaringNamesByInstance.get(childLink.instance);
17410
+ if (entry) entry.stateNames.push(stateName);else declaringNamesByInstance.set(childLink.instance, {
17411
+ childLink,
17412
+ stateNames: [stateName]
17413
+ });
17414
+ }
17415
+ let children;
17416
+ for (const _ref of declaringNamesByInstance.values()) {
17417
+ const childLink = _ref.childLink;
17418
+ const stateNames = _ref.stateNames;
17419
+ const onPath = isOnActivePath && stateNames.includes(activeState);
17420
+ if (childLink.instance[MACHINA_TYPE] === "Fsm" && !onPath) continue;
17421
+ const childSnapshot = childLink.dehydrate(client, onPath);
17422
+ if (childSnapshot) {
17423
+ children !== null && children !== void 0 ? children : children = {};
17424
+ for (const stateName of stateNames) children[stateName] = childSnapshot;
17425
+ }
17426
+ }
17427
+ return children;
17428
+ }
17429
+ snapshotDeferredInput(item) {
17430
+ let clonedArgs;
17431
+ try {
17432
+ clonedArgs = cloneJsonSafe(item.args, "args");
17433
+ } catch (err) {
17434
+ if (!(err instanceof NonSerializableValueError)) throw err;
17435
+ const untilPart = item.untilState ? " (until \"".concat(item.untilState, "\")") : "";
17436
+ throw new Error("dehydrate: deferred input \"".concat(item.inputName, "\"").concat(untilPart, " in FSM \"").concat(this.id, "\" has a non-serializable value at ").concat(err.path, " (").concat(err.label, ")"));
17437
+ }
17438
+ const snapshot = {
17439
+ inputName: item.inputName,
17440
+ args: clonedArgs
17441
+ };
17442
+ if (item.untilState !== void 0) snapshot.untilState = item.untilState;
17443
+ return snapshot;
17444
+ }
17445
+ on(eventName, callback) {
17446
+ if (this.disposed) return {
17447
+ off() {}
17448
+ };
17449
+ return this.emitter.on(eventName, callback);
17450
+ }
17451
+ /**
17452
+ * Emit a custom event through the FSM. Built-in lifecycle events are
17453
+ * emitted automatically — this is for user-defined events from handlers.
17454
+ * No-ops when disposed.
17455
+ */
17456
+ emit(eventName, data) {
17457
+ if (this.disposed) return;
17458
+ this.emitter.emit(eventName, data);
17459
+ }
17460
+ /**
17461
+ * Permanently shut down this FSM. Irreversible — all subsequent method
17462
+ * calls become silent no-ops. Tears down child subscriptions, clears all
17463
+ * listeners, and cascades disposal to child FSMs (unless `preserveChildren`
17464
+ * is set). The same child appearing in multiple states is disposed once.
17465
+ */
17466
+ dispose(options) {
17467
+ this.disposed = true;
17468
+ for (const sub of this.childSubscriptions) sub.off();
17469
+ if (!(options !== null && options !== void 0 && options.preserveChildren)) {
17470
+ const seen = /* @__PURE__ */new Set();
17471
+ for (const stateName of Object.keys(this.states)) {
17472
+ var _this$states$stateNam3;
17473
+ const childLink = (_this$states$stateNam3 = this.states[stateName]) === null || _this$states$stateNam3 === void 0 ? void 0 : _this$states$stateNam3._child;
17474
+ if (childLink && !seen.has(childLink.instance)) {
17475
+ seen.add(childLink.instance);
17476
+ childLink.dispose();
17477
+ }
17478
+ }
17479
+ }
17480
+ this.emitter.clear();
17481
+ }
17482
+ /**
17483
+ * Walks all states at construction time, detects raw FSM instances assigned
17484
+ * to _child, and wraps them into ChildLink adapters via createChildLink().
17485
+ * Must run BEFORE setupChildSubscriptions() so the subscriptions see
17486
+ * ChildLink objects, not raw FSM instances.
17487
+ */
17488
+ wrapChildLinks() {
17489
+ for (const stateName of Object.keys(this.states)) {
17490
+ const stateObj = this.states[stateName];
17491
+ const rawChild = stateObj === null || stateObj === void 0 ? void 0 : stateObj._child;
17492
+ if (!rawChild) continue;
17493
+ if (typeof rawChild !== "object") throw new Error("State \"".concat(stateName, "\"._child: expected an Fsm or BehavioralFsm instance, got ").concat(String(rawChild)));
17494
+ if (!(MACHINA_TYPE in rawChild)) throw new Error("State \"".concat(stateName, "\"._child: expected an Fsm or BehavioralFsm instance, got a plain object"));
17495
+ stateObj._child = createChildLink(rawChild);
17496
+ }
17497
+ }
17498
+ /**
17499
+ * Walks all states at construction time, finds states with _child, and
17500
+ * subscribes once to each unique child's wildcard events. Subscriptions are
17501
+ * stored for cleanup in dispose(). We deduplicate by `childLink.instance`
17502
+ * (the underlying Fsm/BehavioralFsm instance), not the `ChildLink` wrapper —
17503
+ * `wrapChildLinks()` mints a fresh wrapper per declaring state, so a child
17504
+ * shared across states would otherwise get one subscription PER declaring
17505
+ * state, each independently walking known clients and relaying events. That
17506
+ * silently double-fires client-less relays (Fsm-child events, or a
17507
+ * BehavioralFsm child's custom `emit()` with no `client` in the payload)
17508
+ * whenever two different clients are active on two different declaring
17509
+ * names at once — the same wrapper-vs-instance identity bug
17510
+ * `collectChildSnapshots()` had before its #184 fix.
17511
+ */
17512
+ setupChildSubscriptions() {
17513
+ const seenInstances = /* @__PURE__ */new Set();
17514
+ for (const stateName of Object.keys(this.states)) {
17515
+ var _this$states$stateNam4;
17516
+ const childLink = (_this$states$stateNam4 = this.states[stateName]) === null || _this$states$stateNam4 === void 0 ? void 0 : _this$states$stateNam4._child;
17517
+ if (!childLink || seenInstances.has(childLink.instance)) continue;
17518
+ seenInstances.add(childLink.instance);
17519
+ const sub = childLink.onAny((eventName, data) => {
17520
+ if (eventName === "nohandler") {
17521
+ var _payload$args;
17522
+ const payload = data;
17523
+ if (payload.client !== void 0) this.bubbleNohandler(payload.client, childLink, payload.inputName, (_payload$args = payload.args) !== null && _payload$args !== void 0 ? _payload$args : []);else for (const ref of this.knownClients) {
17524
+ var _payload$args2;
17525
+ const client = ref.deref();
17526
+ if (client === void 0) {
17527
+ this.knownClients.delete(ref);
17528
+ continue;
17529
+ }
17530
+ this.bubbleNohandler(client, childLink, payload.inputName, (_payload$args2 = payload.args) !== null && _payload$args2 !== void 0 ? _payload$args2 : []);
17531
+ }
17532
+ return;
17533
+ }
17534
+ const payload = data;
17535
+ if (payload && typeof payload === "object" && "client" in payload) {
17536
+ if (this.isChildActiveForClient(payload.client, childLink)) this.emitter.emit(eventName, data);
17537
+ } else for (const ref of this.knownClients) {
17538
+ const client = ref.deref();
17539
+ if (!client) {
17540
+ this.knownClients.delete(ref);
17541
+ continue;
17542
+ }
17543
+ if (this.isChildActiveForClient(client, childLink)) {
17544
+ this.emitter.emit(eventName, data);
17545
+ break;
17546
+ }
17547
+ }
17548
+ });
17549
+ this.childSubscriptions.push(sub);
17550
+ }
17551
+ }
17552
+ /**
17553
+ * Bubbles a child nohandler to the parent for the given client.
17554
+ * Only fires if the client is currently in a state that has this childLink.
17555
+ * Extracted from the lambda in setupChildSubscriptions to keep it readable.
17556
+ */
17557
+ bubbleNohandler(client, childLink, inputName, args) {
17558
+ if (!this.isChildActiveForClient(client, childLink)) return;
17559
+ const meta = this.clients.get(client);
17560
+ meta.currentActionArgs = args;
17561
+ this.handleLocally(client, inputName, args, meta);
17562
+ }
17563
+ /**
17564
+ * Returns true if the given client is currently in a parent state whose
17565
+ * _child resolves to the same underlying instance as childLink. Returns
17566
+ * false if the client has no meta (never initialized) or is in a state
17567
+ * with a different (or no) child.
17568
+ *
17569
+ * Compares `.instance`, not the `ChildLink` wrapper itself — setupChildSubscriptions()
17570
+ * dedupes subscriptions by instance and keeps only ONE representative wrapper
17571
+ * per shared child, so the client's actual active declaring state may hold a
17572
+ * DIFFERENT wrapper for that same instance (wrapChildLinks() mints one per
17573
+ * declaring state). Comparing wrappers directly would only ever match the one
17574
+ * declaring state whose wrapper happened to be kept for the subscription,
17575
+ * silently breaking relay for every other declaring name of a shared child.
17576
+ */
17577
+ isChildActiveForClient(client, childLink) {
17578
+ var _this$states$meta$sta3;
17579
+ const meta = this.clients.get(client);
17580
+ if (!meta) return false;
17581
+ return ((_this$states$meta$sta3 = this.states[meta.state]) === null || _this$states$meta$sta3 === void 0 || (_this$states$meta$sta3 = _this$states$meta$sta3._child) === null || _this$states$meta$sta3 === void 0 ? void 0 : _this$states$meta$sta3.instance) === childLink.instance;
17582
+ }
17583
+ /**
17584
+ * The inner handler dispatch — no delegation, no initialization side effects
17585
+ * beyond what getOrCreateClientMeta already did. Called by handle() after
17586
+ * the delegation check, and by the nohandler child listener for bubbling.
17587
+ */
17588
+ handleLocally(client, inputName, args, meta) {
17589
+ var _stateObj$inputName2;
17590
+ const stateObj = this.states[meta.state];
17591
+ const handler = (_stateObj$inputName2 = stateObj === null || stateObj === void 0 ? void 0 : stateObj[inputName]) !== null && _stateObj$inputName2 !== void 0 ? _stateObj$inputName2 : stateObj === null || stateObj === void 0 ? void 0 : stateObj["*"];
17592
+ if (!handler) {
17593
+ this.emitter.emit("nohandler", {
17594
+ inputName,
17595
+ args,
17596
+ client
17597
+ });
17598
+ meta.currentActionArgs = void 0;
17599
+ return;
17600
+ }
17601
+ try {
17602
+ this.emitter.emit("handling", {
17603
+ inputName,
17604
+ client
17605
+ });
17606
+ const handlerArgs = this.buildHandlerArgs(client, inputName, meta);
17607
+ let targetState = void 0;
17608
+ if (typeof handler === "string") targetState = handler;else if (typeof handler === "function") targetState = handler(handlerArgs, ...args);
17609
+ this.emitter.emit("handled", {
17610
+ inputName,
17611
+ client
17612
+ });
17613
+ if (typeof targetState === "string") this.transition(client, targetState);
17614
+ } finally {
17615
+ meta.currentActionArgs = void 0;
17616
+ }
17617
+ }
17618
+ getOrCreateClientMeta(client) {
17619
+ let meta = this.clients.get(client);
17620
+ if (meta) return meta;
17621
+ meta = {
17622
+ state: void 0,
17623
+ deferredQueue: []
17624
+ };
17625
+ this.clients.set(client, meta);
17626
+ this.knownClients.add(new WeakRef(client));
17627
+ this.transition(client, this.initialState);
17628
+ return meta;
17629
+ }
17630
+ buildHandlerArgs(client, inputName, meta) {
17631
+ return {
17632
+ ctx: client,
17633
+ inputName,
17634
+ defer: opts => {
17635
+ if (!meta.currentActionArgs) return;
17636
+ const deferred = {
17637
+ inputName,
17638
+ args: [...meta.currentActionArgs],
17639
+ untilState: opts === null || opts === void 0 ? void 0 : opts.until
17640
+ };
17641
+ meta.deferredQueue.push(deferred);
17642
+ this.emitter.emit("deferred", {
17643
+ inputName,
17644
+ client
17645
+ });
17646
+ },
17647
+ emit: (evtName, evtData) => {
17648
+ this.emitter.emit(evtName, evtData);
17649
+ }
17650
+ };
17651
+ }
17652
+ processQueue(client, meta) {
17653
+ const toReplay = [];
17654
+ const remaining = [];
17655
+ for (const item of meta.deferredQueue) if (item.untilState === void 0 || item.untilState === meta.state) toReplay.push(item);else remaining.push(item);
17656
+ meta.deferredQueue = remaining;
17657
+ for (const item of toReplay) this.handle(client, item.inputName, ...item.args);
17658
+ }
17659
+ };
17660
+ /**
17661
+ * Internal factory called by `wrapChildLinks()` during construction.
17662
+ * Wraps a raw Fsm or BehavioralFsm instance in a uniform `ChildLink` adapter
17663
+ * so the parent engine doesn't need to know which type it's talking to.
17664
+ *
17665
+ * Users never call this directly — they assign an FSM instance to `_child`
17666
+ * in their state config and `wrapChildLinks()` handles the wrapping.
17667
+ */
17668
+ function createChildLink(child) {
17669
+ if (!child || typeof child !== "object") throw new Error("createChildLink: expected an Fsm or BehavioralFsm instance, got ".concat(String(child)));
17670
+ const childType = child[MACHINA_TYPE];
17671
+ if (childType === "BehavioralFsm") return {
17672
+ instance: child,
17673
+ canHandle(client, inputName) {
17674
+ return child.canHandle(client, inputName);
17675
+ },
17676
+ handle(client, inputName) {
17677
+ for (var _len2 = arguments.length, args = new Array(_len2 > 2 ? _len2 - 2 : 0), _key2 = 2; _key2 < _len2; _key2++) {
17678
+ args[_key2 - 2] = arguments[_key2];
17679
+ }
17680
+ child.handle(client, inputName, ...args);
17681
+ },
17682
+ reset(client) {
17683
+ child.transition(client, child.initialState);
17684
+ },
17685
+ onAny(callback) {
17686
+ return child.on("*", callback);
17687
+ },
17688
+ compositeState(client) {
17689
+ return child.compositeState(client);
17690
+ },
17691
+ rehydrate(client, compositeState) {
17692
+ child.rehydrate(client, compositeState);
17693
+ },
17694
+ dehydrate(client, isOnActivePath) {
17695
+ return child.dehydrate(client, isOnActivePath);
17696
+ },
17697
+ planRehydrate(client, snapshot) {
17698
+ return child.planSnapshotWrites(client, snapshot);
17699
+ },
17700
+ dispose() {
17701
+ child.dispose();
17702
+ }
17703
+ };
17704
+ if (childType === "Fsm") return {
17705
+ instance: child,
17706
+ canHandle(_client, inputName) {
17707
+ return child.canHandle(inputName);
17708
+ },
17709
+ handle(_client, inputName) {
17710
+ for (var _len3 = arguments.length, args = new Array(_len3 > 2 ? _len3 - 2 : 0), _key3 = 2; _key3 < _len3; _key3++) {
17711
+ args[_key3 - 2] = arguments[_key3];
17712
+ }
17713
+ child.handle(inputName, ...args);
17714
+ },
17715
+ reset(_client) {
17716
+ child.reset();
17717
+ },
17718
+ onAny(callback) {
17719
+ return child.on("*", callback);
17720
+ },
17721
+ compositeState(_client) {
17722
+ return child.compositeState();
17723
+ },
17724
+ rehydrate(_client, _compositeState) {
17725
+ throw new Error("rehydrate: cannot rehydrate an Fsm child. Fsm owns its own context; rehydrate is only valid for BehavioralFsm hierarchies.");
17726
+ },
17727
+ dehydrate(_client, _isOnActivePath) {
17728
+ throw new Error("dehydrate: cannot dehydrate an Fsm child. Fsm owns its own context; dehydrate is only valid for BehavioralFsm hierarchies.");
17729
+ },
17730
+ planRehydrate(_client, _snapshot) {
17731
+ throw new Error("rehydrate: cannot rehydrate an Fsm child. Fsm owns its own context; rehydrate is only valid for BehavioralFsm hierarchies.");
17732
+ },
17733
+ dispose() {
17734
+ child.dispose();
17735
+ }
17736
+ };
17737
+ throw new Error("createChildLink: expected an Fsm or BehavioralFsm instance, got [MACHINA_TYPE] = ".concat(String(childType !== null && childType !== void 0 ? childType : "undefined")));
17738
+ }
17739
+
17740
+ //#endregion
17741
+ //#region src/fsm.ts
17742
+ /**
17743
+ * Single-client FSM. Wraps a BehavioralFsm and uses the config's `context`
17744
+ * object as the implicit client, so callers never pass a client argument.
17745
+ *
17746
+ * Prefer `createFsm()` over constructing this directly — the factory infers
17747
+ * all generic parameters from the config object.
17748
+ *
17749
+ * All public methods silently no-op after `dispose()` is called.
17750
+ *
17751
+ * @typeParam TCtx - The context type, inferred from `config.context`.
17752
+ * @typeParam TStateNames - String literal union of valid state names.
17753
+ * @typeParam TInputNames - String literal union of valid input names.
17754
+ * @typeParam TBubbles - String literal union of inputs this FSM declares via
17755
+ * `bubbles`. Type-only — carried so `BubblesOfInstance` can extract it from
17756
+ * a constructed instance; nothing at runtime reads this generic.
17757
+ */
17758
+ var Fsm = class Fsm {
17759
+ constructor(config) {
17760
+ var _config$context;
17761
+ _defineProperty(this, "id", void 0);
17762
+ _defineProperty(this, "initialState", void 0);
17763
+ _defineProperty(this, MACHINA_TYPE, "Fsm");
17764
+ _defineProperty(this, "states", void 0);
17765
+ _defineProperty(this, "bfsm", void 0);
17766
+ _defineProperty(this, "context", void 0);
17767
+ _defineProperty(this, "emitter", new Emitter());
17768
+ _defineProperty(this, "disposed", false);
17769
+ this.id = config.id;
17770
+ this.initialState = config.initialState;
17771
+ this.context = (_config$context = config.context) !== null && _config$context !== void 0 ? _config$context : {};
17772
+ this.bfsm = new BehavioralFsm(config);
17773
+ this.states = config.states;
17774
+ this.bfsm.on("*", (eventName, data) => {
17775
+ if (data && typeof data === "object" && "client" in data) {
17776
+ data.client;
17777
+ const payload = _objectWithoutProperties(data, _excluded);
17778
+ this.emitter.emit(eventName, payload);
17779
+ } else this.emitter.emit(eventName, data);
17780
+ });
17781
+ this.bfsm.transition(this.context, config.initialState);
17782
+ }
17783
+ /**
17784
+ * Dispatch an input to the current state's handler.
17785
+ * If a `_child` FSM in the current state can handle it, delegation occurs
17786
+ * there first; unhandled inputs bubble up to the parent.
17787
+ * No-ops silently when disposed.
17788
+ */
17789
+ handle(inputName) {
17790
+ if (this.disposed) return;
17791
+ for (var _len4 = arguments.length, args = new Array(_len4 > 1 ? _len4 - 1 : 0), _key4 = 1; _key4 < _len4; _key4++) {
17792
+ args[_key4 - 1] = arguments[_key4];
17793
+ }
17794
+ this.bfsm.handle(this.context, inputName, ...args);
17795
+ }
17796
+ /**
17797
+ * Returns true if the current state has a handler for `inputName`
17798
+ * (or a catch-all `"*"` handler), or if the current state's `_child`
17799
+ * chain can handle it (checked recursively, matching `handle()`'s
17800
+ * delegation reach). Does not trigger initialization or any side
17801
+ * effects. Returns false when disposed.
17802
+ */
17803
+ canHandle(inputName) {
17804
+ if (this.disposed) return false;
17805
+ return this.bfsm.canHandle(this.context, inputName);
17806
+ }
17807
+ /**
17808
+ * Transition back to `initialState`, firing `_onEnter` and lifecycle
17809
+ * events as if entering it fresh. No-ops silently when disposed.
17810
+ */
17811
+ reset() {
17812
+ if (this.disposed) return;
17813
+ this.bfsm.reset(this.context);
17814
+ }
17815
+ /**
17816
+ * Returns the current state name. Always defined — Fsm eagerly
17817
+ * initializes into `initialState` during construction.
17818
+ */
17819
+ currentState() {
17820
+ return this.bfsm.currentState(this.context);
17821
+ }
17822
+ /**
17823
+ * Directly transition to `toState`, firing `_onExit`, `_onEnter`, and
17824
+ * lifecycle events. Same-state transitions are silently ignored.
17825
+ * No-ops when disposed.
17826
+ */
17827
+ transition(toState) {
17828
+ if (this.disposed) return;
17829
+ this.bfsm.transition(this.context, toState);
17830
+ }
17831
+ /**
17832
+ * Returns the current state as a dot-delimited path that includes
17833
+ * any active child FSM states (e.g. `"active.connecting.retrying"`).
17834
+ * Returns just the current state name when no child is active.
17835
+ */
17836
+ compositeState() {
17837
+ return this.bfsm.compositeState(this.context);
17838
+ }
17839
+ on(eventName, callback) {
17840
+ if (this.disposed) return {
17841
+ off() {}
17842
+ };
17843
+ return this.emitter.on(eventName, callback);
17844
+ }
17845
+ /**
17846
+ * Emit a custom event through the FSM. Built-in lifecycle events are
17847
+ * emitted automatically — this is for user-defined events from handlers.
17848
+ * Routes through the BehavioralFsm so all relay paths are consistent.
17849
+ * No-ops when disposed.
17850
+ */
17851
+ emit(eventName, data) {
17852
+ if (this.disposed) return;
17853
+ this.bfsm.emit(eventName, data);
17854
+ }
17855
+ /**
17856
+ * Permanently shut down this FSM. Irreversible — all subsequent method
17857
+ * calls become silent no-ops. Clears all listeners and cascades disposal
17858
+ * to child FSMs (unless `preserveChildren` is set).
17859
+ */
17860
+ dispose(options) {
17861
+ this.disposed = true;
17862
+ this.bfsm.dispose(options);
17863
+ this.emitter.clear();
17864
+ }
17865
+ };
17866
+ /**
17867
+ * Create a single-client FSM from a config object.
17868
+ *
17869
+ * Generic parameters are inferred automatically:
17870
+ * - `TCtx` comes from `config.context` (defaults to `{}` if omitted).
17871
+ * - `TStates` is captured with `const` inference to preserve string literal
17872
+ * types, enabling compile-time validation of transition targets and `handle()`
17873
+ * input names.
17874
+ *
17875
+ * State names, input names, and all handler signatures derive from `TStates`.
17876
+ *
17877
+ * @example
17878
+ * ```ts
17879
+ * const light = createFsm({
17880
+ * id: "traffic-light",
17881
+ * initialState: "green",
17882
+ * context: { tickCount: 0 },
17883
+ * states: {
17884
+ * green: { timeout: "yellow" },
17885
+ * yellow: { timeout: "red" },
17886
+ * red: { timeout: "green" },
17887
+ * },
17888
+ * });
17889
+ *
17890
+ * light.handle("timeout"); // transitions green → yellow
17891
+ * ```
17892
+ */
17893
+ function createFsm(config) {
17894
+ return new Fsm(config);
17895
+ }/**
17896
+ * Declares a handler for one input, restoring the payload typing that machina's `...unknown[]`
17897
+ * handler arguments give up.
17898
+ */
17899
+ function on(handler) {
17900
+ return handler;
17901
+ }
17902
+ /**
17903
+ * Whether a transport-originated input belongs to the attempt that currently owns the session.
17904
+ * Inputs from a superseded attempt are dropped: its transport is already being replaced, so it can
17905
+ * neither declare the session live nor take it down.
17906
+ */
17907
+ function isCurrentAttempt(ctx, event) {
17908
+ return event.attemptId === ctx.attemptId;
17909
+ }
17910
+ const attemptEstablished = on((_ref, event) => {
17911
+ let ctx = _ref.ctx;
17912
+ if (!isCurrentAttempt(ctx, event)) {
17913
+ return;
17914
+ }
17915
+ return 'connected';
17916
+ });
17917
+ const startConnect = on(_ref2 => {
17918
+ let ctx = _ref2.ctx;
17919
+ ctx.attemptId += 1;
17920
+ ctx.lastError = undefined;
17921
+ return 'connecting';
17922
+ });
17923
+ const startReconnect = on(_ref3 => {
17924
+ let ctx = _ref3.ctx;
17925
+ ctx.attemptId += 1;
17926
+ ctx.lastError = undefined;
17927
+ return 'reconnecting';
17928
+ });
17929
+ const requestClose = on((_ref4, event) => {
17930
+ let ctx = _ref4.ctx;
17931
+ ctx.closeReason = event.reason;
17932
+ return 'disconnecting';
17933
+ });
17934
+ const signalStates = {
17935
+ new: {
17936
+ connect: startConnect,
17937
+ close: requestClose
17938
+ },
17939
+ connecting: {
17940
+ connectComplete: attemptEstablished,
17941
+ // An initial connect has no session to fall back on, so failure is terminal.
17942
+ connectFailed: on((_ref5, event) => {
17943
+ let ctx = _ref5.ctx;
17944
+ ctx.lastError = event.error;
17945
+ return 'closed';
17946
+ }),
17947
+ close: requestClose
17948
+ },
17949
+ connected: {
17950
+ reconnect: startReconnect,
17951
+ transportFailed: on((_ref6, event) => {
17952
+ let ctx = _ref6.ctx;
17953
+ if (!isCurrentAttempt(ctx, event)) {
17954
+ // a transport that has already been replaced, reporting its close late
17955
+ return;
17956
+ }
17957
+ ctx.lastError = event.reason;
17958
+ return 'offline';
17959
+ }),
17960
+ close: requestClose
17961
+ },
17962
+ offline: {
17963
+ connect: startConnect,
17964
+ reconnect: startReconnect,
17965
+ close: requestClose
17966
+ },
17967
+ reconnecting: {
17968
+ reconnectComplete: attemptEstablished,
17969
+ reconnectFailed: on((_ref7, event) => {
17970
+ let ctx = _ref7.ctx;
17971
+ ctx.lastError = event.error;
17972
+ return event.recoverable ? 'offline' : 'closed';
17973
+ }),
17974
+ close: requestClose
17975
+ },
17976
+ // Owns the transport until the close handshake settles. Every path into this state is followed
17977
+ // by a `closeComplete`, so it cannot become a trap.
17978
+ disconnecting: {
17979
+ closeComplete: 'closed'
17980
+ },
17981
+ // No transport, but the session may still be resumable: the engine resumes after an unexpected
17982
+ // close just as it does from `offline`.
17983
+ closed: {
17984
+ connect: startConnect,
17985
+ reconnect: startReconnect
17986
+ }
17987
+ };
17988
+ /**
17989
+ * Lifecycle model of the signal connection.
17990
+ *
17991
+ * The machine deliberately does not own connection attempts: `SignalClient` performs the
17992
+ * asynchronous work and reports the outcome. It also does not decide *whether* to reconnect —
17993
+ * that policy (backoff, resume vs. full reconnect, region failover, giving up) belongs to
17994
+ * `RTCEngine`, so transport loss lands in `offline` rather than starting a reconnect on its own.
17995
+ *
17996
+ * Each client gets its own instance: the context is mutable and per-connection.
17997
+ */
17998
+ function createSignalMachine() {
17999
+ let initialState = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'new';
18000
+ const context = {
18001
+ attemptId: 0
18002
+ };
18003
+ return createFsm({
18004
+ id: 'signal',
18005
+ initialState: initialState,
18006
+ context,
18007
+ states: signalStates
18008
+ });
16623
18009
  }/**
16624
18010
  * [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) with [Streams API](https://developer.mozilla.org/en-US/docs/Web/API/Streams_API)
16625
18011
  *
@@ -16657,7 +18043,14 @@ class WebSocketStream {
16657
18043
  let data = _ref2.data;
16658
18044
  return controller.enqueue(data);
16659
18045
  };
16660
- ws.onerror = e => controller.error(e);
18046
+ ws.onerror = e => controller.error(ConnectionError.websocket(e instanceof Error ? "".concat(e.name, ": ").concat(e.message) : "Encountered unknown websocket error: ".concat(String(e))));
18047
+ ws.onclose = ev => {
18048
+ if (ev.wasClean) {
18049
+ controller.close();
18050
+ } else {
18051
+ controller.error(ConnectionError.websocket("WS closed unexpectedly with code ".concat(ev.code)));
18052
+ }
18053
+ };
16661
18054
  },
16662
18055
  cancel: closeWithInfo
16663
18056
  }),
@@ -16696,7 +18089,7 @@ class WebSocketStream {
16696
18089
  resolve(reason);
16697
18090
  }
16698
18091
  });
16699
- ws.onclose = _ref3 => {
18092
+ ws.addEventListener('close', _ref3 => {
16700
18093
  let code = _ref3.code,
16701
18094
  reason = _ref3.reason;
16702
18095
  resolve({
@@ -16704,7 +18097,7 @@ class WebSocketStream {
16704
18097
  reason
16705
18098
  });
16706
18099
  ws.removeEventListener('error', rejectHandler);
16707
- };
18100
+ });
16708
18101
  ws.addEventListener('error', rejectHandler);
16709
18102
  });
16710
18103
  if (options.signal) {
@@ -16713,6 +18106,16 @@ class WebSocketStream {
16713
18106
  this.close = closeWithInfo;
16714
18107
  }
16715
18108
  }const passThroughQueueSignals = ['syncState', 'trickle', 'offer', 'answer', 'simulate', 'leave'];
18109
+ /**
18110
+ * Whether a failed resume may be followed by another one. Mirrors how `RTCEngine` classifies these
18111
+ * errors: a server leave ends the session, and an expired token cannot be recovered by retrying.
18112
+ */
18113
+ function isRecoverableReconnectError(error) {
18114
+ if (error instanceof ConnectionError) {
18115
+ return error.reason !== ConnectionErrorReason.LeaveRequest && error.reason !== ConnectionErrorReason.NotAllowed;
18116
+ }
18117
+ return true;
18118
+ }
16716
18119
  function canPassThroughQueue(req) {
16717
18120
  const canPass = passThroughQueueSignals.indexOf(req.case) >= 0;
16718
18121
  livekitLogger.trace('request allowed to bypass queue:', {
@@ -16729,6 +18132,24 @@ var SignalConnectionState;
16729
18132
  SignalConnectionState[SignalConnectionState["DISCONNECTING"] = 3] = "DISCONNECTING";
16730
18133
  SignalConnectionState[SignalConnectionState["DISCONNECTED"] = 4] = "DISCONNECTED";
16731
18134
  })(SignalConnectionState || (SignalConnectionState = {}));
18135
+ /**
18136
+ * Public projection of the lifecycle machine's states. `new`, `offline` and `closed` are all
18137
+ * reported as `DISCONNECTED`: they differ in what may happen next
18138
+ */
18139
+ function lifecycleToConnectionState(lifecycle) {
18140
+ switch (lifecycle) {
18141
+ case 'connected':
18142
+ return SignalConnectionState.CONNECTED;
18143
+ case 'connecting':
18144
+ return SignalConnectionState.CONNECTING;
18145
+ case 'reconnecting':
18146
+ return SignalConnectionState.RECONNECTING;
18147
+ case 'disconnecting':
18148
+ return SignalConnectionState.DISCONNECTING;
18149
+ default:
18150
+ return SignalConnectionState.DISCONNECTED;
18151
+ }
18152
+ }
16732
18153
  /** specifies how much time (in ms) we allow for the ws to close its connection gracefully before continuing */
16733
18154
  const MAX_WS_CLOSE_TIME = 250;
16734
18155
  /**
@@ -16738,13 +18159,41 @@ const JOIN_RESPONSE_TIMEOUT = 5000;
16738
18159
  /** @internal */
16739
18160
  class SignalClient {
16740
18161
  get currentState() {
16741
- return this.state;
18162
+ return lifecycleToConnectionState(this.lifecycleState);
16742
18163
  }
16743
18164
  get isDisconnected() {
16744
- return this.state === SignalConnectionState.DISCONNECTING || this.state === SignalConnectionState.DISCONNECTED;
18165
+ const state = this.currentState;
18166
+ return state === SignalConnectionState.DISCONNECTING || state === SignalConnectionState.DISCONNECTED;
18167
+ }
18168
+ /** Runtime lifecycle state, finer grained than the public {@link currentState} projection. */
18169
+ get lifecycleState() {
18170
+ return this.machine.currentState();
18171
+ }
18172
+ /** Id of the current connection attempt and of the transport it owns. */
18173
+ get attemptId() {
18174
+ return this.machine.context.attemptId;
18175
+ }
18176
+ /**
18177
+ * Applies a lifecycle input and reports whether it moved the machine
18178
+ */
18179
+ sendLifecycleInput(input) {
18180
+ const before = this.lifecycleState;
18181
+ this.machine.handle(input.type, input);
18182
+ return this.lifecycleState !== before;
18183
+ }
18184
+ /**
18185
+ * Waits out a close that is already in flight. Establishing a session while one is tearing down
18186
+ * would race it for the transport — the teardown can close the socket the new attempt just
18187
+ * opened.
18188
+ */
18189
+ settleInFlightClose() {
18190
+ return __awaiter(this, void 0, void 0, function* () {
18191
+ this.log.debug('waiting for an in-flight close to settle before establishing a session');
18192
+ (yield this.closingLock.lock())();
18193
+ });
16745
18194
  }
16746
18195
  get isEstablishingConnection() {
16747
- return this.state === SignalConnectionState.CONNECTING || this.state === SignalConnectionState.RECONNECTING;
18196
+ return this.lifecycleState === 'connecting' || this.lifecycleState === 'reconnecting';
16748
18197
  }
16749
18198
  getNextRequestId() {
16750
18199
  this._requestId += 1;
@@ -16756,7 +18205,6 @@ class SignalClient {
16756
18205
  var _a;
16757
18206
  /** signal rtt in milliseconds */
16758
18207
  this.rtt = 0;
16759
- this.state = SignalConnectionState.DISCONNECTED;
16760
18208
  this.log = livekitLogger;
16761
18209
  this._requestId = 0;
16762
18210
  this.useV0SignalPath = false;
@@ -16782,7 +18230,18 @@ class SignalClient {
16782
18230
  this.queuedRequests = [];
16783
18231
  this.closingLock = new _();
16784
18232
  this.connectionLock = new _();
16785
- this.state = SignalConnectionState.DISCONNECTED;
18233
+ this.machine = createSignalMachine();
18234
+ this.machine.on('transitioned', _ref => {
18235
+ let fromState = _ref.fromState,
18236
+ toState = _ref.toState;
18237
+ this.log.debug("signal lifecycle: ".concat(fromState, " -> ").concat(toState));
18238
+ });
18239
+ this.machine.on('nohandler', _ref2 => {
18240
+ let inputName = _ref2.inputName;
18241
+ this.log.debug("ignoring signal lifecycle input ".concat(inputName, " in state ").concat(this.lifecycleState));
18242
+ });
18243
+ // no-op unless a development tool asked for it; see utils/machineInspector
18244
+ announceMachine('signal', this.machine);
16786
18245
  }
16787
18246
  get logContext() {
16788
18247
  var _a, _b;
@@ -16794,12 +18253,30 @@ class SignalClient {
16794
18253
  let useV0Path = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false;
16795
18254
  let publisherOffer = arguments.length > 5 ? arguments[5] : undefined;
16796
18255
  return function* () {
16797
- // during a full reconnect, we'd want to start the sequence even if currently
16798
- // connected
16799
- _this.state = SignalConnectionState.CONNECTING;
18256
+ if (_this.lifecycleState === 'disconnecting') {
18257
+ yield _this.settleInFlightClose();
18258
+ }
18259
+ if (!_this.sendLifecycleInput({
18260
+ type: 'connect'
18261
+ })) {
18262
+ // Proceeding would open a transport the lifecycle does not own: its completion would be
18263
+ // discarded and the session left claiming a transport that had been torn down. Every caller
18264
+ // that restarts a session closes the previous one first (see RTCEngine.restartConnection).
18265
+ throw ConnectionError.internal("cannot establish a signal session from '".concat(_this.lifecycleState, "', close the current one first"));
18266
+ }
16800
18267
  _this.options = opts;
16801
- const res = yield _this.connect(url, token, opts, abortSignal, useV0Path, publisherOffer);
16802
- return res;
18268
+ try {
18269
+ const res = yield _this.connect(url, token, opts, abortSignal, useV0Path, publisherOffer);
18270
+ return res;
18271
+ } catch (e) {
18272
+ // reported here rather than at each rejection site inside connect(), so that no failure
18273
+ // path can leave the machine stuck in `connecting`
18274
+ _this.sendLifecycleInput({
18275
+ type: 'connectFailed',
18276
+ error: e
18277
+ });
18278
+ throw e;
18279
+ }
16803
18280
  }();
16804
18281
  });
16805
18282
  }
@@ -16809,15 +18286,31 @@ class SignalClient {
16809
18286
  this.log.warn('attempted to reconnect without signal options being set, ignoring');
16810
18287
  return;
16811
18288
  }
16812
- this.state = SignalConnectionState.RECONNECTING;
18289
+ if (this.lifecycleState === 'disconnecting') {
18290
+ yield this.settleInFlightClose();
18291
+ }
18292
+ if (!this.sendLifecycleInput({
18293
+ type: 'reconnect'
18294
+ })) {
18295
+ throw ConnectionError.internal("cannot resume the signal session from '".concat(this.lifecycleState, "'"));
18296
+ }
16813
18297
  // clear ping interval and restart it once reconnected
16814
18298
  this.clearPingInterval();
16815
- const res = yield this.connect(url, token, Object.assign(Object.assign({}, this.options), {
16816
- reconnect: true,
16817
- sid,
16818
- reconnectReason: reason
16819
- }), undefined, this.useV0SignalPath);
16820
- return res;
18299
+ try {
18300
+ const res = yield this.connect(url, token, Object.assign(Object.assign({}, this.options), {
18301
+ reconnect: true,
18302
+ sid,
18303
+ reconnectReason: reason
18304
+ }), undefined, this.useV0SignalPath);
18305
+ return res;
18306
+ } catch (e) {
18307
+ this.sendLifecycleInput({
18308
+ type: 'reconnectFailed',
18309
+ error: e,
18310
+ recoverable: isRecoverableReconnectError(e)
18311
+ });
18312
+ throw e;
18313
+ }
16821
18314
  });
16822
18315
  }
16823
18316
  connect(url_1, token_1, opts_1, abortSignal_1) {
@@ -16854,7 +18347,11 @@ class SignalClient {
16854
18347
  this.close();
16855
18348
  }
16856
18349
  cleanupAbortHandlers();
16857
- reject(ConnectionError.cancelled(reason));
18350
+ // The caller may already have classified this failure: the connect timeout hands us a
18351
+ // Timeout error. Only a genuine abort is a cancellation, and reporting a stalled connect as
18352
+ // one makes the engine read it as user intent — skipping region failover and never
18353
+ // recording the attempt against the backoff strategy (see Room.connect).
18354
+ reject(eventOrError instanceof ConnectionError ? eventOrError : ConnectionError.cancelled(reason));
16858
18355
  });
16859
18356
  abortSignal === null || abortSignal === void 0 ? void 0 : abortSignal.addEventListener('abort', abortHandler);
16860
18357
  const cleanupAbortHandlers = () => {
@@ -16864,48 +18361,62 @@ class SignalClient {
16864
18361
  const wsTimeout = setTimeout(() => {
16865
18362
  abortHandler(ConnectionError.timeout('room connection has timed out (signal)'));
16866
18363
  }, opts.websocketTimeout);
16867
- const handleSignalConnected = (connection, firstMessage) => {
16868
- this.handleSignalConnected(connection, wsTimeout, firstMessage);
16869
- };
16870
18364
  const redactedUrl = new URL(rtcUrl);
16871
18365
  if (redactedUrl.searchParams.has('access_token')) {
16872
18366
  redactedUrl.searchParams.set('access_token', '<redacted>');
16873
18367
  }
16874
18368
  if (this.ws) {
16875
18369
  const startClose = performance.now();
16876
- yield this.close(false);
18370
+ yield this.teardownTransport('replaced by a new connection attempt');
16877
18371
  this.log.debug("closed previous ws connection in ".concat(performance.now() - startClose, "ms"));
16878
18372
  }
18373
+ // the transport created below belongs to this attempt; events arriving from it after a
18374
+ // newer attempt has started are dropped by the machine
18375
+ const attemptId = this.attemptId;
16879
18376
  this.log.info("signal connecting to ".concat(redactedUrl), {
16880
18377
  reconnect: opts.reconnect,
16881
18378
  reconnectReason: opts.reconnectReason
16882
18379
  });
16883
18380
  this.ws = new WebSocketStream(rtcUrl);
18381
+ // A failed upgrade closes the socket, so `opened` and `closed` settle for the same cause — but
18382
+ // only the `opened` path can classify it (a 401 is known after asking the validate endpoint).
18383
+ // Noting the failure *before* the close handler is registered means it runs first, so a close
18384
+ // that merely reflects a failed upgrade stands down and lets the classified error reach the
18385
+ // caller. Any other close still fails the attempt straight away: after a successful upgrade
18386
+ // nothing else would, short of the first-message timeout.
18387
+ let upgradeFailed = false;
18388
+ this.ws.opened.catch(() => {
18389
+ upgradeFailed = true;
18390
+ });
16884
18391
  try {
16885
18392
  this.ws.closed.then(closeInfo => {
16886
- if (this.isEstablishingConnection) {
18393
+ if (this.isEstablishingConnection && !upgradeFailed) {
16887
18394
  reject(ConnectionError.internal("Websocket got closed during a (re)connection attempt: ".concat(closeInfo.reason)));
16888
18395
  }
16889
- if (closeInfo.closeCode !== 1000) {
16890
- this.log.warn("websocket closed", {
16891
- reason: closeInfo.reason,
16892
- code: closeInfo.closeCode,
16893
- wasClean: closeInfo.closeCode === 1000,
16894
- state: this.state
16895
- });
16896
- if (this.state === SignalConnectionState.CONNECTED) {
16897
- this.handleOnClose(closeInfo.reason || 'Unexpected WS error');
16898
- }
16899
- }
18396
+ this.log.debug('websocket closed', {
18397
+ reason: closeInfo.reason,
18398
+ code: closeInfo.closeCode,
18399
+ attemptId,
18400
+ state: this.lifecycleState
18401
+ });
18402
+ // Every close of the live transport is reported, including a clean 1000 one: a server
18403
+ // that drops signalling closes cleanly — a migration that never sends its
18404
+ // `Leave{action=RESUME}` does exactly that — and treating that as "nothing happened"
18405
+ // leaves the client believing it is still connected until something else notices.
18406
+ // Closes we caused ourselves, and closes from a transport that has since been
18407
+ // replaced, are dropped by the machine's state and attempt guards below rather than by
18408
+ // a close-code test.
18409
+ this.handleOnClose(closeInfo.reason || (closeInfo.closeCode === 1000 ? 'server closed the signal connection' : 'Unexpected WS error'), attemptId);
16900
18410
  return;
16901
18411
  }).catch(reason => {
16902
- if (this.isEstablishingConnection) {
18412
+ if (this.isEstablishingConnection && !upgradeFailed) {
16903
18413
  reject(ConnectionError.internal("Websocket error during a (re)connection attempt: ".concat(reason)));
16904
18414
  }
16905
18415
  });
16906
18416
  const connection = yield this.ws.opened.catch(reason => __awaiter(this, void 0, void 0, function* () {
16907
- if (this.state !== SignalConnectionState.CONNECTED) {
16908
- this.state = SignalConnectionState.DISCONNECTED;
18417
+ if (this.lifecycleState !== 'connected') {
18418
+ // claimed synchronously, before the await below: the socket's close event is already
18419
+ // on its way and would otherwise reject first with a less useful error
16909
18420
  clearTimeout(wsTimeout);
16910
18421
  const error = yield this.handleConnectionError(reason, validateUrl);
16911
18422
  reject(error);
@@ -16970,7 +18481,7 @@ class SignalClient {
16970
18481
  }
16971
18482
  // Handle successful connection
16972
18483
  const firstMessageToProcess = validation.shouldProcessFirstMessage ? firstSignalResponse : undefined;
16973
- handleSignalConnected(connection, firstMessageToProcess);
18484
+ this.handleSignalConnected(connection, wsTimeout, attemptId, firstMessageToProcess);
16974
18485
  resolve(validation.response);
16975
18486
  } catch (e) {
16976
18487
  reject(e);
@@ -16993,14 +18504,22 @@ class SignalClient {
16993
18504
  if (this.signalLatency) {
16994
18505
  yield sleep(this.signalLatency);
16995
18506
  }
16996
- const _yield$signalReader$r = yield signalReader.read(),
16997
- done = _yield$signalReader$r.done,
16998
- value = _yield$signalReader$r.value;
16999
- if (done) {
18507
+ try {
18508
+ const _yield$signalReader$r = yield signalReader.read(),
18509
+ done = _yield$signalReader$r.done,
18510
+ value = _yield$signalReader$r.value;
18511
+ if (done) {
18512
+ break;
18513
+ }
18514
+ const resp = parseSignalResponse(value);
18515
+ this.handleSignalResponse(resp);
18516
+ } catch (e) {
18517
+ this.log.error("error reading from signal stream", {
18518
+ error: e
18519
+ });
18520
+ yield this.close(false, 'error in reading loop');
17000
18521
  break;
17001
18522
  }
17002
- const resp = parseSignalResponse(value);
17003
- this.handleSignalResponse(resp);
17004
18523
  }
17005
18524
  });
17006
18525
  }
@@ -17010,40 +18529,53 @@ class SignalClient {
17010
18529
  let updateState = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true;
17011
18530
  let reason = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'Close method called on signal client';
17012
18531
  return function* () {
17013
- if ([SignalConnectionState.DISCONNECTING || SignalConnectionState.DISCONNECTED].includes(_this3.state)) {
17014
- _this3.log.debug("ignoring signal close as it's already in disconnecting state");
17015
- return;
17016
- }
18532
+ // when the lifecycle is already shutting down (or another close owns it), only the transport
18533
+ // teardown below still applies — it is idempotent, so callers can always await a close
18534
+ const drivesLifecycle = updateState && _this3.sendLifecycleInput({
18535
+ type: 'close',
18536
+ reason
18537
+ });
17017
18538
  const unlock = yield _this3.closingLock.lock();
17018
18539
  try {
17019
- _this3.clearPingInterval();
17020
- if (updateState) {
17021
- _this3.state = SignalConnectionState.DISCONNECTING;
17022
- }
17023
- if (_this3.ws) {
17024
- _this3.ws.close({
17025
- closeCode: 1000,
17026
- reason
17027
- });
17028
- // calling `ws.close()` only starts the closing handshake (CLOSING state), prefer to wait until state is actually CLOSED
17029
- const closePromise = _this3.ws.closed;
17030
- _this3.ws = undefined;
17031
- _this3.streamWriter = undefined;
17032
- yield Promise.race([closePromise, sleep(MAX_WS_CLOSE_TIME)]);
17033
- }
17034
- } catch (e) {
17035
- _this3.log.debug('websocket error while closing', {
17036
- error: e
17037
- });
18540
+ yield _this3.teardownTransport(reason);
17038
18541
  } finally {
17039
- if (updateState) {
17040
- _this3.state = SignalConnectionState.DISCONNECTED;
18542
+ if (drivesLifecycle) {
18543
+ _this3.sendLifecycleInput({
18544
+ type: 'closeComplete'
18545
+ });
17041
18546
  }
17042
18547
  unlock();
17043
18548
  }
17044
18549
  }();
17045
18550
  });
17046
18551
  }
18552
+ /**
18553
+ * Releases the transport and everything tied to it, without touching the lifecycle state. Used
18554
+ * both by {@link close} and by paths that replace the transport under a live lifecycle (a new
18555
+ * attempt, or an unexpected close that leaves the client in `offline`).
18556
+ */
18557
+ teardownTransport(reason) {
18558
+ return __awaiter(this, void 0, void 0, function* () {
18559
+ try {
18560
+ this.clearPingInterval();
18561
+ if (this.ws) {
18562
+ this.ws.close({
18563
+ closeCode: 1000,
18564
+ reason
18565
+ });
18566
+ // calling `ws.close()` only starts the closing handshake (CLOSING state), prefer to wait until state is actually CLOSED
18567
+ const closePromise = this.ws.closed;
18568
+ this.ws = undefined;
18569
+ this.streamWriter = undefined;
18570
+ yield Promise.race([closePromise, sleep(MAX_WS_CLOSE_TIME)]);
18571
+ }
18572
+ } catch (e) {
18573
+ this.log.debug('websocket error while closing', {
18574
+ error: e
18575
+ });
18576
+ }
18577
+ });
18578
+ }
17047
18579
  // initial offer after joining
17048
18580
  sendOffer(offer, offerId) {
17049
18581
  this.log.debug('sending offer', {
@@ -17222,7 +18754,8 @@ class SignalClient {
17222
18754
  // capture all requests while reconnecting and put them in a queue
17223
18755
  // unless the request originates from the queue, then don't enqueue again
17224
18756
  const canQueue = !fromQueue && !canPassThroughQueue(message);
17225
- if (canQueue && _this5.state === SignalConnectionState.RECONNECTING) {
18757
+ const isHoldingRequests = _this5.lifecycleState === 'reconnecting' || _this5.queuedRequests.length > 0;
18758
+ if (canQueue && isHoldingRequests) {
17226
18759
  _this5.queuedRequests.push(() => __awaiter(_this5, void 0, void 0, function* () {
17227
18760
  yield this.sendRequest(message, true);
17228
18761
  }));
@@ -17235,7 +18768,11 @@ class SignalClient {
17235
18768
  if (_this5.signalLatency) {
17236
18769
  yield sleep(_this5.signalLatency);
17237
18770
  }
17238
- if (_this5.isDisconnected) {
18771
+ // `leave` is the one request whose purpose is to be sent on the way out (an aborted connect
18772
+ // attempt tells the server before tearing down), so it is allowed through for as long as the
18773
+ // transport is still there
18774
+ const isLeaveOnShutdown = message.case === 'leave' && !!_this5.streamWriter;
18775
+ if (_this5.isDisconnected && !isLeaveOnShutdown) {
17239
18776
  // Skip requests if the signal layer is disconnected
17240
18777
  // This can happen if an event is sent in the mist of room.connect() initializing
17241
18778
  _this5.log.debug("skipping signal request (type: ".concat(message.case, ") - SignalClient disconnected"));
@@ -17389,17 +18926,39 @@ class SignalClient {
17389
18926
  }
17390
18927
  }
17391
18928
  }
17392
- handleOnClose(reason) {
17393
- return __awaiter(this, void 0, void 0, function* () {
17394
- if (this.state === SignalConnectionState.DISCONNECTED) return;
17395
- const onCloseCallback = this.onClose;
17396
- yield this.close(undefined, reason);
17397
- this.log.info("websocket connection closed: ".concat(reason), {
17398
- reason
17399
- });
17400
- if (onCloseCallback) {
17401
- onCloseCallback(reason);
17402
- }
18929
+ /**
18930
+ * Handles a transport we lost without asking to. The client goes to `offline` rather than
18931
+ * `closed`: whether this session gets resumed, restarted or given up on is the engine's call.
18932
+ */
18933
+ handleOnClose(reason_1) {
18934
+ return __awaiter(this, arguments, void 0, function (reason) {
18935
+ var _this6 = this;
18936
+ let attemptId = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : this.attemptId;
18937
+ return function* () {
18938
+ const onCloseCallback = _this6.onClose;
18939
+ if (!_this6.sendLifecycleInput({
18940
+ type: 'transportFailed',
18941
+ attemptId,
18942
+ reason
18943
+ })) {
18944
+ // a close we caused ourselves, or one from a transport that has since been replaced: logged
18945
+ // rather than dropped silently, because a close that goes unnoticed leaves the client writing
18946
+ // to a dead socket
18947
+ _this6.log.debug("ignoring transport close in state ".concat(_this6.lifecycleState), {
18948
+ reason,
18949
+ attemptId,
18950
+ currentAttemptId: _this6.attemptId
18951
+ });
18952
+ return;
18953
+ }
18954
+ yield _this6.teardownTransport(reason);
18955
+ _this6.log.info("websocket connection closed: ".concat(reason), {
18956
+ reason
18957
+ });
18958
+ if (onCloseCallback) {
18959
+ onCloseCallback(reason);
18960
+ }
18961
+ }();
17403
18962
  });
17404
18963
  }
17405
18964
  handleWSError(error) {
@@ -17456,10 +19015,27 @@ class SignalClient {
17456
19015
  * @param firstMessage Optional first message to process
17457
19016
  * @internal
17458
19017
  */
17459
- handleSignalConnected(connection, timeoutHandle, firstMessage) {
17460
- this.state = SignalConnectionState.CONNECTED;
17461
- this.log.info('signal connected');
19018
+ handleSignalConnected(connection, timeoutHandle, attemptId, firstMessage) {
17462
19019
  clearTimeout(timeoutHandle);
19020
+ const established = this.sendLifecycleInput(this.lifecycleState === 'reconnecting' ? {
19021
+ type: 'reconnectComplete',
19022
+ attemptId
19023
+ } : {
19024
+ type: 'connectComplete',
19025
+ attemptId
19026
+ });
19027
+ if (!established) {
19028
+ // The attempt was abandoned while we waited for the server's first message: it was closed, or
19029
+ // a newer attempt superseded it. Arming the ping interval and the read loop here would outlive
19030
+ // the session that owns them, so leave the transport to whoever holds the lifecycle now.
19031
+ this.log.debug('discarding a connection whose attempt no longer owns the session', {
19032
+ attemptId,
19033
+ currentAttemptId: this.attemptId,
19034
+ state: this.lifecycleState
19035
+ });
19036
+ return;
19037
+ }
19038
+ this.log.info('signal connected');
17463
19039
  this.startPingInterval();
17464
19040
  this.startReadingLoop(connection.readable.getReader(), firstMessage);
17465
19041
  }
@@ -17477,7 +19053,7 @@ class SignalClient {
17477
19053
  isValid: true,
17478
19054
  response: firstSignalResponse.message.value
17479
19055
  };
17480
- } else if (this.state === SignalConnectionState.RECONNECTING && ((_b = firstSignalResponse.message) === null || _b === void 0 ? void 0 : _b.case) !== 'leave') {
19056
+ } else if (this.lifecycleState === 'reconnecting' && ((_b = firstSignalResponse.message) === null || _b === void 0 ? void 0 : _b.case) !== 'leave') {
17481
19057
  if (((_c = firstSignalResponse.message) === null || _c === void 0 ? void 0 : _c.case) === 'reconnect') {
17482
19058
  return {
17483
19059
  isValid: true,
@@ -18968,8 +20544,10 @@ class PCTransport extends eventsExports.EventEmitter {
18968
20544
  var _a;
18969
20545
  return (_a = this.pc) === null || _a === void 0 ? void 0 : _a.remoteDescription;
18970
20546
  }
20547
+ /** stats of the underlying connection, `undefined` when there is none */
18971
20548
  getStats() {
18972
- return this.pc.getStats();
20549
+ var _a;
20550
+ return (_a = this._pc) === null || _a === void 0 ? void 0 : _a.getStats();
18973
20551
  }
18974
20552
  getMaxMessageSize() {
18975
20553
  var _a, _b;
@@ -21259,12 +22837,17 @@ class LocalTrack extends Track {
21259
22837
  type: 'audio',
21260
22838
  streamId: v.id,
21261
22839
  packetsSent: v.packetsSent,
21262
- packetsLost: v.packetsLost,
21263
22840
  bytesSent: v.bytesSent,
21264
- timestamp: v.timestamp,
21265
- roundTripTime: v.roundTripTime,
21266
- jitter: v.jitter
22841
+ timestamp: v.timestamp
21267
22842
  };
22843
+ // loss, jitter and RTT are only known from what the remote reports back,
22844
+ // the same way the video sender picks them up
22845
+ const remote = stats.get(v.remoteId);
22846
+ if (remote) {
22847
+ audioStats.packetsLost = remote.packetsLost;
22848
+ audioStats.jitter = remote.jitter;
22849
+ audioStats.roundTripTime = remote.roundTripTime;
22850
+ }
21268
22851
  }
21269
22852
  });
21270
22853
  return audioStats;
@@ -22606,7 +24189,7 @@ class RTCEngine extends eventsExports.EventEmitter {
22606
24189
  const disconnect = duration => {
22607
24190
  this.log.warn("could not recover connection after ".concat(this.reconnectAttempts, " attempts, ").concat(duration, "ms. giving up"));
22608
24191
  this.emit(EngineEvent.Disconnected);
22609
- this.close();
24192
+ this.close("gave up reconnecting after ".concat(this.reconnectAttempts, " attempts, ").concat(duration, "ms"));
22610
24193
  };
22611
24194
  const duration = Date.now() - this.reconnectStart;
22612
24195
  let delay = this.getNextRetryDelay({
@@ -22847,7 +24430,12 @@ class RTCEngine extends eventsExports.EventEmitter {
22847
24430
  }();
22848
24431
  });
22849
24432
  }
22850
- close() {
24433
+ /**
24434
+ * @param reason why the session is ending, recorded by the signal lifecycle. Worth passing
24435
+ * wherever the caller knows more than "someone called close" — the server's leave reason, or
24436
+ * having given up on reconnecting.
24437
+ */
24438
+ close(reason) {
22851
24439
  return __awaiter(this, void 0, void 0, function* () {
22852
24440
  const unlock = yield this.closingLock.lock();
22853
24441
  if (this.isClosed) {
@@ -22864,7 +24452,7 @@ class RTCEngine extends eventsExports.EventEmitter {
22864
24452
  this.clearLostQualityTimeout();
22865
24453
  this.cleanupLossyDataStats();
22866
24454
  yield this.cleanupPeerConnections();
22867
- yield this.cleanupClient();
24455
+ yield this.cleanupClient(reason);
22868
24456
  } finally {
22869
24457
  unlock();
22870
24458
  }
@@ -22884,9 +24472,9 @@ class RTCEngine extends eventsExports.EventEmitter {
22884
24472
  cleanupLossyDataStats() {
22885
24473
  this.lossyChannel.stopThresholdTuning();
22886
24474
  }
22887
- cleanupClient() {
24475
+ cleanupClient(reason) {
22888
24476
  return __awaiter(this, void 0, void 0, function* () {
22889
- yield this.client.close();
24477
+ yield this.client.close(true, reason);
22890
24478
  this.client.resetCallbacks();
22891
24479
  // Any in-flight addTrack requests are orphaned by the signal reconnect — the new session
22892
24480
  // won't deliver `trackPublishedResponse` for them, so reject the pending resolvers and
@@ -23121,6 +24709,7 @@ class RTCEngine extends eventsExports.EventEmitter {
23121
24709
  this.handleDisconnect('signal', ReconnectReason.RR_SIGNAL_DISCONNECTED);
23122
24710
  };
23123
24711
  this.client.onLeave = leave => {
24712
+ var _a;
23124
24713
  this.log.info("client leave request received (action=".concat(leave === null || leave === void 0 ? void 0 : leave.action, ")"), {
23125
24714
  reason: leave === null || leave === void 0 ? void 0 : leave.reason
23126
24715
  });
@@ -23131,7 +24720,7 @@ class RTCEngine extends eventsExports.EventEmitter {
23131
24720
  switch (leave.action) {
23132
24721
  case LeaveRequest_Action.DISCONNECT:
23133
24722
  this.emit(EngineEvent.Disconnected, leave === null || leave === void 0 ? void 0 : leave.reason);
23134
- this.close();
24723
+ this.close("server leave: ".concat((_a = DisconnectReason[leave.reason]) !== null && _a !== void 0 ? _a : leave.reason));
23135
24724
  break;
23136
24725
  case LeaveRequest_Action.RECONNECT:
23137
24726
  this.fullReconnectOnNext = true;
@@ -23486,7 +25075,7 @@ class RTCEngine extends eventsExports.EventEmitter {
23486
25075
  } else {
23487
25076
  this.log.info("could not recover connection after ".concat(this.reconnectAttempts, " attempts, ").concat(Date.now() - this.reconnectStart, "ms. giving up"));
23488
25077
  this.emit(EngineEvent.Disconnected);
23489
- yield this.close();
25078
+ yield this.close("gave up reconnecting after ".concat(this.reconnectAttempts, " attempts, ").concat(Date.now() - this.reconnectStart, "ms"));
23490
25079
  }
23491
25080
  } finally {
23492
25081
  this.attemptingReconnect = false;
@@ -31731,6 +33320,7 @@ class DeferrableMap extends Map {
31731
33320
  ConnectionState["SignalReconnecting"] = "signalReconnecting";
31732
33321
  })(ConnectionState || (ConnectionState = {}));
31733
33322
  const CONNECTION_RECONCILE_FREQUENCY_MS = 4 * 1000;
33323
+ const STATS_LOG_FREQUENCY_MS = 30 * 1000;
31734
33324
  /**
31735
33325
  * In LiveKit, a room is the logical grouping for a list of participants.
31736
33326
  * Participants in a room can publish tracks, and subscribe to others' tracks.
@@ -31749,7 +33339,7 @@ class Room extends eventsExports.EventEmitter {
31749
33339
  */
31750
33340
  constructor(options) {
31751
33341
  var _this;
31752
- var _a, _b, _c, _d, _e, _f;
33342
+ var _a, _b, _c, _d, _e, _f, _g;
31753
33343
  super();
31754
33344
  _this = this;
31755
33345
  this.state = ConnectionState.Disconnected;
@@ -31764,6 +33354,7 @@ class Room extends eventsExports.EventEmitter {
31764
33354
  this.e2eeStateMutex = new _();
31765
33355
  this.isVideoPlaybackBlocked = false;
31766
33356
  this.log = livekitLogger;
33357
+ this.statsLog = livekitLogger;
31767
33358
  this.bufferedEvents = [];
31768
33359
  this.isResuming = false;
31769
33360
  this.pendingTrackAddedCallbacks = new Map();
@@ -32196,7 +33787,7 @@ class Room extends eventsExports.EventEmitter {
32196
33787
  let remoteParticipant = this.remoteParticipants.get(info.identity);
32197
33788
  // when it's disconnected, send updates
32198
33789
  if (info.state === ParticipantInfo_State.DISCONNECTED) {
32199
- this.handleParticipantDisconnected(info.identity, remoteParticipant);
33790
+ this.handleParticipantDisconnected(info.identity, remoteParticipant, info.disconnectReason === DisconnectReason.UNKNOWN_REASON ? undefined : info.disconnectReason);
32200
33791
  } else {
32201
33792
  // create participant if doesn't exist
32202
33793
  this.getOrCreateParticipant(info.identity, info);
@@ -32448,6 +34039,34 @@ class Room extends eventsExports.EventEmitter {
32448
34039
  this.getAllRemoteParticipantIdentities = () => {
32449
34040
  return Array.from(this.remoteParticipants.keys());
32450
34041
  };
34042
+ /**
34043
+ * Dumps stats of both peer connections.
34044
+ */
34045
+ this.logWebRTCStats = () => __awaiter(this, void 0, void 0, function* () {
34046
+ var _a, _b, _c, _d;
34047
+ const pcManager = (_a = this.engine) === null || _a === void 0 ? void 0 : _a.pcManager;
34048
+ if (!pcManager) {
34049
+ return;
34050
+ }
34051
+ try {
34052
+ const _yield$Promise$all = yield Promise.all([pcManager.publisher.getStats(), (_b = pcManager.subscriber) === null || _b === void 0 ? void 0 : _b.getStats()]),
34053
+ _yield$Promise$all2 = _slicedToArray(_yield$Promise$all, 2),
34054
+ publisher = _yield$Promise$all2[0],
34055
+ subscriber = _yield$Promise$all2[1];
34056
+ const publisherStats = publisher && summarizeStatsReport(publisher);
34057
+ const subscriberStats = subscriber && summarizeStatsReport(subscriber);
34058
+ this.statsLog.info("webrtc stats", {
34059
+ publisher: publisherStats === null || publisherStats === void 0 ? void 0 : publisherStats.connection,
34060
+ subscriber: subscriberStats === null || subscriberStats === void 0 ? void 0 : subscriberStats.connection,
34061
+ inbound: [...((_c = publisherStats === null || publisherStats === void 0 ? void 0 : publisherStats.inbound) !== null && _c !== void 0 ? _c : []), ...((_d = subscriberStats === null || subscriberStats === void 0 ? void 0 : subscriberStats.inbound) !== null && _d !== void 0 ? _d : [])],
34062
+ outbound: publisherStats === null || publisherStats === void 0 ? void 0 : publisherStats.outbound
34063
+ });
34064
+ } catch (error) {
34065
+ this.statsLog.debug('could not collect webrtc stats', {
34066
+ error
34067
+ });
34068
+ }
34069
+ });
32451
34070
  this.onLocalParticipantMetadataChanged = metadata => {
32452
34071
  this.emit(RoomEvent.ParticipantMetadataChanged, metadata, this.localParticipant);
32453
34072
  };
@@ -32518,12 +34137,15 @@ class Room extends eventsExports.EventEmitter {
32518
34137
  this.sidToIdentity = new Map();
32519
34138
  this.options = Object.assign(Object.assign({}, roomOptionDefaults), options);
32520
34139
  this.log = getLogger((_a = this.options.loggerName) !== null && _a !== void 0 ? _a : LoggerNames.Room, () => this.logContext);
34140
+ // its own logger name, so the stats dumps can be silenced or routed
34141
+ // separately from the rest of the room's logs
34142
+ this.statsLog = getLogger(LoggerNames.Stats, () => this.logContext);
32521
34143
  this.transcriptionReceivedTimes = new Map();
32522
34144
  this.options.audioCaptureDefaults = Object.assign(Object.assign({}, audioDefaults), options === null || options === void 0 ? void 0 : options.audioCaptureDefaults);
32523
34145
  this.options.videoCaptureDefaults = Object.assign(Object.assign({}, videoDefaults), options === null || options === void 0 ? void 0 : options.videoCaptureDefaults);
32524
34146
  this.options.publishDefaults = Object.assign(Object.assign({}, publishDefaults), options === null || options === void 0 ? void 0 : options.publishDefaults);
32525
34147
  this.maybeCreateEngine();
32526
- this.incomingDataStreamManager = new IncomingDataStreamManager();
34148
+ this.incomingDataStreamManager = new IncomingDataStreamManager((_b = this.options.dataStream) === null || _b === void 0 ? void 0 : _b.maxPayloadByteLength);
32527
34149
  this.outgoingDataStreamManager = new OutgoingDataStreamManager(this.engine, this.log, this.getRemoteParticipantClientProtocol, this.getRemoteParticipantCapabilities, this.getAllRemoteParticipantIdentities);
32528
34150
  this.incomingDataTrackManager = new IncomingDataTrackManager({
32529
34151
  e2eeManager: this.e2eeManager
@@ -32586,15 +34208,15 @@ class Room extends eventsExports.EventEmitter {
32586
34208
  this.setupE2EE();
32587
34209
  }
32588
34210
  this.engine.e2eeManager = this.e2eeManager;
32589
- this.incomingDataTrackManager.updateE2eeManager((_b = this.e2eeManager) !== null && _b !== void 0 ? _b : null);
32590
- this.outgoingDataTrackManager.updateE2eeManager((_c = this.e2eeManager) !== null && _c !== void 0 ? _c : null);
34211
+ this.incomingDataTrackManager.updateE2eeManager((_c = this.e2eeManager) !== null && _c !== void 0 ? _c : null);
34212
+ this.outgoingDataTrackManager.updateE2eeManager((_d = this.e2eeManager) !== null && _d !== void 0 ? _d : null);
32591
34213
  if (this.options.videoCaptureDefaults.deviceId) {
32592
34214
  this.localParticipant.activeDeviceMap.set('videoinput', unwrapConstraint(this.options.videoCaptureDefaults.deviceId));
32593
34215
  }
32594
34216
  if (this.options.audioCaptureDefaults.deviceId) {
32595
34217
  this.localParticipant.activeDeviceMap.set('audioinput', unwrapConstraint(this.options.audioCaptureDefaults.deviceId));
32596
34218
  }
32597
- if ((_d = this.options.audioOutput) === null || _d === void 0 ? void 0 : _d.deviceId) {
34219
+ if ((_e = this.options.audioOutput) === null || _e === void 0 ? void 0 : _e.deviceId) {
32598
34220
  this.switchActiveDevice('audiooutput', unwrapConstraint(this.options.audioOutput.deviceId)).catch(e => this.log.warn("Could not set audio output: ".concat(e.message)));
32599
34221
  }
32600
34222
  if (isWeb()) {
@@ -32621,7 +34243,7 @@ class Room extends eventsExports.EventEmitter {
32621
34243
  onDeviceChange = this.handleDeviceChange;
32622
34244
  }
32623
34245
  // in order to catch device changes prior to room connection we need to register the event in the constructor
32624
- (_f = (_e = navigator.mediaDevices) === null || _e === void 0 ? void 0 : _e.addEventListener) === null || _f === void 0 ? void 0 : _f.call(_e, 'devicechange', onDeviceChange, {
34246
+ (_g = (_f = navigator.mediaDevices) === null || _f === void 0 ? void 0 : _f.addEventListener) === null || _g === void 0 ? void 0 : _g.call(_f, 'devicechange', onDeviceChange, {
32625
34247
  signal: cleanupController.signal
32626
34248
  });
32627
34249
  }
@@ -33442,7 +35064,7 @@ class Room extends eventsExports.EventEmitter {
33442
35064
  this.emit(RoomEvent.Disconnected, reason);
33443
35065
  }
33444
35066
  }
33445
- handleParticipantDisconnected(identity, participant) {
35067
+ handleParticipantDisconnected(identity, participant, disconnectReason) {
33446
35068
  // remove and send event
33447
35069
  this.remoteParticipants.delete(identity);
33448
35070
  if (!participant) {
@@ -33453,7 +35075,7 @@ class Room extends eventsExports.EventEmitter {
33453
35075
  participant.trackPublications.forEach(publication => {
33454
35076
  participant.unpublishTrack(publication.trackSid, true);
33455
35077
  });
33456
- this.emit(RoomEvent.ParticipantDisconnected, participant);
35078
+ this.emit(RoomEvent.ParticipantDisconnected, participant, disconnectReason);
33457
35079
  participant.setDisconnected();
33458
35080
  this.rpcClientManager.handleParticipantDisconnected(participant.identity);
33459
35081
  }
@@ -33696,6 +35318,19 @@ class Room extends eventsExports.EventEmitter {
33696
35318
  }();
33697
35319
  }));
33698
35320
  }
35321
+ setStatsLogging(enabled) {
35322
+ if (enabled) {
35323
+ if (!this.statsLogInterval) {
35324
+ this.statsLogInterval = CriticalTimers.setInterval(() => {
35325
+ // logWebRTCStats handles its own errors, nothing to await here
35326
+ this.logWebRTCStats();
35327
+ }, STATS_LOG_FREQUENCY_MS);
35328
+ }
35329
+ } else if (this.statsLogInterval) {
35330
+ CriticalTimers.clearInterval(this.statsLogInterval);
35331
+ this.statsLogInterval = undefined;
35332
+ }
35333
+ }
33699
35334
  registerConnectionReconcile() {
33700
35335
  this.clearConnectionReconcile();
33701
35336
  let consecutiveFailures = 0;
@@ -33746,6 +35381,7 @@ class Room extends eventsExports.EventEmitter {
33746
35381
  this.log.info("connection state changed: ".concat(this.state, " -> ").concat(state));
33747
35382
  this.state = state;
33748
35383
  this.incomingDataStreamManager.setConnected(state === ConnectionState.Connected);
35384
+ this.setStatsLogging(state === ConnectionState.Connected);
33749
35385
  this.emit(RoomEvent.ConnectionStateChanged, this.state);
33750
35386
  return true;
33751
35387
  }
@@ -34918,15 +36554,22 @@ function isObject(input) {
34918
36554
  const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS;
34919
36555
  function isResponseTokenValid(response) {
34920
36556
  const jwtPayload = decodeTokenPayload(response.participantToken);
34921
- if (!(jwtPayload === null || jwtPayload === void 0 ? void 0 : jwtPayload.nbf) || !(jwtPayload === null || jwtPayload === void 0 ? void 0 : jwtPayload.exp)) {
34922
- return true;
36557
+ // Missing exp: TokenSourceCached would otherwise return this response forever.
36558
+ // nbf is optional (RFC 7519); do not skip the exp check when it is absent.
36559
+ if (!(jwtPayload === null || jwtPayload === void 0 ? void 0 : jwtPayload.exp)) {
36560
+ return false;
34923
36561
  }
34924
36562
  const now = new Date();
34925
- const nbfInMilliseconds = jwtPayload.nbf * ONE_SECOND_IN_MILLISECONDS;
34926
- const nbfDate = new Date(nbfInMilliseconds);
36563
+ if (jwtPayload.nbf) {
36564
+ const nbfInMilliseconds = jwtPayload.nbf * ONE_SECOND_IN_MILLISECONDS;
36565
+ const nbfDate = new Date(nbfInMilliseconds);
36566
+ if (nbfDate > now) {
36567
+ return false;
36568
+ }
36569
+ }
34927
36570
  const expInMilliseconds = jwtPayload.exp * ONE_SECOND_IN_MILLISECONDS;
34928
36571
  const expDate = new Date(expInMilliseconds - ONE_MINUTE_IN_MILLISECONDS);
34929
- return nbfDate <= now && expDate > now;
36572
+ return expDate > now;
34930
36573
  }
34931
36574
  /** Given a LiveKit generated participant token, decodes and returns the associated {@link TokenPayload} data. */
34932
36575
  function decodeTokenPayload(token) {
@@ -35313,4 +36956,4 @@ const serializers = {
35313
36956
  json,
35314
36957
  raw,
35315
36958
  custom
35316
- };export{AudioPresets,BackupCodecPolicy,BaseKeyProvider,CLIENT_PROTOCOL_DATA_STREAM_RPC,CLIENT_PROTOCOL_DATA_STREAM_V2,CLIENT_PROTOCOL_DEFAULT,CheckStatus,Checker,ConnectionCheck,ConnectionError,ConnectionErrorReason,ConnectionQuality,ConnectionState,CriticalTimers,CryptorError,CryptorErrorReason,CryptorEvent,DataPacket_Kind,DataStreamError,DataStreamErrorReason,DataTrackPacket,DefaultReconnectPolicy,DeviceUnsupportedError,DisconnectReason,EncryptionEvent,Encryption_Type,EngineEvent,ExternalE2EEKeyProvider,FrameMetadataManager,KeyHandlerEvent,KeyProviderEvent,LivekitError,LivekitReasonedError,LocalAudioTrack,LocalDataTrack,LocalParticipant,LocalTrack,LocalTrackPublication,LocalTrackRecorder,LocalVideoTrack,LogLevel,LoggerNames,MediaDeviceFailure,_ as Mutex,NegotiationError,PacketTrailerManager,Participant,ParticipantEvent,ParticipantInfo_Kind as ParticipantKind,PublishDataError,PublishTrackError,RemoteAudioTrack,RemoteDataTrack,RemoteParticipant,RemoteTrack,RemoteTrackPublication,RemoteVideoTrack,Room,RoomEvent,RpcError,ScreenSharePresets,SignalReconnectError,SignalRequestError,SimulatedError,SubscriptionError,TokenSource,TokenSourceConfigurable,TokenSourceFixed,Track,TrackEvent,TrackInvalidError,TrackPublication,TrackType,UnexpectedConnectionState,UnsupportedServer,VideoPreset,VideoPresets,VideoPresets43,VideoQuality,areTokenSourceFetchOptionsEqual,asEncryptablePacket,attachToElement,attributeTypings as attributes,audioCodecs,clientProtocol,compareVersions,createAudioAnalyser,createE2EEKey,createKeyMaterialFromBuffer,createKeyMaterialFromString,createLocalAudioTrack,createLocalScreenTracks,createLocalTracks,createLocalVideoTrack,decodeTokenPayload,deriveKeys,detachTrack,facingModeFromDeviceLabel,facingModeFromLocalTrack,getBrowser,getEmptyAudioStreamTrack,getEmptyVideoStreamTrack,getLogger,importKey,isAudioCodec,isAudioTrack,isBackupCodec,isBackupVideoCodec,isBrowserSupported,isE2EESupported,isInsertableStreamSupported,isLocalParticipant,isLocalTrack,isRemoteParticipant,isRemoteTrack,isScriptTransformSupported,isSerializer,isVideoCodec,isVideoFrame,isVideoTrack,needsRbspUnescaping,parseRbsp,protocolVersion,ratchet,serializers,setLogExtension,setLogLevel,supportsAV1,supportsAdaptiveStream,supportsAudioOutputSelection,supportsDynacast,supportsVP9,version,videoCodecs,writeRbsp};//# sourceMappingURL=livekit-client.esm.mjs.map
36959
+ };export{AudioPresets,BackupCodecPolicy,BaseKeyProvider,CLIENT_PROTOCOL_DATA_STREAM_RPC,CLIENT_PROTOCOL_DATA_STREAM_V2,CLIENT_PROTOCOL_DEFAULT,CheckStatus,Checker,ConnectionCheck,ConnectionError,ConnectionErrorReason,ConnectionQuality,ConnectionState,CriticalTimers,CryptorError,CryptorErrorReason,CryptorEvent,DataPacket_Kind,DataStreamError,DataStreamErrorReason,DataTrackPacket,DefaultReconnectPolicy,DeviceUnsupportedError,DisconnectReason,EncryptionEvent,Encryption_Type,EngineEvent,ExternalE2EEKeyProvider,FrameMetadataManager,KeyHandlerEvent,KeyProviderEvent,LivekitError,LivekitReasonedError,LocalAudioTrack,LocalDataTrack,LocalParticipant,LocalTrack,LocalTrackPublication,LocalTrackRecorder,LocalVideoTrack,LogLevel,LoggerNames,MediaDeviceFailure,_ as Mutex,NegotiationError,PacketTrailerManager,Participant,ParticipantEvent,ParticipantInfo_Kind as ParticipantKind,PublishDataError,PublishTrackError,RemoteAudioTrack,RemoteDataTrack,RemoteParticipant,RemoteTrack,RemoteTrackPublication,RemoteVideoTrack,Room,RoomEvent,RpcError,ScreenSharePresets,SignalReconnectError,SignalRequestError,SimulatedError,SubscriptionError,TokenSource,TokenSourceConfigurable,TokenSourceFixed,Track,TrackEvent,TrackInvalidError,TrackPublication,TrackType,UnexpectedConnectionState,UnsupportedServer,VideoPreset,VideoPresets,VideoPresets43,VideoQuality,areTokenSourceFetchOptionsEqual,asEncryptablePacket,attachToElement,attributeTypings as attributes,audioCodecs,clientProtocol,compareVersions,createAudioAnalyser,createE2EEKey,createKeyMaterialFromBuffer,createKeyMaterialFromString,createLocalAudioTrack,createLocalScreenTracks,createLocalTracks,createLocalVideoTrack,decodeTokenPayload,deriveKeys,detachTrack,facingModeFromDeviceLabel,facingModeFromLocalTrack,getBrowser,getEmptyAudioStreamTrack,getEmptyVideoStreamTrack,getLogger,importKey,isAudioCodec,isAudioTrack,isBackupCodec,isBackupVideoCodec,isBrowserSupported,isE2EESupported,isInsertableStreamSupported,isLocalParticipant,isLocalTrack,isRemoteParticipant,isRemoteTrack,isSVCCodec,isScriptTransformSupported,isSerializer,isVideoCodec,isVideoFrame,isVideoTrack,needsRbspUnescaping,parseRbsp,protocolVersion,ratchet,serializers,setLogExtension,setLogLevel,supportsAV1,supportsAdaptiveStream,supportsAudioOutputSelection,supportsDynacast,supportsH265,supportsVP9,version,videoCodecs,writeRbsp};//# sourceMappingURL=livekit-client.esm.mjs.map