@signalwire/js 4.0.0-rc.2 → 4.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as DEFAULT_DEGRADATION_BITRATE_THRESHOLD_KBPS, $t as VAD_HOLD_MS, A as SerializationError, At as DEFAULT_STATS_POLLING_INTERVAL_MS, B as VertoInviteHandlerError, Bt as DEVICE_TOKEN_ENDPOINT, C as MessageParseError, Ct as DEFAULT_REINVITE_MAX_ATTEMPTS, D as RecoveryError, Dt as DEFAULT_STATS_JITTER_SPIKE_MULTIPLIER, E as RPCTimeoutError, Et as DEFAULT_STATS_HISTORY_SIZE, F as TransportConnectionError, Ft as DEVICE_REFRESH_ENDPOINT, G as CREDENTIAL_ACTIVATE_TIMEOUT_MS, Gt as INVITE_VERSION, H as WebSocketConnectionError, Ht as DEVICE_TOKEN_REFRESH_MAX_RETRIES, I as UnexpectedError, It as DEVICE_STORAGE_KEY_AUDIO_INPUT, J as CREDENTIAL_REFRESH_MAX_RETRIES, Jt as PREFERENCES_STORAGE_KEY, K as CREDENTIAL_REFRESH_BUFFER_MS, Kt as PEER_CONNECTION_RECOVERY_POLL_MS, L as UnimplementedError, Lt as DEVICE_STORAGE_KEY_AUDIO_OUTPUT, M as StorageReadError, Mt as DEFAULT_STEREO_AUDIO, N as StorageWriteError, Nt as DEFAULT_STEREO_MAX_AVERAGE_BITRATE, O as RequestError, Ot as DEFAULT_STATS_NO_PACKET_THRESHOLD_MS, P as TokenRefreshError, Pt as DEFAULT_SYNC_DEVICES_TO_ACTIVE_CALLS, Q as DEFAULT_CONNECTION_TIMEOUT_MS, Qt as SAT_REFRESH_SCOPE, R as ValidationError, Rt as DEVICE_STORAGE_KEY_VIDEO_INPUT, S as MediaTrackError, St as DEFAULT_REINVITE_DEBOUNCE_TIME_MS, T as PreflightError, Tt as DEFAULT_STATS_BASELINE_SAMPLES, U as WebSocketTimeoutError, Ut as DEVICE_TOKEN_REFRESH_RETRY_BASE_MS, V as VertoPongError, Vt as DEVICE_TOKEN_REFRESH_BUFFER_MS, W as AUDIO_LEVEL_POLL_INTERVAL_MS, Wt as ICE_GATHERING_COMPLETE_TIMEOUT_MS, X as DEFAULT_AUTO_MUTE_VIDEO_ON_HIDDEN, Xt as RPC_ERROR_INVALID_PARAMS, Y as CREDENTIAL_REFRESH_RETRY_BASE_MS, Yt as RPC_ERROR_AUTHENTICATION_FAILED, Z as DEFAULT_CHECK_CONNECTION_ON_VISIBLE, Zt as RPC_ERROR_REQUESTER_VALIDATION_FAILED, _ as DeviceTokenError, _t as DEFAULT_RECONNECT_DELAY_MAX_MS, a as filterNull, at as DEFAULT_ENABLE_RELAY_FALLBACK, b as JSONRPCError, bt as DEFAULT_RECOVERY_DEBOUNCE_TIME_MS, c as setLogLevel, ct as DEFAULT_ICE_DISCONNECTED_GRACE_PERIOD_MS, d as CallCreateError, dt as DEFAULT_KEYFRAME_BURST_WINDOW_MS$1, en as VAD_THRESHOLD, et as DEFAULT_DEGRADATION_RECOVERY_THRESHOLD_KBPS, f as CollectionFetchError, ft as DEFAULT_KEYFRAME_COOLDOWN_MS$1, g as DeserializationError, gt as DEFAULT_RECONNECT_CALLS_TIMEOUT_MS, h as DependencyError, ht as DEFAULT_PERSIST_DEVICE_SELECTION, i as getValueFrom, it as DEFAULT_ENABLE_NETWORK_CHANGE_DETECTION, j as StorageNotAvailableError, jt as DEFAULT_STATS_RTT_SPIKE_MULTIPLIER, k as RequestTimeoutError, kt as DEFAULT_STATS_PACKET_LOSS_THRESHOLD, l as setLogger, lt as DEFAULT_ICE_GATHERING_TIMEOUT_MS, m as DPoPInitError, mt as DEFAULT_MAX_RECOVERY_ATTEMPTS, n as filterAs, nt as DEFAULT_DEVICE_POLLING_INTERVAL_MS, o as getLogger, ot as DEFAULT_ENABLE_SERVER_HANGUP_INTERCEPTION, p as ConversationError, pt as DEFAULT_KEYFRAME_MAX_BURST$1, q as CREDENTIAL_REFRESH_MAX_DELAY_MS, qt as PEER_CONNECTION_RECOVERY_WAIT_MS, rt as DEFAULT_ENABLE_AUTO_DEGRADATION, s as setDebugOptions, st as DEFAULT_ICE_CANDIDATE_TIMEOUT_MS, t as throwOnRPCError, tt as DEFAULT_DEVICE_DEBOUNCE_TIME_MS, u as AuthStateHandlerError, ut as DEFAULT_ICE_RESTART_TIMEOUT_MS$1, v as InvalidCredentialsError, vt as DEFAULT_RECONNECT_DELAY_MIN_MS, w as OverconstrainedFallbackError, wt as DEFAULT_REINVITE_TIMEOUT_MS, x as MediaAccessError, xt as DEFAULT_REFRESH_DEVICES_ON_VISIBLE, y as InvalidParams, yt as DEFAULT_RECOVERY_COOLDOWN_MS, z as VertoAttachHandlerError, zt as DEVICE_TOKEN_DEFAULT_EXPIRE_IN } from "./operators-BlUtq-t0.mjs";
1
+ import { $ as CREDENTIAL_REFRESH_BUFFER_MS, $t as ICE_GATHERING_COMPLETE_TIMEOUT_MS, A as RPCTimeoutError, At as DEFAULT_REFRESH_DEVICES_ON_VISIBLE, B as TransportConnectionError, Bt as DEFAULT_STATS_RTT_SPIKE_MULTIPLIER, C as JSONRPCError, Ct as DEFAULT_MAX_RECOVERY_ATTEMPTS, D as OverconstrainedFallbackError, Dt as DEFAULT_RECONNECT_DELAY_MIN_MS, E as MessageParseError, Et as DEFAULT_RECONNECT_DELAY_MAX_MS, F as SerializationError, Ft as DEFAULT_STATS_HISTORY_SIZE, G as VertoInviteHandlerError, Gt as DEVICE_STORAGE_KEY_AUDIO_INPUT, H as UnimplementedError, Ht as DEFAULT_STEREO_MAX_AVERAGE_BITRATE, I as StorageNotAvailableError, It as DEFAULT_STATS_JITTER_SPIKE_MULTIPLIER, J as WebSocketTimeoutError, Jt as DEVICE_TOKEN_DEFAULT_EXPIRE_IN, K as VertoPongError, Kt as DEVICE_STORAGE_KEY_AUDIO_OUTPUT, L as StorageReadError, Lt as DEFAULT_STATS_NO_PACKET_THRESHOLD_MS, M as RequestError, Mt as DEFAULT_REINVITE_MAX_ATTEMPTS, N as RequestTimeoutError, Nt as DEFAULT_REINVITE_TIMEOUT_MS, O as ParticipantNotReadyError, Ot as DEFAULT_RECOVERY_COOLDOWN_MS, P as ScreenShareAlreadyActiveError, Pt as DEFAULT_STATS_BASELINE_SAMPLES, Q as CREDENTIAL_EXPIRY_SKEW_MS, Qt as DEVICE_TOKEN_REFRESH_RETRY_BASE_MS, R as StorageWriteError, Rt as DEFAULT_STATS_PACKET_LOSS_THRESHOLD, S as InvalidParams, St as DEFAULT_KEYFRAME_MAX_BURST$1, T as MediaTrackError, Tt as DEFAULT_RECONNECT_CALLS_TIMEOUT_MS, U as ValidationError, Ut as DEFAULT_SYNC_DEVICES_TO_ACTIVE_CALLS, V as UnexpectedError, Vt as DEFAULT_STEREO_AUDIO, W as VertoAttachHandlerError, Wt as DEVICE_REFRESH_ENDPOINT, X as AUDIO_LEVEL_POLL_INTERVAL_MS, Xt as DEVICE_TOKEN_REFRESH_BUFFER_MS, Y as isMediaDeviceInUse, Yt as DEVICE_TOKEN_ENDPOINT, Z as CREDENTIAL_ACTIVATE_TIMEOUT_MS, Zt as DEVICE_TOKEN_REFRESH_MAX_RETRIES, _ as DPoPInitError, _t as DEFAULT_ICE_DISCONNECTED_GRACE_PERIOD_MS, a as filterNull, an as RPC_ERROR_INVALID_PARAMS, at as DEFAULT_CALL_SIGNALING_TIMEOUT_MS, b as DeviceTokenError, bt as DEFAULT_KEYFRAME_BURST_WINDOW_MS$1, c as setLogLevel, cn as VAD_HOLD_MS, ct as DEFAULT_DEGRADATION_BITRATE_THRESHOLD_KBPS, d as AuxiliaryLegCancelledError, dt as DEFAULT_DEVICE_POLLING_INTERVAL_MS, en as INVITE_VERSION, et as CREDENTIAL_REFRESH_MAX_DELAY_MS, f as AuxiliaryLegTimeoutError, ft as DEFAULT_ENABLE_AUTO_DEGRADATION, g as ConversationError, gt as DEFAULT_ICE_CANDIDATE_TIMEOUT_MS, h as CollectionFetchError, ht as DEFAULT_ENABLE_SERVER_HANGUP_INTERCEPTION, i as getValueFrom, in as RPC_ERROR_AUTHENTICATION_FAILED, it as DEFAULT_AUX_LEG_CONNECT_TIMEOUT_MS, j as RecoveryError, jt as DEFAULT_REINVITE_DEBOUNCE_TIME_MS, k as PreflightError, kt as DEFAULT_RECOVERY_DEBOUNCE_TIME_MS, l as setLogger, ln as VAD_THRESHOLD, lt as DEFAULT_DEGRADATION_RECOVERY_THRESHOLD_KBPS, m as CallNotReadyError, mt as DEFAULT_ENABLE_RELAY_FALLBACK, n as filterAs, nn as PEER_CONNECTION_RECOVERY_WAIT_MS, nt as CREDENTIAL_REFRESH_RETRY_BASE_MS, o as getLogger, on as RPC_ERROR_REQUESTER_VALIDATION_FAILED, ot as DEFAULT_CHECK_CONNECTION_ON_VISIBLE, p as CallCreateError, pt as DEFAULT_ENABLE_NETWORK_CHANGE_DETECTION, q as WebSocketConnectionError, qt as DEVICE_STORAGE_KEY_VIDEO_INPUT, rn as PREFERENCES_STORAGE_KEY, rt as DEFAULT_AUTO_MUTE_VIDEO_ON_HIDDEN, s as setDebugOptions, sn as SAT_REFRESH_SCOPE, st as DEFAULT_CONNECTION_TIMEOUT_MS, t as throwOnRPCError, tn as PEER_CONNECTION_RECOVERY_POLL_MS, tt as CREDENTIAL_REFRESH_MAX_RETRIES, u as AuthStateHandlerError, ut as DEFAULT_DEVICE_DEBOUNCE_TIME_MS, v as DependencyError, vt as DEFAULT_ICE_GATHERING_TIMEOUT_MS, w as MediaAccessError, wt as DEFAULT_PERSIST_DEVICE_SELECTION, x as InvalidCredentialsError, xt as DEFAULT_KEYFRAME_COOLDOWN_MS$1, y as DeserializationError, yt as DEFAULT_ICE_RESTART_TIMEOUT_MS$1, z as TokenRefreshError, zt as DEFAULT_STATS_POLLING_INTERVAL_MS } from "./operators-BVxi3KpN.mjs";
2
2
  import { jwtDecode } from "jwt-decode";
3
3
  import { BehaviorSubject, EMPTY, EmptyError, NEVER, Observable, ReplaySubject, Subject, TimeoutError, animationFrameScheduler, asapScheduler, auditTime, catchError, combineLatest, debounceTime, defer, distinctUntilChanged, exhaustMap, filter, first, firstValueFrom, from, interval, lastValueFrom, map, merge, mergeMap, observeOn, of, pipe, race, scan, share, shareReplay, skip, skipWhile, startWith, switchMap, take, takeUntil, tap, throwError, timeout, timer, toArray, withLatestFrom } from "rxjs";
4
4
  import { v4 } from "uuid";
@@ -131,7 +131,7 @@ const asyncRetry = async ({ asyncCallable, maxRetries: retries = DEFAULT_MAX_RET
131
131
 
132
132
  //#endregion
133
133
  //#region src/controllers/HTTPRequestController.ts
134
- const logger$31 = getLogger();
134
+ const logger$32 = getLogger();
135
135
  const GET_PARAMS = {
136
136
  method: "GET",
137
137
  headers: { Accept: "application/json" }
@@ -195,7 +195,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
195
195
  this._responses$.next(response);
196
196
  return response;
197
197
  } catch (error) {
198
- logger$31.error("[HTTPRequestController] Request error:", error);
198
+ logger$32.error("[HTTPRequestController] Request error:", error);
199
199
  this._status$.next("error");
200
200
  const err = error instanceof Error ? error : new Error("HTTP request failed", { cause: error });
201
201
  this._errors$.next(err);
@@ -222,7 +222,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
222
222
  const url = this.buildURL(request.url);
223
223
  const headers = this.buildHeaders(request.headers);
224
224
  const timeout$1 = request.timeout ?? this.requestTimeout;
225
- logger$31.debug("[HTTPRequestController] Executing request:", {
225
+ logger$32.debug("[HTTPRequestController] Executing request:", {
226
226
  method: request.method,
227
227
  url,
228
228
  headers: Object.keys(headers).reduce((acc, key) => {
@@ -242,7 +242,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
242
242
  });
243
243
  clearTimeout(timeoutId);
244
244
  const httpResponse = await this.convertResponse(response);
245
- logger$31.debug("[HTTPRequestController] Response received:", {
245
+ logger$32.debug("[HTTPRequestController] Response received:", {
246
246
  status: response.status,
247
247
  statusText: response.statusText,
248
248
  headers: [...response.headers.entries()],
@@ -252,7 +252,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
252
252
  } catch (error) {
253
253
  clearTimeout(timeoutId);
254
254
  if (error instanceof Error && error.name === "AbortError") throw new RequestTimeoutError(`Request timeout after ${timeout$1}ms`, { cause: error });
255
- logger$31.error("[HTTPRequestController] Request failed:", error);
255
+ logger$32.error("[HTTPRequestController] Request failed:", error);
256
256
  throw error;
257
257
  }
258
258
  }
@@ -266,8 +266,8 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
266
266
  const credential = this.getCredential();
267
267
  if (credential.token) {
268
268
  headers.Authorization = `Bearer ${credential.token}`;
269
- logger$31.debug("[HTTPRequestController] Using Bearer token auth, token length:", credential.token.length);
270
- } else logger$31.warn("[HTTPRequestController] No credentials available for authentication");
269
+ logger$32.debug("[HTTPRequestController] Using Bearer token auth, token length:", credential.token.length);
270
+ } else logger$32.warn("[HTTPRequestController] No credentials available for authentication");
271
271
  return headers;
272
272
  }
273
273
  /**
@@ -415,7 +415,7 @@ function fromMsToSec(milliseconds) {
415
415
 
416
416
  //#endregion
417
417
  //#region src/containers/PreferencesContainer.ts
418
- const logger$30 = getLogger();
418
+ const logger$31 = getLogger();
419
419
  var PreferencesContainer = class PreferencesContainer {
420
420
  static get instance() {
421
421
  this._instance ??= new PreferencesContainer();
@@ -1077,7 +1077,7 @@ var ClientPreferences = class {
1077
1077
  if (!this._storage) return;
1078
1078
  const data = collectStoredPreferences();
1079
1079
  this._storage.setItem(PREFERENCES_STORAGE_KEY, data, "local").catch((error) => {
1080
- logger$30.error(`[ClientPreferences] Failed to save preferences: ${String(error)}`);
1080
+ logger$31.error(`[ClientPreferences] Failed to save preferences: ${String(error)}`);
1081
1081
  });
1082
1082
  }
1083
1083
  /** Loads preferences from storage and applies them to the container. */
@@ -1086,7 +1086,7 @@ var ClientPreferences = class {
1086
1086
  this._storage.getItem(PREFERENCES_STORAGE_KEY, "local").then((stored) => {
1087
1087
  if (stored) applyStoredPreferences(stored);
1088
1088
  }).catch((error) => {
1089
- logger$30.error(`[ClientPreferences] Failed to load preferences: ${String(error)}`);
1089
+ logger$31.error(`[ClientPreferences] Failed to load preferences: ${String(error)}`);
1090
1090
  });
1091
1091
  }
1092
1092
  };
@@ -1107,7 +1107,7 @@ function toError(value) {
1107
1107
 
1108
1108
  //#endregion
1109
1109
  //#region src/controllers/NavigatorDeviceController.ts
1110
- const logger$29 = getLogger();
1110
+ const logger$30 = getLogger();
1111
1111
  /** Maps a device kind to its storage key. */
1112
1112
  const DEVICE_STORAGE_KEYS = {
1113
1113
  audioinput: DEVICE_STORAGE_KEY_AUDIO_INPUT,
@@ -1129,7 +1129,7 @@ var NavigatorDeviceController = class extends Destroyable {
1129
1129
  super();
1130
1130
  this.webRTCApiProvider = webRTCApiProvider;
1131
1131
  this.deviceChangeHandler = () => {
1132
- logger$29.debug("[DeviceController] Device change detected");
1132
+ logger$30.debug("[DeviceController] Device change detected");
1133
1133
  this.enumerateDevices();
1134
1134
  };
1135
1135
  this._devicesState$ = this.createBehaviorSubject(initialDevicesState);
@@ -1194,13 +1194,13 @@ var NavigatorDeviceController = class extends Destroyable {
1194
1194
  return this.cachedObservable("videoInputDevices$", () => this._devicesState$.pipe(map((state) => state.videoinput), distinctUntilChanged(), takeUntil(this.destroyed$)));
1195
1195
  }
1196
1196
  get selectedAudioInputDevice$() {
1197
- return this.cachedObservable("selectedAudioInputDevice$", () => this._selectedDevicesState$.asObservable().pipe(map((state) => state.audioinput), distinctUntilChanged(), takeUntil(this.destroyed$), tap((info) => logger$29.debug("[DeviceController] Selected audio input device changed:", info))));
1197
+ return this.cachedObservable("selectedAudioInputDevice$", () => this._selectedDevicesState$.asObservable().pipe(map((state) => state.audioinput), distinctUntilChanged(), takeUntil(this.destroyed$), tap((info) => logger$30.debug("[DeviceController] Selected audio input device changed:", info))));
1198
1198
  }
1199
1199
  get selectedAudioOutputDevice$() {
1200
- return this.cachedObservable("selectedAudioOutputDevice$", () => this._selectedDevicesState$.asObservable().pipe(map((state) => state.audiooutput), distinctUntilChanged(), takeUntil(this.destroyed$), tap((info) => logger$29.debug("[DeviceController] Selected audio output device changed:", info))));
1200
+ return this.cachedObservable("selectedAudioOutputDevice$", () => this._selectedDevicesState$.asObservable().pipe(map((state) => state.audiooutput), distinctUntilChanged(), takeUntil(this.destroyed$), tap((info) => logger$30.debug("[DeviceController] Selected audio output device changed:", info))));
1201
1201
  }
1202
1202
  get selectedVideoInputDevice$() {
1203
- return this.cachedObservable("selectedVideoInputDevice$", () => this._selectedDevicesState$.asObservable().pipe(map((state) => state.videoinput), distinctUntilChanged(), takeUntil(this.destroyed$), tap((info) => logger$29.debug("[DeviceController] Selected video input device changed:", info))));
1203
+ return this.cachedObservable("selectedVideoInputDevice$", () => this._selectedDevicesState$.asObservable().pipe(map((state) => state.videoinput), distinctUntilChanged(), takeUntil(this.destroyed$), tap((info) => logger$30.debug("[DeviceController] Selected video input device changed:", info))));
1204
1204
  }
1205
1205
  get selectedAudioInputDevice() {
1206
1206
  if (this._audioInputDisabled$.value) return null;
@@ -1275,7 +1275,7 @@ var NavigatorDeviceController = class extends Destroyable {
1275
1275
  if (device) this.persistDeviceSelection("audioinput", device);
1276
1276
  }
1277
1277
  selectVideoInputDevice(device) {
1278
- logger$29.debug("[DeviceController] Setting selected video input device:", device);
1278
+ logger$30.debug("[DeviceController] Setting selected video input device:", device);
1279
1279
  if (this._videoInputDisabled$.value && device) this._videoInputDisabled$.next(false);
1280
1280
  const previous = this._selectedDevicesState$.value.videoinput;
1281
1281
  if (previous && previous.deviceId !== device?.deviceId) this._deviceHistory.push("videoinput", previous);
@@ -1332,7 +1332,7 @@ var NavigatorDeviceController = class extends Destroyable {
1332
1332
  }
1333
1333
  const fromHistory = this._deviceHistory.findInHistory(kind, devices);
1334
1334
  if (fromHistory) {
1335
- logger$29.debug(`[DeviceController] Device disappeared, falling back to history: ${fromHistory.label}`);
1335
+ logger$30.debug(`[DeviceController] Device disappeared, falling back to history: ${fromHistory.label}`);
1336
1336
  this.emitDeviceRecovered(kind, selected, fromHistory, "device_disconnected");
1337
1337
  return fromHistory;
1338
1338
  }
@@ -1385,7 +1385,7 @@ var NavigatorDeviceController = class extends Destroyable {
1385
1385
  try {
1386
1386
  await this._storageManager.setItem(DEVICE_STORAGE_KEYS[kind], stored, "local");
1387
1387
  } catch (error) {
1388
- logger$29.error(`[DeviceController] Failed to persist device selection for ${kind}:`, error);
1388
+ logger$30.error(`[DeviceController] Failed to persist device selection for ${kind}:`, error);
1389
1389
  }
1390
1390
  }
1391
1391
  async loadPersistedDevices() {
@@ -1401,7 +1401,7 @@ var NavigatorDeviceController = class extends Destroyable {
1401
1401
  [kind]: stored
1402
1402
  };
1403
1403
  } catch (error) {
1404
- logger$29.error(`[DeviceController] Failed to load persisted device for ${kind}:`, error);
1404
+ logger$30.error(`[DeviceController] Failed to load persisted device for ${kind}:`, error);
1405
1405
  }
1406
1406
  }
1407
1407
  /** Clears device history, persisted selections, and re-enumerates devices. */
@@ -1419,7 +1419,7 @@ var NavigatorDeviceController = class extends Destroyable {
1419
1419
  this.disableDeviceMonitoring();
1420
1420
  this.webRTCApiProvider.mediaDevices.addEventListener("devicechange", this.deviceChangeHandler);
1421
1421
  if (PreferencesContainer.instance.devicePollingInterval > 0) this._devicesPoolingSubscription = interval(PreferencesContainer.instance.devicePollingInterval).subscribe(() => {
1422
- logger$29.debug("[DeviceController] Polling devices due to interval");
1422
+ logger$30.debug("[DeviceController] Polling devices due to interval");
1423
1423
  this.enumerateDevices();
1424
1424
  });
1425
1425
  this.enumerateDevices();
@@ -1445,13 +1445,13 @@ var NavigatorDeviceController = class extends Destroyable {
1445
1445
  videoinput: []
1446
1446
  });
1447
1447
  this._devicesState$.next(devicesByKind);
1448
- logger$29.debug("[DeviceController] Devices enumerated:", {
1448
+ logger$30.debug("[DeviceController] Devices enumerated:", {
1449
1449
  audioInputs: devicesByKind.audioinput.length,
1450
1450
  audioOutputs: devicesByKind.audiooutput.length,
1451
1451
  videoInputs: devicesByKind.videoinput.length
1452
1452
  });
1453
1453
  } catch (error) {
1454
- logger$29.error("[DeviceController] Failed to enumerate devices:", error);
1454
+ logger$30.error("[DeviceController] Failed to enumerate devices:", error);
1455
1455
  this._errors$.next(toError(error));
1456
1456
  }
1457
1457
  }
@@ -1467,7 +1467,7 @@ var NavigatorDeviceController = class extends Destroyable {
1467
1467
  stream.getTracks().forEach((t) => t.stop());
1468
1468
  return capabilities;
1469
1469
  } catch (error) {
1470
- logger$29.error("[DeviceController] Failed to get device capabilities:", error);
1470
+ logger$30.error("[DeviceController] Failed to get device capabilities:", error);
1471
1471
  this._errors$.next(toError(error));
1472
1472
  throw error;
1473
1473
  }
@@ -1718,7 +1718,7 @@ var DependencyContainer = class {
1718
1718
 
1719
1719
  //#endregion
1720
1720
  //#region src/controllers/CryptoController.ts
1721
- const logger$28 = getLogger();
1721
+ const logger$29 = getLogger();
1722
1722
  const DPOP_DB_NAME = "sw-dpop";
1723
1723
  const DPOP_DB_VERSION = 1;
1724
1724
  const DPOP_STORE_NAME = "keys";
@@ -1777,7 +1777,7 @@ async function loadKeyPairFromDB() {
1777
1777
  tx.oncomplete = () => db.close();
1778
1778
  });
1779
1779
  } catch (error) {
1780
- logger$28.warn("[DPoP] Failed to load key pair from IndexedDB:", error);
1780
+ logger$29.warn("[DPoP] Failed to load key pair from IndexedDB:", error);
1781
1781
  return null;
1782
1782
  }
1783
1783
  }
@@ -1797,7 +1797,7 @@ async function saveKeyPairToDB(keyPair) {
1797
1797
  };
1798
1798
  });
1799
1799
  } catch (error) {
1800
- logger$28.warn("[DPoP] Failed to save key pair to IndexedDB:", error);
1800
+ logger$29.warn("[DPoP] Failed to save key pair to IndexedDB:", error);
1801
1801
  }
1802
1802
  }
1803
1803
  async function deleteKeyPairFromDB() {
@@ -1816,7 +1816,7 @@ async function deleteKeyPairFromDB() {
1816
1816
  };
1817
1817
  });
1818
1818
  } catch (error) {
1819
- logger$28.warn("[DPoP] Failed to delete key pair from IndexedDB:", error);
1819
+ logger$29.warn("[DPoP] Failed to delete key pair from IndexedDB:", error);
1820
1820
  }
1821
1821
  }
1822
1822
  /**
@@ -1876,13 +1876,13 @@ var CryptoController = class {
1876
1876
  this._publicJwk = await crypto.subtle.exportKey("jwk", stored.publicKey);
1877
1877
  this._fingerprint = await computeJwkThumbprint(this._publicJwk);
1878
1878
  this._initialized = true;
1879
- logger$28.debug("[DPoP] Key pair restored from IndexedDB, fingerprint:", this._fingerprint);
1879
+ logger$29.debug("[DPoP] Key pair restored from IndexedDB, fingerprint:", this._fingerprint);
1880
1880
  return this._fingerprint;
1881
1881
  } catch (error) {
1882
- logger$28.warn("[DPoP] Stored key pair unusable, generating new one:", error);
1882
+ logger$29.warn("[DPoP] Stored key pair unusable, generating new one:", error);
1883
1883
  await deleteKeyPairFromDB();
1884
1884
  }
1885
- logger$28.debug("[DPoP] Generating RSA key pair");
1885
+ logger$29.debug("[DPoP] Generating RSA key pair");
1886
1886
  this._keyPair = await crypto.subtle.generateKey({
1887
1887
  name: "RSASSA-PKCS1-v1_5",
1888
1888
  modulusLength: 2048,
@@ -1897,7 +1897,7 @@ var CryptoController = class {
1897
1897
  this._fingerprint = await computeJwkThumbprint(this._publicJwk);
1898
1898
  this._initialized = true;
1899
1899
  await saveKeyPairToDB(this._keyPair);
1900
- logger$28.debug("[DPoP] Key pair generated and persisted, fingerprint:", this._fingerprint);
1900
+ logger$29.debug("[DPoP] Key pair generated and persisted, fingerprint:", this._fingerprint);
1901
1901
  return this._fingerprint;
1902
1902
  }
1903
1903
  /**
@@ -1963,7 +1963,7 @@ var CryptoController = class {
1963
1963
  this._fingerprint = null;
1964
1964
  this._initialized = false;
1965
1965
  deleteKeyPairFromDB();
1966
- logger$28.debug("[DPoP] Controller destroyed");
1966
+ logger$29.debug("[DPoP] Controller destroyed");
1967
1967
  }
1968
1968
  get publicJwk() {
1969
1969
  if (!this._publicJwk) throw new DPoPInitError("CryptoController not initialized. Call init() first.");
@@ -1986,7 +1986,7 @@ var CryptoController = class {
1986
1986
 
1987
1987
  //#endregion
1988
1988
  //#region src/controllers/NetworkMonitor.ts
1989
- const logger$27 = getLogger();
1989
+ const logger$28 = getLogger();
1990
1990
  /**
1991
1991
  * Safely check whether we are running in a browser environment
1992
1992
  * with `window` and the relevant event targets.
@@ -2043,7 +2043,7 @@ var NetworkMonitor = class extends Destroyable {
2043
2043
  }
2044
2044
  attachListeners() {
2045
2045
  if (!hasBrowserNetworkEvents()) {
2046
- logger$27.debug("NetworkMonitor: no browser environment detected, skipping event listeners");
2046
+ logger$28.debug("NetworkMonitor: no browser environment detected, skipping event listeners");
2047
2047
  return;
2048
2048
  }
2049
2049
  window.addEventListener("online", this._onOnline);
@@ -2051,7 +2051,7 @@ var NetworkMonitor = class extends Destroyable {
2051
2051
  const connection = getNetworkConnection();
2052
2052
  if (connection) connection.addEventListener("change", this._onConnectionChange);
2053
2053
  this._listenersAttached = true;
2054
- logger$27.debug("NetworkMonitor: event listeners attached");
2054
+ logger$28.debug("NetworkMonitor: event listeners attached");
2055
2055
  }
2056
2056
  removeListeners() {
2057
2057
  if (!this._listenersAttached) return;
@@ -2062,10 +2062,10 @@ var NetworkMonitor = class extends Destroyable {
2062
2062
  if (connection) connection.removeEventListener("change", this._onConnectionChange);
2063
2063
  }
2064
2064
  this._listenersAttached = false;
2065
- logger$27.debug("NetworkMonitor: event listeners removed");
2065
+ logger$28.debug("NetworkMonitor: event listeners removed");
2066
2066
  }
2067
2067
  handleOnline() {
2068
- logger$27.info("NetworkMonitor: browser went online");
2068
+ logger$28.info("NetworkMonitor: browser went online");
2069
2069
  this._isOnline$.next(true);
2070
2070
  this._networkChange$.next({
2071
2071
  type: "online",
@@ -2074,7 +2074,7 @@ var NetworkMonitor = class extends Destroyable {
2074
2074
  });
2075
2075
  }
2076
2076
  handleOffline() {
2077
- logger$27.info("NetworkMonitor: browser went offline");
2077
+ logger$28.info("NetworkMonitor: browser went offline");
2078
2078
  this._isOnline$.next(false);
2079
2079
  this._networkChange$.next({
2080
2080
  type: "offline",
@@ -2083,7 +2083,7 @@ var NetworkMonitor = class extends Destroyable {
2083
2083
  }
2084
2084
  handleConnectionChange() {
2085
2085
  const networkType = getNetworkType();
2086
- logger$27.info(`NetworkMonitor: connection changed — effectiveType=${networkType ?? "unknown"}`);
2086
+ logger$28.info(`NetworkMonitor: connection changed — effectiveType=${networkType ?? "unknown"}`);
2087
2087
  this._networkChange$.next({
2088
2088
  type: "connection_change",
2089
2089
  timestamp: Date.now(),
@@ -2198,7 +2198,7 @@ function getNavigatorMediaDevices() {
2198
2198
 
2199
2199
  //#endregion
2200
2200
  //#region src/controllers/PreflightRunner.ts
2201
- const logger$26 = getLogger();
2201
+ const logger$27 = getLogger();
2202
2202
  const DEFAULT_MEDIA_TEST_DURATION_S = 10;
2203
2203
  const ICE_GATHERING_TIMEOUT_MS = 1e4;
2204
2204
  const SIGNALING_RTT_TIMEOUT_MS = 5e3;
@@ -2247,7 +2247,7 @@ var PreflightRunner = class extends Destroyable {
2247
2247
  if (!this._options.skipMediaTest) try {
2248
2248
  bandwidth = await this.testMediaBandwidth(destination);
2249
2249
  } catch (error) {
2250
- logger$26.warn("[PreflightRunner] Media bandwidth test failed:", error);
2250
+ logger$27.warn("[PreflightRunner] Media bandwidth test failed:", error);
2251
2251
  warnings.push("Media bandwidth test failed");
2252
2252
  }
2253
2253
  return {
@@ -2259,7 +2259,7 @@ var PreflightRunner = class extends Destroyable {
2259
2259
  warnings
2260
2260
  };
2261
2261
  } catch (error) {
2262
- logger$26.error("[PreflightRunner] Preflight test failed:", error);
2262
+ logger$27.error("[PreflightRunner] Preflight test failed:", error);
2263
2263
  throw new PreflightError("preflight", error instanceof Error ? error : new Error(String(error)));
2264
2264
  } finally {
2265
2265
  this.destroy();
@@ -2290,7 +2290,7 @@ var PreflightRunner = class extends Destroyable {
2290
2290
  if (track.kind === "video" && track.readyState === "live") videoWorking = true;
2291
2291
  }
2292
2292
  } catch (error) {
2293
- logger$26.warn("[PreflightRunner] Device test failed:", error);
2293
+ logger$27.warn("[PreflightRunner] Device test failed:", error);
2294
2294
  } finally {
2295
2295
  if (audioStream) audioStream.getTracks().forEach((t) => t.stop());
2296
2296
  }
@@ -2348,7 +2348,7 @@ var PreflightRunner = class extends Destroyable {
2348
2348
  rttMs
2349
2349
  };
2350
2350
  } catch (error) {
2351
- logger$26.warn("[PreflightRunner] ICE connectivity test failed:", error);
2351
+ logger$27.warn("[PreflightRunner] ICE connectivity test failed:", error);
2352
2352
  return {
2353
2353
  type: "failed",
2354
2354
  turnReachable: false,
@@ -2395,7 +2395,7 @@ var PreflightRunner = class extends Destroyable {
2395
2395
 
2396
2396
  //#endregion
2397
2397
  //#region src/controllers/VisibilityController.ts
2398
- const logger$25 = getLogger();
2398
+ const logger$26 = getLogger();
2399
2399
  /**
2400
2400
  * Checks whether the document visibility API is available.
2401
2401
  */
@@ -2432,8 +2432,8 @@ var VisibilityController = class extends Destroyable {
2432
2432
  this._boundHandler = this._handleVisibilityChange.bind(this);
2433
2433
  if (this._hasVisibilityApi) {
2434
2434
  document.addEventListener("visibilitychange", this._boundHandler);
2435
- logger$25.debug("VisibilityController: listening for visibilitychange events");
2436
- } else logger$25.debug("VisibilityController: document visibility API not available, defaulting to visible");
2435
+ logger$26.debug("VisibilityController: listening for visibilitychange events");
2436
+ } else logger$26.debug("VisibilityController: document visibility API not available, defaulting to visible");
2437
2437
  }
