@kaltura-sdk/avatars 1.21.1 → 1.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.esm.js CHANGED
@@ -23829,6 +23829,11 @@ const avatarConnectionMachine = setup({
23829
23829
  });
23830
23830
 
23831
23831
  const ACTIVE_SESSION_STORE_ID = 'agenticExperienceAvatar';
23832
+ const PROBE_STORE_ID = 'agenticExperienceAvatarProbe';
23833
+ const HEARTBEAT_INTERVAL_MS = 2000;
23834
+ const HEARTBEAT_STALE_AFTER_MS = 6000;
23835
+ // A live owner answers within one postMessage round trip; this only has to outlast a busy main thread.
23836
+ const PROBE_TIMEOUT_MS = 1000;
23832
23837
  function createId() {
23833
23838
  return typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
23834
23839
  }
@@ -23837,18 +23842,29 @@ function createId() {
23837
23842
  * just overwriting the shared record: the previous owner sees an id that is
23838
23843
  * no longer its own and ends its own call (a close callback could not cross
23839
23844
  * frames).
23845
+ *
23846
+ * `pagehide` can't be trusted to release (crashed/frozen tabs, an iframe the
23847
+ * host removes while the avatar is mid-call), so the owner also refreshes
23848
+ * `heartbeatAt` and everyone else ignores a record that stopped refreshing.
23849
+ * `verifyConflictOwner()` shortcuts that wait: a removed iframe can't answer a ping.
23840
23850
  */
23841
23851
  class ActiveSessionCoordinator {
23842
23852
  constructor(_options) {
23843
23853
  this._options = _options;
23844
23854
  this.id = createId();
23845
23855
  this._owns = false;
23856
+ // Heartbeats change every record, so only `id`/`preventDisconnections` count as a change.
23846
23857
  this._conflict = new TinyDataStore({
23847
23858
  initialValue: null,
23848
- compare: 'deep'
23859
+ compare: (a, b) => (a === null || a === void 0 ? void 0 : a.id) === (b === null || b === void 0 ? void 0 : b.id) && (a === null || a === void 0 ? void 0 : a.preventDisconnections) === (b === null || b === void 0 ? void 0 : b.preventDisconnections)
23849
23860
  });
23861
+ // Owners that failed a probe, keyed to the heartbeat they were last seen with; a newer heartbeat revives them.
23862
+ this._abandoned = new Map();
23863
+ this._pendingProbe = null;
23850
23864
  this._win = window;
23851
23865
  this._onPageHide = () => this.release();
23866
+ this._heartbeatTimer = null;
23867
+ this._expiryTimer = null;
23852
23868
  this._logger = _options.logger;
23853
23869
  this._global = getOrCreateGlobalTinyDataStore({
23854
23870
  id: ACTIVE_SESSION_STORE_ID,
@@ -23856,6 +23872,14 @@ class ActiveSessionCoordinator {
23856
23872
  logger: _options.logger
23857
23873
  });
23858
23874
  this._unsubscribe = this._global.onChanges(value => this._handle(value));
23875
+ this._probe = getOrCreateGlobalTinyDataStore({
23876
+ id: PROBE_STORE_ID,
23877
+ initialValue: null,
23878
+ logger: _options.logger
23879
+ });
23880
+ this._unsubscribeProbe = this._probe.onChanges(probe => this._handleProbe(probe), {
23881
+ replayLastValue: false
23882
+ });
23859
23883
  // A closed iframe must not leave the page locked.
23860
23884
  this._win.addEventListener('pagehide', this._onPageHide);
23861
23885
  }
@@ -23875,25 +23899,57 @@ class ActiveSessionCoordinator {
23875
23899
  preventDisconnections
23876
23900
  }
23877
23901
  });
23878
- this._global.setData(() => ({
23879
- id: this.id,
23880
- preventDisconnections
23881
- }));
23902
+ this._publish(preventDisconnections);
23903
+ this._startHeartbeat();
23882
23904
  }
23883
23905
  /** Republishes the flag only while this avatar owns the call; never claims an idle one */