2438
2438
  /**
2439
2439
  * Observable of the current visibility state.
@@ -2458,7 +2458,7 @@ var VisibilityController = class extends Destroyable {
2458
2458
  destroy() {
2459
2459
  if (this._hasVisibilityApi) {
2460
2460
  document.removeEventListener("visibilitychange", this._boundHandler);
2461
- logger$25.debug("VisibilityController: removed visibilitychange listener");
2461
+ logger$26.debug("VisibilityController: removed visibilitychange listener");
2462
2462
  }
2463
2463
  super.destroy();
2464
2464
  }
@@ -2476,7 +2476,7 @@ var VisibilityController = class extends Destroyable {
2476
2476
  timestamp: Date.now()
2477
2477
  };
2478
2478
  this._visibilityChange$.next(changeEvent);
2479
- logger$25.debug("VisibilityController: visibility changed", {
2479
+ logger$26.debug("VisibilityController: visibility changed", {
2480
2480
  from: previousState,
2481
2481
  to: newState
2482
2482
  });
@@ -2677,15 +2677,57 @@ const RPCEventAckResponse = (id) => makeRPCResponse({
2677
2677
  result: {}
2678
2678
  });
2679
2679
 
2680
+ //#endregion
2681
+ //#region src/utils/authRecovery.ts
2682
+ /**
2683
+ * Walk an error's `error`/`cause` chain looking for a {@link JSONRPCError}.
2684
+ * Errors thrown by call creation are wrapped (e.g. `CallCreateError`), so the
2685
+ * underlying signaling error is nested. Bounded by a visited set to guard
2686
+ * against cyclic causes.
2687
+ */
2688
+ function findJSONRPCError(error) {
2689
+ const seen = /* @__PURE__ */ new Set();
2690
+ let current = error;
2691
+ while (current instanceof Error && !seen.has(current)) {
2692
+ seen.add(current);
2693
+ if (current instanceof JSONRPCError) return current;
2694
+ current = current.error ?? current.cause;
2695
+ }
2696
+ }
2697
+ /**
2698
+ * Whether an error is a session-recoverable authentication failure
2699
+ * (`-32002` authentication failed or `-32003` requester validation failed)
2700
+ * that a credential re-mint + retry can heal.
2701
+ */
2702
+ function isRecoverableAuthError(error) {
2703
+ const rpcError = findJSONRPCError(error);
2704
+ return rpcError !== void 0 && (rpcError.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || rpcError.code === RPC_ERROR_AUTHENTICATION_FAILED);
2705
+ }
2706
+ /**
2707
+ * Whether an error is specifically a requester-validation rejection
2708
+ * (`-32003`) — the server refusing the session's credential.
2709
+ *
2710
+ * Narrower than {@link isRecoverableAuthError} on purpose. `-32002` is
2711
+ * overloaded server-side: a rejected reattach arrives as `-32002` with
2712
+ * `cause: INVALID_MSG_UNSPECIFIED` and message `CALL ERROR`, which is a
2713
+ * call-level rejection and says nothing about the credential. Use this where
2714
+ * the decision must not be fooled by that, such as deciding whether retrying
2715
+ * an operation could possibly succeed.
2716
+ */
2717
+ function isRequesterValidationError(error) {
2718
+ return findJSONRPCError(error)?.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED;
2719
+ }
2720
+
2680
2721
  //#endregion
2681
2722
  //#region src/managers/AttachManager.ts
2682
- const logger$24 = getLogger();
2723
+ const logger$25 = getLogger();
2683
2724
  var AttachManager = class {
2684
- constructor(storage, deviceController, reconnectCallsTimeout, attachKey) {
2725
+ constructor(storage, deviceController, reconnectCallsTimeout, attachKey, credentialRecovered) {
2685
2726
  this.storage = storage;
2686
2727
  this.deviceController = deviceController;
2687
2728
  this.reconnectCallsTimeout = reconnectCallsTimeout;
2688
2729
  this.attachKey = attachKey;
2730
+ this.credentialRecovered = credentialRecovered;
2689
2731
  this.writeQueue = Promise.resolve();
2690
2732
  }
2691
2733
  async detachAll() {
@@ -2700,7 +2742,7 @@ var AttachManager = class {
2700
2742
  try {
2701
2743
  return await this.storage.getItem(this.attachKey) ?? {};
2702
2744
  } catch (error) {
2703
- logger$24.warn("[AttachManager] Failed to retrieve attached calls from storage", error);
2745
+ logger$25.warn("[AttachManager] Failed to retrieve attached calls from storage", error);
2704
2746
  return {};
2705
2747
  }
2706
2748
  }
@@ -2708,7 +2750,7 @@ var AttachManager = class {
2708
2750
  try {
2709
2751
  await this.storage.setItem(this.attachKey, attached);
2710
2752
  } catch (error) {
2711
- logger$24.warn("[AttachManager] Failed to write attached calls to storage", error);
2753
+ logger$25.warn("[AttachManager] Failed to write attached calls to storage", error);
2712
2754
  }
2713
2755
  }
2714
2756
  /**
@@ -2727,11 +2769,39 @@ var AttachManager = class {
2727
2769
  }
2728
2770
  async attach(call) {
2729
2771
  if (!call.to) {
2730
- logger$24.warn("[AttachManager] Skip attach for calls with no destination");
2772
+ logger$25.warn("[AttachManager] Skip attach for calls with no destination");
2731
2773
  return;
2732
2774
  }
2775
+ const attachment = this.buildAttachment(call, call.to);
2776
+ await this.mutate((attached) => ({
2777
+ ...attached,
2778
+ [call.id]: attachment
2779
+ }));
2780
+ }
2781
+ /**
2782
+ * Keep an already-stored call's reference alive and current — the periodic
2783
+ * refresh the `verto.ping` keepalive drives.
2784
+ *
2785
+ * Only ever updates: a call with no record is one nothing wants reattached,
2786
+ * and re-creating it here would undo a `detach`. That matters because a ping
2787
+ * can land in the window between `bye()` detaching and the call being torn
2788
+ * down, and a record revived there survives the hangup — so the next page
2789
+ * load dials a call nobody is on. The existence check and the write share
2790
+ * one {@link mutate} turn, so a concurrent detach cannot slip between them.
2791
+ */
2792
+ async refresh(call) {
2793
+ if (!call.to) return;
2733
2794
  const destination = call.to;
2734
- const attachment = {
2795
+ await this.mutate((attached) => {
2796
+ if (!Object.hasOwn(attached, call.id)) return attached;
2797
+ return {
2798
+ ...attached,
2799
+ [call.id]: this.buildAttachment(call, destination)
2800
+ };
2801
+ });
2802
+ }
2803
+ buildAttachment(call, destination) {
2804
+ return {
2735
2805
  nodeId: call.nodeId,
2736
2806
  destination,
2737
2807
  mediaDirections: call.mediaDirections,
@@ -2739,10 +2809,6 @@ var AttachManager = class {
2739
2809
  videoInputDevice: call.mediaDirections.video !== "inactive" ? this.deviceController.selectedVideoInputDevice : null,
2740
2810
  attachedAt: Date.now()
2741
2811
  };
2742
- await this.mutate((attached) => ({
2743
- ...attached,
2744
- [call.id]: attachment
2745
- }));
2746
2812
  }
2747
2813
  async detach(call) {
2748
2814
  await this.mutate((attached) => {
@@ -2765,8 +2831,14 @@ var AttachManager = class {
2765
2831
  * rejecting. Once that fix is deployed, this will work for both
2766
2832
  * page reloads and WebSocket reconnects.
2767
2833
  *
2768
- * Failed reattach attempts are handled gracefully — the stale call
2769
- * reference is cleaned up from storage.
2834
+ * A failed reattach does NOT generally cost the stored reference. It is
2835
+ * discarded only when the server denied the reattach on a session whose
2836
+ * credential it had already accepted — a verified reauthentication followed
2837
+ * by a refusal is the server saying the call is gone, and that is the one
2838
+ * refusal worth acting on. Until then the credential may be what is being
2839
+ * refused, and the record is the only way a later reload can try again;
2840
+ * keeping it costs nothing, since `detachExpired` reaps it once it is older
2841
+ * than `reconnectCallsTimeout`.
2770
2842
  */
2771
2843
  async reattachCalls() {
2772
2844
  const attached = await this.readAttached();
@@ -2775,25 +2847,31 @@ var AttachManager = class {
2775
2847
  const { destination } = attachment;
2776
2848
  const options = this.buildCallOptions(attachment);
2777
2849
  let succeeded = false;
2850
+ let refusedOnCredentials = false;
2778
2851
  for (let attempt = 1; attempt <= 3; attempt++) try {
2779
2852
  await this.session.createOutboundCall(destination, {
2780
2853
  callId,
2781
2854
  ...options
2782
2855
  });
2783
- logger$24.info(`[AttachManager] Reattached call ${callId} (attempt ${attempt})`);
2856
+ logger$25.info(`[AttachManager] Reattached call ${callId} (attempt ${attempt})`);
2784
2857
  succeeded = true;
2785
2858
  break;
2786
2859
  } catch (error) {
2787
- logger$24.warn(`[AttachManager] Reattach attempt ${attempt}/3 failed for call ${callId}:`, error);
2860
+ logger$25.warn(`[AttachManager] Reattach attempt ${attempt}/3 failed for call ${callId}:`, error);
2861
+ if (isRequesterValidationError(error)) {
2862
+ refusedOnCredentials = true;
2863
+ logger$25.warn(`[AttachManager] Reattach of ${callId} was refused on credentials; not retrying.`);
2864
+ break;
2865
+ }
2788
2866
  if (attempt < 3) await new Promise((r) => setTimeout(r, (attempt + 1) * 1e3));
2789
2867
  }
2790
- if (!succeeded) {
2791
- logger$24.warn(`[AttachManager] Reattach failed after 3 attempts for call ${callId}, removing reference`);
2868
+ if (!succeeded) if (this.credentialRecovered() && !refusedOnCredentials) {
2869
+ logger$25.warn(`[AttachManager] Reattach of ${callId} was denied after a verified reauthentication, removing reference`);
2792
2870
  await this.detach({
2793
2871
  id: callId,
2794
2872
  mediaDirections: attachment.mediaDirections
2795
2873
  });
2796
- }
2874
+ } else logger$25.warn(`[AttachManager] Reattach failed for call ${callId}; keeping the reference (credential refused or never proven good)`);
2797
2875
  }
2798
2876
  }
2799
2877
  /**
@@ -3106,7 +3184,7 @@ function toggleHandraiseMethod(is) {
3106
3184
 
3107
3185
  //#endregion
3108
3186
  //#region src/core/entities/Participant.ts
3109
- const logger$23 = getLogger();
3187
+ const logger$24 = getLogger();
3110
3188
  const initialState = {};
3111
3189
  /**
3112
3190
  * Represents a participant in a call.
@@ -3116,9 +3194,9 @@ const initialState = {};
3116
3194
  * the local participant with additional device control.
3117
3195
  */
3118
3196
  var Participant = class extends Destroyable {
3119
- constructor(id, executeMethod, deviceController) {
3197
+ constructor(id, callExecuteMethod, deviceController) {
3120
3198
  super();
3121
- this.executeMethod = executeMethod;
3199
+ this.callExecuteMethod = callExecuteMethod;
3122
3200
  this.deviceController = deviceController;
3123
3201
  this._state$ = this.createBehaviorSubject(initialState);
3124
3202
  this.id = id;
@@ -3340,22 +3418,55 @@ var Participant = class extends Destroyable {
3340
3418
  get value() {
3341
3419
  return this._state$.value;
3342
3420
  }
3421
+ /**
3422
+ * Target triple for member RPCs, built from the participant's own state.
3423
+ * The backend locates the member's session by the target `call_id`/`node_id`,
3424
+ * so this must always be the participant's own call context — never the
3425
+ * local call's id (issue #19400).
3426
+ *
3427
+ * Reading it doubles as a readiness probe: it throws until the first full
3428
+ * member event (`member.joined`/`member.updated` or the `call.joined`
3429
+ * roster) arrives, and never regresses afterwards.
3430
+ *
3431
+ * @throws {ParticipantNotReadyError} If the member state has not been
3432
+ * received yet (e.g. a participant first seen via `member.talking`) — an
3433
+ * empty call context can never address the member, so fail fast instead of
3434
+ * sending a doomed RPC.
3435
+ */
3436
+ get target() {
3437
+ const { call_id, node_id } = this._state$.value;
3438
+ if (!call_id || !node_id) throw new ParticipantNotReadyError(this.id);
3439
+ return {
3440
+ member_id: this.id,
3441
+ call_id,
3442
+ node_id
3443
+ };
3444
+ }
3445
+ /**
3446
+ * Executes a member RPC against this participant, injecting its own
3447
+ * {@link target} as the target.
3448
+ *
3449
+ * @throws {ParticipantNotReadyError} Via {@link target}, when the
3450
+ * member state has not been received yet.
3451
+ */
3452
+ async executeMethod(method, args) {
3453
+ return this.callExecuteMethod(this.target, method, args);
3454
+ }
3343
3455
  /** Toggles the deafened state (mutes/unmutes incoming audio). */
3344
3456
  async toggleDeaf() {
3345
- const method = toggleDeafMethod(this.deaf);
3346
- await this.executeMethod(this.id, method, {});
3457
+ await this.executeMethod(toggleDeafMethod(this.deaf), {});
3347
3458
  }
3348
3459
  /** Toggles the hand-raised state. */
3349
3460
  async toggleHandraise() {
3350
- await this.executeMethod(this.id, toggleHandraiseMethod(this.handraised), {});
3461
+ await this.executeMethod(toggleHandraiseMethod(this.handraised), {});
3351
3462
  }
3352
3463
  /** Mutes the participant's audio. */
3353
3464
  async mute() {
3354
- await this.executeMethod(this.id, "call.mute", { channels: ["audio"] });
3465
+ await this.executeMethod("call.mute", { channels: ["audio"] });
3355
3466
  }
3356
3467
  /** Unmutes the participant's audio. */
3357
3468
  async unmute() {
3358
- await this.executeMethod(this.id, "call.unmute", { channels: ["audio"] });
3469
+ await this.executeMethod("call.unmute", { channels: ["audio"] });
3359
3470
  }
3360
3471
  /** Toggles the participant's audio mute state. */
3361
3472
  async toggleMute() {
@@ -3363,11 +3474,11 @@ var Participant = class extends Destroyable {
3363
3474
  }
3364
3475
  /** Mutes the participant's video. */
3365
3476
  async muteVideo() {
3366
- await this.executeMethod(this.id, "call.mute", { channels: ["video"] });
3477
+ await this.executeMethod("call.mute", { channels: ["video"] });
3367
3478
  }
3368
3479
  /** Unmutes the participant's video. */
3369
3480
  async unmuteVideo() {
3370
- await this.executeMethod(this.id, "call.unmute", { channels: ["video"] });
3481
+ await this.executeMethod("call.unmute", { channels: ["video"] });
3371
3482
  }
3372
3483
  /** Toggles the participant's video mute state. */
3373
3484
  async toggleMuteVideo() {
@@ -3375,7 +3486,7 @@ var Participant = class extends Destroyable {
3375
3486
  }
3376
3487
  /** Toggles echo cancellation on the audio input. */
3377
3488
  async toggleEchoCancellation() {
3378
- await this.executeMethod(this.id, "call.audioflags.set", {
3489
+ await this.executeMethod("call.audioflags.set", {
3379
3490
  echo_cancellation: !this.echoCancellation,
3380
3491
  auto_gain: this.autoGain,
3381
3492
  noise_suppression: this.noiseSuppression
@@ -3383,7 +3494,7 @@ var Participant = class extends Destroyable {
3383
3494
  }
3384
3495
  /** Toggles automatic gain control on the audio input. */
3385
3496
  async toggleAudioInputAutoGain() {
3386
- await this.executeMethod(this.id, "call.audioflags.set", {
3497
+ await this.executeMethod("call.audioflags.set", {
3387
3498
  echo_cancellation: this.echoCancellation,
3388
3499
  auto_gain: !this.autoGain,
3389
3500
  noise_suppression: this.noiseSuppression
@@ -3391,7 +3502,7 @@ var Participant = class extends Destroyable {
3391
3502
  }
3392
3503
  /** Toggles noise suppression on the audio input. */
3393
3504
  async toggleNoiseSuppression() {
3394
- await this.executeMethod(this.id, "call.audioflags.set", {
3505
+ await this.executeMethod("call.audioflags.set", {
3395
3506
  echo_cancellation: this.echoCancellation,
3396
3507
  auto_gain: this.autoGain,
3397
3508
  noise_suppression: !this.noiseSuppression
@@ -3399,7 +3510,7 @@ var Participant = class extends Destroyable {
3399
3510
  }
3400
3511
  /** Toggles low-bitrate mode for this participant's media. */
3401
3512
  async toggleLowbitrate() {
3402
- await this.executeMethod(this.id, "call.lowbitrate.set", { lowbitrate: !this.lowbitrate });
3513
+ await this.executeMethod("call.lowbitrate.set", { lowbitrate: !this.lowbitrate });
3403
3514
  }
3404
3515
  /**
3405
3516
  * Adjusts the **conference-only** microphone energy gate / sensitivity level
@@ -3415,7 +3526,7 @@ var Participant = class extends Destroyable {
3415
3526
  * (integer, larger values are more sensitive).
3416
3527
  */
3417
3528
  async setAudioInputSensitivity(value) {
3418
- await this.executeMethod(this.id, "call.microphone.sensitivity.set", { sensitivity: value });
3529
+ await this.executeMethod("call.microphone.sensitivity.set", { sensitivity: value });
3419
3530
  }
3420
3531
  /**
3421
3532
  * Sets the **server-side** microphone volume on this participant's bridged
@@ -3428,7 +3539,7 @@ var Participant = class extends Destroyable {
3428
3539
  * @param value - Volume level (0-100).
3429
3540
  */
3430
3541
  async setAudioInputVolume(value) {
3431
- await this.executeMethod(this.id, "call.microphone.volume.set", { volume: value });
3542
+ await this.executeMethod("call.microphone.volume.set", { volume: value });
3432
3543
  }
3433
3544
  /**
3434
3545
  * Sets the **server-side** speaker volume on this participant's bridged call
@@ -3442,45 +3553,31 @@ var Participant = class extends Destroyable {
3442
3553
  * @param value - Volume level (0-100).
3443
3554
  */
3444
3555
  async setAudioOutputVolume(value) {
3445
- await this.executeMethod(this.id, "call.speaker.volume.set", { volume: value });
3556
+ await this.executeMethod("call.speaker.volume.set", { volume: value });
3446
3557
  }
3447
3558
  /**
3448
3559
  * Sets the participant's position in the video layout.
3449
3560
  *
3450
- * Requires the `member.position` capability. The gateway keys positions by the
3451
- * **target member's own** `call_id`/`node_id` (see issue #19400 and the legacy
3452
- * `setPositions` implementation), so this sends the participant's own call
3453
- * context — matching {@link Participant.remove}. A resolved promise does not
3454
- * guarantee a visible change: the backend silently returns `200` (no-op) for
3455
- * non-conference targets.
3561
+ * Requires the `member.position` capability. The gateway requires a
3562
+ * `targets` array of `{ target, position }` entries (issue #19400). A
3563
+ * resolved promise does not guarantee a visible change: the backend silently
3564
+ * returns `200` (no-op) for non-conference targets.
3456
3565
  *
3457
3566
  * @param value - The {@link VideoPosition} to assign (e.g. `'auto'`, `'reserved-0'`).
3458
3567
  */
3459
3568
  async setPosition(value) {
3460
- const state = this._state$.value;
3461
- const target = {
3462
- member_id: this.id,
3463
- call_id: state.call_id ?? "",
3464
- node_id: state.node_id ?? ""
3465
- };
3466
- await this.executeMethod(target, "call.member.position.set", { targets: [{
3467
- target,
3569
+ await this.executeMethod("call.member.position.set", { targets: [{
3570
+ target: this.target,
3468
3571
  position: value
3469
3572
  }] });
3470
3573
  }
3471
3574
  /** Removes this participant from the call. */
3472
3575
  async remove() {
3473
- const state = this._state$.value;
3474
- const target = {
3475
- member_id: this.id,
3476
- call_id: state.call_id ?? "",
3477
- node_id: state.node_id ?? ""
3478
- };
3479
- await this.executeMethod(target, "call.member.remove", {});
3576
+ await this.executeMethod("call.member.remove", { targets: [this.target] });
3480
3577
  }
3481
3578
  /** Ends the call for this participant. */
3482
3579
  async end() {
3483
- await this.executeMethod(this.id, "call.end", {});
3580
+ await this.executeMethod("call.end", {});
3484
3581
  }
3485
3582
  /**
3486
3583
  * Replaces custom metadata for this participant.
@@ -3500,7 +3597,7 @@ var Participant = class extends Destroyable {
3500
3597
  }
3501
3598
  /** Destroys the participant, releasing all subscriptions and references. */
3502
3599
  destroy() {
3503
- this.executeMethod = void 0;
3600
+ this.callExecuteMethod = void 0;
3504
3601
  super.destroy();
3505
3602
  }
3506
3603
  };
@@ -3512,8 +3609,8 @@ var Participant = class extends Destroyable {
3512
3609
  */
3513
3610
  var SelfParticipant = class extends Participant {
3514
3611
  /** @internal */
3515
- constructor(id, executeMethod, vertoManager, deviceController) {
3516
- super(id, executeMethod, deviceController);
3612
+ constructor(id, callExecuteMethod, vertoManager, deviceController) {
3613
+ super(id, callExecuteMethod, deviceController);
3517
3614
  this.vertoManager = vertoManager;
3518
3615
  this._studioAudio$ = this.createBehaviorSubject(false);
3519
3616
  this.capabilities = new SelfCapabilities();
@@ -3537,7 +3634,7 @@ var SelfParticipant = class extends Participant {
3537
3634
  async enableStudioAudio() {
3538
3635
  if (this._studioAudio$.value) return;
3539
3636
  this._studioAudio$.next(true);
3540
- await this.executeMethod(this.id, "call.audioflags.set", {
3637
+ await this.executeMethod("call.audioflags.set", {
3541
3638
  echo_cancellation: false,
3542
3639
  auto_gain: false,
3543
3640
  noise_suppression: false
@@ -3550,7 +3647,7 @@ var SelfParticipant = class extends Participant {
3550
3647
  async disableStudioAudio() {
3551
3648
  if (!this._studioAudio$.value) return;
3552
3649
  this._studioAudio$.next(false);
3553
- await this.executeMethod(this.id, "call.audioflags.set", {
3650
+ await this.executeMethod("call.audioflags.set", {
3554
3651
  echo_cancellation: true,
3555
3652
  auto_gain: true,
3556
3653
  noise_suppression: true
@@ -3559,17 +3656,27 @@ var SelfParticipant = class extends Participant {
3559
3656
  /**
3560
3657
  * Starts sharing the local screen.
3561
3658
  *
3659
+ * A call carries at most one screen share. Read `screenShareStatus` before
3660
+ * calling and treat `'starting'`/`'stopping'` as busy.
3661
+ *
3562
3662
  * The call is unaffected when acquisition fails.
3563
3663
  *
3664
+ * @param options - Pass `{ audio: true }` to also request the shared
3665
+ * surface's audio. Defaults to video only.
3666
+ * @throws {ScreenShareAlreadyActiveError} When this call is already
3667
+ * sharing a screen. Call {@link stopScreenShare} before starting another.
3668
+ * @throws {AuxiliaryLegCancelledError} When {@link stopScreenShare} removes
3669
+ * the share before its leg finishes connecting.
3564
3670
  * @throws The raw `getDisplayMedia` error. A dismissed picker or a
3565
3671
  * permission denial rejects with a `NotAllowedError` `DOMException` —
3566
3672
  * inspect `error.name` to tell benign cancels apart from real failures.
3567
3673
  */
3568
- async startScreenShare() {
3674
+ async startScreenShare(options) {
3569
3675
  try {
3570
- await this.vertoManager.addScreenMedia();
3676
+ await this.vertoManager.addScreenMedia(options);
3571
3677
  } catch (error) {
3572
- logger$23.error("[Participant.startScreenShare] Screen share error:", error);
3678
+ if (error instanceof AuxiliaryLegCancelledError) logger$24.debug("[Participant.startScreenShare] Screen share cancelled before connecting.");
3679
+ else logger$24.error("[Participant.startScreenShare] Screen share error:", error);
3573
3680
  throw error;
3574
3681
  }
3575
3682
  }
@@ -3590,14 +3697,18 @@ var SelfParticipant = class extends Participant {
3590
3697
  *
3591
3698
  * The call is unaffected when acquisition fails.
3592
3699
  *
3700
+ * @throws {AuxiliaryLegCancelledError} When {@link removeAdditionalDevice}
3701
+ * removes the device before its leg finishes connecting.
3593
3702
  * @throws The raw `getUserMedia` error (e.g. `NotAllowedError` on
3594
- * permission denial) — inspect `error.name` to decide how to react.
3703
+ * permission denial) — inspect `error.name` to decide how to react — or
3704
+ * `AuxiliaryLegTimeoutError` if the leg does not connect in time.
3595
3705
  */
3596
3706
  async addAdditionalDevice(options) {
3597
3707
  try {
3598
3708
  await this.vertoManager.addInputDevice(options);
3599
3709
  } catch (error) {
3600
- logger$23.error("[Participant.addAdditionalDevice] Additional device error:", error);
3710
+ if (error instanceof AuxiliaryLegCancelledError) logger$24.debug("[Participant.addAdditionalDevice] Device removed before connecting.");
3711
+ else logger$24.error("[Participant.addAdditionalDevice] Additional device error:", error);
3601
3712
  throw error;
3602
3713
  }
3603
3714
  }
@@ -3632,22 +3743,31 @@ var SelfParticipant = class extends Participant {
3632
3743
  this.deviceController.selectAudioInputDevice(device);
3633
3744
  if (options.savePreference) PreferencesContainer.instance.preferredAudioInput = device;
3634
3745
  }
3635
- /** Updates the audio input track constraints for the active call. */
3746
+ /**
3747
+ * Updates the audio input track constraints for the active call.
3748
+ * @returns whether the constraints reached the media the call is sending.
3749
+ */
3636
3750
  async setAudioInputDeviceConstraints(constraints) {
3637
- await this.vertoManager.updateMediaConstraints({ audio: constraints });
3751
+ return this.vertoManager.updateMediaConstraints({ audio: constraints });
3638
3752
  }
3639
- /** Updates both audio and video input track constraints for the active call. */
3753
+ /**
3754
+ * Updates both audio and video input track constraints for the active call.
3755
+ * @returns whether both kinds took the constraints.
3756
+ */
3640
3757
  async setInputDevicesConstraints(constraints) {
3641
- await this.vertoManager.updateMediaConstraints(constraints);
3758
+ return this.vertoManager.updateMediaConstraints(constraints);
3642
3759
  }
3643
3760
  /** Selects the video input device for future calls. Optionally saves as a preference. */
3644
3761
  selectVideoInputDevice(device, options = {}) {
3645
3762
  this.deviceController.selectVideoInputDevice(device);
3646
3763
  if (options.savePreference) PreferencesContainer.instance.preferredVideoInput = device;
3647
3764
  }
3648
- /** Updates the video input track constraints for the active call. */
3765
+ /**
3766
+ * Updates the video input track constraints for the active call.
3767
+ * @returns whether the constraints reached the media the call is sending.
3768
+ */
3649
3769
  async setVideoInputDeviceConstraints(constraints) {
3650
- await this.vertoManager.updateMediaConstraints({ video: constraints });
3770
+ return this.vertoManager.updateMediaConstraints({ video: constraints });
3651
3771
  }
3652
3772
  /** Selects the audio output device. Optionally saves as a preference. */
3653
3773
  selectAudioOutputDevice(device, options = {}) {
@@ -3660,7 +3780,7 @@ var SelfParticipant = class extends Participant {
3660
3780
  */
3661
3781
  exitStudioModeIfActive() {
3662
3782
  if (this._studioAudio$.value) {
3663
- logger$23.debug("[SelfParticipant] Exiting studio audio mode due to individual flag toggle");
3783
+ logger$24.debug("[SelfParticipant] Exiting studio audio mode due to individual flag toggle");
3664
3784
  this._studioAudio$.next(false);
3665
3785
  }
3666
3786
  }
@@ -3684,7 +3804,7 @@ var SelfParticipant = class extends Participant {
3684
3804
  try {
3685
3805
  await super.mute();
3686
3806
  } catch (error) {
3687
- logger$23.warn("[Participant.toggleAudioInput] Server Error while muting audio input, proceeding with local toggle anyway", error);
3807
+ logger$24.warn("[Participant.toggleAudioInput] Server Error while muting audio input, proceeding with local toggle anyway", error);
3688
3808
  } finally {
3689
3809
  this.vertoManager.muteMainAudioInputDevice();
3690
3810
  }
@@ -3694,7 +3814,7 @@ var SelfParticipant = class extends Participant {
3694
3814
  try {
3695
3815
  await super.unmute();
3696
3816
  } catch (error) {
3697
- logger$23.warn("[Participant.toggleAudioInput] Server Error while unmuting audio input, proceeding with local toggle anyway", error);
3817
+ logger$24.warn("[Participant.toggleAudioInput] Server Error while unmuting audio input, proceeding with local toggle anyway", error);
3698
3818
  } finally {
3699
3819
  await this.vertoManager.unmuteMainAudioInputDevice();
3700
3820
  }
@@ -3704,7 +3824,7 @@ var SelfParticipant = class extends Participant {
3704
3824
  try {
3705
3825
  await super.muteVideo();
3706
3826
  } catch (error) {
3707
- logger$23.warn("[Participant.toggleVideoInput] Server Error while muting video input, proceeding with local toggle anyway", error);
3827
+ logger$24.warn("[Participant.toggleVideoInput] Server Error while muting video input, proceeding with local toggle anyway", error);
3708
3828
  } finally {
3709
3829
  this.vertoManager.muteMainVideoInputDevice();
3710
3830
  }
@@ -3714,7 +3834,7 @@ var SelfParticipant = class extends Participant {
3714
3834
  try {
3715
3835
  await super.unmuteVideo();
3716
3836
  } catch (error) {
3717
- logger$23.warn("[Participant.toggleVideoInput] Server Error while unmuting video input, proceeding with local toggle anyway", error);
3837
+ logger$24.warn("[Participant.toggleVideoInput] Server Error while unmuting video input, proceeding with local toggle anyway", error);
3718
3838
  } finally {
3719
3839
  await this.vertoManager.unmuteMainVideoInputDevice();
3720
3840
  }
@@ -3808,7 +3928,7 @@ function isLayoutChangedPayload(value) {
3808
3928
 
3809
3929
  //#endregion
3810
3930
  //#region src/managers/CallEventsManager.ts
3811
- const logger$22 = getLogger();
3931
+ const logger$23 = getLogger();
3812
3932
  const initialSessionState = {};
3813
3933
  /** @internal */
3814
3934
  var CallEventsManager = class extends Destroyable {
@@ -3912,7 +4032,7 @@ var CallEventsManager = class extends Destroyable {
3912
4032
  }
3913
4033
  initSubscriptions() {
3914
4034
  this.subscribeTo(this.callJoinedEvent$, (callJoinedEvent) => {
3915
- logger$22.debug("[CallEventsManager] Handling call.joined event for call/session IDs:", {
4035
+ logger$23.debug("[CallEventsManager] Handling call.joined event for call/session IDs:", {
3916
4036
  callId: callJoinedEvent.call_id,
3917
4037
  roomSessionId: callJoinedEvent.room_session_id
3918
4038
  });
@@ -3939,19 +4059,19 @@ var CallEventsManager = class extends Destroyable {
3939
4059
  if (this._self$.value?.capabilities.setLayout) this.updateLayouts();
3940
4060
  });
3941
4061
  this.subscribeTo(this.memberUpdates$, (member) => {
3942
- logger$22.debug("[CallEventsManager] Handling member update event for member ID:", member);
4062
+ logger$23.debug("[CallEventsManager] Handling member update event for member ID:", member);
3943
4063
  this.upsertParticipant(member);
3944
4064
  });
3945
4065
  this.subscribeTo(this.webRtcCallSession.memberLeft$, (memberLeftEvent) => {
3946
- logger$22.debug("[CallEventsManager] Handling member.left event for member ID:", memberLeftEvent.member.member_id);
4066
+ logger$23.debug("[CallEventsManager] Handling member.left event for member ID:", memberLeftEvent.member.member_id);
3947
4067
  const participants = { ...this._participants$.value };
3948
4068
  if (memberLeftEvent.member.member_id in participants) {
3949
4069
  delete participants[memberLeftEvent.member.member_id];
3950
4070
  this._participants$.next(participants);
3951
- } else logger$22.warn(`[CallEventsManager] Received member.left event for unknown member ID: ${memberLeftEvent.member.member_id}`);
4071
+ } else logger$23.warn(`[CallEventsManager] Received member.left event for unknown member ID: ${memberLeftEvent.member.member_id}`);
3952
4072
  });
3953
4073
  this.subscribeTo(this.webRtcCallSession.callUpdated$, (callUpdatedEvent) => {
3954
- logger$22.debug("[CallEventsManager] Handling call.updated event:", callUpdatedEvent);
4074
+ logger$23.debug("[CallEventsManager] Handling call.updated event:", callUpdatedEvent);
3955
4075
  const roomSession = callUpdatedEvent.room_session;
3956
4076
  this._sessionState$.next({
3957
4077
  ...this._sessionState$.value,
@@ -3966,7 +4086,7 @@ var CallEventsManager = class extends Destroyable {
3966
4086
  });
3967
4087
  });
3968
4088
  this.subscribeTo(this.layoutChangedEvent$, (layoutChangedEvent) => {
3969
- logger$22.debug("[CallEventsManager] Handling layout.changed event:", layoutChangedEvent);
4089
+ logger$23.debug("[CallEventsManager] Handling layout.changed event:", layoutChangedEvent);
3970
4090
  this._sessionState$.next({
3971
4091
  ...this._sessionState$.value,
3972
4092
  layout_name: layoutChangedEvent.id,
@@ -3976,10 +4096,10 @@ var CallEventsManager = class extends Destroyable {
3976
4096
  });
3977
4097
  }
3978
4098
  updateParticipantPositions(layoutChangedEvent) {
3979
- if (Object.keys(this._participants$.value).length > 0 && !layoutChangedEvent.layers.some((layer) => !!layer.member_id)) logger$22.warn("[CallEventsManager] No layers with member_id found in layout.changed event. Nothing to update.");
4099
+ if (Object.keys(this._participants$.value).length > 0 && !layoutChangedEvent.layers.some((layer) => !!layer.member_id)) logger$23.warn("[CallEventsManager] No layers with member_id found in layout.changed event. Nothing to update.");
3980
4100
  layoutChangedEvent.layers.filter((layer) => !!layer.member_id).filter((layer) => {
3981
4101
  if (!(layer.member_id in this._participants$.value)) {
3982
- logger$22.warn(`[CallEventsManager] Skipping layout layer for unknown member_id: ${layer.member_id}`);
4102
+ logger$23.warn(`[CallEventsManager] Skipping layout layer for unknown member_id: ${layer.member_id}`);
3983
4103
  return false;
3984
4104
  }
3985
4105
  return true;
@@ -3997,12 +4117,17 @@ var CallEventsManager = class extends Destroyable {
3997
4117
  updateLayouts() {
3998
4118
  if (!this.selfId) return;
3999
4119
  this.webRtcCallSession.executeMethod(this.selfId, "call.layout.list", {}).then((response) => {
4120
+ const layouts = response.result?.layouts;
4121
+ if (!layouts) {
4122
+ logger$23.warn("[CallEventsManager] Layout list response carried no layouts; keeping current layouts");
4123
+ return;
4124
+ }
4000
4125
  this._sessionState$.next({
4001
4126
  ...this._sessionState$.value,
4002
- layouts: response.result.layouts
4127
+ layouts
4003
4128
  });
4004
4129
  }).catch((error) => {
4005
- logger$22.error("[CallEventsManager] Error fetching layouts:", error);
4130
+ logger$23.error("[CallEventsManager] Error fetching layouts:", error);
4006
4131
  });
4007
4132
  }
4008
4133
  updateParticipants(members) {
@@ -4018,7 +4143,7 @@ var CallEventsManager = class extends Destroyable {
4018
4143
  }
4019
4144
  const participant = this._participants$.value[member.member_id];
4020
4145
  const oldValue = participant.value;
4021
- logger$22.debug("[CallEventsManager] Updating participant:", member.member_id, {
4146
+ logger$23.debug("[CallEventsManager] Updating participant:", member.member_id, {
4022
4147
  oldValue,
4023
4148
  newValue: member
4024
4149
  });
@@ -4031,17 +4156,17 @@ var CallEventsManager = class extends Destroyable {
4031
4156
  }
4032
4157
  get callJoinedEvent$() {
4033
4158
  return this.cachedObservable("callJoinedEvent$", () => this.webRtcCallSession.callEvent$.pipe(filter(isCallJoinedPayload), tap((event) => {
4034
- logger$22.debug("[CallEventsManager] Call joined event:", event);
4159
+ logger$23.debug("[CallEventsManager] Call joined event:", event);
4035
4160
  })));
4036
4161
  }
4037
4162
  get layoutChangedEvent$() {
4038
4163
  return this.cachedObservable("layoutChangedEvent$", () => this.webRtcCallSession.callEvent$.pipe(filterAs(isLayoutChangedPayload, "layout"), tap((event) => {
4039
- logger$22.debug("[CallEventsManager] Layout changed event:", event);
4164
+ logger$23.debug("[CallEventsManager] Layout changed event:", event);
4040
4165
  })));
4041
4166
  }
4042
4167
  get memberUpdates$() {
4043
4168
  return this.cachedObservable("memberUpdates$", () => merge(this.webRtcCallSession.memberJoined$, this.webRtcCallSession.memberUpdated$, this.webRtcCallSession.memberTalking$).pipe(map((event) => event.member), tap((event) => {
4044
- logger$22.debug("[CallEventsManager] Member update event:", event);
4169
+ logger$23.debug("[CallEventsManager] Member update event:", event);
4045
4170
  })));
4046
4171
  }
4047
4172
  destroy() {
@@ -4060,6 +4185,111 @@ var CallEventsManager = class extends Destroyable {
4060
4185
  }
4061
4186
  };
4062
4187
 
4188
+ //#endregion
4189
+ //#region src/controllers/ConstraintFallbackHelper.ts
4190
+ /**
4191
+ * ConstraintFallbackHelper - Provides getUserMedia with automatic constraint
4192
+ * fallback on OverconstrainedError.
4193
+ *
4194
+ * When a specific device ID is requested, the helper tries progressively
4195
+ * looser constraints:
4196
+ * 1. `{ deviceId: { exact: deviceId } }` -- exact match
4197
+ * 2. `{ deviceId: deviceId }` -- preferred (browser may pick another)
4198
+ * 3. `{}` -- no constraint, browser default
4199
+ *
4200
+ * This prevents stale device IDs from blocking call setup.
4201
+ *
4202
+ * @see Section 5.8 and Section 11 of the Implementation Guide
4203
+ */
4204
+ const logger$22 = getLogger();
4205
+ /**
4206
+ * Attempts getUserMedia with progressively looser constraints.
4207
+ *
4208
+ * The function tries three levels of constraint specificity for the given
4209
+ * device kind. Each level is only attempted if the previous one fails with
4210
+ * an OverconstrainedError. Non-OverconstrainedError failures (e.g.,
4211
+ * NotAllowedError) are thrown immediately without fallback.
4212
+ *
4213
+ * @param mediaDevices - Anything exposing `getUserMedia` (a full
4214
+ * `WebRTCMediaDevices`, or a shim wrapping one)
4215
+ * @param constraints - The full MediaStreamConstraints to use as a base
4216
+ * @param kind - Which track kind to apply fallback to ('audio' | 'video')
4217
+ * @param deviceId - The device ID to try (if undefined, calls getUserMedia as-is)
4218
+ * @returns The stream and the fallback level that succeeded
4219
+ * @throws When all fallback levels fail, or when a non-OverconstrainedError occurs
4220
+ */
4221
+ async function getUserMediaWithFallback(mediaDevices, constraints, kind, deviceId) {
4222
+ if (!deviceId) return {
4223
+ stream: await mediaDevices.getUserMedia(constraints),
4224
+ fallbackLevel: "default"
4225
+ };
4226
+ const baseConstraints = typeof constraints[kind] === "object" ? constraints[kind] : {};
4227
+ try {
4228
+ const exactConstraints = {
4229
+ ...constraints,
4230
+ [kind]: {
4231
+ ...baseConstraints,
4232
+ deviceId: { exact: deviceId }
4233
+ }
4234
+ };
4235
+ return {
4236
+ stream: await mediaDevices.getUserMedia(exactConstraints),
4237
+ fallbackLevel: "exact"
4238
+ };
4239
+ } catch (error) {
4240
+ if (!isOverconstrainedError(error)) throw error;
4241
+ logger$22.debug(`[ConstraintFallbackHelper] Exact constraint failed for ${kind}, trying preferred`, { deviceId });
4242
+ }
4243
+ try {
4244
+ const preferredConstraints = {
4245
+ ...constraints,
4246
+ [kind]: {
4247
+ ...baseConstraints,
4248
+ deviceId
4249
+ }
4250
+ };
4251
+ return {
4252
+ stream: await mediaDevices.getUserMedia(preferredConstraints),
4253
+ fallbackLevel: "preferred"
4254
+ };
4255
+ } catch (error) {
4256
+ if (!isOverconstrainedError(error)) throw error;
4257
+ logger$22.debug(`[ConstraintFallbackHelper] Preferred constraint failed for ${kind}, trying default`, { deviceId });
4258
+ }
4259
+ try {
4260
+ const defaultConstraints = {
4261
+ ...constraints,
4262
+ [kind]: { ...baseConstraints }
4263
+ };
4264
+ if (typeof defaultConstraints[kind] === "object") {
4265
+ const { deviceId: _removed, ...rest } = defaultConstraints[kind];
4266
+ defaultConstraints[kind] = rest;
4267
+ }
4268
+ const stream = await mediaDevices.getUserMedia(defaultConstraints);
4269
+ logger$22.warn(`[ConstraintFallbackHelper] Fell back to browser default for ${kind}`, { requestedDeviceId: deviceId });
4270
+ return {
4271
+ stream,
4272
+ fallbackLevel: "default"
4273
+ };
4274
+ } catch (error) {
4275
+ logger$22.error(`[ConstraintFallbackHelper] All fallback levels exhausted for ${kind}`, {
4276
+ deviceId,
4277
+ error
4278
+ });
4279
+ throw error;
4280
+ }
4281
+ }
4282
+ /**
4283
+ * Checks whether an error is an OverconstrainedError.
4284
+ *
4285
+ * Browsers may throw either a native OverconstrainedError or a DOMException
4286
+ * with a specific name.
4287
+ */
4288
+ function isOverconstrainedError(error) {
4289
+ if (error instanceof Error) return error.name === "OverconstrainedError" || error.name === "ConstraintNotSatisfiedError";
4290
+ return false;
4291
+ }
4292
+
4063
4293
  //#endregion
4064
4294
  //#region src/helpers/SDPHelper.ts
4065
4295
  /**
@@ -4615,6 +4845,7 @@ var LocalStreamController = class extends Destroyable {
4615
4845
  this._localAudioTracks$ = this.createBehaviorSubject([]);
4616
4846
  this._localVideoTracks$ = this.createBehaviorSubject([]);
4617
4847
  this._mediaTrackEnded$ = this.createSubject();
4848
+ this._trackOrigins = /* @__PURE__ */ new WeakMap();
4618
4849
  }
4619
4850
  get localStream$() {
4620
4851
  return this._localStream$.asObservable().pipe(takeUntil(this.destroyed$));
@@ -4637,6 +4868,22 @@ var LocalStreamController = class extends Destroyable {
4637
4868
  get localVideoTracks() {
4638
4869
  return this._localVideoTracks$.value;
4639
4870
  }
4871
+ tagTracks(tracks, origin) {
4872
+ for (const track of tracks) this._trackOrigins.set(track, origin);
4873
+ }
4874
+ setTrackOrigin(track, origin) {
4875
+ this._trackOrigins.set(track, origin);
4876
+ }
4877
+ getTrackOrigin(track) {
4878
+ return this._trackOrigins.get(track);
4879
+ }
4880
+ /**
4881
+ * Fail-safe: an unrecorded track reads as not-a-device-capture, so a missed
4882
+ * tagging site leaves media alone rather than destroying it.
4883
+ */
4884
+ isDeviceCapture(track) {
4885
+ return this._trackOrigins.get(track) === "device";
4886
+ }
4640
4887
  /**
4641
4888
  * Build the local media stream based on the provided options.
4642
4889
  */
@@ -4646,13 +4893,16 @@ var LocalStreamController = class extends Destroyable {
4646
4893
  if (this.options.inputAudioStream ?? this.options.inputVideoStream) {
4647
4894
  const tracks = [...this.options.inputAudioStream?.getTracks() ?? [], ...this.options.inputVideoStream?.getTracks() ?? []];
4648
4895
  stream = new MediaStream(tracks);
4896
+ this.tagTracks(tracks, "application");
4649
4897
  } else if (this.options.propose === "screenshare") {
4650
- logger$19.debug("[LocalStreamController] Requesting display media for screen sharing with audio:", Boolean(this.options.inputAudioDeviceConstraints));
4898
+ const audio = this.options.screenShareAudio ?? false;
4899
+ logger$19.debug("[LocalStreamController] Requesting display media for screen sharing with audio:", audio);
4651
4900
  stream = await this.options.getDisplayMedia({
4652
4901
  video: true,
4653
- audio: Boolean(this.options.inputAudioDeviceConstraints)
4902
+ audio
4654
4903
  });
4655
4904
  logger$19.debug("[LocalStreamController] Screen share media obtained:", stream);
4905
+ this.tagTracks(stream.getTracks(), "display");
4656
4906
  } else {
4657
4907
  const constraints = {
4658
4908
  audio: this.options.inputAudioDeviceConstraints,
@@ -4661,6 +4911,7 @@ var LocalStreamController = class extends Destroyable {
4661
4911
  logger$19.debug("[LocalStreamController] Requesting user media with constraints:", constraints);
4662
4912
  stream = await this.options.getUserMedia(constraints);
4663
4913
  logger$19.debug("[LocalStreamController] User media obtained:", stream);
4914
+ this.tagTracks(stream.getTracks(), "device");
4664
4915
  }
4665
4916
  this._localStream$.next(stream);
4666
4917
  this._localAudioTracks$.next(stream.getAudioTracks());
@@ -4670,10 +4921,13 @@ var LocalStreamController = class extends Destroyable {
4670
4921
  /**
4671
4922
  * Add a local media track to the local stream.
4672
4923
  * @param track - The MediaStreamTrack to add
4924
+ * @param origin - Defaults to `'device'`; every internal caller passes a
4925
+ * fresh `getUserMedia` capture.
4673
4926
  * @returns The MediaStream (either existing or newly created)
4674
4927
  */
4675
- addTrack(track) {
4928
+ addTrack(track, origin = "device") {
4676
4929
  const localStream = this._localStream$.value ?? new MediaStream();
4930
+ this._trackOrigins.set(track, origin);
4677
4931
  track.addEventListener("ended", this.mediaTrackEndedHandler);
4678
4932
  localStream.addTrack(track);
4679
4933
  this._localStream$.next(localStream);
@@ -4943,15 +5197,27 @@ var TransceiverController = class extends Destroyable {
4943
5197
  for (let i = 0; i < Number(msStreamsNumber); i++) this.peerConnection.addTransceiver("video", { direction: "recvonly" });
4944
5198
  }
4945
5199
  }
5200
+ /**
5201
+ * @returns whether every live sender of the kind took the constraints. A
5202
+ * skipped non-device sender, an exhausted fallback, and having no live sender
5203
+ * at all all report `false` — `mediaParamsUpdated.applied` is built from this,
5204
+ * and an application told `true` cannot tell a working push from a no-op.
5205
+ */
4946
5206
  async updateSendersConstraints(kind, constraints) {
4947
5207
  if (!constraints) {
4948
5208
  this.stopTrackSender(kind);
4949
- return Promise.resolve();
5209
+ return false;
4950
5210
  }
4951
5211
  const senders = this.peerConnection.getSenders().filter((sender) => sender.track?.kind === kind && sender.track.readyState === "live");
5212
+ let applied = senders.length > 0;
4952
5213
  for (const sender of senders) {
4953
5214
  const { track } = sender;
4954
5215
  if (track) {
5216
+ if (!this.options.localStreamController.isDeviceCapture(track)) {
5217
+ logger$18.debug(`[TransceiverController] Skipping ${kind} constraints for a non-device track (origin: ${this.options.localStreamController.getTrackOrigin(track) ?? "unrecorded"}), track ${track.id}`);
5218
+ applied = false;
5219
+ continue;
5220
+ }
4955
5221
  const constraintsToApply = {
4956
5222
  ...track.getConstraints(),
4957
5223
  ...constraints
@@ -4967,33 +5233,39 @@ var TransceiverController = class extends Destroyable {
4967
5233
  } catch (fallbackError) {
4968
5234
  logger$18.warn(`[TransceiverController] Track replacement fallback also failed for ${kind} track:`, fallbackError);
4969
5235
  this.options.onError?.(new MediaTrackError("updateSendersConstraints", kind, fallbackError));
5236
+ applied = false;
4970
5237
  }
4971
5238
  }
4972
5239
  }
4973
5240
  }
5241
+ return applied;
4974
5242
  }
4975
5243
  /**
4976
- * Fallback when applyConstraints fails: stop the current track, acquire a new
4977
- * one via getUserMedia with the merged constraints (preserving the current
4978
- * deviceId), replace the sender track, and update the localStream.
5244
+ * Fallback when applyConstraints fails, which on iOS Safari it silently does.
4979
5245
  *
4980
- * This is critical for iOS Safari where applyConstraints on audio tracks
4981
- * silently fails or throws.
5246
+ * Order matters: acquiring before stopping means a failed acquisition leaves
5247
+ * the existing media playing. The deviceId goes through the fallback ladder
5248
+ * rather than pinned `{ exact }`, so a stale id degrades instead of failing.
4982
5249
  */
4983
5250
  async replaceTrackFallback(sender, oldTrack, kind, mergedConstraints) {
4984
5251
  const { deviceId } = oldTrack.getSettings();
4985
- const constraintsWithDevice = {
4986
- ...mergedConstraints,
4987
- ...deviceId ? { deviceId: { exact: deviceId } } : {}
4988
- };
4989
- const trackId = oldTrack.id;
5252
+ const { stream, fallbackLevel } = await getUserMediaWithFallback({ getUserMedia: this.options.getUserMedia }, { [kind]: mergedConstraints }, kind, deviceId);
5253
+ const newTrack = stream.getTracks().find((t) => t.kind === kind);
5254
+ if (!newTrack) {
5255
+ stream.getTracks().forEach((t) => t.stop());
5256
+ throw new MediaTrackError("replaceTrackFallback", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
5257
+ }
5258
+ try {
5259
+ await sender.replaceTrack(newTrack);
5260
+ } catch (error) {
5261
+ stream.getTracks().forEach((t) => t.stop());
5262
+ throw error;
5263
+ }
5264
+ const oldTrackId = oldTrack.id;
5265
+ this.options.localStreamController.removeTrack(oldTrackId);
4990
5266
  oldTrack.stop();
4991
- this.options.localStreamController.removeTrack(trackId);
4992
- const newTrack = (await this.options.getUserMedia({ [kind]: constraintsWithDevice })).getTracks().find((t) => t.kind === kind);
4993
- if (!newTrack) throw new MediaTrackError("replaceTrackFallback", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
4994
- await sender.replaceTrack(newTrack);
4995
5267
  this.options.localStreamController.addTrack(newTrack);
4996
- logger$18.debug(`[TransceiverController] Track replacement fallback succeeded for ${kind}. New track: ${newTrack.id}`);
5268
+ logger$18.debug(`[TransceiverController] Track replacement fallback succeeded for ${kind} (deviceId fallback level: ${fallbackLevel}). New track: ${newTrack.id}`);
4997
5269
  }
4998
5270
  getMediaDirections() {
4999
5271
  if (this.peerConnection.connectionState === "connected") return this.peerConnection.getTransceivers().reduce((acc, transceiver) => {
@@ -5062,48 +5334,44 @@ var RTCPeerConnectionController = class extends Destroyable {
5062
5334
  this.negotiationNeeded$.next();
5063
5335
  };
5064
5336
  this.updateSelectedInputDevice = async (kind, deviceInfo) => {
5337
+ const { localStream } = this;
5338
+ if (!localStream) {
5339
+ logger$17.warn("[RTCPeerConnectionController] No local stream available to update input device.");
5340
+ return;
5341
+ }
5342
+ const currentTrack = localStream.getTracks().find((track) => track.kind === kind);
5343
+ if (!currentTrack) {
5344
+ logger$17.debug(`[RTCPeerConnectionController] No ${kind} track to switch.`);
5345
+ return;
5346
+ }
5347
+ if (!deviceInfo) {
5348
+ logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected: none`);
5349
+ this.stopTrackSender(kind);
5350
+ return;
5351
+ }
5352
+ const constraints = {
5353
+ ...currentTrack.getConstraints(),
5354
+ ...this.deviceController.deviceInfoToConstraints(deviceInfo)
5355
+ };
5065
5356
  try {
5066
- const { localStream } = this;
5067
- if (!localStream) {
5068
- logger$17.warn("[RTCPeerConnectionController] No local stream available to update input device.");
5069
- return;
5070
- }
5071
- logger$17.debug(`[RTCPeerConnectionController] Updating selected ${kind} input device:`, localStream.getTracks());
5072
- const track = localStream.getTracks().find((track$1) => track$1.kind === kind);
5073
- if (track) {
5074
- this.transceiverController?.stopTrackSender(kind);
5075
- this.localStreamController.removeTrack(track.id);
5076
- logger$17.debug(`[RTCPeerConnectionController] Stopped existing ${kind} track: ${track.id}`, localStream.getTracks());
5077
- if (!deviceInfo) {
5078
- logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected: none`);
5079
- return;
5080
- }
5081
- const streamTrack = (await this.getUserMedia({ [kind]: {
5082
- ...track.getConstraints(),
5083
- ...this.deviceController.deviceInfoToConstraints(deviceInfo)
5084
- } })).getTracks().find((t) => t.kind === kind);
5085
- if (streamTrack) {
5086
- logger$17.debug(`[RTCPeerConnectionController] Adding new ${kind} track: ${streamTrack.id}`);
5087
- this.localStreamController.addTrack(streamTrack);
5088
- await this.transceiverController?.replaceSenderTrack(kind, streamTrack);
5089
- logger$17.debug(`[RTCPeerConnectionController] Added new ${kind} track: ${streamTrack.id}`, this.localStream?.getTracks());
5090
- }
5091
- }
5092
- logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected:`, deviceInfo?.label);
5357
+ const newTrack = await this.acquireInputTrack(kind, constraints, deviceInfo, currentTrack);
5358
+ await this.attachInputTrack(kind, newTrack, currentTrack);
5359
+ logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected:`, deviceInfo.label, newTrack.id);
5093
5360
  } catch (error) {
5094
5361
  logger$17.error(`[RTCPeerConnectionController] Failed to select ${kind} input device:`, error);
5095
5362
  this._errors$.next(new MediaTrackError("updateSelectedInputDevice", kind, error));
5096
- throw error;
5097
5363
  }
5098
5364
  };
5099
5365
  this._isNegotiating$ = this.createBehaviorSubject(false);
5100
5366
  this._memberId = null;
5367
+ this._nodeId = null;
5101
5368
  this._iceConnectionState$ = this.createReplaySubject(1);
5102
5369
  this._connectionState$ = this.createReplaySubject(1);
5103
5370
  this._signalingState$ = this.createReplaySubject(1);
5104
5371
  this._iceGatheringState$ = this.createReplaySubject(1);
5105
5372
  this._errors$ = this.createReplaySubject(1);
5106
5373
  this._iceCandidates$ = this.createReplaySubject(1);
5374
+ this._localMediaSettled$ = this.createReplaySubject(1);
5107
5375
  this._initialized$ = this.createReplaySubject(1);
5108
5376
  this._remoteDescription$ = this.createReplaySubject(1);
5109
5377
  this._remoteStream$ = this.createBehaviorSubject(null);
@@ -5136,6 +5404,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5136
5404
  inputVideoStream: this.options.inputVideoStream,
5137
5405
  inputAudioDeviceConstraints: this.inputAudioDeviceConstraints,
5138
5406
  inputVideoDeviceConstraints: this.inputVideoDeviceConstraints,
5407
+ screenShareAudio: this.options.screenShareAudio,
5139
5408
  getUserMedia: async (constraints) => this.getUserMedia(constraints),
5140
5409
  getDisplayMedia: async (options$1) => this.getDisplayMedia(options$1)
5141
5410
  });
@@ -5162,6 +5431,13 @@ var RTCPeerConnectionController = class extends Destroyable {
5162
5431
  get memberId() {
5163
5432
  return this._memberId;
5164
5433
  }
5434
+ /** The node this leg's invite landed on — auxiliary legs are placed independently. */
5435
+ setNodeId(nodeId) {
5436
+ this._nodeId = nodeId;
5437
+ }
5438
+ get nodeId() {
5439
+ return this._nodeId;
5440
+ }
5165
5441
  stopTrackSender(kind, options = { updateTransceiverDirection: false }) {
5166
5442
  const audioCovered = kind === "audio" || kind === "both";
5167
5443
  if (audioCovered && this._localAudioPipeline) this.stopRawAudioInputForPipeline();
@@ -5171,10 +5447,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5171
5447
  }
5172
5448
  stopRawAudioInputForPipeline() {
5173
5449
  const rawTracks = this.localStreamController.localAudioTracks;
5174
- for (const track of rawTracks) if (track.readyState === "live") {
5175
- track.stop();
5176
- this.localStreamController.removeTrack(track.id);
5177
- }
5450
+ for (const track of rawTracks) if (track.readyState === "live") this.localStreamController.removeTrack(track.id);
5178
5451
  this._localAudioPipeline?.setInputTrack(null);
5179
5452
  }
5180
5453
  get isNegotiating$() {
@@ -5207,6 +5480,10 @@ var RTCPeerConnectionController = class extends Destroyable {
5207
5480
  get remoteDescription$() {
5208
5481
  return this.cachedObservable("remoteDescription$", () => this._remoteDescription$.asObservable().pipe(takeUntil(this.destroyed$)));
5209
5482
  }
5483
+ /** Emits once local media is settled — acquired, or knowingly receive-only. */
5484
+ get localMediaSettled$() {
5485
+ return this.cachedObservable("localMediaSettled$", () => this._localMediaSettled$.asObservable().pipe(takeUntil(this.destroyed$)));
5486
+ }
5210
5487
  get localStream$() {
5211
5488
  return this.cachedObservable("localStream$", () => this.localStreamController.localStream$.pipe(takeUntil(this.destroyed$)));
5212
5489
  }
@@ -5234,6 +5511,9 @@ var RTCPeerConnectionController = class extends Destroyable {
5234
5511
  get propose() {
5235
5512
  return this.options.propose ?? "main";
5236
5513
  }
5514
+ get connectionState() {
5515
+ return this.peerConnection?.connectionState;
5516
+ }
5237
5517
  get isAdditionalDevice() {
5238
5518
  return this.propose === "additional-device";
5239
5519
  }
@@ -5362,7 +5642,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5362
5642
  this._isNegotiating$.next(true);
5363
5643
  await this._setRemoteDescription(this.sdpInit);
5364
5644
  } else {
5365
- await this.setupTrackHandling();
5645
+ if (!await this.setupTrackHandling()) return;
5366
5646
  this._initialized$.next(true);
5367
5647
  }
5368
5648
  } catch (error) {
@@ -5486,7 +5766,10 @@ var RTCPeerConnectionController = class extends Destroyable {
5486
5766
  inputVideoDeviceConstraints: this.inputVideoDeviceConstraints
5487
5767
  });
5488
5768
  }
5489
- await this.setupLocalTracks();
5769
+ if (!await this.setupLocalTracks()) {
5770
+ logger$17.debug("[RTCPeerConnectionController] Inbound answer abandoned; the connection went away.");
5771
+ return;
5772
+ }
5490
5773
  const { answerOptions } = this;
5491
5774
  logger$17.debug("[RTCPeerConnectionController] Creating inbound answer with options:", answerOptions);
5492
5775
  await this.createAnswer(answerOptions);
@@ -5624,35 +5907,52 @@ var RTCPeerConnectionController = class extends Destroyable {
5624
5907
  }
5625
5908
  /**
5626
5909
  * Setup track handling for remote tracks.
5910
+ *
5911
+ * @returns `false` when the connection went away while local media was being
5912
+ * acquired — see {@link setupLocalTracks}.
5627
5913
  */
5628
5914
  async setupTrackHandling() {
5629
5915
  if (!this.peerConnection) throw new DependencyError("RTCPeerConnection is not initialized");
5630
- await this.setupLocalTracks();
5916
+ if (!await this.setupLocalTracks()) return false;
5631
5917
  await this.setupRemoteTracks();
5918
+ return true;
5632
5919
  }
5920
+ /**
5921
+ * @returns `false` when the connection was torn down while getUserMedia was
5922
+ * in flight. The acquisition is not cancellable, so the caller must stop
5923
+ * rather than go on to touch a peer connection that is closed or gone.
5924
+ */
5633
5925
  async setupLocalTracks() {
5634
5926
  logger$17.debug("[RTCPeerConnectionController] Setting up local tracks/transceivers.");
5635
5927
  if (this.hasNoLocalMediaToSend()) {
5636
5928
  if (!this.receiveAudio && !this.receiveVideo) throw new InvalidParams("Call requests no media: enable audio/video or receiveAudio/receiveVideo");
5637
5929
  logger$17.debug("[RTCPeerConnectionController] No local media requested; negotiating receive-only.");
5638
5930
  this.setupReceiveOnlyTransceivers();
5639
- return;
5931
+ this._localMediaSettled$.next();
5932
+ return true;
5640
5933
  }
5641
5934
  let localStream;
5642
5935
  try {
5643
5936
  localStream = this.localStream ?? await this.localStreamController.buildLocalStream();
5644
5937
  } catch (error) {
5645
5938
  this.handleLocalMediaFailure(error);
5646
- return;
5939
+ this._localMediaSettled$.next();
5940
+ return true;
5941
+ }
5942
+ if (!this.peerConnection || this.peerConnection.signalingState === "closed") {
5943
+ logger$17.debug("[RTCPeerConnectionController] Local media arrived after teardown; releasing it.");
5944
+ localStream.getTracks().forEach((track) => track.stop());
5945
+ return false;
5647
5946
  }
5947
+ this._localMediaSettled$.next();
5648
5948
  if (this.transceiverController?.useAddStream ?? false) {
5649
5949
  logger$17.warn("[RTCPeerConnectionController] Using deprecated addStream API to add local stream.");
5650
- this.peerConnection?.addStream(localStream);
5950
+ this.peerConnection.addStream(localStream);
5651
5951
  if (!this.isNegotiating) {
5652
5952
  logger$17.debug("[RTCPeerConnectionController] Forcing negotiationneeded after local tracks setup.");
5653
5953
  this.negotiationNeeded$.next();
5654
5954
  }
5655
- return;
5955
+ return true;
5656
5956
  }
5657
5957
  for (const kind of ["audio", "video"]) {
5658
5958
  const tracks = (kind === "audio" ? localStream.getAudioTracks() : localStream.getVideoTracks()).map((track, index) => ({
@@ -5666,10 +5966,11 @@ var RTCPeerConnectionController = class extends Destroyable {
5666
5966
  await this.transceiverController?.setupTransceiverSender(track, localStream, transceivers[index]);
5667
5967
  } else {
5668
5968
  logger$17.debug(`[RTCPeerConnectionController] Using addTrack for local ${kind} track:`, track.id);
5669
- this.peerConnection?.addTrack(track, localStream);
5969
+ this.peerConnection.addTrack(track, localStream);
5670
5970
  }
5671
5971
  }
5672
5972
  }
5973
+ return true;
5673
5974
  }
5674
5975
  /** True for a main connection with no local media to send. */
5675
5976
  hasNoLocalMediaToSend() {
@@ -5758,6 +6059,61 @@ var RTCPeerConnectionController = class extends Destroyable {
5758
6059
  this._localAudioPipeline.setInputTrack(newTrack);
5759
6060
  }
5760
6061
  /**
6062
+ * Capture the newly selected device, leaving the current capture running.
6063
+ *
6064
+ * A rejection must leave the current track sending, so nothing is released
6065
+ * until the replacement is in hand. The one exception is hardware that admits
6066
+ * a single opener — a phone's front and back cameras, typically — which
6067
+ * rejects the second capture until the first is closed.
6068
+ */
6069
+ async acquireInputTrack(kind, constraints, deviceInfo, currentTrack) {
6070
+ try {
6071
+ return await this.captureTrack(kind, constraints, deviceInfo.deviceId);
6072
+ } catch (error) {
6073
+ if (!isMediaDeviceInUse(error)) throw error;
6074
+ logger$17.warn(`[RTCPeerConnectionController] ${kind} device is held exclusively; releasing the current capture to retry:`, error);
6075
+ const previousDeviceId = currentTrack.getSettings().deviceId;
6076
+ this.stopTrackSender(kind);
6077
+ try {
6078
+ return await this.captureTrack(kind, constraints, deviceInfo.deviceId);
6079
+ } catch (retryError) {
6080
+ await this.restorePreviousInputTrack(kind, constraints, previousDeviceId, currentTrack);
6081
+ throw retryError;
6082
+ }
6083
+ }
6084
+ }
6085
+ async captureTrack(kind, constraints, deviceId) {
6086
+ const { stream, fallbackLevel } = await getUserMediaWithFallback({ getUserMedia: async (c) => this.getUserMedia(c) }, { [kind]: constraints }, kind, deviceId);
6087
+ const track = stream.getTracks().find((t) => t.kind === kind);
6088
+ if (!track) {
6089
+ stream.getTracks().forEach((t) => t.stop());
6090
+ throw new MediaTrackError("updateSelectedInputDevice", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
6091
+ }
6092
+ if (fallbackLevel !== "exact") logger$17.warn(`[RTCPeerConnectionController] ${kind} device acquired at fallback level '${fallbackLevel}'; the capture may not be the requested device.`);
6093
+ return track;
6094
+ }
6095
+ /** Best-effort return to the device that was released for an exclusive retry. */
6096
+ async restorePreviousInputTrack(kind, constraints, previousDeviceId, releasedTrack) {
6097
+ try {
6098
+ const restored = await this.captureTrack(kind, constraints, previousDeviceId);
6099
+ await this.attachInputTrack(kind, restored, releasedTrack);
6100
+ } catch (error) {
6101
+ logger$17.error(`[RTCPeerConnectionController] Failed to restore the previous ${kind} device:`, error);
6102
+ }
6103
+ }
6104
+ async attachInputTrack(kind, newTrack, oldTrack) {
6105
+ const pipelineOwnsAudio = kind === "audio" && this._localAudioPipeline;
6106
+ if (!pipelineOwnsAudio) try {
6107
+ await this.transceiverController?.replaceSenderTrack(kind, newTrack);
6108
+ } catch (error) {
6109
+ newTrack.stop();
6110
+ throw error;
6111
+ }
6112
+ this.localStreamController.removeTrack(oldTrack.id);
6113
+ this.localStreamController.addTrack(newTrack);
6114
+ if (pipelineOwnsAudio) this._localAudioPipeline?.setInputTrack(newTrack);
6115
+ }
6116
+ /**
5761
6117
  * Return the lazily-created {@link LocalAudioPipeline}, constructing it on
5762
6118
  * first access. On creation the current audio sender's track is routed
5763
6119
  * through the pipeline (input → gain → analyser → destination) and the
@@ -5790,6 +6146,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5790
6146
  const sender = (this.transceiverController?.audioTransceivers.at(0))?.sender ?? this.peerConnection.getSenders().find((s) => s.track?.kind === "audio");
5791
6147
  if (!sender || !raw) return;
5792
6148
  try {
6149
+ this.localStreamController.setTrackOrigin(this._localAudioPipeline.outputTrack, "processed");
5793
6150
  await sender.replaceTrack(this._localAudioPipeline.outputTrack);
5794
6151
  } catch (error) {
5795
6152
  logger$17.warn("[RTCPeerConnectionController] Failed to route audio sender through pipeline:", error);
@@ -5850,37 +6207,68 @@ var RTCPeerConnectionController = class extends Destroyable {
5850
6207
  for (const existingTrack of existingTracks) this.removeLocalTrack(existingTrack.id);
5851
6208
  this.addLocalTrack(track);
5852
6209
  }
6210
+ /**
6211
+ * @returns whether the constraints reached the media the leg is sending.
6212
+ *
6213
+ * With the pipeline engaged the audio sender carries the processed
6214
+ * destination track, so the sender scan would find nothing it may touch and
6215
+ * every audio constraint API would silently no-op. The constraints belong to
6216
+ * the pipeline's device source, which is the capture that sender ultimately
6217
+ * carries.
6218
+ */
5853
6219
  async updateSendersConstraints(kind, constraints) {
5854
- await this.transceiverController?.updateSendersConstraints(kind, constraints);
6220
+ if (kind === "audio" && this._localAudioPipeline) {
6221
+ if (!constraints) {
6222
+ this.stopTrackSender("audio");
6223
+ return false;
6224
+ }
6225
+ return this.applyPipelineSourceConstraints(constraints);
6226
+ }
6227
+ return await this.transceiverController?.updateSendersConstraints(kind, constraints) ?? false;
5855
6228
  }
5856
6229
  /**
5857
- * Replace the current audio track with a new one using the given constraints.
5858
- * Used for server-pushed audio constraint changes where applyConstraints
5859
- * fails on iOS Safari. Stops the current track, acquires a new one via
5860
- * getUserMedia, and replaces the sender track.
6230
+ * Mirror of the sender path for a piped audio leg: same merge, same fallback
6231
+ * ladder, same device-capture invariant — but the swap target is the pipeline
6232
+ * input, so the sender keeps emitting the pipeline's output track and its
6233
+ * identity survives the change.
5861
6234
  */
5862
- async replaceAudioTrackWithConstraints(constraints) {
5863
- const senders = this.peerConnection?.getSenders().filter((s) => s.track?.kind === "audio" && s.track.readyState === "live");
5864
- if (!senders || senders.length === 0) {
5865
- logger$17.warn("[RTCPeerConnectionController] No live audio sender to replace");
5866
- return;
6235
+ async applyPipelineSourceConstraints(constraints) {
6236
+ const pipeline = this._localAudioPipeline;
6237
+ const source = this.localStreamController.localAudioTracks.at(0);
6238
+ if (!pipeline || !source) {
6239
+ logger$17.debug("[RTCPeerConnectionController] No pipeline input to constrain.");
6240
+ return false;
5867
6241
  }
5868
- for (const sender of senders) {
5869
- const oldTrack = sender.track;
5870
- if (!oldTrack) continue;
5871
- const { deviceId } = oldTrack.getSettings();
5872
- const mergedConstraints = {
5873
- ...oldTrack.getConstraints(),
5874
- ...constraints,
5875
- ...deviceId ? { deviceId: { exact: deviceId } } : {}
5876
- };
5877
- const trackId = oldTrack.id;
5878
- oldTrack.stop();
5879
- this.localStreamController.removeTrack(trackId);
5880
- const newTrack = (await this.getUserMedia({ audio: mergedConstraints })).getAudioTracks()[0];
5881
- await sender.replaceTrack(newTrack);
6242
+ if (!this.localStreamController.isDeviceCapture(source)) {
6243
+ logger$17.debug(`[RTCPeerConnectionController] Skipping audio constraints for a non-device pipeline input (origin: ${this.localStreamController.getTrackOrigin(source) ?? "unrecorded"}).`);
6244
+ return false;
6245
+ }
6246
+ const merged = {
6247
+ ...source.getConstraints(),
6248
+ ...constraints
6249
+ };
6250
+ try {
6251
+ await source.applyConstraints(merged);
6252
+ logger$17.debug("[RTCPeerConnectionController] Pipeline input constraints updated:", merged);
6253
+ return true;
6254
+ } catch (error) {
6255
+ logger$17.warn("[RTCPeerConnectionController] applyConstraints failed on the pipeline input, re-acquiring:", error);
6256
+ }
6257
+ try {
6258
+ const { stream } = await getUserMediaWithFallback({ getUserMedia: async (c) => this.getUserMedia(c) }, { audio: merged }, "audio", source.getSettings().deviceId);
6259
+ const newTrack = stream.getAudioTracks().at(0);
6260
+ if (!newTrack) {
6261
+ stream.getTracks().forEach((track) => track.stop());
6262
+ throw new Error("getUserMedia returned no audio track");
6263
+ }
6264
+ this.localStreamController.removeTrack(source.id);
5882
6265
  this.localStreamController.addTrack(newTrack);
5883
- logger$17.debug(`[RTCPeerConnectionController] Audio track replaced for server-pushed params. New track: ${newTrack.id}`);
6266
+ pipeline.setInputTrack(newTrack);
6267
+ return true;
6268
+ } catch (error) {
6269
+ logger$17.warn("[RTCPeerConnectionController] Failed to re-acquire the pipeline input for constraints:", error);
6270
+ this._errors$.next(new MediaTrackError("updateSendersConstraints", "audio", error));
6271
+ return false;
5884
6272
  }
5885
6273
  }
5886
6274
  /**
@@ -5999,6 +6387,48 @@ const logger$16 = getLogger();
5999
6387
  function resolveInviteNodeId(args) {
6000
6388
  return args.isInvite && !args.reattach && !args.explicitNodeId ? "" : args.currentNodeId ?? "";
6001
6389
  }
6390
+ /**
6391
+ * Surface the real outcome of a `webrtc.verto` reply.
6392
+ *
6393
+ * A webrtc.verto response nests several envelopes, each keyed by a verto-style
6394
+ * string `code` ("200" ok, "400"/etc. fail) rather than a JSON-RPC `error`. An outer
6395
+ * layer reports only whether the frame was delivered; an inner layer carries the op's
6396
+ * own outcome:
6397
+ *
6398
+ * response.result = { code:"200", result:{…} } ← delivery acknowledgement
6399
+ * .result = { jsonrpc, id, result:{…} } ← the reply payload
6400
+ * .result = { code:"400", message:"Bad request" } ← the actual op outcome
6401
+ *
6402
+ * A failure can appear at any layer (delivery refused, or the op itself rejected
6403
+ * deeper down), so walk every nested `.result` object and return the FIRST non-2xx
6404
+ * `code` with its message. Returns null when every `code` seen is 2xx or absent —
6405
+ * i.e. the op succeeded. This is the only way to detect that e.g. a mute/kick was
6406
+ * rejected, since the outer delivery `code` is "200" (delivered) even then.
6407
+ *
6408
+ * Pure function — exported for unit testing.
6409
+ */
6410
+ function findNestedVertoFailure(response) {
6411
+ let node = response;
6412
+ while (node !== null && typeof node === "object") {
6413
+ const obj = node;
6414
+ const err = obj.error;
6415
+ if (err !== null && typeof err === "object") {
6416
+ const e = err;
6417
+ const errCode = typeof e.code === "string" || typeof e.code === "number" ? String(e.code) : void 0;
6418
+ if (errCode !== void 0 && !/^2\d\d$/.test(errCode)) return {
6419
+ code: errCode,
6420
+ message: typeof e.message === "string" ? e.message : void 0
6421
+ };
6422
+ }
6423
+ const code = typeof obj.code === "string" || typeof obj.code === "number" ? String(obj.code) : void 0;
6424
+ if (code !== void 0 && !/^2\d\d$/.test(code)) return {
6425
+ code,
6426
+ message: typeof obj.message === "string" ? obj.message : void 0
6427
+ };
6428
+ node = obj.result !== null && typeof obj.result === "object" ? obj.result : null;
6429
+ }
6430
+ return null;
6431
+ }
6002
6432
  var VertoManager = class extends Destroyable {
6003
6433
  constructor(callSession) {
6004
6434
  super();
@@ -6021,7 +6451,7 @@ var WebRTCVertoManager = class extends VertoManager {
6021
6451
  this._signalingStatus$ = this.createReplaySubject(1);
6022
6452
  this._screenShareStatus$ = this.createBehaviorSubject("none");
6023
6453
  this._rtcPeerConnectionsMap = /* @__PURE__ */ new Map();
6024
- this._screenShareTimeoutMs = 5e4;
6454
+ this._legErrors$ = this.createSubject();
6025
6455
  this._nodeId$ = this.createBehaviorSubject(options.nodeId ?? null);
6026
6456
  this.onError = options.onError;
6027
6457
  this.onModifyFailed = options.onModifyFailed;
@@ -6069,6 +6499,10 @@ var WebRTCVertoManager = class extends VertoManager {
6069
6499
  get selfId$() {
6070
6500
  return this._selfId$.asObservable();
6071
6501
  }
6502
+ /** Separates the media phase of call creation from the signalling phase. */
6503
+ get localMediaSettled$() {
6504
+ return this.mainPeerConnection.localMediaSettled$;
6505
+ }
6072
6506
  get localStream() {
6073
6507
  return this._rtcPeerConnectionsMap.get(this.webRtcCallSession.id)?.localStream ?? null;
6074
6508
  }
@@ -6125,41 +6559,101 @@ var WebRTCVertoManager = class extends VertoManager {
6125
6559
  const { mediaParams, callID } = event;
6126
6560
  const rtcPeerConnController = this._rtcPeerConnectionsMap.get(callID);
6127
6561
  const { audio, video } = mediaParams;
6128
- (async () => {
6129
- try {
6130
- if (audio && rtcPeerConnController) await rtcPeerConnController.replaceAudioTrackWithConstraints(audio);
6131
- if (video) await rtcPeerConnController?.updateSendersConstraints("video", video);
6132
- this.webRtcCallSession.emitMediaParamsUpdated({
6133
- audio,
6134
- video,
6135
- timestamp: Date.now()
6136
- });
6137
- } catch (error) {
6138
- logger$16.warn("[WebRTCManager] Error applying server-pushed media params:", error);
6139
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
6140
- }
6141
- })();
6562
+ if (!rtcPeerConnController) {
6563
+ logger$16.warn(`[WebRTCManager] Ignoring server-pushed media params for unknown leg ${callID}`);
6564
+ return;
6565
+ }
6566
+ this.applyServerMediaParams(rtcPeerConnController, audio, video);
6142
6567
  });
6143
6568
  this.subscribeTo(this.vertoPing$, (vertoPing) => {
6144
- this.attachManager.attach(this.buildAttachableCall());
6569
+ this.attachManager.refresh(this.buildAttachableCall());
6145
6570
  this.sendVertoPong(vertoPing);
6146
6571
  });
6147
6572
  }
6148
6573
  /**
6149
- * Set node_id/selfId only when the current value is null.
6574
+ * An auxiliary-leg failure must never destroy the call; main-leg and
6575
+ * call-level errors keep `CallFactory.isFatalError`'s classification.
6150
6576
  *
6151
- * During reattach, `call.joined` and `verto.answer` events can deliver
6152
- * these identifiers before the `verto.invite` RPC response (`CALL CREATED`)
6153
- * arrives. These methods let early events populate them eagerly so that
6154
- * downstream RPC calls (e.g. `call.layout.list`) don't fail with empty
6155
- * identifiers. `processInviteResponse()` remains the authoritative source
6156
- * and always overwrites unconditionally.
6577
+ * Every site holding a peer connection reports through here, so the invariant
6578
+ * is structural rather than per-call-site — which is how cloud-product#20523
6579
+ * happened, with only one of fourteen sites passing `{ fatal: false }`.
6580
+ *
6581
+ * `override` composes rather than replaces: a caller may force non-fatal for a
6582
+ * reason of its own (a `verto.info` frame is best-effort whichever leg carries
6583
+ * it), and an auxiliary leg stays non-fatal regardless.
6157
6584
  */
6158
- setNodeIdIfNull(nodeId) {
6159
- if (!this._nodeId$.value && nodeId) {
6160
- logger$16.debug(`[WebRTCManager] Early node_id set: ${nodeId}`);
6161
- this._nodeId$.next(nodeId);
6162
- }
6585
+ reportLegError(error, rtcPeerConnController, override) {
6586
+ const leg = rtcPeerConnController?.propose;
6587
+ const legId = rtcPeerConnController?.id;
6588
+ const auxiliary = Boolean(rtcPeerConnController) && !rtcPeerConnController?.isMainDevice;
6589
+ this.onError?.(error, {
6590
+ ...override?.fatal === false || auxiliary ? { fatal: false } : {},
6591
+ ...leg ? { leg } : {},
6592
+ ...legId ? { legId } : {}
6593
+ });
6594
+ if (legId) this._legErrors$.next({
6595
+ legId,
6596
+ error
6597
+ });
6598
+ }
6599
+ /**
6600
+ * Errors reported for one leg, as a stream that fails with them.
6601
+ *
6602
+ * Signaling failures are reported, never thrown — so nothing that waits on a
6603
+ * leg's progress would otherwise learn of a rejected invite. Merging this in
6604
+ * lets the wait end with the reason the server gave.
6605
+ */
6606
+ legError$(legId) {
6607
+ return this._legErrors$.pipe(filter((report) => report.legId === legId), map((report) => {
6608
+ throw report.error;
6609
+ }));
6610
+ }
6611
+ /**
6612
+ * Audio and video are applied independently so a failure in one cannot
6613
+ * suppress the other, and `mediaParamsUpdated` is emitted whatever happens —
6614
+ * an application should not be starved of the params by a constraint failure.
6615
+ */
6616
+ async applyServerMediaParams(rtcPeerConnController, audio, video) {
6617
+ const failures = [];
6618
+ let applied = true;
6619
+ if (audio) try {
6620
+ applied = await rtcPeerConnController.updateSendersConstraints("audio", audio) && applied;
6621
+ } catch (error) {
6622
+ applied = false;
6623
+ failures.push(toError(error));
6624
+ }
6625
+ if (video) try {
6626
+ applied = await rtcPeerConnController.updateSendersConstraints("video", video) && applied;
6627
+ } catch (error) {
6628
+ applied = false;
6629
+ failures.push(toError(error));
6630
+ }
6631
+ this.webRtcCallSession.emitMediaParamsUpdated({
6632
+ audio,
6633
+ video,
6634
+ timestamp: Date.now(),
6635
+ applied
6636
+ });
6637
+ for (const failure of failures) {
6638
+ logger$16.warn("[WebRTCManager] Error applying server-pushed media params:", failure);
6639
+ this.reportLegError(failure, rtcPeerConnController, { fatal: false });
6640
+ }
6641
+ }
6642
+ /**
6643
+ * Set node_id/selfId only when the current value is null.
6644
+ *
6645
+ * During reattach, `call.joined` and `verto.answer` events can deliver
6646
+ * these identifiers before the `verto.invite` RPC response (`CALL CREATED`)
6647
+ * arrives. These methods let early events populate them eagerly so that
6648
+ * downstream RPC calls (e.g. `call.layout.list`) don't fail with empty
6649
+ * identifiers. `processInviteResponse()` remains the authoritative source and
6650
+ * overwrites unconditionally — for selfId, on the main leg only.
6651
+ */
6652
+ setNodeIdIfNull(nodeId) {
6653
+ if (!this._nodeId$.value && nodeId) {
6654
+ logger$16.debug(`[WebRTCManager] Early node_id set: ${nodeId}`);
6655
+ this._nodeId$.next(nodeId);
6656
+ }
6163
6657
  }
6164
6658
  setSelfIdIfNull(selfId) {
6165
6659
  if (!this._selfId$.value && selfId) {
@@ -6176,24 +6670,33 @@ var WebRTCVertoManager = class extends VertoManager {
6176
6670
  this.onError?.(new VertoPongError(error));
6177
6671
  }
6178
6672
  }
6673
+ /**
6674
+ * @returns whether the constraints reached the media the call is sending.
6675
+ * `false` is an outcome, not a failure: the leg may have no live sender of
6676
+ * the kind, or carry media the SDK did not capture and may not replace.
6677
+ * A failure behind it still reaches the call's `errors$`, so a caller that
6678
+ * ignores this value learns of it there.
6679
+ */
6179
6680
  async updateMediaConstraints(options = {}) {
6180
6681
  const { audio, video } = options;
6682
+ let applied = true;
6181
6683
  try {
6182
- if (audio) await this.mainPeerConnection.updateSendersConstraints("audio", audio);
6183
- if (video) await this.mainPeerConnection.updateSendersConstraints("video", video);
6684
+ if (audio) applied = await this.mainPeerConnection.updateSendersConstraints("audio", audio) && applied;
6685
+ if (video) applied = await this.mainPeerConnection.updateSendersConstraints("video", video) && applied;
6184
6686
  } catch (error) {
6185
6687
  logger$16.warn("[WebRTCManager] Error updating media constraints:", error);
6186
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
6688
+ this.reportLegError(toError(error), this.mainPeerConnection);
6187
6689
  throw error;
6188
6690
  }
6691
+ return applied;
6189
6692
  }
6190
6693
  get selfId() {
6191
6694
  return this._selfId$.value;
6192
6695
  }
6193
6696
  /** Build an AttachableCall from the current call state. */
6194
- buildAttachableCall(idOverride) {
6697
+ buildAttachableCall(idOverride, nodeIdOverride) {
6195
6698
  return {
6196
- nodeId: this.nodeId ?? void 0,
6699
+ nodeId: nodeIdOverride ?? this.nodeId ?? void 0,
6197
6700
  id: idOverride ?? this.webRtcCallSession.id,
6198
6701
  to: this.webRtcCallSession.to,
6199
6702
  mediaDirections: this.webRtcCallSession.mediaDirections
@@ -6323,24 +6826,49 @@ var WebRTCVertoManager = class extends VertoManager {
6323
6826
  get vertoPing$() {
6324
6827
  return this.cachedObservable("vertoPing$", () => this.webRtcCallSession.webrtcMessages$.pipe(filterAs(isVertoPingInnerParams, "params"), takeUntil(this.destroyed$)));
6325
6828
  }
6829
+ /**
6830
+ * Send a member-control op in-dialog via verto.info.
6831
+ *
6832
+ * The control payload rides in `params.command` — a sibling of `dialogParams`,
6833
+ * at the same level as `dtmf` in {@link sendDigits} — and the inner verto.info
6834
+ * is matched to this call's channel by `dialogParams.callID`, the in-dialog
6835
+ * convention for member-scoped frames. Because it is delivered on the dialog
6836
+ * itself, control lands on the call's own channel with no {node_id,call_id,member_id}
6837
+ * "self" tuple to get wrong. The outer webrtc.verto envelope (added by executeVerto)
6838
+ * still carries the own-leg callID + node_id for session routing.
6839
+ *
6840
+ * Keep `command` OUT of `dialogParams`: it is read at the params level, and
6841
+ * filterVertoParams rewrites/filters dialogParams keys but passes params-level
6842
+ * keys through verbatim.
6843
+ */
6844
+ async sendCallControl(method, params) {
6845
+ const response = await this.executeVerto(VertoInfo({
6846
+ dialogParams: { callID: this.webRtcCallSession.id },
6847
+ command: {
6848
+ method,
6849
+ params
6850
+ }
6851
+ }));
6852
+ const failure = findNestedVertoFailure(response);
6853
+ if (failure) throw new JSONRPCError(Number.parseInt(failure.code, 10) || 0, `Call control "${method}" failed (code ${failure.code})${failure.message ? `: ${failure.message}` : ""}`, void 0);
6854
+ return response;
6855
+ }
6326
6856
  async executeVerto(message, optionals = {}) {
6327
- const webrtcVertoMessage = WebrtcVerto({
6857
+ const params = {
6328
6858
  callID: optionals.callID ?? this.webRtcCallSession.id,
6329
6859
  node_id: optionals.node_id ?? this._nodeId$.value ?? "",
6330
6860
  message,
6331
6861
  subscribe: optionals.subscribe
6332
- });
6862
+ };
6863
+ const webrtcVertoMessage = WebrtcVerto(params);
6333
6864
  const response = await this.webRtcCallSession.execute(webrtcVertoMessage);
6334
- if (response.error) {
6335
- const error = new JSONRPCError(response.error.code, response.error.message, response.error.data);
6336
- this.onError?.(error);
6337
- return response;
6338
- }
6865
+ const nonFatal = message.method === "verto.info" ? { fatal: false } : void 0;
6339
6866
  const innerResult = getValueFrom(response, "result.result");
6340
- if (innerResult?.error) {
6341
- const error = new JSONRPCError(innerResult.error.code, innerResult.error.message, innerResult.error.data);
6342
- this.onError?.(error);
6343
- return response;
6867
+ const failure = response.error ?? innerResult?.error;
6868
+ if (failure) {
6869
+ const error = new JSONRPCError(failure.code, failure.message, failure.data);
6870
+ if (message.method === "verto.invite" || message.method === "verto.answer") throw error;
6871
+ this.reportLegError(error, this._rtcPeerConnectionsMap.get(params.callID), nonFatal);
6344
6872
  }
6345
6873
  return response;
6346
6874
  }
@@ -6359,8 +6887,9 @@ var WebRTCVertoManager = class extends VertoManager {
6359
6887
  default:
6360
6888
  }
6361
6889
  } catch (error) {
6890
+ if (vertoMethod === "verto.answer") throw error;
6362
6891
  logger$16.error(`[WebRTCManager] Error sending Verto ${vertoMethod}:`, error);
6363
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
6892
+ this.reportLegError(toError(error), rtcPeerConnController);
6364
6893
  if (vertoMethod === "verto.modify") this.onModifyFailed?.();
6365
6894
  }
6366
6895
  }
@@ -6376,7 +6905,7 @@ var WebRTCVertoManager = class extends VertoManager {
6376
6905
  } catch (error) {
6377
6906
  logger$16.warn("[WebRTCManager] Error processing modify response:", error);
6378
6907
  const modifyError = error instanceof Error ? error : new Error(String(error), { cause: error });
6379
- this.onError?.(modifyError);
6908
+ this.reportLegError(modifyError, rtcPeerConnController);
6380
6909
  }
6381
6910
  }
6382
6911
  }
@@ -6389,37 +6918,35 @@ var WebRTCVertoManager = class extends VertoManager {
6389
6918
  status,
6390
6919
  signalingError
6391
6920
  });
6392
- this.onError?.(signalingError);
6921
+ this.reportLegError(signalingError, null, { fatal: false });
6393
6922
  return;
6394
6923
  }
6395
6924
  if (rtcPeerConnController.isMainDevice) this._signalingStatus$.next(status);
6396
6925
  }
6397
6926
  processInviteResponse(response, rtcPeerConnController) {
6398
- if (!response.error && getValueFrom(response, "result.result.result.message") === "CALL CREATED") {
6927
+ if (getValueFrom(response, "result.result.result.message") === "CALL CREATED") {
6399
6928
  this.emitMainSignalingStatus(rtcPeerConnController.id, "trying");
6400
- this._nodeId$.next(getValueFrom(response, "result.node_id") ?? null);
6929
+ const nodeId = getValueFrom(response, "result.node_id") ?? null;
6401
6930
  const memberId = getValueFrom(response, "result.result.result.memberID") ?? null;
6402
- const callId = getValueFrom(response, "result.result.result.callID") ?? null;
6931
+ const callId = getValueFrom(response, "result.result.result.callID");
6403
6932
  logger$16.debug("[WebRTCManager] Verto invite response:", {
6404
6933
  callId,
6405
6934
  memberId,
6406
6935
  response
6407
6936
  });
6408
- this._selfId$.next(memberId);
6409
6937
  rtcPeerConnController.setMemberId(memberId);
6410
- if (callId) {
6411
- this.webRtcCallSession.addCallId(callId);
6412
- this.attachManager.attach(this.buildAttachableCall(callId));
6413
- } else logger$16.warn("[WebRTCManager] Cannot attach call, missing callId:", {
6414
- nodeId: this.nodeId,
6415
- callId
6416
- });
6938
+ rtcPeerConnController.setNodeId(nodeId);
6939
+ if (rtcPeerConnController.isMainDevice) {
6940
+ this._selfId$.next(memberId);
6941
+ this._nodeId$.next(nodeId);
6942
+ this.attachManager.attach(this.buildAttachableCall(callId, nodeId ?? void 0));
6943
+ }
6944
+ if (callId) this.webRtcCallSession.addCallId(callId);
6417
6945
  logger$16.info("[WebRTCManager] Verto invite successful");
6418
6946
  logger$16.debug(`[WebRTCManager] nodeid: ${this._nodeId$.value}, selfId: ${this._selfId$.value}`);
6419
6947
  } else {
6420
6948
  logger$16.error("[WebRTCManager] Verto invite failed:", response);
6421
- const inviteError = response.error ? new JSONRPCError(response.error.code, response.error.message, response.error.data) : /* @__PURE__ */ new Error("Verto invite failed: unexpected response");
6422
- this.onError?.(inviteError);
6949
+ this.reportLegError(/* @__PURE__ */ new Error("Verto invite failed: unexpected response"), rtcPeerConnController);
6423
6950
  }
6424
6951
  }
6425
6952
  get RTCPeerConnectionConfig() {
@@ -6458,7 +6985,7 @@ var WebRTCVertoManager = class extends VertoManager {
6458
6985
  this._rtcPeerConnectionsMap.set(rtcPeerConnController.id, rtcPeerConnController);
6459
6986
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
6460
6987
  this.subscribeTo(rtcPeerConnController.errors$, (error) => {
6461
- this.onError?.(error);
6988
+ this.reportLegError(error, rtcPeerConnController);
6462
6989
  });
6463
6990
  if (options.initOffer) this.handleInboundAnswer(rtcPeerConnController);
6464
6991
  }
@@ -6487,7 +7014,7 @@ var WebRTCVertoManager = class extends VertoManager {
6487
7014
  await rtcPeerConnController.acceptInbound(answerOptions);
6488
7015
  } catch (error) {
6489
7016
  logger$16.error("[WebRTCManager] Error creating inbound answer:", error);
6490
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
7017
+ this.reportLegError(toError(error), rtcPeerConnController);
6491
7018
  }
6492
7019
  }
6493
7020
  }
@@ -6559,7 +7086,7 @@ var WebRTCVertoManager = class extends VertoManager {
6559
7086
  isInvite: isVertoInviteMessage(vertoMessage),
6560
7087
  reattach: this.webRtcCallSession.options.reattach === true,
6561
7088
  explicitNodeId: this.webRtcCallSession.options.nodeId,
6562
- currentNodeId: this._nodeId$.value
7089
+ currentNodeId: rtcPeerConnController.nodeId ?? this._nodeId$.value
6563
7090
  }),
6564
7091
  subscribe
6565
7092
  };
@@ -6591,7 +7118,7 @@ var WebRTCVertoManager = class extends VertoManager {
6591
7118
  await this.attachManager.attach(this.buildAttachableCall());
6592
7119
  } catch (error) {
6593
7120
  logger$16.error("[WebRTCManager] Error sending Verto answer:", error);
6594
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
7121
+ this.reportLegError(toError(error), rtcPeerConnectionController);
6595
7122
  await rtcPeerConnectionController.updateAnswerStatus({ status: "failed" });
6596
7123
  }
6597
7124
  }
@@ -6663,15 +7190,19 @@ var WebRTCVertoManager = class extends VertoManager {
6663
7190
  await this.mainPeerConnection.restoreTrackSender(deviceKind);
6664
7191
  } else {
6665
7192
  const error = new InvalidParams("No valid device to be added");
6666
- this.onError?.(error);
7193
+ this.reportLegError(error, this.mainPeerConnection);
6667
7194
  throw error;
6668
7195
  }
6669
7196
  }
6670
- async addScreenMedia(options = { audio: false }) {
6671
- await this.initAdditionalPeerConnection("screenshare", options);
7197
+ async addScreenMedia(options = {}) {
7198
+ await this.initAdditionalPeerConnection("screenshare", {
7199
+ audio: false,
7200
+ screenShareAudio: options.audio ?? false
7201
+ });
6672
7202
  }
6673
7203
  async initAdditionalPeerConnection(propose, options) {
6674
7204
  const isScreenShare = propose === "screenshare";
7205
+ if (isScreenShare && this._screenShareId && this._rtcPeerConnectionsMap.has(this._screenShareId)) throw new ScreenShareAlreadyActiveError(this._screenShareId);
6675
7206
  let firstPeerConnectionError;
6676
7207
  let rtcPeerConnController = null;
6677
7208
  try {
@@ -6688,21 +7219,36 @@ var WebRTCVertoManager = class extends VertoManager {
6688
7219
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
6689
7220
  this.subscribeTo(rtcPeerConnController.errors$, (error) => {
6690
7221
  firstPeerConnectionError ??= error;
6691
- this.onError?.(error, { fatal: false });
7222
+ this.reportLegError(error, rtcPeerConnController);
6692
7223
  });
6693
- await firstValueFrom(rtcPeerConnController.connectionState$.pipe(filter((state) => state === "connected"), take(1), timeout(this._screenShareTimeoutMs), takeUntil(this.destroyed$)));
7224
+ const pc = rtcPeerConnController;
7225
+ await firstValueFrom(merge(pc.localMediaSettled$.pipe(take(1), switchMap(() => pc.connectionState$.pipe(filter((state) => state === "connected"), take(1), timeout(DEFAULT_AUX_LEG_CONNECT_TIMEOUT_MS)))), this.legError$(pc.id)).pipe(takeUntil(merge(this.destroyed$, pc.destroyed$))));
6694
7226
  if (isScreenShare) this._screenShareStatus$.next("started");
6695
7227
  logger$16.info(`[WebRTCManager] Additional peer connection connected (${propose}).`);
6696
7228
  return rtcPeerConnController.id;
6697
7229
  } catch (error) {
6698
- logger$16.warn("[WebRTCManager] Error initializing additional peer connection:", error);
6699
- if (rtcPeerConnController) rtcPeerConnController.destroy();
6700
- if (isScreenShare) this._screenShareStatus$.next("none");
7230
+ const cancelled = error instanceof AuxiliaryLegCancelledError;
7231
+ const aborted = error instanceof EmptyError && !firstPeerConnectionError;
7232
+ if (!cancelled && !aborted) logger$16.warn("[WebRTCManager] Error initializing additional peer connection:", error);
7233
+ if (rtcPeerConnController && this._rtcPeerConnectionsMap.has(rtcPeerConnController.id)) {
7234
+ rtcPeerConnController.destroy();
7235
+ this._rtcPeerConnectionsMap.delete(rtcPeerConnController.id);
7236
+ this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
7237
+ }
7238
+ if (isScreenShare) {
7239
+ this._screenShareId = void 0;
7240
+ this._screenShareStatus$.next("none");
7241
+ }
7242
+ if (cancelled) {
7243
+ logger$16.debug("[WebRTCManager] Additional peer connection removed before connecting.");
7244
+ throw error;
7245
+ }
6701
7246
  if (firstPeerConnectionError) throw firstPeerConnectionError instanceof MediaAccessError && firstPeerConnectionError.originalError instanceof Error ? firstPeerConnectionError.originalError : firstPeerConnectionError;
6702
7247
  if (error instanceof EmptyError) {
6703
7248
  logger$16.debug("[WebRTCManager] Additional peer connection aborted before connecting.");
6704
7249
  return;
6705
7250
  }
7251
+ if (error instanceof TimeoutError) throw new AuxiliaryLegTimeoutError(propose, error);
6706
7252
  throw error instanceof Error ? error : new Error(String(error), { cause: error });
6707
7253
  }
6708
7254
  }
@@ -6719,7 +7265,10 @@ var WebRTCVertoManager = class extends VertoManager {
6719
7265
  if (removeTrack) return this.mainPeerConnection.stopTrackSender(removeTrack, { updateTransceiverDirection: true });
6720
7266
  }
6721
7267
  async removeScreenMedia() {
6722
- if (!["starting", "started"].includes(this._screenShareStatus$.value)) logger$16.warn("[WebRTCManager] No active screen share to stop.");
7268
+ if (!["starting", "started"].includes(this._screenShareStatus$.value)) {
7269
+ logger$16.warn("[WebRTCManager] No active screen share to stop.");
7270
+ return;
7271
+ }
6723
7272
  if (!this._screenShareId) {
6724
7273
  logger$16.debug("[WebRTCManager] No screen share peer connection found.");
6725
7274
  return;
@@ -6734,6 +7283,10 @@ var WebRTCVertoManager = class extends VertoManager {
6734
7283
  try {
6735
7284
  if (rtcPeerConnController) await this.executeVertoBye(rtcPeerConnController);
6736
7285
  } finally {
7286
+ if (rtcPeerConnController && rtcPeerConnController.connectionState !== "connected") this._legErrors$.next({
7287
+ legId: id,
7288
+ error: new AuxiliaryLegCancelledError(rtcPeerConnController.propose)
7289
+ });
6737
7290
  rtcPeerConnController?.destroy();
6738
7291
  this._rtcPeerConnectionsMap.delete(id);
6739
7292
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
@@ -6748,7 +7301,10 @@ var WebRTCVertoManager = class extends VertoManager {
6748
7301
  await this.executeVerto(VertoBye({
6749
7302
  ...causeParams,
6750
7303
  dialogParams: this.dialogParams(rtcPeerConnController)
6751
- }));
7304
+ }), {
7305
+ callID: rtcPeerConnController.id,
7306
+ node_id: rtcPeerConnController.nodeId ?? void 0
7307
+ });
6752
7308
  } catch (error) {
6753
7309
  logger$16.warn("[WebRTCManager] Call might already be disconnected, error sending Verto bye:", error);
6754
7310
  throw error;
@@ -7631,14 +8187,35 @@ function mosToQualityLevel(mos) {
7631
8187
  }
7632
8188
 
7633
8189
  //#endregion
7634
- //#region src/core/entities/Call.ts
7635
- const logger$12 = getLogger();
8190
+ //#region src/utils/unwrapVertoReply.ts
7636
8191
  /**
7637
- * Verto method for setting member layout positions. Its gateway DTO requires a
7638
- * `targets` array whose entries are `{ target, position }` (NOT bare targets),
7639
- * so {@link WebRTCCall.buildMethodParams} special-cases it. See issue #19400.
8192
+ * Unwrap a method's own reply from a `webrtc.verto` envelope.
8193
+ *
8194
+ * A control verb sent in-dialog comes back nested two levels deep:
8195
+ *
8196
+ * ```
8197
+ * { result: { node_id, code, result: { jsonrpc, id, result: <method payload> } } }
8198
+ * ```
8199
+ *
8200
+ * whereas the routed transport resolves the method payload directly under
8201
+ * `.result`. Readers want the latter shape, and the difference is silent when it
8202
+ * is wrong — `response.result.layouts` simply evaluates to `undefined` against the
8203
+ * envelope, so the data arrives, nothing throws, and the caller sees an empty
8204
+ * value. That exact failure produced an empty layout dropdown with a successful
8205
+ * request behind it.
8206
+ *
8207
+ * Accepting both shapes here keeps every reader indifferent to which transport
8208
+ * produced the response. Anything that is not a nested envelope (a plain ack, or
8209
+ * an already-unwrapped reply) passes through untouched.
7640
8210
  */
7641
- const POSITION_SET_METHOD = "call.member.position.set";
8211
+ function unwrapVertoReply(response) {
8212
+ const inner = getValueFrom(response, "result.result");
8213
+ return inner && typeof inner === "object" && "result" in inner ? inner : response;
8214
+ }
8215
+
8216
+ //#endregion
8217
+ //#region src/core/entities/Call.ts
8218
+ const logger$12 = getLogger();
7642
8219
  /**
7643
8220
  * Ratio between the critical and warning RTT spike multipliers.
7644
8221
  * Warning threshold = baseline * warningMultiplier (default 3x)
@@ -7734,8 +8311,11 @@ var WebRTCCall = class extends Destroyable {
7734
8311
  emitError(callError) {
7735
8312
  if (this._status$.value === "destroyed" || this._status$.value === "failed") return;
7736
8313
  this._errors$.next(callError);
7737
- if (callError.fatal) {
8314
+ if (callError.fatal && this._status$.value !== "disconnecting") {
7738
8315
  this._status$.next("failed");
8316
+ this.vertoManager.bye().catch((error) => {
8317
+ logger$12.debug("[Call] fatal-teardown bye failed (signaling likely already dead):", error);
8318
+ });
7739
8319
  this.destroy();
7740
8320
  }
7741
8321
  }
@@ -7790,7 +8370,7 @@ var WebRTCCall = class extends Destroyable {
7790
8370
  /** Toggles the call lock state, preventing or allowing new participants from joining. */
7791
8371
  async toggleLock() {
7792
8372
  const method = this.locked ? "call.unlock" : "call.lock";
7793
- await this.executeMethod(this.selfId ?? "", method, {});
8373
+ await this.executeMethod(this.callSelf, method, {});
7794
8374
  }
7795
8375
  /**
7796
8376
  * Toggles the hold state of the call (pauses/resumes local media transmission).
@@ -7839,14 +8419,25 @@ var WebRTCCall = class extends Destroyable {
7839
8419
  *
7840
8420
  * Constructs call context (node_id, call_id, member_id) and sends the RPC request.
7841
8421
  *
7842
- * @param target - Target member ID string, or a {@link MemberTarget} object.
8422
+ * @param target - Target {@link MemberTarget} triple, or the local member's
8423
+ * ID string for self-operations (any other string is rejected — a bare
8424
+ * member id cannot carry the remote member's own call context).
7843
8425
  * @param method - Verto method name (e.g. `'call.mute'`, `'call.member.remove'`).
7844
8426
  * @param args - Parameters for the RPC method.
7845
8427
  * @returns The RPC response.
8428
+ * @throws {CallNotReadyError} If the call has no self member context yet.
8429
+ * @throws {InvalidParams} If a string target is not the local member's ID.
7846
8430
  * @throws {JSONRPCError} If the RPC call returns an error.
7847
8431
  */
7848
8432
  async executeMethod(target, method, args) {
7849
- const params = this.buildMethodParams(target, args, method);
8433
+ const self = this.callSelf;
8434
+ if (typeof target === "string" && target !== self.member_id) throw new InvalidParams(`Target member ID ${target} does not match call's self member ID ${self.member_id}`);
8435
+ if (this.clientSession.callControl === "in-dialog") return this.executeMethodInDialog(target, method, args);
8436
+ const params = {
8437
+ ...args,
8438
+ self,
8439
+ target: typeof target === "string" ? self : target
8440
+ };
7850
8441
  const request = buildRPCRequest({
7851
8442
  method,
7852
8443
  params
@@ -7860,29 +8451,98 @@ var WebRTCCall = class extends Destroyable {
7860
8451
  throw error;
7861
8452
  }
7862
8453
  }
7863
- buildMethodParams(target, args, method) {
7864
- const self = {
7865
- node_id: this.nodeId ?? "",
7866
- call_id: this.id,
7867
- member_id: this.vertoManager.selfId ?? ""
7868
- };
7869
- if (method === POSITION_SET_METHOD) return {
7870
- ...args,
7871
- self
7872
- };
7873
- if (typeof target === "object") return {
7874
- ...args,
7875
- self,
7876
- targets: [target]
7877
- };
7878
- return {
7879
- ...args,
7880
- self,
7881
- target: {
7882
- node_id: this.nodeId ?? "",
8454
+ /**
8455
+ * `executeMethod` for a call opened with `callControl: 'in-dialog'`.
8456
+ *
8457
+ * Translates the routed transport's calling convention into the in-dialog one. No
8458
+ * `self` tuple is sent, but a `target` is — the same {call_id, member_id} the routed
8459
+ * transport puts in `target` (minus node_id), for self-ops and cross-member ops alike.
8460
+ *
8461
+ * Target shapes are per-verb and irregular, so they are centralised here rather
8462
+ * than left to callers: most verbs take a singular `target`, `call.member.remove`
8463
+ * takes a plural `targets` array, and `call.member.position.set` takes a flat
8464
+ * `targets` of `{call_id, position}` — the one verb keyed on call_id rather than
8465
+ * member_id, so the member triple `Participant.setPosition` built is unwrapped.
8466
+ */
8467
+ async executeMethodInDialog(target, method, args) {
8468
+ const control = { ...args };
8469
+ if (method === "call.member.position.set") control.targets = (args.targets ?? []).map((entry) => ({
8470
+ call_id: entry.target?.call_id ?? entry.call_id,
8471
+ position: entry.position
8472
+ }));
8473
+ else {
8474
+ const member = typeof target === "object" ? {
8475
+ call_id: target.call_id,
8476
+ member_id: target.member_id
8477
+ } : {
7883
8478
  call_id: this.id,
7884
8479
  member_id: target
7885
- }
8480
+ };
8481
+ if (method === "call.member.remove") control.targets = [member];
8482
+ else control.target = member;
8483
+ }
8484
+ return this.sendCommand(method, control);
8485
+ }
8486
+ /**
8487
+ * Sends a `call.*` control verb **in-dialog** via `verto.info`, as an alternative
8488
+ * to the routed {@link executeMethod} transport.
8489
+ *
8490
+ * Why both exist: `executeMethod` addresses the member with an explicit
8491
+ * `{node_id, call_id, member_id}` tuple, which does not resolve for every conference,
8492
+ * so the op can fail. An in-dialog frame carries the verb on the member's own
8493
+ * signaling channel instead, so control works without the client needing to know how
8494
+ * the conference is hosted.
8495
+ *
8496
+ * The trade-off is reach: the in-dialog transport is only accepted for calls that
8497
+ * join a conference over SWML (e.g. an SWML `join_conference`); use the routed
8498
+ * default otherwise.
8499
+ *
8500
+ * `params` are sent verbatim — nothing is built for you, which includes the target.
8501
+ * **A self-directed op still needs one**, or it is refused; name yourself explicitly:
8502
+ *
8503
+ * ```ts
8504
+ * const { call_id, member_id } = call.self.target;
8505
+ * await call.sendCommand('call.mute', { channels: ['audio'], target: { call_id, member_id } });
8506
+ * ```
8507
+ *
8508
+ * Never include `node_id` — only the two ids. The shapes are per-verb: most take a
8509
+ * singular `target`, `call.member.remove` takes a plural `targets` array, and
8510
+ * `call.member.position.set` takes a flat `targets: [{call_id, position}]` (the one
8511
+ * verb keyed on `call_id` rather than `member_id`). Verbs that act on the call as a
8512
+ * whole, or that the SDK does not wrap at all, take no target.
8513
+ *
8514
+ * For the typed alternative that handles all of this, create the client with
8515
+ * `callControl: 'in-dialog'` and use the ordinary `Call`/`Participant` methods.
8516
+ *
8517
+ * @internal Not part of the supported surface while the in-dialog transport is still
8518
+ * rolling out. `WebRTCCall` is exported from the package entry, so without this tag
8519
+ * TypeDoc publishes the method — and the example above — as public API.
8520
+ *
8521
+ * @param method - A `call.*` method name (e.g. `'call.mute'`).
8522
+ * @param params - Method parameters, sent verbatim.
8523
+ * @returns The method's own reply, unwrapped from the `verto.info` envelope.
8524
+ * @throws {JSONRPCError} If the control op fails.
8525
+ */
8526
+ async sendCommand(method, params = {}) {
8527
+ return unwrapVertoReply(await this.vertoManager.sendCallControl(method, params));
8528
+ }
8529
+ /**
8530
+ * The local leg's member triple — sent as `self` in every member RPC
8531
+ * envelope, and as the `target` of call-scoped self-operations (e.g. lock,
8532
+ * layout).
8533
+ *
8534
+ * @throws {CallNotReadyError} Before `call.joined` delivers the self member
8535
+ * context (`selfId`/`nodeId`) — an RPC without it cannot be routed, so fail
8536
+ * fast instead of sending a doomed request.
8537
+ */
8538
+ get callSelf() {
8539
+ const node_id = this.nodeId;
8540
+ const member_id = this.vertoManager.selfId;
8541
+ if (!node_id || !member_id) throw new CallNotReadyError(this.id);
8542
+ return {
8543
+ node_id,
8544
+ call_id: this.id,
8545
+ member_id
7886
8546
  };
7887
8547
  }
7888
8548
  /** Observable of the current call status (e.g. `'ringing'`, `'connected'`). */
@@ -8038,13 +8698,13 @@ var WebRTCCall = class extends Destroyable {
8038
8698
  get bandwidthConstrained$() {
8039
8699
  return this.deferEmission(this._bandwidthConstrained$.asObservable());
8040
8700
  }
8041
- /** Observable that emits when server-pushed media params are applied. */
8701
+ /** Observable that emits when the server pushes media params. */
8042
8702
  get mediaParamsUpdated$() {
8043
8703
  return this.deferEmission(this._mediaParamsUpdated$.asObservable());
8044
8704
  }
8045
8705
  /**
8046
8706
  * @internal Emit a media params update event.
8047
- * Called by the VertoManager when server-pushed media params are applied.
8707
+ * Called by the VertoManager when the server pushes media params.
8048
8708
  */
8049
8709
  emitMediaParamsUpdated(event) {
8050
8710
  this._mediaParamsUpdated$.next(event);
@@ -8252,6 +8912,10 @@ var WebRTCCall = class extends Destroyable {
8252
8912
  get selfId$() {
8253
8913
  return this.vertoManager.selfId$;
8254
8914
  }
8915
+ /** @internal Lets call creation bound the media and signalling phases apart. */
8916
+ get localMediaSettled$() {
8917
+ return this.vertoManager.localMediaSettled$;
8918
+ }
8255
8919
  /** Local participant's member ID, or `null` if not joined. */
8256
8920
  get selfId() {
8257
8921
  return this.vertoManager.selfId;
@@ -8445,12 +9109,17 @@ var WebRTCCall = class extends Destroyable {
8445
9109
  *
8446
9110
  * **These operations are NOT atomic.** The layout is applied first, then each
8447
9111
  * member position sequentially, so members may briefly flash into their
8448
- * default slots before being moved to the requested positions.
9112
+ * default slots before being moved to the requested positions. Targeted
9113
+ * members are validated upfront, though: when any of them has no
9114
+ * {@link Participant.target | member call context} yet, the whole call
9115
+ * rejects before any request is sent and the layout is left unchanged.
8449
9116
  *
8450
9117
  * @param layout - Layout name (must be one of {@link layouts}).
8451
9118
  * @param positions - Optional map of member IDs to {@link VideoPosition} values.
8452
9119
  * When omitted or empty, only the layout is changed.
8453
9120
  * @throws {InvalidParams} If the layout is not in the available {@link layouts}.
9121
+ * @throws {ParticipantNotReadyError} If a targeted member's call context has
9122
+ * not been received yet — thrown before any request is sent.
8454
9123
  *
8455
9124
  * @example
8456
9125
  * ```ts
@@ -8461,18 +9130,19 @@ var WebRTCCall = class extends Destroyable {
8461
9130
  */
8462
9131
  async setLayout(layout, positions) {
8463
9132
  if (!this.layouts.includes(layout)) throw new InvalidParams(`Layout ${layout} is not available in the current call layouts: ${this.layouts.join(", ")}`);
8464
- const selfId = await firstValueFrom(this.selfId$.pipe(filter((id) => id !== null)));
8465
- await this.executeMethod(selfId, "call.layout.set", { layout });
8466
- const positionEntries = Object.entries(positions ?? {});
8467
- if (positionEntries.length === 0) return;
8468
- for (const [memberId, position] of positionEntries) {
9133
+ const targets = [];
9134
+ for (const [memberId, position] of Object.entries(positions ?? {})) {
8469
9135
  const participant = this.participants.find((p) => p.id === memberId);
8470
9136
  if (!participant) {
8471
9137
  logger$12.warn(`[Call] setLayout: member ${memberId} not found in participants; skipping position ${position}`);
8472
9138
  continue;
8473
9139
  }
8474
- await participant.setPosition(position);
9140
+ participant.target;
9141
+ targets.push([participant, position]);
8475
9142
  }
9143
+ const selfId = await firstValueFrom(this.selfId$.pipe(filter((id) => id !== null)));
9144
+ await this.executeMethod(selfId, "call.layout.set", { layout });
9145
+ for (const [participant, position] of targets) await participant.setPosition(position);
8476
9146
  }
8477
9147
  /**
8478
9148
  * Transfers the call to another destination.
@@ -8574,17 +9244,28 @@ var WebRTCCall = class extends Destroyable {
8574
9244
  * (notably iOS Safari) fall back to re-acquiring the track with the new
8575
9245
  * constraint set and plumbing the replacement through the local audio
8576
9246
  * pipeline if one is active.
9247
+ *
9248
+ * @returns whether the constraint reached the microphone. `false` is an
9249
+ * outcome rather than an error — a leg sending media the SDK did not capture
9250
+ * is left alone — so a UI that reflects the toggle must read it. Any failure
9251
+ * behind a `false` is also reported on {@link errors$}.
8577
9252
  */
8578
9253
  async setEchoCancellation(enabled) {
8579
- await this.vertoManager.updateMediaConstraints({ audio: { echoCancellation: enabled } });
9254
+ return this.vertoManager.updateMediaConstraints({ audio: { echoCancellation: enabled } });
8580
9255
  }
8581
- /** Toggle browser noise suppression on the local mic at runtime. */
9256
+ /**
9257
+ * Toggle browser noise suppression on the local mic at runtime.
9258
+ * @returns whether the constraint reached the microphone.
9259
+ */
8582
9260
  async setNoiseSuppression(enabled) {
8583
- await this.vertoManager.updateMediaConstraints({ audio: { noiseSuppression: enabled } });
9261
+ return this.vertoManager.updateMediaConstraints({ audio: { noiseSuppression: enabled } });
8584
9262
  }
8585
- /** Toggle browser automatic gain control on the local mic at runtime. */
9263
+ /**
9264
+ * Toggle browser automatic gain control on the local mic at runtime.
9265
+ * @returns whether the constraint reached the microphone.
9266
+ */
8586
9267
  async setAutoGainControl(enabled) {
8587
- await this.vertoManager.updateMediaConstraints({ audio: { autoGainControl: enabled } });
9268
+ return this.vertoManager.updateMediaConstraints({ audio: { autoGainControl: enabled } });
8588
9269
  }
8589
9270
  /**
8590
9271
  * Observable of the aggregate remote audio level, 0..1 RMS. The server
@@ -8641,6 +9322,9 @@ var WebRTCCall = class extends Destroyable {
8641
9322
  /**
8642
9323
  * Infers the semantic error category from a raw Error thrown by VertoManager
8643
9324
  * or an RTCPeerConnection layer.
9325
+ *
9326
+ * Pure function — exported for unit testing.
9327
+ * @internal
8644
9328
  */
8645
9329
  function inferCallErrorKind(error) {
8646
9330
  if (error instanceof RPCTimeoutError) return "timeout";
@@ -8659,7 +9343,14 @@ const RECOVERABLE_RPC_CODES = new Set([
8659
9343
  RPC_ERROR_AUTHENTICATION_FAILED,
8660
9344
  RPC_ERROR_INVALID_PARAMS
8661
9345
  ]);
8662
- /** Determines whether an error should be fatal (destroy the call). */
9346
+ /**
9347
+ * A *fallback*: callers knowing which leg failed pass an explicit `fatal` and
9348
+ * never reach here, so the default-fatal branch only sees call- and main-leg
9349
+ * errors. Auxiliary legs go through `WebRTCVertoManager.reportLegError`.
9350
+ *
9351
+ * Pure function — exported for unit testing.
9352
+ * @internal
9353
+ */
8663
9354
  function isFatalError(error) {
8664
9355
  if (error instanceof VertoPongError) return false;
8665
9356
  if (error instanceof MediaTrackError) return false;
@@ -8694,7 +9385,9 @@ var CallFactory = class {
8694
9385
  kind: inferCallErrorKind(error),
8695
9386
  fatal: options$1?.fatal ?? isFatalError(error),
8696
9387
  error,
8697
- callId: callInstance.id
9388
+ callId: callInstance.id,
9389
+ ...options$1?.leg ? { leg: options$1.leg } : {},
9390
+ ...options$1?.legId ? { legId: options$1.legId } : {}
8698
9391
  };
8699
9392
  callInstance.emitError(callError);
8700
9393
  },
@@ -9162,6 +9855,30 @@ const logger$9 = getLogger();
9162
9855
  function shouldAbortDial(callError) {
9163
9856
  return callError.fatal || !(callError.error instanceof MediaAccessError);
9164
9857
  }
9858
+ /**
9859
+ * Wait for a dialed call to be ready, or for the failure that stops it.
9860
+ *
9861
+ * Local media acquisition is deliberately unbounded: a permission prompt or a
9862
+ * device picker is human time, and `getUserMedia` cannot be cancelled anyway.
9863
+ * The clock starts only once acquisition settles, so a slow human never spends
9864
+ * the server's budget.
9865
+ *
9866
+ * `merge` rather than `race`, because the two legs settle asymmetrically. A
9867
+ * fatal acquisition failure reports the error and then destroys the call in the
9868
+ * same synchronous step; `errors$` defers delivery by a microtask while
9869
+ * `localMediaSettled$` completes immediately. Under `race` that bare completion
9870
+ * ended the wait first and `dial()` rejected with an RxJS `EmptyError`, burying
9871
+ * the `NotAllowedError` applications are told to inspect. Under `merge` the
9872
+ * completed leg is simply spent, and the queued error — enqueued before the
9873
+ * completion, so delivered before it — arrives to reject the wait. A dial
9874
+ * abandoned with no error at all still ends both legs, and the resulting
9875
+ * `EmptyError` remains the benign-cancel signal.
9876
+ *
9877
+ * Exported for unit testing.
9878
+ */
9879
+ async function awaitDialReady(session, signalingTimeoutMs) {
9880
+ return firstValueFrom(merge(session.localMediaSettled$.pipe(take(1), switchMap(() => session.selfId$.pipe(filter((id) => Boolean(id)), take(1), timeout(signalingTimeoutMs)))), session.errors$.pipe(filter(shouldAbortDial), take(1), switchMap((callError) => throwError(() => callError.error)))));
9881
+ }
9165
9882
  const getAddressSearchURI = (options) => {
9166
9883
  const to = options.to?.split("?")[0];
9167
9884
  const from$1 = options.from?.startsWith("subscriber://") ? options.from.replace("subscriber://", "") : options.from;
@@ -9178,7 +9895,6 @@ var ClientSessionManager = class extends Destroyable {
9178
9895
  this.authorizationStateKey = authorizationStateKey;
9179
9896
  this.attachManager = attachManager;
9180
9897
  this.dpopManager = dpopManager;
9181
- this.callCreateTimeout = 6e3;
9182
9898
  this.agent = `signalwire-js/4.0.0`;
9183
9899
  this.eventAcks = true;
9184
9900
  this.authorizationState$ = this.createReplaySubject(1);
@@ -9187,6 +9903,7 @@ var ClientSessionManager = class extends Destroyable {
9187
9903
  minor: 0,
9188
9904
  revision: 0
9189
9905
  };
9906
+ this.callControl = "routed";
9190
9907
  this._authorization$ = this.createBehaviorSubject(void 0);
9191
9908
  this._errors$ = this.createReplaySubject(1);
9192
9909
  this._authState$ = this.createBehaviorSubject({ kind: "unauthenticated" });
@@ -9387,21 +10104,18 @@ var ClientSessionManager = class extends Destroyable {
9387
10104
  }
9388
10105
  async handleAuthenticationError(error) {
9389
10106
  logger$9.error("Authentication error:", error);
9390
- const isRecoverableAuthError = error instanceof JSONRPCError && (error.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === RPC_ERROR_INVALID_PARAMS || error.code === RPC_ERROR_AUTHENTICATION_FAILED);
10107
+ const isRecoverableAuthError$1 = error instanceof JSONRPCError && (error.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === RPC_ERROR_INVALID_PARAMS || error.code === RPC_ERROR_AUTHENTICATION_FAILED);
9391
10108
  const hasStoredState = await firstValueFrom(this.authorizationState$.pipe(take(1))) !== void 0;
9392
- if (isRecoverableAuthError && hasStoredState) {
10109
+ if (isRecoverableAuthError$1 && hasStoredState) {
9393
10110
  logger$9.debug("[Session] Recoverable auth error — cleaning up stored state and reconnecting fresh");
9394
- try {
9395
- await this.cleanupStoredConnectionParams();
9396
- } catch (cleanupError) {
9397
- logger$9.error("Failed to cleanup stored connection params:", cleanupError);
9398
- } finally {
9399
- this.transport.reconnect();
9400
- }
10111
+ await this.discardResumeStateAndReconnect();
9401
10112
  } else this._errors$.next(error);
9402
10113
  }
9403
10114
  /**
9404
- * Clear the resume state (authorization_state + protocol) only.
10115
+ * Clear the resume state (authorization_state + protocol) and ask the
10116
+ * transport to reconnect. The `connected` event re-triggers
10117
+ * `authenticate()`, which now has no stored state and so performs a fresh
10118
+ * connect.
9405
10119
  *
9406
10120
  * This is the stale-auth-state recovery helper used by handleAuthError:
9407
10121
  * the server rejected a reconnect, so the resume state is discarded and a
@@ -9409,9 +10123,24 @@ var ClientSessionManager = class extends Destroyable {
9409
10123
  * session lives on through the reconnect and reattachCalls() needs the
9410
10124
  * stored call references afterwards. Do NOT add detachAll() here.
9411
10125
  *
10126
+ * Connect-time recovery only. A *request* refused on an already
10127
+ * authenticated session is never healed here: dropping the resume state
10128
+ * destroys the association between the socket and the previous session,
10129
+ * which is what reattach depends on. That path mints a fresh credential and
10130
+ * reauthenticates instead (see `SignalWire.recoverAndRetry`).
10131
+ *
9412
10132
  * For public teardown (disconnect/destroy), use {@link teardownSessionState}
9413
10133
  * instead, which clears the attach records as well.
9414
10134
  */
10135
+ async discardResumeStateAndReconnect() {
10136
+ try {
10137
+ await this.cleanupStoredConnectionParams();
10138
+ } catch (cleanupError) {
10139
+ logger$9.error("Failed to cleanup stored connection params:", cleanupError);
10140
+ } finally {
10141
+ this.transport.reconnect();
10142
+ }
10143
+ }
9415
10144
  async cleanupStoredConnectionParams() {
9416
10145
  await this.transport.setProtocol(void 0);
9417
10146
  await this.updateAuthorizationStateInStorage(void 0);
@@ -9485,11 +10214,15 @@ var ClientSessionManager = class extends Destroyable {
9485
10214
  const isReconnect = hasReconnectState && storedToken;
9486
10215
  let dpopToken;
9487
10216
  if (isReconnect) logger$9.debug("[Session] Reconnecting with stored jwt_token + authorization_state");
9488
- else if (this.onBeforeReconnect && this.clientBound) {
9489
- logger$9.debug("[Session] Refreshing credentials before fresh connect");
9490
- await this.onBeforeReconnect();
10217
+ else {
10218
+ const credential = this.getCredential();
10219
+ const credentialExpired = credential.expiry_at !== void 0 && credential.expiry_at <= Date.now() + CREDENTIAL_EXPIRY_SKEW_MS;
10220
+ if (this.onBeforeReconnect && (this.clientBound || credentialExpired)) {
10221
+ logger$9.debug("[Session] Refreshing credentials before fresh connect");
10222
+ await this.onBeforeReconnect();
10223
+ }
9491
10224
  }
9492
- if ((!isReconnect || this.clientBound) && this.dpopManager?.initialized) try {
10225
+ if (this.dpopManager?.initialized) try {
9493
10226
  dpopToken = await this.dpopManager.createRpcProof({ method: "signalwire.connect" });
9494
10227
  } catch (error) {
9495
10228
  if (this.clientBound) throw error;
@@ -9522,6 +10255,7 @@ var ClientSessionManager = class extends Destroyable {
9522
10255
  });
9523
10256
  if (response.protocol) await this.transport.setProtocol(response.protocol);
9524
10257
  this._authorization$.next(response.authorization);
10258
+ if (response.authorization.cnf?.jkt) this._wasClientBound = true;
9525
10259
  this._iceServers$.next(response.ice_servers ?? []);
9526
10260
  this._authState$.next({ kind: "authenticated" });
9527
10261
  logger$9.debug("[Session] Authentication completed successfully");
@@ -9588,7 +10322,7 @@ var ClientSessionManager = class extends Destroyable {
9588
10322
  to: destinationURI,
9589
10323
  ...options
9590
10324
  });
9591
- await firstValueFrom(race(callSession.selfId$.pipe(filter((id) => Boolean(id)), take(1), timeout(this.callCreateTimeout)), callSession.errors$.pipe(filter(shouldAbortDial), take(1), switchMap((callError) => throwError(() => callError.error)))));
10325
+ await awaitDialReady(callSession, DEFAULT_CALL_SIGNALING_TIMEOUT_MS);
9592
10326
  this._calls$.next({
9593
10327
  [`${callSession.id}`]: callSession,
9594
10328
  ...this._calls$.value
@@ -9641,12 +10375,23 @@ var ClientSessionWrapper = class {
9641
10375
  get authenticated() {
9642
10376
  return this.clientSessionManager.authenticated;
9643
10377
  }
10378
+ /**
10379
+ * Whether the session is using a Client Bound SAT (DPoP). Sticky — set
10380
+ * when the binding is established or restored from a resumed session's
10381
+ * server authorization.
10382
+ */
10383
+ get clientBound() {
10384
+ return this.clientSessionManager.clientBound;
10385
+ }
9644
10386
  get signalingEvent$() {
9645
10387
  return this.clientSessionManager.signalingEvent$;
9646
10388
  }
9647
10389
  get iceServers() {
9648
10390
  return this.clientSessionManager.iceServers;
9649
10391
  }
10392
+ get callControl() {
10393
+ return this.clientSessionManager.callControl;
10394
+ }
9650
10395
  async execute(request, options) {
9651
10396
  return this.clientSessionManager.execute(request, options);
9652
10397
  }
@@ -9841,7 +10586,7 @@ var DeviceTokenManager = class extends Destroyable {
9841
10586
  await session.reauthenticate(tokenData.token, rpcProof, { clientBound: true });
9842
10587
  updateCredential({ token: tokenData.token });
9843
10588
  logger$7.info("[DeviceToken] Client Bound SAT activated successfully");
9844
- this._currentToken$.next(tokenData);
10589
+ this.emitCurrentToken(tokenData);
9845
10590
  return { activated: true };
9846
10591
  } catch (error) {
9847
10592
  logger$7.error("[DeviceToken] Failed to activate Client Bound SAT:", error);
@@ -9853,6 +10598,21 @@ var DeviceTokenManager = class extends Destroyable {
9853
10598
  }
9854
10599
  }
9855
10600
  /**
10601
+ * Emit a freshly received token to the reactive pipeline, stamping an
10602
+ * absolute `expires_at` when the response carried only `expires_in`.
10603
+ * Resolving the expiry at RECEIVE time (not at read time) is what lets
10604
+ * {@link refreshNowIfDue} detect due-ness on resume: a bare `expires_in`
10605
+ * re-resolved later would always compute a full TTL from "now" and never
10606
+ * cross the refresh buffer.
10607
+ */
10608
+ emitCurrentToken(token) {
10609
+ const stamped = token.expires_at ? token : {
10610
+ ...token,
10611
+ expires_at: resolveExpiresAt(token)
10612
+ };
10613
+ this._currentToken$.next(stamped);
10614
+ }
10615
+ /**
9856
10616
  * Returns true when the cached token has enough headroom before expiry to
9857
10617
  * be safely reused on reactivation. The headroom matches the refresh
9858
10618
  * buffer, so a token within the refresh window is treated as stale (the
@@ -9870,7 +10630,7 @@ var DeviceTokenManager = class extends Destroyable {
9870
10630
  method: "POST",
9871
10631
  uri: DEVICE_TOKEN_ENDPOINT
9872
10632
  });
9873
- const response = await this.http.request({
10633
+ const response = await this.http().request({
9874
10634
  url: DEVICE_TOKEN_ENDPOINT,
9875
10635
  ...POST_PARAMS,
9876
10636
  body: JSON.stringify({
@@ -9897,7 +10657,7 @@ var DeviceTokenManager = class extends Destroyable {
9897
10657
  uri: DEVICE_REFRESH_ENDPOINT,
9898
10658
  accessToken: currentToken
9899
10659
  });
9900
- const response = await this.http.request({
10660
+ const response = await this.http().request({
9901
10661
  url: DEVICE_REFRESH_ENDPOINT,
9902
10662
  ...POST_PARAMS,
9903
10663
  body: JSON.stringify({
@@ -9945,7 +10705,7 @@ var DeviceTokenManager = class extends Destroyable {
9945
10705
  const currentToken = this.getCredential().token;
9946
10706
  if (!currentToken) throw new TokenRefreshError("No current token available for refresh");
9947
10707
  const newTokenData = await this.retryRefresh(session, currentToken, updateCredential);
9948
- this._currentToken$.next(newTokenData);
10708
+ this.emitCurrentToken(newTokenData);
9949
10709
  } catch (error) {
9950
10710
  logger$7.error("[DeviceToken] Automatic Client Bound SAT refresh failed:", error);
9951
10711
  this.errorHandler(error instanceof TokenRefreshError ? error : new TokenRefreshError("Automatic token refresh failed", error));
@@ -9972,6 +10732,22 @@ var DeviceTokenManager = class extends Destroyable {
9972
10732
  throw lastError instanceof Error ? lastError : new TokenRefreshError("All refresh retries exhausted", lastError);
9973
10733
  }
9974
10734
  /**
10735
+ * Force an immediate refresh when the cached Client Bound SAT is already
10736
+ * past its refresh window. Called on resume from suspension where
10737
+ * background-tab throttling can delay the reactive timer past the buffer.
10738
+ * A no-op when no token is cached or it still has headroom; the normal
10739
+ * {@link executeRefresh} guards (paused / in-progress / unauthenticated)
10740
+ * still apply.
10741
+ */
10742
+ refreshNowIfDue() {
10743
+ const token = this._currentToken$.value;
10744
+ if (!token) return;
10745
+ if (resolveExpiresAt(token) * 1e3 - Date.now() <= DEVICE_TOKEN_REFRESH_BUFFER_MS) {
10746
+ logger$7.debug("[DeviceToken] Resume: cached SAT past refresh window; refreshing now");
10747
+ this.executeRefresh();
10748
+ }
10749
+ }
10750
+ /**
9975
10751
  * Stops the reactive refresh pipeline from firing. Use when the underlying
9976
10752
  * session is being torn down (e.g., during {@link SignalWire.disconnect})
9977
10753
  * so a scheduled refresh cannot fire against a destroyed session.
@@ -10022,6 +10798,7 @@ var CredentialRefreshCoordinator = class extends Destroyable {
10022
10798
  this.deps = deps;
10023
10799
  this._activating = false;
10024
10800
  this._activationGeneration = 0;
10801
+ this._developerRefreshInProgress = false;
10025
10802
  if (dpopManager?.initialized) this._deviceTokenManager = (deps.deviceTokenManagerFactory ?? defaultDeviceTokenManagerFactory)(dpopManager, deps.http, (error) => deps.notifier.onError(error), () => deps.store.read());
10026
10803
  }
10027
10804
  /** True when the Client Bound SAT path is available (DPoP initialized). */
@@ -10040,28 +10817,120 @@ var CredentialRefreshCoordinator = class extends Destroyable {
10040
10817
  * invokes `deps.onRefreshExhausted` so the orchestrator can disconnect.
10041
10818
  */
10042
10819
  scheduleDeveloperRefresh(provider, expiresAt, attempt = 0) {
10820
+ this._activeProvider = provider;
10043
10821
  if (this._developerTimerId !== void 0) clearTimeout(this._developerTimerId);
10044
10822
  const refreshInterval = attempt === 0 ? Math.max(expiresAt - Date.now() - CREDENTIAL_REFRESH_BUFFER_MS, 1e3) : Math.min(CREDENTIAL_REFRESH_RETRY_BASE_MS * Math.pow(2, attempt) * (.5 + Math.random() * .5), CREDENTIAL_REFRESH_MAX_DELAY_MS);
10045
- this._developerTimerId = setTimeout(async () => {
10823
+ this._developerTimerId = setTimeout(() => {
10824
+ this._developerTimerId = void 0;
10825
+ this.executeDeveloperRefresh(provider, expiresAt, attempt);
10826
+ }, refreshInterval);
10827
+ }
10828
+ /**
10829
+ * Runs the developer-provided refresh once: mints a new credential, stores
10830
+ * and persists it, reauthenticates the live session (via the notifier), and
10831
+ * reschedules against the new expiry. On failure retries with backoff up to
10832
+ * {@link CREDENTIAL_REFRESH_MAX_RETRIES}, then signals exhaustion.
10833
+ *
10834
+ * Shared by the scheduled timer tick and {@link forceRefreshIfDue}. The
10835
+ * `_developerRefreshInProgress` guard prevents the two from overlapping.
10836
+ */
10837
+ async executeDeveloperRefresh(provider, expiresAt, attempt) {
10838
+ if (this._developerRefreshInProgress) {
10839
+ logger$6.debug("[Coordinator] Developer refresh already in progress; skipping");
10840
+ return;
10841
+ }
10842
+ this._developerRefreshInProgress = true;
10843
+ try {
10844
+ const newCredentials = await this.refreshCredential(provider);
10845
+ this.deps.store.write(newCredentials);
10846
+ this.deps.store.persist(newCredentials);
10046
10847
  try {
10047
- if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
10048
- const newCredentials = await provider.refresh();
10049
- this.deps.store.write(newCredentials);
10050
- this.deps.store.persist(newCredentials);
10051
- logger$6.info("[Coordinator] Credentials refreshed successfully.");
10052
- if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
10053
- } catch (error) {
10054
- const nextAttempt = attempt + 1;
10055
- logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
10056
- this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
10057
- if (nextAttempt < CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
10058
- else {
10059
- logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
10060
- this.deps.notifier.onError(new TokenRefreshError("Credential refresh failed after max retries"));
10061
- this.deps.notifier.onRefreshExhausted();
10062
- }
10848
+ await this.deps.notifier.onCredentialRefreshed(newCredentials);
10849
+ } catch (reauthError) {
10850
+ logger$6.warn("[Coordinator] onCredentialRefreshed rejected (non-fatal):", reauthError);
10063
10851
  }
10064
- }, refreshInterval);
10852
+ logger$6.info("[Coordinator] Credentials refreshed successfully.");
10853
+ if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
10854
+ } catch (error) {
10855
+ const nextAttempt = attempt + 1;
10856
+ logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
10857
+ this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
10858
+ if (nextAttempt < CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
10859
+ else {
10860
+ logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
10861
+ this.deps.notifier.onError(new TokenRefreshError("Credential refresh failed after max retries"));
10862
+ this.deps.notifier.onRefreshExhausted();
10863
+ }
10864
+ } finally {
10865
+ this._developerRefreshInProgress = false;
10866
+ }
10867
+ }
10868
+ /**
10869
+ * Force an immediate refresh when the current credential is already past its
10870
+ * scheduled refresh window. Called on resume from suspension, where
10871
+ * background-tab timer throttling can delay the armed refresh well past
10872
+ * expiry, leaving the live session stale.
10873
+ *
10874
+ * Routes to whichever mechanism is armed: the developer timer if armed,
10875
+ * otherwise the Client Bound SAT pipeline. A no-op when nothing is due.
10876
+ */
10877
+ forceRefreshIfDue() {
10878
+ if (this._developerTimerId !== void 0 && this._activeProvider) {
10879
+ const expiry = this.deps.store.read().expiry_at;
10880
+ if (expiry !== void 0 && Date.now() >= expiry - CREDENTIAL_REFRESH_BUFFER_MS) {
10881
+ logger$6.debug("[Coordinator] Resume: credential past refresh window; forcing refresh");
10882
+ clearTimeout(this._developerTimerId);
10883
+ this._developerTimerId = void 0;
10884
+ this.executeDeveloperRefresh(this._activeProvider, expiry, 0);
10885
+ }
10886
+ return;
10887
+ }
10888
+ this._deviceTokenManager?.refreshNowIfDue();
10889
+ }
10890
+ /**
10891
+ * Sync the credential's expiry from the server-provided authorization (the
10892
+ * `signalwire.connect` result). SATs are opaque JWE, so
10893
+ * `fabric_subscriber.expires_at` is the authoritative expiry of the token
10894
+ * the session actually connected with — the provider-reported `expiry_at`
10895
+ * is only a hint (and may be wrong or absent). Corrects the stored
10896
+ * credential and re-arms the developer refresh timer against the real
10897
+ * deadline when the provider supports `refresh()`.
10898
+ */
10899
+ syncExpiryFromAuthorization(authorization, provider) {
10900
+ const expiresAtSec = authorization?.fabric_subscriber?.expires_at;
10901
+ if (!expiresAtSec) return;
10902
+ const expiryAt = expiresAtSec * 1e3;
10903
+ const credential = this.deps.store.read();
10904
+ if (credential.expiry_at === expiryAt) return;
10905
+ logger$6.debug(`[Coordinator] Correcting credential expiry from server authorization: ${new Date(expiryAt).toISOString()}`);
10906
+ const updated = {
10907
+ ...credential,
10908
+ expiry_at: expiryAt
10909
+ };
10910
+ this.deps.store.write(updated);
10911
+ this.deps.store.persist(updated);
10912
+ if (provider?.refresh) this.scheduleDeveloperRefresh(provider, expiryAt);
10913
+ }
10914
+ /**
10915
+ * Invoke `provider.refresh()` deduped against any concurrent developer
10916
+ * refresh. Concurrent callers — the scheduled tick, a resume-forced refresh,
10917
+ * and the orchestrator's -32003 recovery / reconnect re-mint — share one
10918
+ * in-flight promise, so a provider backed by one-time-use rotating refresh
10919
+ * tokens is never invoked twice in parallel.
10920
+ *
10921
+ * The caller owns applying the returned credential (store write, session
10922
+ * reauth, rescheduling); this method only serializes the network call.
10923
+ */
10924
+ async refreshCredential(provider) {
10925
+ if (this._refreshInFlight) return this._refreshInFlight;
10926
+ if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
10927
+ const run = provider.refresh();
10928
+ this._refreshInFlight = run;
10929
+ const clear = () => {
10930
+ if (this._refreshInFlight === run) this._refreshInFlight = void 0;
10931
+ };
10932
+ run.then(clear, clear);
10933
+ return run;
10065
10934
  }
10066
10935
  /**
10067
10936
  * Cancels any scheduled developer-provided refresh. Idempotent.
@@ -10841,6 +11710,13 @@ var TransportManager = class extends Destroyable {
10841
11710
  //#endregion
10842
11711
  //#region src/clients/SignalWire.ts
10843
11712
  const logger$1 = getLogger();
11713
+ /**
11714
+ * Storage key for the client-bound marker. The SAT and authorization_state are
11715
+ * both opaque to the SDK, so on a page reload the preflight recovery — which
11716
+ * runs before any session exists — has no other way to know the session was
11717
+ * client-bound. See {@link SignalWire.persistClientBoundMarker}.
11718
+ */
11719
+ const CLIENT_BOUND_STORAGE_KEY = "sw:client_bound";
10844
11720
  const buildOptionsFromDestination = (destination) => {
10845
11721
  if (typeof destination === "string") {
10846
11722
  const queryStartIndex = destination.indexOf("?");
@@ -10884,6 +11760,7 @@ var SignalWire = class extends Destroyable {
10884
11760
  this.preferences = new ClientPreferences();
10885
11761
  this._user$ = this.createBehaviorSubject(void 0);
10886
11762
  this._directory$ = this.createBehaviorSubject(void 0);
11763
+ this._credentialRecovered = false;
10887
11764
  this._isConnected$ = this.createBehaviorSubject(false);
10888
11765
  this._isRegistered$ = this.createBehaviorSubject(false);
10889
11766
  this._errors$ = this.createReplaySubject(1);
@@ -10922,6 +11799,16 @@ var SignalWire = class extends Destroyable {
10922
11799
  });
10923
11800
  }
10924
11801
  /**
11802
+ * Build the refresh path's own HTTP controller, against whatever host is current.
11803
+ *
11804
+ * Called on first use rather than up front, so `apiHost` already reflects the
11805
+ * token's `ch` claim. Same credential source as the container's controller — only
11806
+ * the instance, and therefore its observable streams, is separate.
11807
+ */
11808
+ createRefreshHttpController() {
11809
+ return new HTTPRequestController(this._deps.apiHost, () => this._deps.credential);
11810
+ }
11811
+ /**
10925
11812
  * Initializes DPoP if not already set up. Returns the fingerprint on success.
10926
11813
  */
10927
11814
  async initDPoP() {
@@ -10948,11 +11835,19 @@ var SignalWire = class extends Destroyable {
10948
11835
  async resolveCredentials() {
10949
11836
  const fingerprint = await this.initDPoP();
10950
11837
  this._refreshCoordinator = new CredentialRefreshCoordinator(this._dpopManager, {
10951
- http: this._deps.http,
11838
+ http: () => {
11839
+ if (!this._refreshHttp || this._refreshHttpHost !== this._deps.apiHost) {
11840
+ this._refreshHttp?.destroy();
11841
+ this._refreshHttp = this.createRefreshHttpController();
11842
+ this._refreshHttpHost = this._deps.apiHost;
11843
+ }
11844
+ return this._refreshHttp;
11845
+ },
10952
11846
  notifier: {
10953
11847
  onError: (error) => this._errors$.next(error),
10954
11848
  onWarning: (warning) => this._warnings$.next(warning),
10955
- onRefreshExhausted: () => void this.disconnect()
11849
+ onRefreshExhausted: () => void this.disconnect(),
11850
+ onCredentialRefreshed: async (credential) => this.reauthenticateLiveSession(credential)
10956
11851
  },
10957
11852
  store: {
10958
11853
  read: () => this._deps.credential,
@@ -10964,6 +11859,7 @@ var SignalWire = class extends Destroyable {
10964
11859
  ...this._deps.credential,
10965
11860
  ...partial
10966
11861
  };
11862
+ this.persistCredential(this._deps.credential);
10967
11863
  },
10968
11864
  persist: (credential) => this.persistCredential(credential)
10969
11865
  }
@@ -11009,20 +11905,181 @@ var SignalWire = class extends Destroyable {
11009
11905
  }
11010
11906
  this._deps.credential = _credentials;
11011
11907
  this.persistCredential(_credentials);
11012
- if (this.isConnected && this._clientSession.authenticated && _credentials.token) try {
11013
- await this._clientSession.reauthenticate(_credentials.token);
11908
+ await this.reauthenticateLiveSession(_credentials);
11909
+ }
11910
+ /**
11911
+ * Reauthenticate the currently-open session with a freshly obtained
11912
+ * credential so the new token takes effect on the live socket immediately —
11913
+ * not just on the next reconnect. No-op when the session is not
11914
+ * connected/authenticated or the credential carries no token (e.g. an
11915
+ * authorization-state-only refresh). Non-fatal: reauth failures surface on
11916
+ * `errors$` without aborting the refresh that triggered this.
11917
+ */
11918
+ async reauthenticateLiveSession(credential) {
11919
+ if (!this.isConnected || !this._clientSession.authenticated || !credential.token) return;
11920
+ try {
11921
+ await this._clientSession.reauthenticate(credential.token);
11014
11922
  logger$1.info("[SignalWire] Session refreshed with new credentials.");
11015
11923
  } catch (error) {
11016
11924
  logger$1.error("[SignalWire] Failed to refresh session with new credentials:", error);
11017
11925
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
11018
11926
  }
11019
11927
  }
11928
+ /**
11929
+ * Recover a session the server is refusing: mint a fresh credential,
11930
+ * reauthenticate the live session with it, and retry the operation.
11931
+ *
11932
+ * The connection is deliberately kept. A reload authenticates the new socket
11933
+ * against the persisted `authorization_state`, and that handshake is what
11934
+ * associates the socket with the previous session — the association reattach
11935
+ * depends on. `signalwire.reauthenticate` swaps the credential *on that same
11936
+ * session*, so recovery never touches the resume state. Discarding it would
11937
+ * heal the credential by destroying the very thing the caller is trying to
11938
+ * get back to.
11939
+ *
11940
+ * The operation is still the verdict, never the RPC. Reauthenticating with
11941
+ * the in-memory token is accepted by a resume even while requests stay
11942
+ * refused, because the persisted `authorization_state` short-circuits token
11943
+ * validation — and `signalwire.reauthenticate` with a *freshly minted* token
11944
+ * has also been observed accepted while `subscriber.online` keeps being
11945
+ * refused (staging run 33826974634). Both look like success and are not.
11946
+ *
11947
+ * @returns the operation's value, or the reason recovery could not deliver
11948
+ * one. `error` is undefined when there was no way to mint at all.
11949
+ */
11950
+ async recoverAndRetry(operation) {
11951
+ if (!await this.remintAndReauthenticate()) return { ok: false };
11952
+ try {
11953
+ const value = await operation();
11954
+ this._credentialRecovered = true;
11955
+ return {
11956
+ ok: true,
11957
+ value
11958
+ };
11959
+ } catch (error) {
11960
+ logger$1.warn("[SignalWire] Reauthentication was accepted but the operation is still refused:", error);
11961
+ return {
11962
+ ok: false,
11963
+ error
11964
+ };
11965
+ }
11966
+ }
11967
+ /**
11968
+ * Re-mint a credential and adopt it only if the live session accepts it.
11969
+ *
11970
+ * The mechanism follows the binding: a client-bound session re-mints a bound
11971
+ * base SAT through `authenticate()` with the DPoP fingerprint, because the
11972
+ * developer refresh handler would hand back an unbound token and silently
11973
+ * degrade the session. An unbound session uses the refresh handler. Rotation
11974
+ * cost is not a reason to skip this — the only reason is having no mechanism.
11975
+ *
11976
+ * @returns whether the session is now running on a freshly accepted credential.
11977
+ */
11978
+ async remintAndReauthenticate() {
11979
+ const provider = this._credentialProvider;
11980
+ if (!provider) return false;
11981
+ const { clientBound } = this._clientSession;
11982
+ if (!clientBound && !provider.refresh) {
11983
+ logger$1.debug("[SignalWire] [SW-NO-REFRESH-HANDLER] Unbound session with no refresh handler; cannot re-mint.");
11984
+ return false;
11985
+ }
11986
+ try {
11987
+ const newCredentials = clientBound ? await provider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0) : await this.remintCredential(provider);
11988
+ if (!newCredentials.token) {
11989
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the current one.");
11990
+ return false;
11991
+ }
11992
+ await this._clientSession.reauthenticate(newCredentials.token);
11993
+ this._deps.credential = newCredentials;
11994
+ this.persistCredential(newCredentials);
11995
+ if (newCredentials.expiry_at && provider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(provider, newCredentials.expiry_at);
11996
+ return true;
11997
+ } catch (error) {
11998
+ logger$1.warn("[SignalWire] Re-mint recovery failed:", error);
11999
+ return false;
12000
+ }
12001
+ }
12002
+ /**
12003
+ * Re-mint a credential via `provider.refresh()`, routed through the
12004
+ * coordinator's shared in-flight guard so concurrent re-mint paths (a
12005
+ * scheduled/resume refresh, -32003 recovery, and reconnect) never fire a
12006
+ * second `provider.refresh()` in parallel — which rotating one-time-use
12007
+ * refresh tokens reject. Falls back to a direct call only if the coordinator
12008
+ * has not been constructed yet.
12009
+ */
12010
+ async remintCredential(provider) {
12011
+ if (this._refreshCoordinator) return this._refreshCoordinator.refreshCredential(provider);
12012
+ if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
12013
+ return provider.refresh();
12014
+ }
12015
+ /**
12016
+ * Re-mint credentials before a fresh (re)connect (`onBeforeReconnect` hook).
12017
+ * The session invokes this only when it is client-bound OR the in-memory
12018
+ * token is expired. The re-mint mechanism depends on the binding:
12019
+ * - Client-bound: `authenticate()` with the DPoP fingerprint to obtain a
12020
+ * fresh base SAT the upcoming reconnect can re-bind (the
12021
+ * DeviceTokenManager re-activates afterwards).
12022
+ * - Unbound: the developer's non-interactive `refresh()` handler.
12023
+ * `authenticate()` is deliberately NOT used here — it may be interactive
12024
+ * (a login prompt) and must not fire on a background reconnect.
12025
+ *
12026
+ * Rejects on failure so the session aborts the reconnect rather than
12027
+ * replaying a stale token.
12028
+ */
12029
+ async refreshCredentialForReconnect() {
12030
+ if (!this._credentialProvider) return;
12031
+ try {
12032
+ let newCredentials;
12033
+ if (this._clientSession?.clientBound ?? await this.wasClientBound()) {
12034
+ logger$1.debug("[SignalWire] Re-minting client-bound base SAT before reconnect");
12035
+ newCredentials = await this._credentialProvider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0);
12036
+ } else if (this._credentialProvider.refresh) {
12037
+ logger$1.debug("[SignalWire] Refreshing unbound credential before reconnect");
12038
+ newCredentials = await this.remintCredential(this._credentialProvider);
12039
+ } else {
12040
+ logger$1.warn("[SignalWire] [SW-NO-REFRESH-HANDLER] Token expired on reconnect but no refresh handler; reconnecting with the existing token.");
12041
+ return;
12042
+ }
12043
+ if (!newCredentials.token) {
12044
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the existing credential for reconnect.");
12045
+ return;
12046
+ }
12047
+ this._deps.credential = newCredentials;
12048
+ this.persistCredential(newCredentials);
12049
+ if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
12050
+ logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
12051
+ } catch (error) {
12052
+ logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
12053
+ this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
12054
+ throw error;
12055
+ }
12056
+ }
11020
12057
  /** Persist credential to localStorage when persistSession is enabled. */
11021
12058
  persistCredential(credential) {
11022
12059
  if (!credential.token) return;
11023
12060
  this._deps.storage.setItem("sw:cached_credential", credential);
11024
12061
  if (this._deps.persistSession) this._deps.storage.setItem("sw:cached_credential", credential, "local");
11025
12062
  }
12063
+ /**
12064
+ * Persist whether the session is client-bound, mirroring the credential's
12065
+ * storage scopes so it survives a reload. The preflight recovery reads it
12066
+ * before any session exists to decide whether to re-bind via `authenticate()`
12067
+ * or refresh an unbound token; the marker tracks the latest binding, so an
12068
+ * unbound reconnect clears a stale marker from an earlier client-bound login.
12069
+ */
12070
+ persistClientBoundMarker(bound) {
12071
+ const scopes = this._deps.persistSession ? ["session", "local"] : ["session"];
12072
+ for (const scope of scopes) if (bound) this._deps.storage.setItem(CLIENT_BOUND_STORAGE_KEY, true, scope);
12073
+ else this._deps.storage.removeItem(CLIENT_BOUND_STORAGE_KEY, scope);
12074
+ }
12075
+ /** Read the persisted client-bound marker (see {@link persistClientBoundMarker}). */
12076
+ async wasClientBound() {
12077
+ const scopes = this._deps.persistSession ? ["local", "session"] : ["session"];
12078
+ for (const scope of scopes) try {
12079
+ if (await this._deps.storage.getItem(CLIENT_BOUND_STORAGE_KEY, scope)) return true;
12080
+ } catch {}
12081
+ return false;
12082
+ }
11026
12083
  async init() {
11027
12084
  this._user$.next(new User(this._deps.http));
11028
12085
  if (!this._options.skipConnection) await this.connect();
@@ -11046,6 +12103,42 @@ var SignalWire = class extends Destroyable {
11046
12103
  } catch (error) {
11047
12104
  logger$1.error("[SignalWire] Failed to reattach calls:", error);
11048
12105
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
12106
+ } finally {
12107
+ this._credentialRecovered = false;
12108
+ }
12109
+ }
12110
+ /**
12111
+ * Fetch the authenticated user profile, recovering a stale credential.
12112
+ *
12113
+ * On a reload the persisted credential can be expired. Unlike the WS resume —
12114
+ * which the server accepts against the persisted `authorization_state` even
12115
+ * with an expired token — this REST preflight has no such short-circuit and is
12116
+ * refused (401). There is no session yet to reauthenticate, so recovery
12117
+ * re-mints the credential through the provider ({@link refreshCredentialForReconnect})
12118
+ * and retries with a FRESH {@link User}: Fetchable memoizes its result
12119
+ * (shareReplay), so reusing the instance would replay the 401 instead of
12120
+ * re-fetching with the new token. Without the user id the transport/session —
12121
+ * and the reattach a reload is trying to preserve — cannot even be addressed.
12122
+ */
12123
+ async fetchUserOrRecover() {
12124
+ const fetchUser = async (user$1) => {
12125
+ if (!await firstValueFrom(user$1.fetched$)) throw new UnexpectedError("Failed to fetch user information - fetched$ emitted false");
12126
+ this._deps.user = user$1;
12127
+ };
12128
+ const user = this._user$.value;
12129
+ if (!user) throw new UnexpectedError("User not initialized before connect");
12130
+ try {
12131
+ await fetchUser(user);
12132
+ } catch (firstError) {
12133
+ logger$1.error(`[SignalWire] Failed to fetch user information: ${firstError instanceof Error ? firstError.message : "Unknown error"}. This usually means the user token is invalid or expired. Re-minting the credential and retrying.`);
12134
+ try {
12135
+ await this.refreshCredentialForReconnect();
12136
+ const refetched = new User(this._deps.http);
12137
+ await fetchUser(refetched);
12138
+ this._user$.next(refetched);
12139
+ } catch (retryError) {
12140
+ throw new UnexpectedError("Error fetching user information", { cause: retryError });
12141
+ }
11049
12142
  }
11050
12143
  }
11051
12144
  /**
@@ -11087,40 +12180,23 @@ var SignalWire = class extends Destroyable {
11087
12180
  */
11088
12181
  async connect() {
11089
12182
  await this.teardownTransportAndSession();
11090
- try {
11091
- const user = this._user$.value;
11092
- if (!user) throw new UnexpectedError("User not initialized before connect");
11093
- if (!await firstValueFrom(user.fetched$)) throw new UnexpectedError("Failed to fetch user information - fetched$ emitted false");
11094
- this._deps.user = user;
11095
- } catch (error) {
11096
- logger$1.error(`[SignalWire] Failed to fetch user information: ${error instanceof Error ? error.message : "Unknown error"}. This usually means the user token is invalid or expired.`);
11097
- throw new UnexpectedError("Error fetching user information", { cause: error });
11098
- }
12183
+ await this.fetchUserOrRecover();
11099
12184
  const errorHandler = (error) => {
11100
12185
  this._errors$.next(error);
11101
12186
  };
11102
12187
  this._transport = new TransportManager(this._deps.storage, this._deps.protocolKey, this._deps.WebSocket, PreferencesContainer.instance.relayHost ?? this._deps.relayHost, errorHandler);
11103
- this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey);
12188
+ this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey, () => this._credentialRecovered);
11104
12189
  this._clientSession = new ClientSessionManager(() => this._deps.credential, this._transport, this._deps.storage, this._deps.authorizationStateKey, this._deps.deviceController, this._attachManager, this._deps.webRTCApiProvider, this._dpopManager, this._networkMonitor?.networkChange$);
11105
12190
  this._publicSession = new ClientSessionWrapper(this._clientSession);
11106
- this._clientSession.onBeforeReconnect = async () => {
11107
- if (!this._credentialProvider) return;
11108
- try {
11109
- const fingerprint = this._dpopManager?.initialized ? this._dpopManager.fingerprint : void 0;
11110
- logger$1.debug("[SignalWire] Credential expired, refreshing before reconnect");
11111
- const newCredentials = await this._credentialProvider.authenticate(fingerprint ? { fingerprint } : void 0);
11112
- this._deps.credential = newCredentials;
11113
- if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
11114
- logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
11115
- } catch (error) {
11116
- logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
11117
- this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
11118
- throw error;
11119
- }
11120
- };
12191
+ this._clientSession.callControl = this._options.callControl ?? "routed";
12192
+ this._clientSession.onBeforeReconnect = async () => this.refreshCredentialForReconnect();
11121
12193
  this.subscribeTo(this._clientSession.errors$, (error) => {
11122
12194
  this._errors$.next(error);
11123
12195
  });
12196
+ this.subscribeTo(this._clientSession.authorization$, (authorization) => {
12197
+ this._refreshCoordinator?.syncExpiryFromAuthorization(authorization, this._credentialProvider);
12198
+ this.persistClientBoundMarker(Boolean(authorization?.cnf?.jkt));
12199
+ });
11124
12200
  await this._clientSession.connect();
11125
12201
  await this._refreshCoordinator?.activate(this._deps.user, this._clientSession);
11126
12202
  this.subscribeTo(this._clientSession.authenticated$.pipe(skip(1), filter(Boolean)), async () => {
@@ -11283,6 +12359,13 @@ var SignalWire = class extends Destroyable {
11283
12359
  }
11284
12360
  try {
11285
12361
  this._visibilityController = new VisibilityController();
12362
+ this.subscribeTo(this._visibilityController.visibilityChange$.pipe(filter((event) => event.to === "visible")), () => {
12363
+ try {
12364
+ this._refreshCoordinator?.forceRefreshIfDue();
12365
+ } catch (error) {
12366
+ logger$1.warn("[SignalWire] Resume credential revalidation failed (non-fatal):", error);
12367
+ }
12368
+ });
11286
12369
  this.subscribeTo(this._visibilityController.visibilityChange$.pipe(filter((event) => event.to === "visible" && PreferencesContainer.instance.refreshDevicesOnVisible)), () => {
11287
12370
  logger$1.debug("[SignalWire] Page visible, re-enumerating devices");
11288
12371
  try {
@@ -11294,7 +12377,7 @@ var SignalWire = class extends Destroyable {
11294
12377
  logger$1.warn("[SignalWire] Failed to initialize VisibilityController:", error);
11295
12378
  }
11296
12379
  try {
11297
- this._diagnosticsCollector = new DiagnosticsCollector({ sdkVersion: "4.0.0-rc.2" });
12380
+ this._diagnosticsCollector = new DiagnosticsCollector({ sdkVersion: "4.0.0-rc.3" });
11298
12381
  } catch (error) {
11299
12382
  logger$1.warn("[SignalWire] Failed to initialize DiagnosticsCollector:", error);
11300
12383
  }
@@ -11365,21 +12448,22 @@ var SignalWire = class extends Destroyable {
11365
12448
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
11366
12449
  throw error;
11367
12450
  }
11368
- logger$1.debug("[SignalWire] Failed to register user, trying reauthentication...");
11369
- try {
11370
- await this._clientSession.reauthenticate(this._deps.credential.token);
11371
- logger$1.debug("[SignalWire] Reauthentication successful, retrying register()");
12451
+ logger$1.debug("[SignalWire] Failed to register user, attempting credential recovery...");
12452
+ const outcome = await this.recoverAndRetry(async () => {
11372
12453
  await this._transport.execute(RPCExecute({
11373
12454
  method: "subscriber.online",
11374
12455
  params: {}
11375
12456
  }));
12457
+ });
12458
+ if (outcome.ok) {
12459
+ logger$1.debug("[SignalWire] Recovery restored registration");
11376
12460
  this._isRegistered$.next(true);
11377
- } catch (reauthError) {
11378
- logger$1.error("[SignalWire] Reauthentication failed during register():", reauthError);
11379
- const registerError = new InvalidCredentialsError("Failed to register user, and reauthentication attempt also failed. Please check your credentials.", { cause: reauthError instanceof Error ? reauthError : new Error(String(reauthError), { cause: reauthError }) });
11380
- this._errors$.next(registerError);
11381
- throw registerError;
12461
+ return;
11382
12462
  }
12463
+ const failureCause = outcome.error ?? error;
12464
+ const registerError = new InvalidCredentialsError("Failed to register user, and credential recovery also failed. Please check your credentials.", { cause: failureCause instanceof Error ? failureCause : new Error(String(failureCause), { cause: failureCause }) });
12465
+ this._errors$.next(registerError);
12466
+ throw registerError;
11383
12467
  }
11384
12468
  }
11385
12469
  /**
@@ -11410,6 +12494,11 @@ var SignalWire = class extends Destroyable {
11410
12494
  * Returns a {@link Call} in `'ringing'` state. Subscribe to {@link Call.status$}
11411
12495
  * to track progression through `'connected'` → `'disconnected'`.
11412
12496
  *
12497
+ * Local media acquisition is deliberately unbounded: an unanswered permission
12498
+ * prompt leaves this promise pending indefinitely, so apply your own bound if
12499
+ * your UI needs one. The 12 s signaling budget starts only once acquisition
12500
+ * settles.
12501
+ *
11413
12502
  * @param destination - Address URI string (e.g. `'/public/my-room'`) or {@link Address} instance.
11414
12503
  * @param options - Media and dial options (audio/video, device constraints). Overrides defaults.
11415
12504
  * @returns The created {@link Call} instance.
@@ -11432,7 +12521,18 @@ var SignalWire = class extends Destroyable {
11432
12521
  };
11433
12522
  await this.waitAuthentication();
11434
12523
  logger$1.debug("[SignalWire] Dialing with options:", computed_options);
11435
- return this._clientSession.createOutboundCall(destination, computed_options);
12524
+ try {
12525
+ return await this._clientSession.createOutboundCall(destination, computed_options);
12526
+ } catch (error) {
12527
+ if (!isRecoverableAuthError(error)) throw error;
12528
+ logger$1.debug("[SignalWire] Dial hit a recoverable auth error; recovering the session and retrying");
12529
+ const outcome = await this.recoverAndRetry(async () => {
12530
+ await this.waitAuthentication();
12531
+ return this._clientSession.createOutboundCall(destination, computed_options);
12532
+ });
12533
+ if (outcome.ok) return outcome.value;
12534
+ throw outcome.error ?? error;
12535
+ }
11436
12536
  }
11437
12537
  /**
11438
12538
  * Runs a multi-phase connectivity test against the given destination.
@@ -11713,10 +12813,13 @@ var SignalWire = class extends Destroyable {
11713
12813
  destroy() {
11714
12814
  this._refreshCoordinator?.destroy();
11715
12815
  this._refreshCoordinator = void 0;
12816
+ this._refreshHttp?.destroy();
12817
+ this._refreshHttp = void 0;
11716
12818
  this._dpopManager?.destroy();
11717
- this._clientSession.teardownSessionState();
11718
- this._transport.destroy();
11719
- this._clientSession.destroy();
12819
+ const session = this._clientSession;
12820
+ session?.teardownSessionState();
12821
+ this._transport?.destroy();
12822
+ session?.destroy();
11720
12823
  try {
11721
12824
  this._networkMonitor?.destroy();
11722
12825
  } catch {}
@@ -11828,7 +12931,7 @@ var StaticCredentialProvider = class {
11828
12931
  /**
11829
12932
  * Library version from package.json, injected at build time.
11830
12933
  */
11831
- const version = "4.0.0-rc.2";
12934
+ const version = "4.0.0-rc.3";
11832
12935
  /**
11833
12936
  * Flag indicating the library has been loaded and is ready to use.
11834
12937
  * For UMD builds: `window.SignalWire.ready`
@@ -11850,12 +12953,12 @@ const ready = true;
11850
12953
  */
11851
12954
  const emitReadyEvent = () => {
11852
12955
  if (typeof window !== "undefined") {
11853
- const event = new CustomEvent("signalwire:js:ready", { detail: { version: "4.0.0-rc.2" } });
12956
+ const event = new CustomEvent("signalwire:js:ready", { detail: { version: "4.0.0-rc.3" } });
11854
12957
  window.dispatchEvent(event);
11855
12958
  }
11856
12959
  };
11857
12960
  emitReadyEvent();
11858
12961
 
11859
12962
  //#endregion
11860
- export { Address, CallCreateError, ClientPreferences, CollectionFetchError, DPoPInitError, DeviceTokenError, EmbedTokenCredentialProvider, InvalidCredentialsError, MediaAccessError, MediaTrackError, MessageParseError, OverconstrainedFallbackError, Participant, PreflightError, RecoveryError, SelfCapabilities, SelfParticipant, SignalWire, StaticCredentialProvider, TokenRefreshError, UnexpectedError, User, VertoPongError, WebRTCCall, embeddableCall, getLogger, isSelfParticipant, ready, setDebugOptions, setLogLevel, setLogger, version };
12963
+ export { Address, AuxiliaryLegCancelledError, AuxiliaryLegTimeoutError, CallCreateError, CallNotReadyError, ClientPreferences, CollectionFetchError, DPoPInitError, DeviceTokenError, EmbedTokenCredentialProvider, InvalidCredentialsError, MediaAccessError, MediaTrackError, MessageParseError, OverconstrainedFallbackError, Participant, ParticipantNotReadyError, PreflightError, RecoveryError, ScreenShareAlreadyActiveError, SelfCapabilities, SelfParticipant, SignalWire, StaticCredentialProvider, TokenRefreshError, UnexpectedError, User, VertoPongError, WebRTCCall, embeddableCall, getLogger, isSelfParticipant, ready, setDebugOptions, setLogLevel, setLogger, version };
11861
12964
  //# sourceMappingURL=index.mjs.map