23884
23906
  updateFlag(preventDisconnections) {
23885
23907
  var _a, _b;
23886
23908
  if (this._owns && ((_a = this._global.getData()) === null || _a === void 0 ? void 0 : _a.id) === this.id && ((_b = this._global.getData()) === null || _b === void 0 ? void 0 : _b.preventDisconnections) !== preventDisconnections) {
23887
- this._global.setData(() => ({
23888
- id: this.id,
23889
- preventDisconnections
23890
- }));
23909
+ this._publish(preventDisconnections);
23891
23910
  }
23892
23911
  }
23912
+ /**
23913
+ * Pings the avatar that owns the call. No answer within `PROBE_TIMEOUT_MS`
23914
+ * (removed iframe, crashed tab) marks it abandoned so `conflict` clears
23915
+ * instead of waiting for its heartbeat to go stale.
23916
+ */
23917
+ verifyConflictOwner() {
23918
+ const record = this._global.getData();
23919
+ if (!record || record.id === this.id || this._pendingProbe) {
23920
+ return;
23921
+ }
23922
+ const nonce = createId();
23923
+ const timer = setTimeout(() => {
23924
+ var _a;
23925
+ this._pendingProbe = null;
23926
+ const current = this._global.getData();
23927
+ // A newer heartbeat in the meantime proves the owner is alive.
23928
+ if ((current === null || current === void 0 ? void 0 : current.id) === record.id && current.heartbeatAt === record.heartbeatAt) {
23929
+ (_a = this._logger) === null || _a === void 0 ? void 0 : _a.log('active avatar session owner did not answer, treating it as abandoned', {
23930
+ data: {
23931
+ id: record.id
23932
+ }
23933
+ });
23934
+ this._abandoned.set(record.id, record.heartbeatAt);
23935
+ this._handle(current);
23936
+ }
23937
+ }, PROBE_TIMEOUT_MS);
23938
+ this._pendingProbe = {
23939
+ nonce,
23940
+ timer
23941
+ };
23942
+ this._probe.setData(() => ({
23943
+ nonce,
23944
+ ownerId: record.id,
23945
+ reply: false
23946
+ }));
23947
+ }
23893
23948
  /** Clears the record only if it is still ours, so a taken-over avatar can't wipe the new owner */
23894
23949
  release() {
23895
23950
  var _a, _b;
23896
23951
  this._owns = false;
23952
+ this._stopHeartbeat();
23897
23953
  if (((_a = this._global.getData()) === null || _a === void 0 ? void 0 : _a.id) === this.id) {
23898
23954
  (_b = this._logger) === null || _b === void 0 ? void 0 : _b.log('releasing the active avatar session', {
23899
23955
  data: {
@@ -23906,15 +23962,85 @@ class ActiveSessionCoordinator {
23906
23962
  dispose() {
23907
23963
  this.release();
23908
23964
  this._unsubscribe();
23965
+ this._unsubscribeProbe();
23966
+ this._clearExpiryTimer();
23967
+ this._clearPendingProbe();
23909
23968
  this._win.removeEventListener('pagehide', this._onPageHide);
23910
23969
  }
23911
- _handle(value) {
23970
+ _publish(preventDisconnections) {
23971
+ this._global.setData(() => ({
23972
+ id: this.id,
23973
+ preventDisconnections,
23974
+ heartbeatAt: Date.now()
23975
+ }));
23976
+ }
23977
+ _startHeartbeat() {
23978
+ if (this._heartbeatTimer) {
23979
+ return;
23980
+ }
23981
+ this._heartbeatTimer = setInterval(() => {
23982
+ const current = this._global.getData();
23983
+ if (this._owns && (current === null || current === void 0 ? void 0 : current.id) === this.id) {
23984
+ this._publish(current.preventDisconnections);
23985
+ }
23986
+ }, HEARTBEAT_INTERVAL_MS);
23987
+ }
23988
+ _stopHeartbeat() {
23989
+ if (this._heartbeatTimer) {
23990
+ clearInterval(this._heartbeatTimer);
23991
+ this._heartbeatTimer = null;
23992
+ }
23993
+ }
23994
+ _clearPendingProbe() {
23995
+ if (this._pendingProbe) {
23996
+ clearTimeout(this._pendingProbe.timer);
23997
+ this._pendingProbe = null;
23998
+ }
23999
+ }
24000
+ _handleProbe(probe) {
23912
24001
  var _a;
23913
- const other = value && value.id !== this.id ? value : null;
24002
+ if (!probe) {
24003
+ return;
24004
+ }
24005
+ if (!probe.reply && probe.ownerId === this.id && this._owns) {
24006
+ this._probe.setData(() => Object.assign(Object.assign({}, probe), {
24007
+ reply: true
24008
+ }));
24009
+ } else if (probe.reply && probe.nonce === ((_a = this._pendingProbe) === null || _a === void 0 ? void 0 : _a.nonce)) {
24010
+ this._clearPendingProbe();
24011
+ }
24012
+ }
24013
+ _clearExpiryTimer() {
24014
+ if (this._expiryTimer) {
24015
+ clearTimeout(this._expiryTimer);
24016
+ this._expiryTimer = null;
24017
+ }
24018
+ }
24019
+ _handle(value) {
24020
+ var _a, _b, _c;
24021
+ this._clearExpiryTimer();
24022
+ // A record without `heartbeatAt` (older bundle in another frame) never expires.
24023
+ const staleIn = value && typeof value.heartbeatAt === 'number' ? value.heartbeatAt + HEARTBEAT_STALE_AFTER_MS - Date.now() : Infinity;
24024
+ const isAbandoned = !!value && value.heartbeatAt !== undefined && value.heartbeatAt <= ((_a = this._abandoned.get(value.id)) !== null && _a !== void 0 ? _a : -Infinity);
24025
+ const isStale = !!value && (staleIn < 0 || isAbandoned);
24026
+ if (isStale && value.id !== this.id) {
24027
+ (_b = this._logger) === null || _b === void 0 ? void 0 : _b.log('ignoring an abandoned active avatar session', {
24028
+ data: {
24029
+ id: value.id,
24030
+ heartbeatAt: value.heartbeatAt
24031
+ }
24032
+ });
24033
+ }
24034
+ const other = value && value.id !== this.id && !isStale ? value : null;
23914
24035
  this._conflict.setData(() => other);
24036
+ if (other && Number.isFinite(staleIn)) {
24037
+ // Nothing is written when the owner dies, so re-evaluate once its record would go stale.
24038
+ this._expiryTimer = setTimeout(() => this._handle(this._global.getData()), staleIn + 1);
24039
+ }
23915
24040
  if (other && this._owns) {
23916
24041
  this._owns = false;
23917
- (_a = this._logger) === null || _a === void 0 ? void 0 : _a.log('active avatar session taken over by another avatar', {
24042
+ this._stopHeartbeat();
24043
+ (_c = this._logger) === null || _c === void 0 ? void 0 : _c.log('active avatar session taken over by another avatar', {
23918
24044
  data: {
23919
24045
  id: this.id,
23920
24046
  by: other.id
@@ -24237,6 +24363,19 @@ class AvatarClientManager extends ActorManager {
24237
24363
  preventDisconnections: (_b = (_a = this._config.runtimeSettings.multipleAvatarBehavior) === null || _a === void 0 ? void 0 : _a.preventDisconnections) !== null && _b !== void 0 ? _b : false
24238
24364
  };
24239
24365
  });
24366
+ // The call that held our connect is gone (ended, abandoned): the user already asked to start, so just start.
24367
+ this._activeSession.conflict.onChanges(conflict => {
24368
+ if (conflict || !this._isTakeoverPending.getData()) {
24369
+ return;
24370
+ }
24371
+ const options = this._pendingTakeoverOptions;
24372
+ this._pendingTakeoverOptions = undefined;
24373
+ this._isTakeoverPending.setData(() => false);
24374
+ this._logger.log('Resuming the held connect: the other avatar call is gone');
24375
+ this.connect(options);
24376
+ }, {
24377
+ replayLastValue: false
24378
+ });
24240
24379
  this._status.onChanges(status => {
24241
24380
  if (status === AvatarConnectionStatus.NotConnected || status === AvatarConnectionStatus.Disconnected || status === AvatarConnectionStatus.Error) {
24242
24381
  this._activeSession.release();
@@ -24459,6 +24598,8 @@ class AvatarClientManager extends ActorManager {
24459
24598
  });
24460
24599
  this._pendingTakeoverOptions = options;
24461
24600
  this._isTakeoverPending.setData(() => true);
24601
+ // An owner that is already gone (removed iframe, crashed tab) can't answer, so don't make the user wait out its heartbeat.
24602
+ this._activeSession.verifyConflictOwner();
24462
24603
  return;
24463
24604
  }
24464
24605
  this._activeSession.claim(this._multipleAvatarBehavior.getData().preventDisconnections);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kaltura-sdk/avatars",
3
- "version": "1.21.1",
3
+ "version": "1.21.2",
4
4
  "author": "kaltura",
5
5
  "license": "AGPL-3.0",
6
6
  "repository": {
@@ -12,8 +12,8 @@
12
12
  "@kaltura-sdk/rtc-avatar": "^1.33.4",
13
13
  "@kaltura-sdk/rtc-core": "^1.26.2",
14
14
  "@local/genie-shared-utils": "^1.0.0",
15
- "@unisphere/core": "^1.101.1",
16
- "@unisphere/genie-types": "^1.47.0",
15
+ "@unisphere/core": "^1.101.2",
16
+ "@unisphere/genie-types": "^1.47.1",
17
17
  "@unisphere/notifications-core": "^1.25.0",
18
18
  "@unisphere/runtime": "^1.98.0",
19
19
  "@unisphere/runtime-js": "^1.98.0",
@@ -1,11 +1,19 @@
1
1
  import { UnisphereLoggerType } from '@unisphere/core';
2
2
  import type { ActiveAvatarSession } from '@unisphere/genie-types';
3
3
  export declare const ACTIVE_SESSION_STORE_ID = "agenticExperienceAvatar";
4
+ export declare const HEARTBEAT_INTERVAL_MS = 2000;
5
+ export declare const HEARTBEAT_STALE_AFTER_MS = 6000;
6
+ export declare const PROBE_TIMEOUT_MS = 1000;
4
7
  /**
5
8
  * Page-wide "who owns the call" record for one avatar manager. Takeover is
6
9
  * just overwriting the shared record: the previous owner sees an id that is
7
10
  * no longer its own and ends its own call (a close callback could not cross
8
11
  * frames).
12
+ *
13
+ * `pagehide` can't be trusted to release (crashed/frozen tabs, an iframe the
14
+ * host removes while the avatar is mid-call), so the owner also refreshes
15
+ * `heartbeatAt` and everyone else ignores a record that stopped refreshing.
16
+ * `verifyConflictOwner()` shortcuts that wait: a removed iframe can't answer a ping.
9
17
  */
10
18
  export declare class ActiveSessionCoordinator {
11
19
  private readonly _options;
@@ -13,10 +21,16 @@ export declare class ActiveSessionCoordinator {
13
21
  private _owns;
14
22
  private readonly _conflict;
15
23
  private readonly _global;
24
+ private readonly _probe;
16
25
  private readonly _unsubscribe;
26
+ private readonly _unsubscribeProbe;
27
+ private readonly _abandoned;
28
+ private _pendingProbe;
17
29
  private readonly _win;
18
30
  private readonly _onPageHide;
19
31
  private readonly _logger?;
32
+ private _heartbeatTimer;
33
+ private _expiryTimer;
20
34
  constructor(_options: {
21
35
  onTakenOver: () => void;
22
36
  logger?: UnisphereLoggerType;
@@ -26,8 +40,20 @@ export declare class ActiveSessionCoordinator {
26
40
  claim(preventDisconnections: boolean): void;
27
41
  /** Republishes the flag only while this avatar owns the call; never claims an idle one */
28
42
  updateFlag(preventDisconnections: boolean): void;
43
+ /**
44
+ * Pings the avatar that owns the call. No answer within `PROBE_TIMEOUT_MS`
45
+ * (removed iframe, crashed tab) marks it abandoned so `conflict` clears
46
+ * instead of waiting for its heartbeat to go stale.
47
+ */
48
+ verifyConflictOwner(): void;
29
49
  /** Clears the record only if it is still ours, so a taken-over avatar can't wipe the new owner */
30
50
  release(): void;
31
51
  dispose(): void;
52
+ private _publish;
53
+ private _startHeartbeat;
54
+ private _stopHeartbeat;
55
+ private _clearPendingProbe;
56
+ private _handleProbe;
57
+ private _clearExpiryTimer;
32
58
  private _handle;
33
59
  }