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

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.cjs CHANGED
@@ -1,4 +1,4 @@
1
- const require_operators = require('./operators-B8ipd4Xl.cjs');
1
+ const require_operators = require('./operators-IoitK-As.cjs');
2
2
  let jwt_decode = require("jwt-decode");
3
3
  let rxjs = require("rxjs");
4
4
  let uuid = require("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 = require_operators.getLogger();
134
+ const logger$32 = require_operators.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$4 = 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 require_operators.RequestTimeoutError(`Request timeout after ${timeout$4}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 = require_operators.getLogger();
418
+ const logger$31 = require_operators.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(require_operators.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(require_operators.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 = require_operators.getLogger();
1110
+ const logger$30 = require_operators.getLogger();
1111
1111
  /** Maps a device kind to its storage key. */
1112
1112
  const DEVICE_STORAGE_KEYS = {
1113
1113
  audioinput: require_operators.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((0, rxjs.map)((state) => state.videoinput), (0, rxjs.distinctUntilChanged)(), (0, rxjs.takeUntil)(this.destroyed$)));
1195
1195
  }
1196
1196
  get selectedAudioInputDevice$() {
1197
- return this.cachedObservable("selectedAudioInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, rxjs.map)((state) => state.audioinput), (0, rxjs.distinctUntilChanged)(), (0, rxjs.takeUntil)(this.destroyed$), (0, rxjs.tap)((info) => logger$29.debug("[DeviceController] Selected audio input device changed:", info))));
1197
+ return this.cachedObservable("selectedAudioInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, rxjs.map)((state) => state.audioinput), (0, rxjs.distinctUntilChanged)(), (0, rxjs.takeUntil)(this.destroyed$), (0, rxjs.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((0, rxjs.map)((state) => state.audiooutput), (0, rxjs.distinctUntilChanged)(), (0, rxjs.takeUntil)(this.destroyed$), (0, rxjs.tap)((info) => logger$29.debug("[DeviceController] Selected audio output device changed:", info))));
1200
+ return this.cachedObservable("selectedAudioOutputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, rxjs.map)((state) => state.audiooutput), (0, rxjs.distinctUntilChanged)(), (0, rxjs.takeUntil)(this.destroyed$), (0, rxjs.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((0, rxjs.map)((state) => state.videoinput), (0, rxjs.distinctUntilChanged)(), (0, rxjs.takeUntil)(this.destroyed$), (0, rxjs.tap)((info) => logger$29.debug("[DeviceController] Selected video input device changed:", info))));
1203
+ return this.cachedObservable("selectedVideoInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, rxjs.map)((state) => state.videoinput), (0, rxjs.distinctUntilChanged)(), (0, rxjs.takeUntil)(this.destroyed$), (0, rxjs.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 = (0, rxjs.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 = require_operators.getLogger();
1721
+ const logger$29 = require_operators.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 require_operators.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 = require_operators.getLogger();
1989
+ const logger$28 = require_operators.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 = require_operators.getLogger();
2201
+ const logger$27 = require_operators.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 require_operators.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 = require_operators.getLogger();
2398
+ const logger$26 = require_operators.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
  });
@@ -2589,6 +2589,12 @@ const RPCReauthenticate = (params) => {
2589
2589
 
2590
2590
  //#endregion
2591
2591
  //#region src/core/RPCMessages/RPCPing.ts
2592
+ const RPCPing = () => {
2593
+ return buildRPCRequest({
2594
+ method: "signalwire.ping",
2595
+ params: { timestamp: Date.now() / 1e3 }
2596
+ });
2597
+ };
2592
2598
  const RPCPingResponse = (id, timestamp) => {
2593
2599
  return makeRPCResponse({
2594
2600
  id,
@@ -2677,15 +2683,57 @@ const RPCEventAckResponse = (id) => makeRPCResponse({
2677
2683
  result: {}
2678
2684
  });
2679
2685
 
2686
+ //#endregion
2687
+ //#region src/utils/authRecovery.ts
2688
+ /**
2689
+ * Walk an error's `error`/`cause` chain looking for a {@link JSONRPCError}.
2690
+ * Errors thrown by call creation are wrapped (e.g. `CallCreateError`), so the
2691
+ * underlying signaling error is nested. Bounded by a visited set to guard
2692
+ * against cyclic causes.
2693
+ */
2694
+ function findJSONRPCError(error) {
2695
+ const seen = /* @__PURE__ */ new Set();
2696
+ let current = error;
2697
+ while (current instanceof Error && !seen.has(current)) {
2698
+ seen.add(current);
2699
+ if (current instanceof require_operators.JSONRPCError) return current;
2700
+ current = current.error ?? current.cause;
2701
+ }
2702
+ }
2703
+ /**
2704
+ * Whether an error is a session-recoverable authentication failure
2705
+ * (`-32002` authentication failed or `-32003` requester validation failed)
2706
+ * that a credential re-mint + retry can heal.
2707
+ */
2708
+ function isRecoverableAuthError(error) {
2709
+ const rpcError = findJSONRPCError(error);
2710
+ return rpcError !== void 0 && (rpcError.code === require_operators.RPC_ERROR_REQUESTER_VALIDATION_FAILED || rpcError.code === require_operators.RPC_ERROR_AUTHENTICATION_FAILED);
2711
+ }
2712
+ /**
2713
+ * Whether an error is specifically a requester-validation rejection
2714
+ * (`-32003`) — the server refusing the session's credential.
2715
+ *
2716
+ * Narrower than {@link isRecoverableAuthError} on purpose. `-32002` is
2717
+ * overloaded server-side: a rejected reattach arrives as `-32002` with
2718
+ * `cause: INVALID_MSG_UNSPECIFIED` and message `CALL ERROR`, which is a
2719
+ * call-level rejection and says nothing about the credential. Use this where
2720
+ * the decision must not be fooled by that, such as deciding whether retrying
2721
+ * an operation could possibly succeed.
2722
+ */
2723
+ function isRequesterValidationError(error) {
2724
+ return findJSONRPCError(error)?.code === require_operators.RPC_ERROR_REQUESTER_VALIDATION_FAILED;
2725
+ }
2726
+
2680
2727
  //#endregion
2681
2728
  //#region src/managers/AttachManager.ts
2682
- const logger$24 = require_operators.getLogger();
2729
+ const logger$25 = require_operators.getLogger();
2683
2730
  var AttachManager = class {
2684
- constructor(storage, deviceController, reconnectCallsTimeout, attachKey) {
2731
+ constructor(storage, deviceController, reconnectCallsTimeout, attachKey, credentialRecovered) {
2685
2732
  this.storage = storage;
2686
2733
  this.deviceController = deviceController;
2687
2734
  this.reconnectCallsTimeout = reconnectCallsTimeout;
2688
2735
  this.attachKey = attachKey;
2736
+ this.credentialRecovered = credentialRecovered;
2689
2737
  this.writeQueue = Promise.resolve();
2690
2738
  }
2691
2739
  async detachAll() {
@@ -2700,7 +2748,7 @@ var AttachManager = class {
2700
2748
  try {
2701
2749
  return await this.storage.getItem(this.attachKey) ?? {};
2702
2750
  } catch (error) {
2703
- logger$24.warn("[AttachManager] Failed to retrieve attached calls from storage", error);
2751
+ logger$25.warn("[AttachManager] Failed to retrieve attached calls from storage", error);
2704
2752
  return {};
2705
2753
  }
2706
2754
  }
@@ -2708,7 +2756,7 @@ var AttachManager = class {
2708
2756
  try {
2709
2757
  await this.storage.setItem(this.attachKey, attached);
2710
2758
  } catch (error) {
2711
- logger$24.warn("[AttachManager] Failed to write attached calls to storage", error);
2759
+ logger$25.warn("[AttachManager] Failed to write attached calls to storage", error);
2712
2760
  }
2713
2761
  }
2714
2762
  /**
@@ -2727,11 +2775,39 @@ var AttachManager = class {
2727
2775
  }
2728
2776
  async attach(call) {
2729
2777
  if (!call.to) {
2730
- logger$24.warn("[AttachManager] Skip attach for calls with no destination");
2778
+ logger$25.warn("[AttachManager] Skip attach for calls with no destination");
2731
2779
  return;
2732
2780
  }
2781
+ const attachment = this.buildAttachment(call, call.to);
2782
+ await this.mutate((attached) => ({
2783
+ ...attached,
2784
+ [call.id]: attachment
2785
+ }));
2786
+ }
2787
+ /**
2788
+ * Keep an already-stored call's reference alive and current — the periodic
2789
+ * refresh the `verto.ping` keepalive drives.
2790
+ *
2791
+ * Only ever updates: a call with no record is one nothing wants reattached,
2792
+ * and re-creating it here would undo a `detach`. That matters because a ping
2793
+ * can land in the window between `bye()` detaching and the call being torn
2794
+ * down, and a record revived there survives the hangup — so the next page
2795
+ * load dials a call nobody is on. The existence check and the write share
2796
+ * one {@link mutate} turn, so a concurrent detach cannot slip between them.
2797
+ */
2798
+ async refresh(call) {
2799
+ if (!call.to) return;
2733
2800
  const destination = call.to;
2734
- const attachment = {
2801
+ await this.mutate((attached) => {
2802
+ if (!Object.hasOwn(attached, call.id)) return attached;
2803
+ return {
2804
+ ...attached,
2805
+ [call.id]: this.buildAttachment(call, destination)
2806
+ };
2807
+ });
2808
+ }
2809
+ buildAttachment(call, destination) {
2810
+ return {
2735
2811
  nodeId: call.nodeId,
2736
2812
  destination,
2737
2813
  mediaDirections: call.mediaDirections,
@@ -2739,10 +2815,6 @@ var AttachManager = class {
2739
2815
  videoInputDevice: call.mediaDirections.video !== "inactive" ? this.deviceController.selectedVideoInputDevice : null,
2740
2816
  attachedAt: Date.now()
2741
2817
  };
2742
- await this.mutate((attached) => ({
2743
- ...attached,
2744
- [call.id]: attachment
2745
- }));
2746
2818
  }
2747
2819
  async detach(call) {
2748
2820
  await this.mutate((attached) => {
@@ -2765,8 +2837,14 @@ var AttachManager = class {
2765
2837
  * rejecting. Once that fix is deployed, this will work for both
2766
2838
  * page reloads and WebSocket reconnects.
2767
2839
  *
2768
- * Failed reattach attempts are handled gracefully — the stale call
2769
- * reference is cleaned up from storage.
2840
+ * A failed reattach does NOT generally cost the stored reference. It is
2841
+ * discarded only when the server denied the reattach on a session whose
2842
+ * credential it had already accepted — a verified reauthentication followed
2843
+ * by a refusal is the server saying the call is gone, and that is the one
2844
+ * refusal worth acting on. Until then the credential may be what is being
2845
+ * refused, and the record is the only way a later reload can try again;
2846
+ * keeping it costs nothing, since `detachExpired` reaps it once it is older
2847
+ * than `reconnectCallsTimeout`.
2770
2848
  */
2771
2849
  async reattachCalls() {
2772
2850
  const attached = await this.readAttached();
@@ -2775,25 +2853,31 @@ var AttachManager = class {
2775
2853
  const { destination } = attachment;
2776
2854
  const options = this.buildCallOptions(attachment);
2777
2855
  let succeeded = false;
2856
+ let refusedOnCredentials = false;
2778
2857
  for (let attempt = 1; attempt <= 3; attempt++) try {
2779
2858
  await this.session.createOutboundCall(destination, {
2780
2859
  callId,
2781
2860
  ...options
2782
2861
  });
2783
- logger$24.info(`[AttachManager] Reattached call ${callId} (attempt ${attempt})`);
2862
+ logger$25.info(`[AttachManager] Reattached call ${callId} (attempt ${attempt})`);
2784
2863
  succeeded = true;
2785
2864
  break;
2786
2865
  } catch (error) {
2787
- logger$24.warn(`[AttachManager] Reattach attempt ${attempt}/3 failed for call ${callId}:`, error);
2866
+ logger$25.warn(`[AttachManager] Reattach attempt ${attempt}/3 failed for call ${callId}:`, error);
2867
+ if (isRequesterValidationError(error)) {
2868
+ refusedOnCredentials = true;
2869
+ logger$25.warn(`[AttachManager] Reattach of ${callId} was refused on credentials; not retrying.`);
2870
+ break;
2871
+ }
2788
2872
  if (attempt < 3) await new Promise((r) => setTimeout(r, (attempt + 1) * 1e3));
2789
2873
  }
2790
- if (!succeeded) {
2791
- logger$24.warn(`[AttachManager] Reattach failed after 3 attempts for call ${callId}, removing reference`);
2874
+ if (!succeeded) if (this.credentialRecovered() && !refusedOnCredentials) {
2875
+ logger$25.warn(`[AttachManager] Reattach of ${callId} was denied after a verified reauthentication, removing reference`);
2792
2876
  await this.detach({
2793
2877
  id: callId,
2794
2878
  mediaDirections: attachment.mediaDirections
2795
2879
  });
2796
- }
2880
+ } else logger$25.warn(`[AttachManager] Reattach failed for call ${callId}; keeping the reference (credential refused or never proven good)`);
2797
2881
  }
2798
2882
  }
2799
2883
  /**
@@ -3106,7 +3190,7 @@ function toggleHandraiseMethod(is) {
3106
3190
 
3107
3191
  //#endregion
3108
3192
  //#region src/core/entities/Participant.ts
3109
- const logger$23 = require_operators.getLogger();
3193
+ const logger$24 = require_operators.getLogger();
3110
3194
  const initialState = {};
3111
3195
  /**
3112
3196
  * Represents a participant in a call.
@@ -3116,9 +3200,9 @@ const initialState = {};
3116
3200
  * the local participant with additional device control.
3117
3201
  */
3118
3202
  var Participant = class extends Destroyable {
3119
- constructor(id, executeMethod, deviceController) {
3203
+ constructor(id, callExecuteMethod, deviceController) {
3120
3204
  super();
3121
- this.executeMethod = executeMethod;
3205
+ this.callExecuteMethod = callExecuteMethod;
3122
3206
  this.deviceController = deviceController;
3123
3207
  this._state$ = this.createBehaviorSubject(initialState);
3124
3208
  this.id = id;
@@ -3340,22 +3424,55 @@ var Participant = class extends Destroyable {
3340
3424
  get value() {
3341
3425
  return this._state$.value;
3342
3426
  }
3427
+ /**
3428
+ * Target triple for member RPCs, built from the participant's own state.
3429
+ * The backend locates the member's session by the target `call_id`/`node_id`,
3430
+ * so this must always be the participant's own call context — never the
3431
+ * local call's id (issue #19400).
3432
+ *
3433
+ * Reading it doubles as a readiness probe: it throws until the first full
3434
+ * member event (`member.joined`/`member.updated` or the `call.joined`
3435
+ * roster) arrives, and never regresses afterwards.
3436
+ *
3437
+ * @throws {ParticipantNotReadyError} If the member state has not been
3438
+ * received yet (e.g. a participant first seen via `member.talking`) — an
3439
+ * empty call context can never address the member, so fail fast instead of
3440
+ * sending a doomed RPC.
3441
+ */
3442
+ get target() {
3443
+ const { call_id, node_id } = this._state$.value;
3444
+ if (!call_id || !node_id) throw new require_operators.ParticipantNotReadyError(this.id);
3445
+ return {
3446
+ member_id: this.id,
3447
+ call_id,
3448
+ node_id
3449
+ };
3450
+ }
3451
+ /**
3452
+ * Executes a member RPC against this participant, injecting its own
3453
+ * {@link target} as the target.
3454
+ *
3455
+ * @throws {ParticipantNotReadyError} Via {@link target}, when the
3456
+ * member state has not been received yet.
3457
+ */
3458
+ async executeMethod(method, args) {
3459
+ return this.callExecuteMethod(this.target, method, args);
3460
+ }
3343
3461
  /** Toggles the deafened state (mutes/unmutes incoming audio). */
3344
3462
  async toggleDeaf() {
3345
- const method = toggleDeafMethod(this.deaf);
3346
- await this.executeMethod(this.id, method, {});
3463
+ await this.executeMethod(toggleDeafMethod(this.deaf), {});
3347
3464
  }
3348
3465
  /** Toggles the hand-raised state. */
3349
3466
  async toggleHandraise() {
3350
- await this.executeMethod(this.id, toggleHandraiseMethod(this.handraised), {});
3467
+ await this.executeMethod(toggleHandraiseMethod(this.handraised), {});
3351
3468
  }
3352
3469
  /** Mutes the participant's audio. */
3353
3470
  async mute() {
3354
- await this.executeMethod(this.id, "call.mute", { channels: ["audio"] });
3471
+ await this.executeMethod("call.mute", { channels: ["audio"] });
3355
3472
  }
3356
3473
  /** Unmutes the participant's audio. */
3357
3474
  async unmute() {
3358
- await this.executeMethod(this.id, "call.unmute", { channels: ["audio"] });
3475
+ await this.executeMethod("call.unmute", { channels: ["audio"] });
3359
3476
  }
3360
3477
  /** Toggles the participant's audio mute state. */
3361
3478
  async toggleMute() {
@@ -3363,11 +3480,11 @@ var Participant = class extends Destroyable {
3363
3480
  }
3364
3481
  /** Mutes the participant's video. */
3365
3482
  async muteVideo() {
3366
- await this.executeMethod(this.id, "call.mute", { channels: ["video"] });
3483
+ await this.executeMethod("call.mute", { channels: ["video"] });
3367
3484
  }
3368
3485
  /** Unmutes the participant's video. */
3369
3486
  async unmuteVideo() {
3370
- await this.executeMethod(this.id, "call.unmute", { channels: ["video"] });
3487
+ await this.executeMethod("call.unmute", { channels: ["video"] });
3371
3488
  }
3372
3489
  /** Toggles the participant's video mute state. */
3373
3490
  async toggleMuteVideo() {
@@ -3375,7 +3492,7 @@ var Participant = class extends Destroyable {
3375
3492
  }
3376
3493
  /** Toggles echo cancellation on the audio input. */
3377
3494
  async toggleEchoCancellation() {
3378
- await this.executeMethod(this.id, "call.audioflags.set", {
3495
+ await this.executeMethod("call.audioflags.set", {
3379
3496
  echo_cancellation: !this.echoCancellation,
3380
3497
  auto_gain: this.autoGain,
3381
3498
  noise_suppression: this.noiseSuppression
@@ -3383,7 +3500,7 @@ var Participant = class extends Destroyable {
3383
3500
  }
3384
3501
  /** Toggles automatic gain control on the audio input. */
3385
3502
  async toggleAudioInputAutoGain() {
3386
- await this.executeMethod(this.id, "call.audioflags.set", {
3503
+ await this.executeMethod("call.audioflags.set", {
3387
3504
  echo_cancellation: this.echoCancellation,
3388
3505
  auto_gain: !this.autoGain,
3389
3506
  noise_suppression: this.noiseSuppression
@@ -3391,7 +3508,7 @@ var Participant = class extends Destroyable {
3391
3508
  }
3392
3509
  /** Toggles noise suppression on the audio input. */
3393
3510
  async toggleNoiseSuppression() {
3394
- await this.executeMethod(this.id, "call.audioflags.set", {
3511
+ await this.executeMethod("call.audioflags.set", {
3395
3512
  echo_cancellation: this.echoCancellation,
3396
3513
  auto_gain: this.autoGain,
3397
3514
  noise_suppression: !this.noiseSuppression
@@ -3399,7 +3516,7 @@ var Participant = class extends Destroyable {
3399
3516
  }
3400
3517
  /** Toggles low-bitrate mode for this participant's media. */
3401
3518
  async toggleLowbitrate() {
3402
- await this.executeMethod(this.id, "call.lowbitrate.set", { lowbitrate: !this.lowbitrate });
3519
+ await this.executeMethod("call.lowbitrate.set", { lowbitrate: !this.lowbitrate });
3403
3520
  }
3404
3521
  /**
3405
3522
  * Adjusts the **conference-only** microphone energy gate / sensitivity level
@@ -3415,7 +3532,7 @@ var Participant = class extends Destroyable {
3415
3532
  * (integer, larger values are more sensitive).
3416
3533
  */
3417
3534
  async setAudioInputSensitivity(value) {
3418
- await this.executeMethod(this.id, "call.microphone.sensitivity.set", { sensitivity: value });
3535
+ await this.executeMethod("call.microphone.sensitivity.set", { sensitivity: value });
3419
3536
  }
3420
3537
  /**
3421
3538
  * Sets the **server-side** microphone volume on this participant's bridged
@@ -3428,7 +3545,7 @@ var Participant = class extends Destroyable {
3428
3545
  * @param value - Volume level (0-100).
3429
3546
  */
3430
3547
  async setAudioInputVolume(value) {
3431
- await this.executeMethod(this.id, "call.microphone.volume.set", { volume: value });
3548
+ await this.executeMethod("call.microphone.volume.set", { volume: value });
3432
3549
  }
3433
3550
  /**
3434
3551
  * Sets the **server-side** speaker volume on this participant's bridged call
@@ -3442,45 +3559,31 @@ var Participant = class extends Destroyable {
3442
3559
  * @param value - Volume level (0-100).
3443
3560
  */
3444
3561
  async setAudioOutputVolume(value) {
3445
- await this.executeMethod(this.id, "call.speaker.volume.set", { volume: value });
3562
+ await this.executeMethod("call.speaker.volume.set", { volume: value });
3446
3563
  }
3447
3564
  /**
3448
3565
  * Sets the participant's position in the video layout.
3449
3566
  *
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.
3567
+ * Requires the `member.position` capability. The gateway requires a
3568
+ * `targets` array of `{ target, position }` entries (issue #19400). A
3569
+ * resolved promise does not guarantee a visible change: the backend silently
3570
+ * returns `200` (no-op) for non-conference targets.
3456
3571
  *
3457
3572
  * @param value - The {@link VideoPosition} to assign (e.g. `'auto'`, `'reserved-0'`).
3458
3573
  */
3459
3574
  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,
3575
+ await this.executeMethod("call.member.position.set", { targets: [{
3576
+ target: this.target,
3468
3577
  position: value
3469
3578
  }] });
3470
3579
  }
3471
3580
  /** Removes this participant from the call. */
3472
3581
  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", {});
3582
+ await this.executeMethod("call.member.remove", { targets: [this.target] });
3480
3583
  }
3481
3584
  /** Ends the call for this participant. */
3482
3585
  async end() {
3483
- await this.executeMethod(this.id, "call.end", {});
3586
+ await this.executeMethod("call.end", {});
3484
3587
  }
3485
3588
  /**
3486
3589
  * Replaces custom metadata for this participant.
@@ -3500,7 +3603,7 @@ var Participant = class extends Destroyable {
3500
3603
  }
3501
3604
  /** Destroys the participant, releasing all subscriptions and references. */
3502
3605
  destroy() {
3503
- this.executeMethod = void 0;
3606
+ this.callExecuteMethod = void 0;
3504
3607
  super.destroy();
3505
3608
  }
3506
3609
  };
@@ -3512,8 +3615,8 @@ var Participant = class extends Destroyable {
3512
3615
  */
3513
3616
  var SelfParticipant = class extends Participant {
3514
3617
  /** @internal */
3515
- constructor(id, executeMethod, vertoManager, deviceController) {
3516
- super(id, executeMethod, deviceController);
3618
+ constructor(id, callExecuteMethod, vertoManager, deviceController) {
3619
+ super(id, callExecuteMethod, deviceController);
3517
3620
  this.vertoManager = vertoManager;
3518
3621
  this._studioAudio$ = this.createBehaviorSubject(false);
3519
3622
  this.capabilities = new SelfCapabilities();
@@ -3537,7 +3640,7 @@ var SelfParticipant = class extends Participant {
3537
3640
  async enableStudioAudio() {
3538
3641
  if (this._studioAudio$.value) return;
3539
3642
  this._studioAudio$.next(true);
3540
- await this.executeMethod(this.id, "call.audioflags.set", {
3643
+ await this.executeMethod("call.audioflags.set", {
3541
3644
  echo_cancellation: false,
3542
3645
  auto_gain: false,
3543
3646
  noise_suppression: false
@@ -3550,7 +3653,7 @@ var SelfParticipant = class extends Participant {
3550
3653
  async disableStudioAudio() {
3551
3654
  if (!this._studioAudio$.value) return;
3552
3655
  this._studioAudio$.next(false);
3553
- await this.executeMethod(this.id, "call.audioflags.set", {
3656
+ await this.executeMethod("call.audioflags.set", {
3554
3657
  echo_cancellation: true,
3555
3658
  auto_gain: true,
3556
3659
  noise_suppression: true
@@ -3559,17 +3662,27 @@ var SelfParticipant = class extends Participant {
3559
3662
  /**
3560
3663
  * Starts sharing the local screen.
3561
3664
  *
3665
+ * A call carries at most one screen share. Read `screenShareStatus` before
3666
+ * calling and treat `'starting'`/`'stopping'` as busy.
3667
+ *
3562
3668
  * The call is unaffected when acquisition fails.
3563
3669
  *
3670
+ * @param options - Pass `{ audio: true }` to also request the shared
3671
+ * surface's audio. Defaults to video only.
3672
+ * @throws {ScreenShareAlreadyActiveError} When this call is already
3673
+ * sharing a screen. Call {@link stopScreenShare} before starting another.
3674
+ * @throws {AuxiliaryLegCancelledError} When {@link stopScreenShare} removes
3675
+ * the share before its leg finishes connecting.
3564
3676
  * @throws The raw `getDisplayMedia` error. A dismissed picker or a
3565
3677
  * permission denial rejects with a `NotAllowedError` `DOMException` —
3566
3678
  * inspect `error.name` to tell benign cancels apart from real failures.
3567
3679
  */
3568
- async startScreenShare() {
3680
+ async startScreenShare(options) {
3569
3681
  try {
3570
- await this.vertoManager.addScreenMedia();
3682
+ await this.vertoManager.addScreenMedia(options);
3571
3683
  } catch (error) {
3572
- logger$23.error("[Participant.startScreenShare] Screen share error:", error);
3684
+ if (error instanceof require_operators.AuxiliaryLegCancelledError) logger$24.debug("[Participant.startScreenShare] Screen share cancelled before connecting.");
3685
+ else logger$24.error("[Participant.startScreenShare] Screen share error:", error);
3573
3686
  throw error;
3574
3687
  }
3575
3688
  }
@@ -3590,14 +3703,18 @@ var SelfParticipant = class extends Participant {
3590
3703
  *
3591
3704
  * The call is unaffected when acquisition fails.
3592
3705
  *
3706
+ * @throws {AuxiliaryLegCancelledError} When {@link removeAdditionalDevice}
3707
+ * removes the device before its leg finishes connecting.
3593
3708
  * @throws The raw `getUserMedia` error (e.g. `NotAllowedError` on
3594
- * permission denial) — inspect `error.name` to decide how to react.
3709
+ * permission denial) — inspect `error.name` to decide how to react — or
3710
+ * `AuxiliaryLegTimeoutError` if the leg does not connect in time.
3595
3711
  */
3596
3712
  async addAdditionalDevice(options) {
3597
3713
  try {
3598
3714
  await this.vertoManager.addInputDevice(options);
3599
3715
  } catch (error) {
3600
- logger$23.error("[Participant.addAdditionalDevice] Additional device error:", error);
3716
+ if (error instanceof require_operators.AuxiliaryLegCancelledError) logger$24.debug("[Participant.addAdditionalDevice] Device removed before connecting.");
3717
+ else logger$24.error("[Participant.addAdditionalDevice] Additional device error:", error);
3601
3718
  throw error;
3602
3719
  }
3603
3720
  }
@@ -3632,22 +3749,31 @@ var SelfParticipant = class extends Participant {
3632
3749
  this.deviceController.selectAudioInputDevice(device);
3633
3750
  if (options.savePreference) PreferencesContainer.instance.preferredAudioInput = device;
3634
3751
  }
3635
- /** Updates the audio input track constraints for the active call. */
3752
+ /**
3753
+ * Updates the audio input track constraints for the active call.
3754
+ * @returns whether the constraints reached the media the call is sending.
3755
+ */
3636
3756
  async setAudioInputDeviceConstraints(constraints) {
3637
- await this.vertoManager.updateMediaConstraints({ audio: constraints });
3757
+ return this.vertoManager.updateMediaConstraints({ audio: constraints });
3638
3758
  }
3639
- /** Updates both audio and video input track constraints for the active call. */
3759
+ /**
3760
+ * Updates both audio and video input track constraints for the active call.
3761
+ * @returns whether both kinds took the constraints.
3762
+ */
3640
3763
  async setInputDevicesConstraints(constraints) {
3641
- await this.vertoManager.updateMediaConstraints(constraints);
3764
+ return this.vertoManager.updateMediaConstraints(constraints);
3642
3765
  }
3643
3766
  /** Selects the video input device for future calls. Optionally saves as a preference. */
3644
3767
  selectVideoInputDevice(device, options = {}) {
3645
3768
  this.deviceController.selectVideoInputDevice(device);
3646
3769
  if (options.savePreference) PreferencesContainer.instance.preferredVideoInput = device;
3647
3770
  }
3648
- /** Updates the video input track constraints for the active call. */
3771
+ /**
3772
+ * Updates the video input track constraints for the active call.
3773
+ * @returns whether the constraints reached the media the call is sending.
3774
+ */
3649
3775
  async setVideoInputDeviceConstraints(constraints) {
3650
- await this.vertoManager.updateMediaConstraints({ video: constraints });
3776
+ return this.vertoManager.updateMediaConstraints({ video: constraints });
3651
3777
  }
3652
3778
  /** Selects the audio output device. Optionally saves as a preference. */
3653
3779
  selectAudioOutputDevice(device, options = {}) {
@@ -3660,7 +3786,7 @@ var SelfParticipant = class extends Participant {
3660
3786
  */
3661
3787
  exitStudioModeIfActive() {
3662
3788
  if (this._studioAudio$.value) {
3663
- logger$23.debug("[SelfParticipant] Exiting studio audio mode due to individual flag toggle");
3789
+ logger$24.debug("[SelfParticipant] Exiting studio audio mode due to individual flag toggle");
3664
3790
  this._studioAudio$.next(false);
3665
3791
  }
3666
3792
  }
@@ -3684,7 +3810,7 @@ var SelfParticipant = class extends Participant {
3684
3810
  try {
3685
3811
  await super.mute();
3686
3812
  } catch (error) {
3687
- logger$23.warn("[Participant.toggleAudioInput] Server Error while muting audio input, proceeding with local toggle anyway", error);
3813
+ logger$24.warn("[Participant.toggleAudioInput] Server Error while muting audio input, proceeding with local toggle anyway", error);
3688
3814
  } finally {
3689
3815
  this.vertoManager.muteMainAudioInputDevice();
3690
3816
  }
@@ -3694,7 +3820,7 @@ var SelfParticipant = class extends Participant {
3694
3820
  try {
3695
3821
  await super.unmute();
3696
3822
  } catch (error) {
3697
- logger$23.warn("[Participant.toggleAudioInput] Server Error while unmuting audio input, proceeding with local toggle anyway", error);
3823
+ logger$24.warn("[Participant.toggleAudioInput] Server Error while unmuting audio input, proceeding with local toggle anyway", error);
3698
3824
  } finally {
3699
3825
  await this.vertoManager.unmuteMainAudioInputDevice();
3700
3826
  }
@@ -3704,7 +3830,7 @@ var SelfParticipant = class extends Participant {
3704
3830
  try {
3705
3831
  await super.muteVideo();
3706
3832
  } catch (error) {
3707
- logger$23.warn("[Participant.toggleVideoInput] Server Error while muting video input, proceeding with local toggle anyway", error);
3833
+ logger$24.warn("[Participant.toggleVideoInput] Server Error while muting video input, proceeding with local toggle anyway", error);
3708
3834
  } finally {
3709
3835
  this.vertoManager.muteMainVideoInputDevice();
3710
3836
  }
@@ -3714,7 +3840,7 @@ var SelfParticipant = class extends Participant {
3714
3840
  try {
3715
3841
  await super.unmuteVideo();
3716
3842
  } catch (error) {
3717
- logger$23.warn("[Participant.toggleVideoInput] Server Error while unmuting video input, proceeding with local toggle anyway", error);
3843
+ logger$24.warn("[Participant.toggleVideoInput] Server Error while unmuting video input, proceeding with local toggle anyway", error);
3718
3844
  } finally {
3719
3845
  await this.vertoManager.unmuteMainVideoInputDevice();
3720
3846
  }
@@ -3808,7 +3934,7 @@ function isLayoutChangedPayload(value) {
3808
3934
 
3809
3935
  //#endregion
3810
3936
  //#region src/managers/CallEventsManager.ts
3811
- const logger$22 = require_operators.getLogger();
3937
+ const logger$23 = require_operators.getLogger();
3812
3938
  const initialSessionState = {};
3813
3939
  /** @internal */
3814
3940
  var CallEventsManager = class extends Destroyable {
@@ -3912,7 +4038,7 @@ var CallEventsManager = class extends Destroyable {
3912
4038
  }
3913
4039
  initSubscriptions() {
3914
4040
  this.subscribeTo(this.callJoinedEvent$, (callJoinedEvent) => {
3915
- logger$22.debug("[CallEventsManager] Handling call.joined event for call/session IDs:", {
4041
+ logger$23.debug("[CallEventsManager] Handling call.joined event for call/session IDs:", {
3916
4042
  callId: callJoinedEvent.call_id,
3917
4043
  roomSessionId: callJoinedEvent.room_session_id
3918
4044
  });
@@ -3939,19 +4065,19 @@ var CallEventsManager = class extends Destroyable {
3939
4065
  if (this._self$.value?.capabilities.setLayout) this.updateLayouts();
3940
4066
  });
3941
4067
  this.subscribeTo(this.memberUpdates$, (member) => {
3942
- logger$22.debug("[CallEventsManager] Handling member update event for member ID:", member);
4068
+ logger$23.debug("[CallEventsManager] Handling member update event for member ID:", member);
3943
4069
  this.upsertParticipant(member);
3944
4070
  });
3945
4071
  this.subscribeTo(this.webRtcCallSession.memberLeft$, (memberLeftEvent) => {
3946
- logger$22.debug("[CallEventsManager] Handling member.left event for member ID:", memberLeftEvent.member.member_id);
4072
+ logger$23.debug("[CallEventsManager] Handling member.left event for member ID:", memberLeftEvent.member.member_id);
3947
4073
  const participants = { ...this._participants$.value };
3948
4074
  if (memberLeftEvent.member.member_id in participants) {
3949
4075
  delete participants[memberLeftEvent.member.member_id];
3950
4076
  this._participants$.next(participants);
3951
- } else logger$22.warn(`[CallEventsManager] Received member.left event for unknown member ID: ${memberLeftEvent.member.member_id}`);
4077
+ } else logger$23.warn(`[CallEventsManager] Received member.left event for unknown member ID: ${memberLeftEvent.member.member_id}`);
3952
4078
  });
3953
4079
  this.subscribeTo(this.webRtcCallSession.callUpdated$, (callUpdatedEvent) => {
3954
- logger$22.debug("[CallEventsManager] Handling call.updated event:", callUpdatedEvent);
4080
+ logger$23.debug("[CallEventsManager] Handling call.updated event:", callUpdatedEvent);
3955
4081
  const roomSession = callUpdatedEvent.room_session;
3956
4082
  this._sessionState$.next({
3957
4083
  ...this._sessionState$.value,
@@ -3966,7 +4092,7 @@ var CallEventsManager = class extends Destroyable {
3966
4092
  });
3967
4093
  });
3968
4094
  this.subscribeTo(this.layoutChangedEvent$, (layoutChangedEvent) => {
3969
- logger$22.debug("[CallEventsManager] Handling layout.changed event:", layoutChangedEvent);
4095
+ logger$23.debug("[CallEventsManager] Handling layout.changed event:", layoutChangedEvent);
3970
4096
  this._sessionState$.next({
3971
4097
  ...this._sessionState$.value,
3972
4098
  layout_name: layoutChangedEvent.id,
@@ -3976,10 +4102,10 @@ var CallEventsManager = class extends Destroyable {
3976
4102
  });
3977
4103
  }
3978
4104
  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.");
4105
+ 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
4106
  layoutChangedEvent.layers.filter((layer) => !!layer.member_id).filter((layer) => {
3981
4107
  if (!(layer.member_id in this._participants$.value)) {
3982
- logger$22.warn(`[CallEventsManager] Skipping layout layer for unknown member_id: ${layer.member_id}`);
4108
+ logger$23.warn(`[CallEventsManager] Skipping layout layer for unknown member_id: ${layer.member_id}`);
3983
4109
  return false;
3984
4110
  }
3985
4111
  return true;
@@ -3997,12 +4123,17 @@ var CallEventsManager = class extends Destroyable {
3997
4123
  updateLayouts() {
3998
4124
  if (!this.selfId) return;
3999
4125
  this.webRtcCallSession.executeMethod(this.selfId, "call.layout.list", {}).then((response) => {
4126
+ const layouts = response.result?.layouts;
4127
+ if (!layouts) {
4128
+ logger$23.warn("[CallEventsManager] Layout list response carried no layouts; keeping current layouts");
4129
+ return;
4130
+ }
4000
4131
  this._sessionState$.next({
4001
4132
  ...this._sessionState$.value,
4002
- layouts: response.result.layouts
4133
+ layouts
4003
4134
  });
4004
4135
  }).catch((error) => {
4005
- logger$22.error("[CallEventsManager] Error fetching layouts:", error);
4136
+ logger$23.error("[CallEventsManager] Error fetching layouts:", error);
4006
4137
  });
4007
4138
  }
4008
4139
  updateParticipants(members) {
@@ -4018,7 +4149,7 @@ var CallEventsManager = class extends Destroyable {
4018
4149
  }
4019
4150
  const participant = this._participants$.value[member.member_id];
4020
4151
  const oldValue = participant.value;
4021
- logger$22.debug("[CallEventsManager] Updating participant:", member.member_id, {
4152
+ logger$23.debug("[CallEventsManager] Updating participant:", member.member_id, {
4022
4153
  oldValue,
4023
4154
  newValue: member
4024
4155
  });
@@ -4031,17 +4162,17 @@ var CallEventsManager = class extends Destroyable {
4031
4162
  }
4032
4163
  get callJoinedEvent$() {
4033
4164
  return this.cachedObservable("callJoinedEvent$", () => this.webRtcCallSession.callEvent$.pipe((0, rxjs.filter)(isCallJoinedPayload), (0, rxjs.tap)((event) => {
4034
- logger$22.debug("[CallEventsManager] Call joined event:", event);
4165
+ logger$23.debug("[CallEventsManager] Call joined event:", event);
4035
4166
  })));
4036
4167
  }
4037
4168
  get layoutChangedEvent$() {
4038
4169
  return this.cachedObservable("layoutChangedEvent$", () => this.webRtcCallSession.callEvent$.pipe(require_operators.filterAs(isLayoutChangedPayload, "layout"), (0, rxjs.tap)((event) => {
4039
- logger$22.debug("[CallEventsManager] Layout changed event:", event);
4170
+ logger$23.debug("[CallEventsManager] Layout changed event:", event);
4040
4171
  })));
4041
4172
  }
4042
4173
  get memberUpdates$() {
4043
4174
  return this.cachedObservable("memberUpdates$", () => (0, rxjs.merge)(this.webRtcCallSession.memberJoined$, this.webRtcCallSession.memberUpdated$, this.webRtcCallSession.memberTalking$).pipe((0, rxjs.map)((event) => event.member), (0, rxjs.tap)((event) => {
4044
- logger$22.debug("[CallEventsManager] Member update event:", event);
4175
+ logger$23.debug("[CallEventsManager] Member update event:", event);
4045
4176
  })));
4046
4177
  }
4047
4178
  destroy() {
@@ -4060,6 +4191,111 @@ var CallEventsManager = class extends Destroyable {
4060
4191
  }
4061
4192
  };
4062
4193
 
4194
+ //#endregion
4195
+ //#region src/controllers/ConstraintFallbackHelper.ts
4196
+ /**
4197
+ * ConstraintFallbackHelper - Provides getUserMedia with automatic constraint
4198
+ * fallback on OverconstrainedError.
4199
+ *
4200
+ * When a specific device ID is requested, the helper tries progressively
4201
+ * looser constraints:
4202
+ * 1. `{ deviceId: { exact: deviceId } }` -- exact match
4203
+ * 2. `{ deviceId: deviceId }` -- preferred (browser may pick another)
4204
+ * 3. `{}` -- no constraint, browser default
4205
+ *
4206
+ * This prevents stale device IDs from blocking call setup.
4207
+ *
4208
+ * @see Section 5.8 and Section 11 of the Implementation Guide
4209
+ */
4210
+ const logger$22 = require_operators.getLogger();
4211
+ /**
4212
+ * Attempts getUserMedia with progressively looser constraints.
4213
+ *
4214
+ * The function tries three levels of constraint specificity for the given
4215
+ * device kind. Each level is only attempted if the previous one fails with
4216
+ * an OverconstrainedError. Non-OverconstrainedError failures (e.g.,
4217
+ * NotAllowedError) are thrown immediately without fallback.
4218
+ *
4219
+ * @param mediaDevices - Anything exposing `getUserMedia` (a full
4220
+ * `WebRTCMediaDevices`, or a shim wrapping one)
4221
+ * @param constraints - The full MediaStreamConstraints to use as a base
4222
+ * @param kind - Which track kind to apply fallback to ('audio' | 'video')
4223
+ * @param deviceId - The device ID to try (if undefined, calls getUserMedia as-is)
4224
+ * @returns The stream and the fallback level that succeeded
4225
+ * @throws When all fallback levels fail, or when a non-OverconstrainedError occurs
4226
+ */
4227
+ async function getUserMediaWithFallback(mediaDevices, constraints, kind, deviceId) {
4228
+ if (!deviceId) return {
4229
+ stream: await mediaDevices.getUserMedia(constraints),
4230
+ fallbackLevel: "default"
4231
+ };
4232
+ const baseConstraints = typeof constraints[kind] === "object" ? constraints[kind] : {};
4233
+ try {
4234
+ const exactConstraints = {
4235
+ ...constraints,
4236
+ [kind]: {
4237
+ ...baseConstraints,
4238
+ deviceId: { exact: deviceId }
4239
+ }
4240
+ };
4241
+ return {
4242
+ stream: await mediaDevices.getUserMedia(exactConstraints),
4243
+ fallbackLevel: "exact"
4244
+ };
4245
+ } catch (error) {
4246
+ if (!isOverconstrainedError(error)) throw error;
4247
+ logger$22.debug(`[ConstraintFallbackHelper] Exact constraint failed for ${kind}, trying preferred`, { deviceId });
4248
+ }
4249
+ try {
4250
+ const preferredConstraints = {
4251
+ ...constraints,
4252
+ [kind]: {
4253
+ ...baseConstraints,
4254
+ deviceId
4255
+ }
4256
+ };
4257
+ return {
4258
+ stream: await mediaDevices.getUserMedia(preferredConstraints),
4259
+ fallbackLevel: "preferred"
4260
+ };
4261
+ } catch (error) {
4262
+ if (!isOverconstrainedError(error)) throw error;
4263
+ logger$22.debug(`[ConstraintFallbackHelper] Preferred constraint failed for ${kind}, trying default`, { deviceId });
4264
+ }
4265
+ try {
4266
+ const defaultConstraints = {
4267
+ ...constraints,
4268
+ [kind]: { ...baseConstraints }
4269
+ };
4270
+ if (typeof defaultConstraints[kind] === "object") {
4271
+ const { deviceId: _removed, ...rest } = defaultConstraints[kind];
4272
+ defaultConstraints[kind] = rest;
4273
+ }
4274
+ const stream = await mediaDevices.getUserMedia(defaultConstraints);
4275
+ logger$22.warn(`[ConstraintFallbackHelper] Fell back to browser default for ${kind}`, { requestedDeviceId: deviceId });
4276
+ return {
4277
+ stream,
4278
+ fallbackLevel: "default"
4279
+ };
4280
+ } catch (error) {
4281
+ logger$22.error(`[ConstraintFallbackHelper] All fallback levels exhausted for ${kind}`, {
4282
+ deviceId,
4283
+ error
4284
+ });
4285
+ throw error;
4286
+ }
4287
+ }
4288
+ /**
4289
+ * Checks whether an error is an OverconstrainedError.
4290
+ *
4291
+ * Browsers may throw either a native OverconstrainedError or a DOMException
4292
+ * with a specific name.
4293
+ */
4294
+ function isOverconstrainedError(error) {
4295
+ if (error instanceof Error) return error.name === "OverconstrainedError" || error.name === "ConstraintNotSatisfiedError";
4296
+ return false;
4297
+ }
4298
+
4063
4299
  //#endregion
4064
4300
  //#region src/helpers/SDPHelper.ts
4065
4301
  /**
@@ -4615,6 +4851,7 @@ var LocalStreamController = class extends Destroyable {
4615
4851
  this._localAudioTracks$ = this.createBehaviorSubject([]);
4616
4852
  this._localVideoTracks$ = this.createBehaviorSubject([]);
4617
4853
  this._mediaTrackEnded$ = this.createSubject();
4854
+ this._trackOrigins = /* @__PURE__ */ new WeakMap();
4618
4855
  }
4619
4856
  get localStream$() {
4620
4857
  return this._localStream$.asObservable().pipe((0, rxjs.takeUntil)(this.destroyed$));
@@ -4637,6 +4874,22 @@ var LocalStreamController = class extends Destroyable {
4637
4874
  get localVideoTracks() {
4638
4875
  return this._localVideoTracks$.value;
4639
4876
  }
4877
+ tagTracks(tracks, origin) {
4878
+ for (const track of tracks) this._trackOrigins.set(track, origin);
4879
+ }
4880
+ setTrackOrigin(track, origin) {
4881
+ this._trackOrigins.set(track, origin);
4882
+ }
4883
+ getTrackOrigin(track) {
4884
+ return this._trackOrigins.get(track);
4885
+ }
4886
+ /**
4887
+ * Fail-safe: an unrecorded track reads as not-a-device-capture, so a missed
4888
+ * tagging site leaves media alone rather than destroying it.
4889
+ */
4890
+ isDeviceCapture(track) {
4891
+ return this._trackOrigins.get(track) === "device";
4892
+ }
4640
4893
  /**
4641
4894
  * Build the local media stream based on the provided options.
4642
4895
  */
@@ -4646,13 +4899,16 @@ var LocalStreamController = class extends Destroyable {
4646
4899
  if (this.options.inputAudioStream ?? this.options.inputVideoStream) {
4647
4900
  const tracks = [...this.options.inputAudioStream?.getTracks() ?? [], ...this.options.inputVideoStream?.getTracks() ?? []];
4648
4901
  stream = new MediaStream(tracks);
4902
+ this.tagTracks(tracks, "application");
4649
4903
  } else if (this.options.propose === "screenshare") {
4650
- logger$19.debug("[LocalStreamController] Requesting display media for screen sharing with audio:", Boolean(this.options.inputAudioDeviceConstraints));
4904
+ const audio = this.options.screenShareAudio ?? false;
4905
+ logger$19.debug("[LocalStreamController] Requesting display media for screen sharing with audio:", audio);
4651
4906
  stream = await this.options.getDisplayMedia({
4652
4907
  video: true,
4653
- audio: Boolean(this.options.inputAudioDeviceConstraints)
4908
+ audio
4654
4909
  });
4655
4910
  logger$19.debug("[LocalStreamController] Screen share media obtained:", stream);
4911
+ this.tagTracks(stream.getTracks(), "display");
4656
4912
  } else {
4657
4913
  const constraints = {
4658
4914
  audio: this.options.inputAudioDeviceConstraints,
@@ -4661,6 +4917,7 @@ var LocalStreamController = class extends Destroyable {
4661
4917
  logger$19.debug("[LocalStreamController] Requesting user media with constraints:", constraints);
4662
4918
  stream = await this.options.getUserMedia(constraints);
4663
4919
  logger$19.debug("[LocalStreamController] User media obtained:", stream);
4920
+ this.tagTracks(stream.getTracks(), "device");
4664
4921
  }
4665
4922
  this._localStream$.next(stream);
4666
4923
  this._localAudioTracks$.next(stream.getAudioTracks());
@@ -4670,10 +4927,13 @@ var LocalStreamController = class extends Destroyable {
4670
4927
  /**
4671
4928
  * Add a local media track to the local stream.
4672
4929
  * @param track - The MediaStreamTrack to add
4930
+ * @param origin - Defaults to `'device'`; every internal caller passes a
4931
+ * fresh `getUserMedia` capture.
4673
4932
  * @returns The MediaStream (either existing or newly created)
4674
4933
  */
4675
- addTrack(track) {
4934
+ addTrack(track, origin = "device") {
4676
4935
  const localStream = this._localStream$.value ?? new MediaStream();
4936
+ this._trackOrigins.set(track, origin);
4677
4937
  track.addEventListener("ended", this.mediaTrackEndedHandler);
4678
4938
  localStream.addTrack(track);
4679
4939
  this._localStream$.next(localStream);
@@ -4943,15 +5203,27 @@ var TransceiverController = class extends Destroyable {
4943
5203
  for (let i = 0; i < Number(msStreamsNumber); i++) this.peerConnection.addTransceiver("video", { direction: "recvonly" });
4944
5204
  }
4945
5205
  }
5206
+ /**
5207
+ * @returns whether every live sender of the kind took the constraints. A
5208
+ * skipped non-device sender, an exhausted fallback, and having no live sender
5209
+ * at all all report `false` — `mediaParamsUpdated.applied` is built from this,
5210
+ * and an application told `true` cannot tell a working push from a no-op.
5211
+ */
4946
5212
  async updateSendersConstraints(kind, constraints) {
4947
5213
  if (!constraints) {
4948
5214
  this.stopTrackSender(kind);
4949
- return Promise.resolve();
5215
+ return false;
4950
5216
  }
4951
5217
  const senders = this.peerConnection.getSenders().filter((sender) => sender.track?.kind === kind && sender.track.readyState === "live");
5218
+ let applied = senders.length > 0;
4952
5219
  for (const sender of senders) {
4953
5220
  const { track } = sender;
4954
5221
  if (track) {
5222
+ if (!this.options.localStreamController.isDeviceCapture(track)) {
5223
+ logger$18.debug(`[TransceiverController] Skipping ${kind} constraints for a non-device track (origin: ${this.options.localStreamController.getTrackOrigin(track) ?? "unrecorded"}), track ${track.id}`);
5224
+ applied = false;
5225
+ continue;
5226
+ }
4955
5227
  const constraintsToApply = {
4956
5228
  ...track.getConstraints(),
4957
5229
  ...constraints
@@ -4967,33 +5239,39 @@ var TransceiverController = class extends Destroyable {
4967
5239
  } catch (fallbackError) {
4968
5240
  logger$18.warn(`[TransceiverController] Track replacement fallback also failed for ${kind} track:`, fallbackError);
4969
5241
  this.options.onError?.(new require_operators.MediaTrackError("updateSendersConstraints", kind, fallbackError));
5242
+ applied = false;
4970
5243
  }
4971
5244
  }
4972
5245
  }
4973
5246
  }
5247
+ return applied;
4974
5248
  }
4975
5249
  /**
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.
5250
+ * Fallback when applyConstraints fails, which on iOS Safari it silently does.
4979
5251
  *
4980
- * This is critical for iOS Safari where applyConstraints on audio tracks
4981
- * silently fails or throws.
5252
+ * Order matters: acquiring before stopping means a failed acquisition leaves
5253
+ * the existing media playing. The deviceId goes through the fallback ladder
5254
+ * rather than pinned `{ exact }`, so a stale id degrades instead of failing.
4982
5255
  */
4983
5256
  async replaceTrackFallback(sender, oldTrack, kind, mergedConstraints) {
4984
5257
  const { deviceId } = oldTrack.getSettings();
4985
- const constraintsWithDevice = {
4986
- ...mergedConstraints,
4987
- ...deviceId ? { deviceId: { exact: deviceId } } : {}
4988
- };
4989
- const trackId = oldTrack.id;
5258
+ const { stream, fallbackLevel } = await getUserMediaWithFallback({ getUserMedia: this.options.getUserMedia }, { [kind]: mergedConstraints }, kind, deviceId);
5259
+ const newTrack = stream.getTracks().find((t) => t.kind === kind);
5260
+ if (!newTrack) {
5261
+ stream.getTracks().forEach((t) => t.stop());
5262
+ throw new require_operators.MediaTrackError("replaceTrackFallback", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
5263
+ }
5264
+ try {
5265
+ await sender.replaceTrack(newTrack);
5266
+ } catch (error) {
5267
+ stream.getTracks().forEach((t) => t.stop());
5268
+ throw error;
5269
+ }
5270
+ const oldTrackId = oldTrack.id;
5271
+ this.options.localStreamController.removeTrack(oldTrackId);
4990
5272
  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 require_operators.MediaTrackError("replaceTrackFallback", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
4994
- await sender.replaceTrack(newTrack);
4995
5273
  this.options.localStreamController.addTrack(newTrack);
4996
- logger$18.debug(`[TransceiverController] Track replacement fallback succeeded for ${kind}. New track: ${newTrack.id}`);
5274
+ logger$18.debug(`[TransceiverController] Track replacement fallback succeeded for ${kind} (deviceId fallback level: ${fallbackLevel}). New track: ${newTrack.id}`);
4997
5275
  }
4998
5276
  getMediaDirections() {
4999
5277
  if (this.peerConnection.connectionState === "connected") return this.peerConnection.getTransceivers().reduce((acc, transceiver) => {
@@ -5062,48 +5340,44 @@ var RTCPeerConnectionController = class extends Destroyable {
5062
5340
  this.negotiationNeeded$.next();
5063
5341
  };
5064
5342
  this.updateSelectedInputDevice = async (kind, deviceInfo) => {
5343
+ const { localStream } = this;
5344
+ if (!localStream) {
5345
+ logger$17.warn("[RTCPeerConnectionController] No local stream available to update input device.");
5346
+ return;
5347
+ }
5348
+ const currentTrack = localStream.getTracks().find((track) => track.kind === kind);
5349
+ if (!currentTrack) {
5350
+ logger$17.debug(`[RTCPeerConnectionController] No ${kind} track to switch.`);
5351
+ return;
5352
+ }
5353
+ if (!deviceInfo) {
5354
+ logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected: none`);
5355
+ this.stopTrackSender(kind);
5356
+ return;
5357
+ }
5358
+ const constraints = {
5359
+ ...currentTrack.getConstraints(),
5360
+ ...this.deviceController.deviceInfoToConstraints(deviceInfo)
5361
+ };
5065
5362
  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);
5363
+ const newTrack = await this.acquireInputTrack(kind, constraints, deviceInfo, currentTrack);
5364
+ await this.attachInputTrack(kind, newTrack, currentTrack);
5365
+ logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected:`, deviceInfo.label, newTrack.id);
5093
5366
  } catch (error) {
5094
5367
  logger$17.error(`[RTCPeerConnectionController] Failed to select ${kind} input device:`, error);
5095
5368
  this._errors$.next(new require_operators.MediaTrackError("updateSelectedInputDevice", kind, error));
5096
- throw error;
5097
5369
  }
5098
5370
  };
5099
5371
  this._isNegotiating$ = this.createBehaviorSubject(false);
5100
5372
  this._memberId = null;
5373
+ this._nodeId = null;
5101
5374
  this._iceConnectionState$ = this.createReplaySubject(1);
5102
5375
  this._connectionState$ = this.createReplaySubject(1);
5103
5376
  this._signalingState$ = this.createReplaySubject(1);
5104
5377
  this._iceGatheringState$ = this.createReplaySubject(1);
5105
5378
  this._errors$ = this.createReplaySubject(1);
5106
5379
  this._iceCandidates$ = this.createReplaySubject(1);
5380
+ this._localMediaSettled$ = this.createReplaySubject(1);
5107
5381
  this._initialized$ = this.createReplaySubject(1);
5108
5382
  this._remoteDescription$ = this.createReplaySubject(1);
5109
5383
  this._remoteStream$ = this.createBehaviorSubject(null);
@@ -5136,6 +5410,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5136
5410
  inputVideoStream: this.options.inputVideoStream,
5137
5411
  inputAudioDeviceConstraints: this.inputAudioDeviceConstraints,
5138
5412
  inputVideoDeviceConstraints: this.inputVideoDeviceConstraints,
5413
+ screenShareAudio: this.options.screenShareAudio,
5139
5414
  getUserMedia: async (constraints) => this.getUserMedia(constraints),
5140
5415
  getDisplayMedia: async (options$1) => this.getDisplayMedia(options$1)
5141
5416
  });
@@ -5162,6 +5437,13 @@ var RTCPeerConnectionController = class extends Destroyable {
5162
5437
  get memberId() {
5163
5438
  return this._memberId;
5164
5439
  }
5440
+ /** The node this leg's invite landed on — auxiliary legs are placed independently. */
5441
+ setNodeId(nodeId) {
5442
+ this._nodeId = nodeId;
5443
+ }
5444
+ get nodeId() {
5445
+ return this._nodeId;
5446
+ }
5165
5447
  stopTrackSender(kind, options = { updateTransceiverDirection: false }) {
5166
5448
  const audioCovered = kind === "audio" || kind === "both";
5167
5449
  if (audioCovered && this._localAudioPipeline) this.stopRawAudioInputForPipeline();
@@ -5171,10 +5453,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5171
5453
  }
5172
5454
  stopRawAudioInputForPipeline() {
5173
5455
  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
- }
5456
+ for (const track of rawTracks) if (track.readyState === "live") this.localStreamController.removeTrack(track.id);
5178
5457
  this._localAudioPipeline?.setInputTrack(null);
5179
5458
  }
5180
5459
  get isNegotiating$() {
@@ -5207,6 +5486,10 @@ var RTCPeerConnectionController = class extends Destroyable {
5207
5486
  get remoteDescription$() {
5208
5487
  return this.cachedObservable("remoteDescription$", () => this._remoteDescription$.asObservable().pipe((0, rxjs.takeUntil)(this.destroyed$)));
5209
5488
  }
5489
+ /** Emits once local media is settled — acquired, or knowingly receive-only. */
5490
+ get localMediaSettled$() {
5491
+ return this.cachedObservable("localMediaSettled$", () => this._localMediaSettled$.asObservable().pipe((0, rxjs.takeUntil)(this.destroyed$)));
5492
+ }
5210
5493
  get localStream$() {
5211
5494
  return this.cachedObservable("localStream$", () => this.localStreamController.localStream$.pipe((0, rxjs.takeUntil)(this.destroyed$)));
5212
5495
  }
@@ -5234,6 +5517,9 @@ var RTCPeerConnectionController = class extends Destroyable {
5234
5517
  get propose() {
5235
5518
  return this.options.propose ?? "main";
5236
5519
  }
5520
+ get connectionState() {
5521
+ return this.peerConnection?.connectionState;
5522
+ }
5237
5523
  get isAdditionalDevice() {
5238
5524
  return this.propose === "additional-device";
5239
5525
  }
@@ -5362,7 +5648,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5362
5648
  this._isNegotiating$.next(true);
5363
5649
  await this._setRemoteDescription(this.sdpInit);
5364
5650
  } else {
5365
- await this.setupTrackHandling();
5651
+ if (!await this.setupTrackHandling()) return;
5366
5652
  this._initialized$.next(true);
5367
5653
  }
5368
5654
  } catch (error) {
@@ -5486,7 +5772,10 @@ var RTCPeerConnectionController = class extends Destroyable {
5486
5772
  inputVideoDeviceConstraints: this.inputVideoDeviceConstraints
5487
5773
  });
5488
5774
  }
5489
- await this.setupLocalTracks();
5775
+ if (!await this.setupLocalTracks()) {
5776
+ logger$17.debug("[RTCPeerConnectionController] Inbound answer abandoned; the connection went away.");
5777
+ return;
5778
+ }
5490
5779
  const { answerOptions } = this;
5491
5780
  logger$17.debug("[RTCPeerConnectionController] Creating inbound answer with options:", answerOptions);
5492
5781
  await this.createAnswer(answerOptions);
@@ -5624,35 +5913,52 @@ var RTCPeerConnectionController = class extends Destroyable {
5624
5913
  }
5625
5914
  /**
5626
5915
  * Setup track handling for remote tracks.
5916
+ *
5917
+ * @returns `false` when the connection went away while local media was being
5918
+ * acquired — see {@link setupLocalTracks}.
5627
5919
  */
5628
5920
  async setupTrackHandling() {
5629
5921
  if (!this.peerConnection) throw new require_operators.DependencyError("RTCPeerConnection is not initialized");
5630
- await this.setupLocalTracks();
5922
+ if (!await this.setupLocalTracks()) return false;
5631
5923
  await this.setupRemoteTracks();
5924
+ return true;
5632
5925
  }
5926
+ /**
5927
+ * @returns `false` when the connection was torn down while getUserMedia was
5928
+ * in flight. The acquisition is not cancellable, so the caller must stop
5929
+ * rather than go on to touch a peer connection that is closed or gone.
5930
+ */
5633
5931
  async setupLocalTracks() {
5634
5932
  logger$17.debug("[RTCPeerConnectionController] Setting up local tracks/transceivers.");
5635
5933
  if (this.hasNoLocalMediaToSend()) {
5636
5934
  if (!this.receiveAudio && !this.receiveVideo) throw new require_operators.InvalidParams("Call requests no media: enable audio/video or receiveAudio/receiveVideo");
5637
5935
  logger$17.debug("[RTCPeerConnectionController] No local media requested; negotiating receive-only.");
5638
5936
  this.setupReceiveOnlyTransceivers();
5639
- return;
5937
+ this._localMediaSettled$.next();
5938
+ return true;
5640
5939
  }
5641
5940
  let localStream;
5642
5941
  try {
5643
5942
  localStream = this.localStream ?? await this.localStreamController.buildLocalStream();
5644
5943
  } catch (error) {
5645
5944
  this.handleLocalMediaFailure(error);
5646
- return;
5945
+ this._localMediaSettled$.next();
5946
+ return true;
5947
+ }
5948
+ if (!this.peerConnection || this.peerConnection.signalingState === "closed") {
5949
+ logger$17.debug("[RTCPeerConnectionController] Local media arrived after teardown; releasing it.");
5950
+ localStream.getTracks().forEach((track) => track.stop());
5951
+ return false;
5647
5952
  }
5953
+ this._localMediaSettled$.next();
5648
5954
  if (this.transceiverController?.useAddStream ?? false) {
5649
5955
  logger$17.warn("[RTCPeerConnectionController] Using deprecated addStream API to add local stream.");
5650
- this.peerConnection?.addStream(localStream);
5956
+ this.peerConnection.addStream(localStream);
5651
5957
  if (!this.isNegotiating) {
5652
5958
  logger$17.debug("[RTCPeerConnectionController] Forcing negotiationneeded after local tracks setup.");
5653
5959
  this.negotiationNeeded$.next();
5654
5960
  }
5655
- return;
5961
+ return true;
5656
5962
  }
5657
5963
  for (const kind of ["audio", "video"]) {
5658
5964
  const tracks = (kind === "audio" ? localStream.getAudioTracks() : localStream.getVideoTracks()).map((track, index) => ({
@@ -5666,10 +5972,11 @@ var RTCPeerConnectionController = class extends Destroyable {
5666
5972
  await this.transceiverController?.setupTransceiverSender(track, localStream, transceivers[index]);
5667
5973
  } else {
5668
5974
  logger$17.debug(`[RTCPeerConnectionController] Using addTrack for local ${kind} track:`, track.id);
5669
- this.peerConnection?.addTrack(track, localStream);
5975
+ this.peerConnection.addTrack(track, localStream);
5670
5976
  }
5671
5977
  }
5672
5978
  }
5979
+ return true;
5673
5980
  }
5674
5981
  /** True for a main connection with no local media to send. */
5675
5982
  hasNoLocalMediaToSend() {
@@ -5758,6 +6065,61 @@ var RTCPeerConnectionController = class extends Destroyable {
5758
6065
  this._localAudioPipeline.setInputTrack(newTrack);
5759
6066
  }
5760
6067
  /**
6068
+ * Capture the newly selected device, leaving the current capture running.
6069
+ *
6070
+ * A rejection must leave the current track sending, so nothing is released
6071
+ * until the replacement is in hand. The one exception is hardware that admits
6072
+ * a single opener — a phone's front and back cameras, typically — which
6073
+ * rejects the second capture until the first is closed.
6074
+ */
6075
+ async acquireInputTrack(kind, constraints, deviceInfo, currentTrack) {
6076
+ try {
6077
+ return await this.captureTrack(kind, constraints, deviceInfo.deviceId);
6078
+ } catch (error) {
6079
+ if (!require_operators.isMediaDeviceInUse(error)) throw error;
6080
+ logger$17.warn(`[RTCPeerConnectionController] ${kind} device is held exclusively; releasing the current capture to retry:`, error);
6081
+ const previousDeviceId = currentTrack.getSettings().deviceId;
6082
+ this.stopTrackSender(kind);
6083
+ try {
6084
+ return await this.captureTrack(kind, constraints, deviceInfo.deviceId);
6085
+ } catch (retryError) {
6086
+ await this.restorePreviousInputTrack(kind, constraints, previousDeviceId, currentTrack);
6087
+ throw retryError;
6088
+ }
6089
+ }
6090
+ }
6091
+ async captureTrack(kind, constraints, deviceId) {
6092
+ const { stream, fallbackLevel } = await getUserMediaWithFallback({ getUserMedia: async (c) => this.getUserMedia(c) }, { [kind]: constraints }, kind, deviceId);
6093
+ const track = stream.getTracks().find((t) => t.kind === kind);
6094
+ if (!track) {
6095
+ stream.getTracks().forEach((t) => t.stop());
6096
+ throw new require_operators.MediaTrackError("updateSelectedInputDevice", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
6097
+ }
6098
+ if (fallbackLevel !== "exact") logger$17.warn(`[RTCPeerConnectionController] ${kind} device acquired at fallback level '${fallbackLevel}'; the capture may not be the requested device.`);
6099
+ return track;
6100
+ }
6101
+ /** Best-effort return to the device that was released for an exclusive retry. */
6102
+ async restorePreviousInputTrack(kind, constraints, previousDeviceId, releasedTrack) {
6103
+ try {
6104
+ const restored = await this.captureTrack(kind, constraints, previousDeviceId);
6105
+ await this.attachInputTrack(kind, restored, releasedTrack);
6106
+ } catch (error) {
6107
+ logger$17.error(`[RTCPeerConnectionController] Failed to restore the previous ${kind} device:`, error);
6108
+ }
6109
+ }
6110
+ async attachInputTrack(kind, newTrack, oldTrack) {
6111
+ const pipelineOwnsAudio = kind === "audio" && this._localAudioPipeline;
6112
+ if (!pipelineOwnsAudio) try {
6113
+ await this.transceiverController?.replaceSenderTrack(kind, newTrack);
6114
+ } catch (error) {
6115
+ newTrack.stop();
6116
+ throw error;
6117
+ }
6118
+ this.localStreamController.removeTrack(oldTrack.id);
6119
+ this.localStreamController.addTrack(newTrack);
6120
+ if (pipelineOwnsAudio) this._localAudioPipeline?.setInputTrack(newTrack);
6121
+ }
6122
+ /**
5761
6123
  * Return the lazily-created {@link LocalAudioPipeline}, constructing it on
5762
6124
  * first access. On creation the current audio sender's track is routed
5763
6125
  * through the pipeline (input → gain → analyser → destination) and the
@@ -5790,6 +6152,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5790
6152
  const sender = (this.transceiverController?.audioTransceivers.at(0))?.sender ?? this.peerConnection.getSenders().find((s) => s.track?.kind === "audio");
5791
6153
  if (!sender || !raw) return;
5792
6154
  try {
6155
+ this.localStreamController.setTrackOrigin(this._localAudioPipeline.outputTrack, "processed");
5793
6156
  await sender.replaceTrack(this._localAudioPipeline.outputTrack);
5794
6157
  } catch (error) {
5795
6158
  logger$17.warn("[RTCPeerConnectionController] Failed to route audio sender through pipeline:", error);
@@ -5850,37 +6213,68 @@ var RTCPeerConnectionController = class extends Destroyable {
5850
6213
  for (const existingTrack of existingTracks) this.removeLocalTrack(existingTrack.id);
5851
6214
  this.addLocalTrack(track);
5852
6215
  }
6216
+ /**
6217
+ * @returns whether the constraints reached the media the leg is sending.
6218
+ *
6219
+ * With the pipeline engaged the audio sender carries the processed
6220
+ * destination track, so the sender scan would find nothing it may touch and
6221
+ * every audio constraint API would silently no-op. The constraints belong to
6222
+ * the pipeline's device source, which is the capture that sender ultimately
6223
+ * carries.
6224
+ */
5853
6225
  async updateSendersConstraints(kind, constraints) {
5854
- await this.transceiverController?.updateSendersConstraints(kind, constraints);
6226
+ if (kind === "audio" && this._localAudioPipeline) {
6227
+ if (!constraints) {
6228
+ this.stopTrackSender("audio");
6229
+ return false;
6230
+ }
6231
+ return this.applyPipelineSourceConstraints(constraints);
6232
+ }
6233
+ return await this.transceiverController?.updateSendersConstraints(kind, constraints) ?? false;
5855
6234
  }
5856
6235
  /**
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.
6236
+ * Mirror of the sender path for a piped audio leg: same merge, same fallback
6237
+ * ladder, same device-capture invariant — but the swap target is the pipeline
6238
+ * input, so the sender keeps emitting the pipeline's output track and its
6239
+ * identity survives the change.
5861
6240
  */
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;
6241
+ async applyPipelineSourceConstraints(constraints) {
6242
+ const pipeline = this._localAudioPipeline;
6243
+ const source = this.localStreamController.localAudioTracks.at(0);
6244
+ if (!pipeline || !source) {
6245
+ logger$17.debug("[RTCPeerConnectionController] No pipeline input to constrain.");
6246
+ return false;
5867
6247
  }
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);
6248
+ if (!this.localStreamController.isDeviceCapture(source)) {
6249
+ logger$17.debug(`[RTCPeerConnectionController] Skipping audio constraints for a non-device pipeline input (origin: ${this.localStreamController.getTrackOrigin(source) ?? "unrecorded"}).`);
6250
+ return false;
6251
+ }
6252
+ const merged = {
6253
+ ...source.getConstraints(),
6254
+ ...constraints
6255
+ };
6256
+ try {
6257
+ await source.applyConstraints(merged);
6258
+ logger$17.debug("[RTCPeerConnectionController] Pipeline input constraints updated:", merged);
6259
+ return true;
6260
+ } catch (error) {
6261
+ logger$17.warn("[RTCPeerConnectionController] applyConstraints failed on the pipeline input, re-acquiring:", error);
6262
+ }
6263
+ try {
6264
+ const { stream } = await getUserMediaWithFallback({ getUserMedia: async (c) => this.getUserMedia(c) }, { audio: merged }, "audio", source.getSettings().deviceId);
6265
+ const newTrack = stream.getAudioTracks().at(0);
6266
+ if (!newTrack) {
6267
+ stream.getTracks().forEach((track) => track.stop());
6268
+ throw new Error("getUserMedia returned no audio track");
6269
+ }
6270
+ this.localStreamController.removeTrack(source.id);
5882
6271
  this.localStreamController.addTrack(newTrack);
5883
- logger$17.debug(`[RTCPeerConnectionController] Audio track replaced for server-pushed params. New track: ${newTrack.id}`);
6272
+ pipeline.setInputTrack(newTrack);
6273
+ return true;
6274
+ } catch (error) {
6275
+ logger$17.warn("[RTCPeerConnectionController] Failed to re-acquire the pipeline input for constraints:", error);
6276
+ this._errors$.next(new require_operators.MediaTrackError("updateSendersConstraints", "audio", error));
6277
+ return false;
5884
6278
  }
5885
6279
  }
5886
6280
  /**
@@ -5999,6 +6393,48 @@ const logger$16 = require_operators.getLogger();
5999
6393
  function resolveInviteNodeId(args) {
6000
6394
  return args.isInvite && !args.reattach && !args.explicitNodeId ? "" : args.currentNodeId ?? "";
6001
6395
  }
6396
+ /**
6397
+ * Surface the real outcome of a `webrtc.verto` reply.
6398
+ *
6399
+ * A webrtc.verto response nests several envelopes, each keyed by a verto-style
6400
+ * string `code` ("200" ok, "400"/etc. fail) rather than a JSON-RPC `error`. An outer
6401
+ * layer reports only whether the frame was delivered; an inner layer carries the op's
6402
+ * own outcome:
6403
+ *
6404
+ * response.result = { code:"200", result:{…} } ← delivery acknowledgement
6405
+ * .result = { jsonrpc, id, result:{…} } ← the reply payload
6406
+ * .result = { code:"400", message:"Bad request" } ← the actual op outcome
6407
+ *
6408
+ * A failure can appear at any layer (delivery refused, or the op itself rejected
6409
+ * deeper down), so walk every nested `.result` object and return the FIRST non-2xx
6410
+ * `code` with its message. Returns null when every `code` seen is 2xx or absent —
6411
+ * i.e. the op succeeded. This is the only way to detect that e.g. a mute/kick was
6412
+ * rejected, since the outer delivery `code` is "200" (delivered) even then.
6413
+ *
6414
+ * Pure function — exported for unit testing.
6415
+ */
6416
+ function findNestedVertoFailure(response) {
6417
+ let node = response;
6418
+ while (node !== null && typeof node === "object") {
6419
+ const obj = node;
6420
+ const err = obj.error;
6421
+ if (err !== null && typeof err === "object") {
6422
+ const e = err;
6423
+ const errCode = typeof e.code === "string" || typeof e.code === "number" ? String(e.code) : void 0;
6424
+ if (errCode !== void 0 && !/^2\d\d$/.test(errCode)) return {
6425
+ code: errCode,
6426
+ message: typeof e.message === "string" ? e.message : void 0
6427
+ };
6428
+ }
6429
+ const code = typeof obj.code === "string" || typeof obj.code === "number" ? String(obj.code) : void 0;
6430
+ if (code !== void 0 && !/^2\d\d$/.test(code)) return {
6431
+ code,
6432
+ message: typeof obj.message === "string" ? obj.message : void 0
6433
+ };
6434
+ node = obj.result !== null && typeof obj.result === "object" ? obj.result : null;
6435
+ }
6436
+ return null;
6437
+ }
6002
6438
  var VertoManager = class extends Destroyable {
6003
6439
  constructor(callSession) {
6004
6440
  super();
@@ -6021,7 +6457,8 @@ var WebRTCVertoManager = class extends VertoManager {
6021
6457
  this._signalingStatus$ = this.createReplaySubject(1);
6022
6458
  this._screenShareStatus$ = this.createBehaviorSubject("none");
6023
6459
  this._rtcPeerConnectionsMap = /* @__PURE__ */ new Map();
6024
- this._screenShareTimeoutMs = 5e4;
6460
+ this._lastVertoPings = /* @__PURE__ */ new Map();
6461
+ this._legErrors$ = this.createSubject();
6025
6462
  this._nodeId$ = this.createBehaviorSubject(options.nodeId ?? null);
6026
6463
  this.onError = options.onError;
6027
6464
  this.onModifyFailed = options.onModifyFailed;
@@ -6069,6 +6506,10 @@ var WebRTCVertoManager = class extends VertoManager {
6069
6506
  get selfId$() {
6070
6507
  return this._selfId$.asObservable();
6071
6508
  }
6509
+ /** Separates the media phase of call creation from the signalling phase. */
6510
+ get localMediaSettled$() {
6511
+ return this.mainPeerConnection.localMediaSettled$;
6512
+ }
6072
6513
  get localStream() {
6073
6514
  return this._rtcPeerConnectionsMap.get(this.webRtcCallSession.id)?.localStream ?? null;
6074
6515
  }
@@ -6125,39 +6566,103 @@ var WebRTCVertoManager = class extends VertoManager {
6125
6566
  const { mediaParams, callID } = event;
6126
6567
  const rtcPeerConnController = this._rtcPeerConnectionsMap.get(callID);
6127
6568
  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
- })();
6569
+ if (!rtcPeerConnController) {
6570
+ logger$16.warn(`[WebRTCManager] Ignoring server-pushed media params for unknown leg ${callID}`);
6571
+ return;
6572
+ }
6573
+ this.applyServerMediaParams(rtcPeerConnController, audio, video);
6142
6574
  });
6143
6575
  this.subscribeTo(this.vertoPing$, (vertoPing) => {
6144
- this.attachManager.attach(this.buildAttachableCall());
6576
+ this._lastVertoPings.set(vertoPing.callID, vertoPing);
6577
+ this.attachManager.refresh(this.buildAttachableCall());
6145
6578
  this.sendVertoPong(vertoPing);
6146
6579
  });
6580
+ this.subscribeTo(this.webRtcCallSession.clientSession.authenticated$.pipe((0, rxjs.skip)(1), (0, rxjs.filter)(Boolean)), () => {
6581
+ this._lastVertoPings.forEach((vertoPing) => void this.sendVertoPong(vertoPing));
6582
+ });
6147
6583
  }
6148
6584
  /**
6149
- * Set node_id/selfId only when the current value is null.
6585
+ * An auxiliary-leg failure must never destroy the call; main-leg and
6586
+ * call-level errors keep `CallFactory.isFatalError`'s classification.
6150
6587
  *
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.
6588
+ * Every site holding a peer connection reports through here, so the invariant
6589
+ * is structural rather than per-call-site — which is how cloud-product#20523
6590
+ * happened, with only one of fourteen sites passing `{ fatal: false }`.
6591
+ *
6592
+ * `override` composes rather than replaces: a caller may force non-fatal for a
6593
+ * reason of its own (a `verto.info` frame is best-effort whichever leg carries
6594
+ * it), and an auxiliary leg stays non-fatal regardless.
6157
6595
  */
6158
- setNodeIdIfNull(nodeId) {
6159
- if (!this._nodeId$.value && nodeId) {
6160
- logger$16.debug(`[WebRTCManager] Early node_id set: ${nodeId}`);
6596
+ reportLegError(error, rtcPeerConnController, override) {
6597
+ const leg = rtcPeerConnController?.propose;
6598
+ const legId = rtcPeerConnController?.id;
6599
+ const auxiliary = Boolean(rtcPeerConnController) && !rtcPeerConnController?.isMainDevice;
6600
+ this.onError?.(error, {
6601
+ ...override?.fatal === false || auxiliary ? { fatal: false } : {},
6602
+ ...leg ? { leg } : {},
6603
+ ...legId ? { legId } : {}
6604
+ });
6605
+ if (legId) this._legErrors$.next({
6606
+ legId,
6607
+ error
6608
+ });
6609
+ }
6610
+ /**
6611
+ * Errors reported for one leg, as a stream that fails with them.
6612
+ *
6613
+ * Signaling failures are reported, never thrown — so nothing that waits on a
6614
+ * leg's progress would otherwise learn of a rejected invite. Merging this in
6615
+ * lets the wait end with the reason the server gave.
6616
+ */
6617
+ legError$(legId) {
6618
+ return this._legErrors$.pipe((0, rxjs.filter)((report) => report.legId === legId), (0, rxjs.map)((report) => {
6619
+ throw report.error;
6620
+ }));
6621
+ }
6622
+ /**
6623
+ * Audio and video are applied independently so a failure in one cannot
6624
+ * suppress the other, and `mediaParamsUpdated` is emitted whatever happens —
6625
+ * an application should not be starved of the params by a constraint failure.
6626
+ */
6627
+ async applyServerMediaParams(rtcPeerConnController, audio, video) {
6628
+ const failures = [];
6629
+ let applied = true;
6630
+ if (audio) try {
6631
+ applied = await rtcPeerConnController.updateSendersConstraints("audio", audio) && applied;
6632
+ } catch (error) {
6633
+ applied = false;
6634
+ failures.push(toError(error));
6635
+ }
6636
+ if (video) try {
6637
+ applied = await rtcPeerConnController.updateSendersConstraints("video", video) && applied;
6638
+ } catch (error) {
6639
+ applied = false;
6640
+ failures.push(toError(error));
6641
+ }
6642
+ this.webRtcCallSession.emitMediaParamsUpdated({
6643
+ audio,
6644
+ video,
6645
+ timestamp: Date.now(),
6646
+ applied
6647
+ });
6648
+ for (const failure of failures) {
6649
+ logger$16.warn("[WebRTCManager] Error applying server-pushed media params:", failure);
6650
+ this.reportLegError(failure, rtcPeerConnController, { fatal: false });
6651
+ }
6652
+ }
6653
+ /**
6654
+ * Set node_id/selfId only when the current value is null.
6655
+ *
6656
+ * During reattach, `call.joined` and `verto.answer` events can deliver
6657
+ * these identifiers before the `verto.invite` RPC response (`CALL CREATED`)
6658
+ * arrives. These methods let early events populate them eagerly so that
6659
+ * downstream RPC calls (e.g. `call.layout.list`) don't fail with empty
6660
+ * identifiers. `processInviteResponse()` remains the authoritative source and
6661
+ * overwrites unconditionally — for selfId, on the main leg only.
6662
+ */
6663
+ setNodeIdIfNull(nodeId) {
6664
+ if (!this._nodeId$.value && nodeId) {
6665
+ logger$16.debug(`[WebRTCManager] Early node_id set: ${nodeId}`);
6161
6666
  this._nodeId$.next(nodeId);
6162
6667
  }
6163
6668
  }
@@ -6176,24 +6681,33 @@ var WebRTCVertoManager = class extends VertoManager {
6176
6681
  this.onError?.(new require_operators.VertoPongError(error));
6177
6682
  }
6178
6683
  }
6684
+ /**
6685
+ * @returns whether the constraints reached the media the call is sending.
6686
+ * `false` is an outcome, not a failure: the leg may have no live sender of
6687
+ * the kind, or carry media the SDK did not capture and may not replace.
6688
+ * A failure behind it still reaches the call's `errors$`, so a caller that
6689
+ * ignores this value learns of it there.
6690
+ */
6179
6691
  async updateMediaConstraints(options = {}) {
6180
6692
  const { audio, video } = options;
6693
+ let applied = true;
6181
6694
  try {
6182
- if (audio) await this.mainPeerConnection.updateSendersConstraints("audio", audio);
6183
- if (video) await this.mainPeerConnection.updateSendersConstraints("video", video);
6695
+ if (audio) applied = await this.mainPeerConnection.updateSendersConstraints("audio", audio) && applied;
6696
+ if (video) applied = await this.mainPeerConnection.updateSendersConstraints("video", video) && applied;
6184
6697
  } catch (error) {
6185
6698
  logger$16.warn("[WebRTCManager] Error updating media constraints:", error);
6186
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
6699
+ this.reportLegError(toError(error), this.mainPeerConnection);
6187
6700
  throw error;
6188
6701
  }
6702
+ return applied;
6189
6703
  }
6190
6704
  get selfId() {
6191
6705
  return this._selfId$.value;
6192
6706
  }
6193
6707
  /** Build an AttachableCall from the current call state. */
6194
- buildAttachableCall(idOverride) {
6708
+ buildAttachableCall(idOverride, nodeIdOverride) {
6195
6709
  return {
6196
- nodeId: this.nodeId ?? void 0,
6710
+ nodeId: nodeIdOverride ?? this.nodeId ?? void 0,
6197
6711
  id: idOverride ?? this.webRtcCallSession.id,
6198
6712
  to: this.webRtcCallSession.to,
6199
6713
  mediaDirections: this.webRtcCallSession.mediaDirections
@@ -6323,24 +6837,49 @@ var WebRTCVertoManager = class extends VertoManager {
6323
6837
  get vertoPing$() {
6324
6838
  return this.cachedObservable("vertoPing$", () => this.webRtcCallSession.webrtcMessages$.pipe(require_operators.filterAs(isVertoPingInnerParams, "params"), (0, rxjs.takeUntil)(this.destroyed$)));
6325
6839
  }
6840
+ /**
6841
+ * Send a member-control op in-dialog via verto.info.
6842
+ *
6843
+ * The control payload rides in `params.command` — a sibling of `dialogParams`,
6844
+ * at the same level as `dtmf` in {@link sendDigits} — and the inner verto.info
6845
+ * is matched to this call's channel by `dialogParams.callID`, the in-dialog
6846
+ * convention for member-scoped frames. Because it is delivered on the dialog
6847
+ * itself, control lands on the call's own channel with no {node_id,call_id,member_id}
6848
+ * "self" tuple to get wrong. The outer webrtc.verto envelope (added by executeVerto)
6849
+ * still carries the own-leg callID + node_id for session routing.
6850
+ *
6851
+ * Keep `command` OUT of `dialogParams`: it is read at the params level, and
6852
+ * filterVertoParams rewrites/filters dialogParams keys but passes params-level
6853
+ * keys through verbatim.
6854
+ */
6855
+ async sendCallControl(method, params) {
6856
+ const response = await this.executeVerto(VertoInfo({
6857
+ dialogParams: { callID: this.webRtcCallSession.id },
6858
+ command: {
6859
+ method,
6860
+ params
6861
+ }
6862
+ }));
6863
+ const failure = findNestedVertoFailure(response);
6864
+ if (failure) throw new require_operators.JSONRPCError(Number.parseInt(failure.code, 10) || 0, `Call control "${method}" failed (code ${failure.code})${failure.message ? `: ${failure.message}` : ""}`, void 0);
6865
+ return response;
6866
+ }
6326
6867
  async executeVerto(message, optionals = {}) {
6327
- const webrtcVertoMessage = WebrtcVerto({
6868
+ const params = {
6328
6869
  callID: optionals.callID ?? this.webRtcCallSession.id,
6329
6870
  node_id: optionals.node_id ?? this._nodeId$.value ?? "",
6330
6871
  message,
6331
6872
  subscribe: optionals.subscribe
6332
- });
6873
+ };
6874
+ const webrtcVertoMessage = WebrtcVerto(params);
6333
6875
  const response = await this.webRtcCallSession.execute(webrtcVertoMessage);
6334
- if (response.error) {
6335
- const error = new require_operators.JSONRPCError(response.error.code, response.error.message, response.error.data);
6336
- this.onError?.(error);
6337
- return response;
6338
- }
6876
+ const nonFatal = message.method === "verto.info" ? { fatal: false } : void 0;
6339
6877
  const innerResult = require_operators.getValueFrom(response, "result.result");
6340
- if (innerResult?.error) {
6341
- const error = new require_operators.JSONRPCError(innerResult.error.code, innerResult.error.message, innerResult.error.data);
6342
- this.onError?.(error);
6343
- return response;
6878
+ const failure = response.error ?? innerResult?.error;
6879
+ if (failure) {
6880
+ const error = new require_operators.JSONRPCError(failure.code, failure.message, failure.data);
6881
+ if (message.method === "verto.invite" || message.method === "verto.answer") throw error;
6882
+ this.reportLegError(error, this._rtcPeerConnectionsMap.get(params.callID), nonFatal);
6344
6883
  }
6345
6884
  return response;
6346
6885
  }
@@ -6359,8 +6898,9 @@ var WebRTCVertoManager = class extends VertoManager {
6359
6898
  default:
6360
6899
  }
6361
6900
  } catch (error) {
6901
+ if (vertoMethod === "verto.answer") throw error;
6362
6902
  logger$16.error(`[WebRTCManager] Error sending Verto ${vertoMethod}:`, error);
6363
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
6903
+ this.reportLegError(toError(error), rtcPeerConnController);
6364
6904
  if (vertoMethod === "verto.modify") this.onModifyFailed?.();
6365
6905
  }
6366
6906
  }
@@ -6376,7 +6916,7 @@ var WebRTCVertoManager = class extends VertoManager {
6376
6916
  } catch (error) {
6377
6917
  logger$16.warn("[WebRTCManager] Error processing modify response:", error);
6378
6918
  const modifyError = error instanceof Error ? error : new Error(String(error), { cause: error });
6379
- this.onError?.(modifyError);
6919
+ this.reportLegError(modifyError, rtcPeerConnController);
6380
6920
  }
6381
6921
  }
6382
6922
  }
@@ -6389,37 +6929,35 @@ var WebRTCVertoManager = class extends VertoManager {
6389
6929
  status,
6390
6930
  signalingError
6391
6931
  });
6392
- this.onError?.(signalingError);
6932
+ this.reportLegError(signalingError, null, { fatal: false });
6393
6933
  return;
6394
6934
  }
6395
6935
  if (rtcPeerConnController.isMainDevice) this._signalingStatus$.next(status);
6396
6936
  }
6397
6937
  processInviteResponse(response, rtcPeerConnController) {
6398
- if (!response.error && require_operators.getValueFrom(response, "result.result.result.message") === "CALL CREATED") {
6938
+ if (require_operators.getValueFrom(response, "result.result.result.message") === "CALL CREATED") {
6399
6939
  this.emitMainSignalingStatus(rtcPeerConnController.id, "trying");
6400
- this._nodeId$.next(require_operators.getValueFrom(response, "result.node_id") ?? null);
6940
+ const nodeId = require_operators.getValueFrom(response, "result.node_id") ?? null;
6401
6941
  const memberId = require_operators.getValueFrom(response, "result.result.result.memberID") ?? null;
6402
- const callId = require_operators.getValueFrom(response, "result.result.result.callID") ?? null;
6942
+ const callId = require_operators.getValueFrom(response, "result.result.result.callID");
6403
6943
  logger$16.debug("[WebRTCManager] Verto invite response:", {
6404
6944
  callId,
6405
6945
  memberId,
6406
6946
  response
6407
6947
  });
6408
- this._selfId$.next(memberId);
6409
6948
  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
- });
6949
+ rtcPeerConnController.setNodeId(nodeId);
6950
+ if (rtcPeerConnController.isMainDevice) {
6951
+ this._selfId$.next(memberId);
6952
+ this._nodeId$.next(nodeId);
6953
+ this.attachManager.attach(this.buildAttachableCall(callId, nodeId ?? void 0));
6954
+ }
6955
+ if (callId) this.webRtcCallSession.addCallId(callId);
6417
6956
  logger$16.info("[WebRTCManager] Verto invite successful");
6418
6957
  logger$16.debug(`[WebRTCManager] nodeid: ${this._nodeId$.value}, selfId: ${this._selfId$.value}`);
6419
6958
  } else {
6420
6959
  logger$16.error("[WebRTCManager] Verto invite failed:", response);
6421
- const inviteError = response.error ? new require_operators.JSONRPCError(response.error.code, response.error.message, response.error.data) : /* @__PURE__ */ new Error("Verto invite failed: unexpected response");
6422
- this.onError?.(inviteError);
6960
+ this.reportLegError(/* @__PURE__ */ new Error("Verto invite failed: unexpected response"), rtcPeerConnController);
6423
6961
  }
6424
6962
  }
6425
6963
  get RTCPeerConnectionConfig() {
@@ -6458,7 +6996,7 @@ var WebRTCVertoManager = class extends VertoManager {
6458
6996
  this._rtcPeerConnectionsMap.set(rtcPeerConnController.id, rtcPeerConnController);
6459
6997
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
6460
6998
  this.subscribeTo(rtcPeerConnController.errors$, (error) => {
6461
- this.onError?.(error);
6999
+ this.reportLegError(error, rtcPeerConnController);
6462
7000
  });
6463
7001
  if (options.initOffer) this.handleInboundAnswer(rtcPeerConnController);
6464
7002
  }
@@ -6487,7 +7025,7 @@ var WebRTCVertoManager = class extends VertoManager {
6487
7025
  await rtcPeerConnController.acceptInbound(answerOptions);
6488
7026
  } catch (error) {
6489
7027
  logger$16.error("[WebRTCManager] Error creating inbound answer:", error);
6490
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
7028
+ this.reportLegError(toError(error), rtcPeerConnController);
6491
7029
  }
6492
7030
  }
6493
7031
  }
@@ -6559,7 +7097,7 @@ var WebRTCVertoManager = class extends VertoManager {
6559
7097
  isInvite: isVertoInviteMessage(vertoMessage),
6560
7098
  reattach: this.webRtcCallSession.options.reattach === true,
6561
7099
  explicitNodeId: this.webRtcCallSession.options.nodeId,
6562
- currentNodeId: this._nodeId$.value
7100
+ currentNodeId: rtcPeerConnController.nodeId ?? this._nodeId$.value
6563
7101
  }),
6564
7102
  subscribe
6565
7103
  };
@@ -6591,7 +7129,7 @@ var WebRTCVertoManager = class extends VertoManager {
6591
7129
  await this.attachManager.attach(this.buildAttachableCall());
6592
7130
  } catch (error) {
6593
7131
  logger$16.error("[WebRTCManager] Error sending Verto answer:", error);
6594
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
7132
+ this.reportLegError(toError(error), rtcPeerConnectionController);
6595
7133
  await rtcPeerConnectionController.updateAnswerStatus({ status: "failed" });
6596
7134
  }
6597
7135
  }
@@ -6663,15 +7201,19 @@ var WebRTCVertoManager = class extends VertoManager {
6663
7201
  await this.mainPeerConnection.restoreTrackSender(deviceKind);
6664
7202
  } else {
6665
7203
  const error = new require_operators.InvalidParams("No valid device to be added");
6666
- this.onError?.(error);
7204
+ this.reportLegError(error, this.mainPeerConnection);
6667
7205
  throw error;
6668
7206
  }
6669
7207
  }
6670
- async addScreenMedia(options = { audio: false }) {
6671
- await this.initAdditionalPeerConnection("screenshare", options);
7208
+ async addScreenMedia(options = {}) {
7209
+ await this.initAdditionalPeerConnection("screenshare", {
7210
+ audio: false,
7211
+ screenShareAudio: options.audio ?? false
7212
+ });
6672
7213
  }
6673
7214
  async initAdditionalPeerConnection(propose, options) {
6674
7215
  const isScreenShare = propose === "screenshare";
7216
+ if (isScreenShare && this._screenShareId && this._rtcPeerConnectionsMap.has(this._screenShareId)) throw new require_operators.ScreenShareAlreadyActiveError(this._screenShareId);
6675
7217
  let firstPeerConnectionError;
6676
7218
  let rtcPeerConnController = null;
6677
7219
  try {
@@ -6688,21 +7230,36 @@ var WebRTCVertoManager = class extends VertoManager {
6688
7230
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
6689
7231
  this.subscribeTo(rtcPeerConnController.errors$, (error) => {
6690
7232
  firstPeerConnectionError ??= error;
6691
- this.onError?.(error, { fatal: false });
7233
+ this.reportLegError(error, rtcPeerConnController);
6692
7234
  });
6693
- await (0, rxjs.firstValueFrom)(rtcPeerConnController.connectionState$.pipe((0, rxjs.filter)((state) => state === "connected"), (0, rxjs.take)(1), (0, rxjs.timeout)(this._screenShareTimeoutMs), (0, rxjs.takeUntil)(this.destroyed$)));
7235
+ const pc = rtcPeerConnController;
7236
+ await (0, rxjs.firstValueFrom)((0, rxjs.merge)(pc.localMediaSettled$.pipe((0, rxjs.take)(1), (0, rxjs.switchMap)(() => pc.connectionState$.pipe((0, rxjs.filter)((state) => state === "connected"), (0, rxjs.take)(1), (0, rxjs.timeout)(require_operators.DEFAULT_AUX_LEG_CONNECT_TIMEOUT_MS)))), this.legError$(pc.id)).pipe((0, rxjs.takeUntil)((0, rxjs.merge)(this.destroyed$, pc.destroyed$))));
6694
7237
  if (isScreenShare) this._screenShareStatus$.next("started");
6695
7238
  logger$16.info(`[WebRTCManager] Additional peer connection connected (${propose}).`);
6696
7239
  return rtcPeerConnController.id;
6697
7240
  } 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");
7241
+ const cancelled = error instanceof require_operators.AuxiliaryLegCancelledError;
7242
+ const aborted = error instanceof rxjs.EmptyError && !firstPeerConnectionError;
7243
+ if (!cancelled && !aborted) logger$16.warn("[WebRTCManager] Error initializing additional peer connection:", error);
7244
+ if (rtcPeerConnController && this._rtcPeerConnectionsMap.has(rtcPeerConnController.id)) {
7245
+ rtcPeerConnController.destroy();
7246
+ this._rtcPeerConnectionsMap.delete(rtcPeerConnController.id);
7247
+ this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
7248
+ }
7249
+ if (isScreenShare) {
7250
+ this._screenShareId = void 0;
7251
+ this._screenShareStatus$.next("none");
7252
+ }
7253
+ if (cancelled) {
7254
+ logger$16.debug("[WebRTCManager] Additional peer connection removed before connecting.");
7255
+ throw error;
7256
+ }
6701
7257
  if (firstPeerConnectionError) throw firstPeerConnectionError instanceof require_operators.MediaAccessError && firstPeerConnectionError.originalError instanceof Error ? firstPeerConnectionError.originalError : firstPeerConnectionError;
6702
7258
  if (error instanceof rxjs.EmptyError) {
6703
7259
  logger$16.debug("[WebRTCManager] Additional peer connection aborted before connecting.");
6704
7260
  return;
6705
7261
  }
7262
+ if (error instanceof rxjs.TimeoutError) throw new require_operators.AuxiliaryLegTimeoutError(propose, error);
6706
7263
  throw error instanceof Error ? error : new Error(String(error), { cause: error });
6707
7264
  }
6708
7265
  }
@@ -6719,7 +7276,10 @@ var WebRTCVertoManager = class extends VertoManager {
6719
7276
  if (removeTrack) return this.mainPeerConnection.stopTrackSender(removeTrack, { updateTransceiverDirection: true });
6720
7277
  }
6721
7278
  async removeScreenMedia() {
6722
- if (!["starting", "started"].includes(this._screenShareStatus$.value)) logger$16.warn("[WebRTCManager] No active screen share to stop.");
7279
+ if (!["starting", "started"].includes(this._screenShareStatus$.value)) {
7280
+ logger$16.warn("[WebRTCManager] No active screen share to stop.");
7281
+ return;
7282
+ }
6723
7283
  if (!this._screenShareId) {
6724
7284
  logger$16.debug("[WebRTCManager] No screen share peer connection found.");
6725
7285
  return;
@@ -6734,6 +7294,10 @@ var WebRTCVertoManager = class extends VertoManager {
6734
7294
  try {
6735
7295
  if (rtcPeerConnController) await this.executeVertoBye(rtcPeerConnController);
6736
7296
  } finally {
7297
+ if (rtcPeerConnController && rtcPeerConnController.connectionState !== "connected") this._legErrors$.next({
7298
+ legId: id,
7299
+ error: new require_operators.AuxiliaryLegCancelledError(rtcPeerConnController.propose)
7300
+ });
6737
7301
  rtcPeerConnController?.destroy();
6738
7302
  this._rtcPeerConnectionsMap.delete(id);
6739
7303
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
@@ -6748,7 +7312,10 @@ var WebRTCVertoManager = class extends VertoManager {
6748
7312
  await this.executeVerto(VertoBye({
6749
7313
  ...causeParams,
6750
7314
  dialogParams: this.dialogParams(rtcPeerConnController)
6751
- }));
7315
+ }), {
7316
+ callID: rtcPeerConnController.id,
7317
+ node_id: rtcPeerConnController.nodeId ?? void 0
7318
+ });
6752
7319
  } catch (error) {
6753
7320
  logger$16.warn("[WebRTCManager] Call might already be disconnected, error sending Verto bye:", error);
6754
7321
  throw error;
@@ -7631,14 +8198,35 @@ function mosToQualityLevel(mos) {
7631
8198
  }
7632
8199
 
7633
8200
  //#endregion
7634
- //#region src/core/entities/Call.ts
7635
- const logger$12 = require_operators.getLogger();
8201
+ //#region src/utils/unwrapVertoReply.ts
7636
8202
  /**
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.
8203
+ * Unwrap a method's own reply from a `webrtc.verto` envelope.
8204
+ *
8205
+ * A control verb sent in-dialog comes back nested two levels deep:
8206
+ *
8207
+ * ```
8208
+ * { result: { node_id, code, result: { jsonrpc, id, result: <method payload> } } }
8209
+ * ```
8210
+ *
8211
+ * whereas the routed transport resolves the method payload directly under
8212
+ * `.result`. Readers want the latter shape, and the difference is silent when it
8213
+ * is wrong — `response.result.layouts` simply evaluates to `undefined` against the
8214
+ * envelope, so the data arrives, nothing throws, and the caller sees an empty
8215
+ * value. That exact failure produced an empty layout dropdown with a successful
8216
+ * request behind it.
8217
+ *
8218
+ * Accepting both shapes here keeps every reader indifferent to which transport
8219
+ * produced the response. Anything that is not a nested envelope (a plain ack, or
8220
+ * an already-unwrapped reply) passes through untouched.
7640
8221
  */
7641
- const POSITION_SET_METHOD = "call.member.position.set";
8222
+ function unwrapVertoReply(response) {
8223
+ const inner = require_operators.getValueFrom(response, "result.result");
8224
+ return inner && typeof inner === "object" && "result" in inner ? inner : response;
8225
+ }
8226
+
8227
+ //#endregion
8228
+ //#region src/core/entities/Call.ts
8229
+ const logger$12 = require_operators.getLogger();
7642
8230
  /**
7643
8231
  * Ratio between the critical and warning RTT spike multipliers.
7644
8232
  * Warning threshold = baseline * warningMultiplier (default 3x)
@@ -7734,8 +8322,11 @@ var WebRTCCall = class extends Destroyable {
7734
8322
  emitError(callError) {
7735
8323
  if (this._status$.value === "destroyed" || this._status$.value === "failed") return;
7736
8324
  this._errors$.next(callError);
7737
- if (callError.fatal) {
8325
+ if (callError.fatal && this._status$.value !== "disconnecting") {
7738
8326
  this._status$.next("failed");
8327
+ this.vertoManager.bye().catch((error) => {
8328
+ logger$12.debug("[Call] fatal-teardown bye failed (signaling likely already dead):", error);
8329
+ });
7739
8330
  this.destroy();
7740
8331
  }
7741
8332
  }
@@ -7790,7 +8381,7 @@ var WebRTCCall = class extends Destroyable {
7790
8381
  /** Toggles the call lock state, preventing or allowing new participants from joining. */
7791
8382
  async toggleLock() {
7792
8383
  const method = this.locked ? "call.unlock" : "call.lock";
7793
- await this.executeMethod(this.selfId ?? "", method, {});
8384
+ await this.executeMethod(this.callSelf, method, {});
7794
8385
  }
7795
8386
  /**
7796
8387
  * Toggles the hold state of the call (pauses/resumes local media transmission).
@@ -7839,14 +8430,25 @@ var WebRTCCall = class extends Destroyable {
7839
8430
  *
7840
8431
  * Constructs call context (node_id, call_id, member_id) and sends the RPC request.
7841
8432
  *
7842
- * @param target - Target member ID string, or a {@link MemberTarget} object.
8433
+ * @param target - Target {@link MemberTarget} triple, or the local member's
8434
+ * ID string for self-operations (any other string is rejected — a bare
8435
+ * member id cannot carry the remote member's own call context).
7843
8436
  * @param method - Verto method name (e.g. `'call.mute'`, `'call.member.remove'`).
7844
8437
  * @param args - Parameters for the RPC method.
7845
8438
  * @returns The RPC response.
8439
+ * @throws {CallNotReadyError} If the call has no self member context yet.
8440
+ * @throws {InvalidParams} If a string target is not the local member's ID.
7846
8441
  * @throws {JSONRPCError} If the RPC call returns an error.
7847
8442
  */
7848
8443
  async executeMethod(target, method, args) {
7849
- const params = this.buildMethodParams(target, args, method);
8444
+ const self = this.callSelf;
8445
+ if (typeof target === "string" && target !== self.member_id) throw new require_operators.InvalidParams(`Target member ID ${target} does not match call's self member ID ${self.member_id}`);
8446
+ if (this.clientSession.callControl === "in-dialog") return this.executeMethodInDialog(target, method, args);
8447
+ const params = {
8448
+ ...args,
8449
+ self,
8450
+ target: typeof target === "string" ? self : target
8451
+ };
7850
8452
  const request = buildRPCRequest({
7851
8453
  method,
7852
8454
  params
@@ -7860,29 +8462,98 @@ var WebRTCCall = class extends Destroyable {
7860
8462
  throw error;
7861
8463
  }
7862
8464
  }
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 ?? "",
8465
+ /**
8466
+ * `executeMethod` for a call opened with `callControl: 'in-dialog'`.
8467
+ *
8468
+ * Translates the routed transport's calling convention into the in-dialog one. No
8469
+ * `self` tuple is sent, but a `target` is — the same {call_id, member_id} the routed
8470
+ * transport puts in `target` (minus node_id), for self-ops and cross-member ops alike.
8471
+ *
8472
+ * Target shapes are per-verb and irregular, so they are centralised here rather
8473
+ * than left to callers: most verbs take a singular `target`, `call.member.remove`
8474
+ * takes a plural `targets` array, and `call.member.position.set` takes a flat
8475
+ * `targets` of `{call_id, position}` — the one verb keyed on call_id rather than
8476
+ * member_id, so the member triple `Participant.setPosition` built is unwrapped.
8477
+ */
8478
+ async executeMethodInDialog(target, method, args) {
8479
+ const control = { ...args };
8480
+ if (method === "call.member.position.set") control.targets = (args.targets ?? []).map((entry) => ({
8481
+ call_id: entry.target?.call_id ?? entry.call_id,
8482
+ position: entry.position
8483
+ }));
8484
+ else {
8485
+ const member = typeof target === "object" ? {
8486
+ call_id: target.call_id,
8487
+ member_id: target.member_id
8488
+ } : {
7883
8489
  call_id: this.id,
7884
8490
  member_id: target
7885
- }
8491
+ };
8492
+ if (method === "call.member.remove") control.targets = [member];
8493
+ else control.target = member;
8494
+ }
8495
+ return this.sendCommand(method, control);
8496
+ }
8497
+ /**
8498
+ * Sends a `call.*` control verb **in-dialog** via `verto.info`, as an alternative
8499
+ * to the routed {@link executeMethod} transport.
8500
+ *
8501
+ * Why both exist: `executeMethod` addresses the member with an explicit
8502
+ * `{node_id, call_id, member_id}` tuple, which does not resolve for every conference,
8503
+ * so the op can fail. An in-dialog frame carries the verb on the member's own
8504
+ * signaling channel instead, so control works without the client needing to know how
8505
+ * the conference is hosted.
8506
+ *
8507
+ * The trade-off is reach: the in-dialog transport is only accepted for calls that
8508
+ * join a conference over SWML (e.g. an SWML `join_conference`); use the routed
8509
+ * default otherwise.
8510
+ *
8511
+ * `params` are sent verbatim — nothing is built for you, which includes the target.
8512
+ * **A self-directed op still needs one**, or it is refused; name yourself explicitly:
8513
+ *
8514
+ * ```ts
8515
+ * const { call_id, member_id } = call.self.target;
8516
+ * await call.sendCommand('call.mute', { channels: ['audio'], target: { call_id, member_id } });
8517
+ * ```
8518
+ *
8519
+ * Never include `node_id` — only the two ids. The shapes are per-verb: most take a
8520
+ * singular `target`, `call.member.remove` takes a plural `targets` array, and
8521
+ * `call.member.position.set` takes a flat `targets: [{call_id, position}]` (the one
8522
+ * verb keyed on `call_id` rather than `member_id`). Verbs that act on the call as a
8523
+ * whole, or that the SDK does not wrap at all, take no target.
8524
+ *
8525
+ * For the typed alternative that handles all of this, create the client with
8526
+ * `callControl: 'in-dialog'` and use the ordinary `Call`/`Participant` methods.
8527
+ *
8528
+ * @internal Not part of the supported surface while the in-dialog transport is still
8529
+ * rolling out. `WebRTCCall` is exported from the package entry, so without this tag
8530
+ * TypeDoc publishes the method — and the example above — as public API.
8531
+ *
8532
+ * @param method - A `call.*` method name (e.g. `'call.mute'`).
8533
+ * @param params - Method parameters, sent verbatim.
8534
+ * @returns The method's own reply, unwrapped from the `verto.info` envelope.
8535
+ * @throws {JSONRPCError} If the control op fails.
8536
+ */
8537
+ async sendCommand(method, params = {}) {
8538
+ return unwrapVertoReply(await this.vertoManager.sendCallControl(method, params));
8539
+ }
8540
+ /**
8541
+ * The local leg's member triple — sent as `self` in every member RPC
8542
+ * envelope, and as the `target` of call-scoped self-operations (e.g. lock,
8543
+ * layout).
8544
+ *
8545
+ * @throws {CallNotReadyError} Before `call.joined` delivers the self member
8546
+ * context (`selfId`/`nodeId`) — an RPC without it cannot be routed, so fail
8547
+ * fast instead of sending a doomed request.
8548
+ */
8549
+ get callSelf() {
8550
+ const node_id = this.nodeId;
8551
+ const member_id = this.vertoManager.selfId;
8552
+ if (!node_id || !member_id) throw new require_operators.CallNotReadyError(this.id);
8553
+ return {
8554
+ node_id,
8555
+ call_id: this.id,
8556
+ member_id
7886
8557
  };
7887
8558
  }
7888
8559
  /** Observable of the current call status (e.g. `'ringing'`, `'connected'`). */
@@ -8038,13 +8709,13 @@ var WebRTCCall = class extends Destroyable {
8038
8709
  get bandwidthConstrained$() {
8039
8710
  return this.deferEmission(this._bandwidthConstrained$.asObservable());
8040
8711
  }
8041
- /** Observable that emits when server-pushed media params are applied. */
8712
+ /** Observable that emits when the server pushes media params. */
8042
8713
  get mediaParamsUpdated$() {
8043
8714
  return this.deferEmission(this._mediaParamsUpdated$.asObservable());
8044
8715
  }
8045
8716
  /**
8046
8717
  * @internal Emit a media params update event.
8047
- * Called by the VertoManager when server-pushed media params are applied.
8718
+ * Called by the VertoManager when the server pushes media params.
8048
8719
  */
8049
8720
  emitMediaParamsUpdated(event) {
8050
8721
  this._mediaParamsUpdated$.next(event);
@@ -8252,6 +8923,10 @@ var WebRTCCall = class extends Destroyable {
8252
8923
  get selfId$() {
8253
8924
  return this.vertoManager.selfId$;
8254
8925
  }
8926
+ /** @internal Lets call creation bound the media and signalling phases apart. */
8927
+ get localMediaSettled$() {
8928
+ return this.vertoManager.localMediaSettled$;
8929
+ }
8255
8930
  /** Local participant's member ID, or `null` if not joined. */
8256
8931
  get selfId() {
8257
8932
  return this.vertoManager.selfId;
@@ -8445,12 +9120,17 @@ var WebRTCCall = class extends Destroyable {
8445
9120
  *
8446
9121
  * **These operations are NOT atomic.** The layout is applied first, then each
8447
9122
  * member position sequentially, so members may briefly flash into their
8448
- * default slots before being moved to the requested positions.
9123
+ * default slots before being moved to the requested positions. Targeted
9124
+ * members are validated upfront, though: when any of them has no
9125
+ * {@link Participant.target | member call context} yet, the whole call
9126
+ * rejects before any request is sent and the layout is left unchanged.
8449
9127
  *
8450
9128
  * @param layout - Layout name (must be one of {@link layouts}).
8451
9129
  * @param positions - Optional map of member IDs to {@link VideoPosition} values.
8452
9130
  * When omitted or empty, only the layout is changed.
8453
9131
  * @throws {InvalidParams} If the layout is not in the available {@link layouts}.
9132
+ * @throws {ParticipantNotReadyError} If a targeted member's call context has
9133
+ * not been received yet — thrown before any request is sent.
8454
9134
  *
8455
9135
  * @example
8456
9136
  * ```ts
@@ -8461,18 +9141,19 @@ var WebRTCCall = class extends Destroyable {
8461
9141
  */
8462
9142
  async setLayout(layout, positions) {
8463
9143
  if (!this.layouts.includes(layout)) throw new require_operators.InvalidParams(`Layout ${layout} is not available in the current call layouts: ${this.layouts.join(", ")}`);
8464
- const selfId = await (0, rxjs.firstValueFrom)(this.selfId$.pipe((0, rxjs.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) {
9144
+ const targets = [];
9145
+ for (const [memberId, position] of Object.entries(positions ?? {})) {
8469
9146
  const participant = this.participants.find((p) => p.id === memberId);
8470
9147
  if (!participant) {
8471
9148
  logger$12.warn(`[Call] setLayout: member ${memberId} not found in participants; skipping position ${position}`);
8472
9149
  continue;
8473
9150
  }
8474
- await participant.setPosition(position);
9151
+ participant.target;
9152
+ targets.push([participant, position]);
8475
9153
  }
9154
+ const selfId = await (0, rxjs.firstValueFrom)(this.selfId$.pipe((0, rxjs.filter)((id) => id !== null)));
9155
+ await this.executeMethod(selfId, "call.layout.set", { layout });
9156
+ for (const [participant, position] of targets) await participant.setPosition(position);
8476
9157
  }
8477
9158
  /**
8478
9159
  * Transfers the call to another destination.
@@ -8574,17 +9255,28 @@ var WebRTCCall = class extends Destroyable {
8574
9255
  * (notably iOS Safari) fall back to re-acquiring the track with the new
8575
9256
  * constraint set and plumbing the replacement through the local audio
8576
9257
  * pipeline if one is active.
9258
+ *
9259
+ * @returns whether the constraint reached the microphone. `false` is an
9260
+ * outcome rather than an error — a leg sending media the SDK did not capture
9261
+ * is left alone — so a UI that reflects the toggle must read it. Any failure
9262
+ * behind a `false` is also reported on {@link errors$}.
8577
9263
  */
8578
9264
  async setEchoCancellation(enabled) {
8579
- await this.vertoManager.updateMediaConstraints({ audio: { echoCancellation: enabled } });
9265
+ return this.vertoManager.updateMediaConstraints({ audio: { echoCancellation: enabled } });
8580
9266
  }
8581
- /** Toggle browser noise suppression on the local mic at runtime. */
9267
+ /**
9268
+ * Toggle browser noise suppression on the local mic at runtime.
9269
+ * @returns whether the constraint reached the microphone.
9270
+ */
8582
9271
  async setNoiseSuppression(enabled) {
8583
- await this.vertoManager.updateMediaConstraints({ audio: { noiseSuppression: enabled } });
9272
+ return this.vertoManager.updateMediaConstraints({ audio: { noiseSuppression: enabled } });
8584
9273
  }
8585
- /** Toggle browser automatic gain control on the local mic at runtime. */
9274
+ /**
9275
+ * Toggle browser automatic gain control on the local mic at runtime.
9276
+ * @returns whether the constraint reached the microphone.
9277
+ */
8586
9278
  async setAutoGainControl(enabled) {
8587
- await this.vertoManager.updateMediaConstraints({ audio: { autoGainControl: enabled } });
9279
+ return this.vertoManager.updateMediaConstraints({ audio: { autoGainControl: enabled } });
8588
9280
  }
8589
9281
  /**
8590
9282
  * Observable of the aggregate remote audio level, 0..1 RMS. The server
@@ -8641,6 +9333,9 @@ var WebRTCCall = class extends Destroyable {
8641
9333
  /**
8642
9334
  * Infers the semantic error category from a raw Error thrown by VertoManager
8643
9335
  * or an RTCPeerConnection layer.
9336
+ *
9337
+ * Pure function — exported for unit testing.
9338
+ * @internal
8644
9339
  */
8645
9340
  function inferCallErrorKind(error) {
8646
9341
  if (error instanceof require_operators.RPCTimeoutError) return "timeout";
@@ -8659,7 +9354,14 @@ const RECOVERABLE_RPC_CODES = new Set([
8659
9354
  require_operators.RPC_ERROR_AUTHENTICATION_FAILED,
8660
9355
  require_operators.RPC_ERROR_INVALID_PARAMS
8661
9356
  ]);
8662
- /** Determines whether an error should be fatal (destroy the call). */
9357
+ /**
9358
+ * A *fallback*: callers knowing which leg failed pass an explicit `fatal` and
9359
+ * never reach here, so the default-fatal branch only sees call- and main-leg
9360
+ * errors. Auxiliary legs go through `WebRTCVertoManager.reportLegError`.
9361
+ *
9362
+ * Pure function — exported for unit testing.
9363
+ * @internal
9364
+ */
8663
9365
  function isFatalError(error) {
8664
9366
  if (error instanceof require_operators.VertoPongError) return false;
8665
9367
  if (error instanceof require_operators.MediaTrackError) return false;
@@ -8694,7 +9396,9 @@ var CallFactory = class {
8694
9396
  kind: inferCallErrorKind(error),
8695
9397
  fatal: options$1?.fatal ?? isFatalError(error),
8696
9398
  error,
8697
- callId: callInstance.id
9399
+ callId: callInstance.id,
9400
+ ...options$1?.leg ? { leg: options$1.leg } : {},
9401
+ ...options$1?.legId ? { legId: options$1.legId } : {}
8698
9402
  };
8699
9403
  callInstance.emitError(callError);
8700
9404
  },
@@ -9162,6 +9866,30 @@ const logger$9 = require_operators.getLogger();
9162
9866
  function shouldAbortDial(callError) {
9163
9867
  return callError.fatal || !(callError.error instanceof require_operators.MediaAccessError);
9164
9868
  }
9869
+ /**
9870
+ * Wait for a dialed call to be ready, or for the failure that stops it.
9871
+ *
9872
+ * Local media acquisition is deliberately unbounded: a permission prompt or a
9873
+ * device picker is human time, and `getUserMedia` cannot be cancelled anyway.
9874
+ * The clock starts only once acquisition settles, so a slow human never spends
9875
+ * the server's budget.
9876
+ *
9877
+ * `merge` rather than `race`, because the two legs settle asymmetrically. A
9878
+ * fatal acquisition failure reports the error and then destroys the call in the
9879
+ * same synchronous step; `errors$` defers delivery by a microtask while
9880
+ * `localMediaSettled$` completes immediately. Under `race` that bare completion
9881
+ * ended the wait first and `dial()` rejected with an RxJS `EmptyError`, burying
9882
+ * the `NotAllowedError` applications are told to inspect. Under `merge` the
9883
+ * completed leg is simply spent, and the queued error — enqueued before the
9884
+ * completion, so delivered before it — arrives to reject the wait. A dial
9885
+ * abandoned with no error at all still ends both legs, and the resulting
9886
+ * `EmptyError` remains the benign-cancel signal.
9887
+ *
9888
+ * Exported for unit testing.
9889
+ */
9890
+ async function awaitDialReady(session, signalingTimeoutMs) {
9891
+ return (0, rxjs.firstValueFrom)((0, rxjs.merge)(session.localMediaSettled$.pipe((0, rxjs.take)(1), (0, rxjs.switchMap)(() => session.selfId$.pipe((0, rxjs.filter)((id) => Boolean(id)), (0, rxjs.take)(1), (0, rxjs.timeout)(signalingTimeoutMs)))), session.errors$.pipe((0, rxjs.filter)(shouldAbortDial), (0, rxjs.take)(1), (0, rxjs.switchMap)((callError) => (0, rxjs.throwError)(() => callError.error)))));
9892
+ }
9165
9893
  const getAddressSearchURI = (options) => {
9166
9894
  const to = options.to?.split("?")[0];
9167
9895
  const from$8 = options.from?.startsWith("subscriber://") ? options.from.replace("subscriber://", "") : options.from;
@@ -9178,7 +9906,6 @@ var ClientSessionManager = class extends Destroyable {
9178
9906
  this.authorizationStateKey = authorizationStateKey;
9179
9907
  this.attachManager = attachManager;
9180
9908
  this.dpopManager = dpopManager;
9181
- this.callCreateTimeout = 6e3;
9182
9909
  this.agent = `signalwire-js/4.0.0`;
9183
9910
  this.eventAcks = true;
9184
9911
  this.authorizationState$ = this.createReplaySubject(1);
@@ -9187,6 +9914,7 @@ var ClientSessionManager = class extends Destroyable {
9187
9914
  minor: 0,
9188
9915
  revision: 0
9189
9916
  };
9917
+ this.callControl = "routed";
9190
9918
  this._authorization$ = this.createBehaviorSubject(void 0);
9191
9919
  this._errors$ = this.createReplaySubject(1);
9192
9920
  this._authState$ = this.createBehaviorSubject({ kind: "unauthenticated" });
@@ -9386,22 +10114,23 @@ var ClientSessionManager = class extends Destroyable {
9386
10114
  await (0, rxjs.firstValueFrom)(this.authenticated$.pipe((0, rxjs.takeUntil)(this.destroyed$), (0, rxjs.filter)(Boolean), (0, rxjs.take)(1), (0, rxjs.timeout)({ first: 15e3 })));
9387
10115
  }
9388
10116
  async handleAuthenticationError(error) {
10117
+ if (error instanceof require_operators.TransportConnectionError) {
10118
+ logger$9.debug("[Session] Socket closed during authentication, waiting for reconnect");
10119
+ return;
10120
+ }
9389
10121
  logger$9.error("Authentication error:", error);
9390
- const isRecoverableAuthError = error instanceof require_operators.JSONRPCError && (error.code === require_operators.RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === require_operators.RPC_ERROR_INVALID_PARAMS || error.code === require_operators.RPC_ERROR_AUTHENTICATION_FAILED);
10122
+ const isRecoverableAuthError$1 = error instanceof require_operators.JSONRPCError && (error.code === require_operators.RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === require_operators.RPC_ERROR_INVALID_PARAMS || error.code === require_operators.RPC_ERROR_AUTHENTICATION_FAILED);
9391
10123
  const hasStoredState = await (0, rxjs.firstValueFrom)(this.authorizationState$.pipe((0, rxjs.take)(1))) !== void 0;
9392
- if (isRecoverableAuthError && hasStoredState) {
10124
+ if (isRecoverableAuthError$1 && hasStoredState) {
9393
10125
  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
- }
10126
+ await this.discardResumeStateAndReconnect();
9401
10127
  } else this._errors$.next(error);
9402
10128
  }
9403
10129
  /**
9404
- * Clear the resume state (authorization_state + protocol) only.
10130
+ * Clear the resume state (authorization_state + protocol) and ask the
10131
+ * transport to reconnect. The `connected` event re-triggers
10132
+ * `authenticate()`, which now has no stored state and so performs a fresh
10133
+ * connect.
9405
10134
  *
9406
10135
  * This is the stale-auth-state recovery helper used by handleAuthError:
9407
10136
  * the server rejected a reconnect, so the resume state is discarded and a
@@ -9409,9 +10138,24 @@ var ClientSessionManager = class extends Destroyable {
9409
10138
  * session lives on through the reconnect and reattachCalls() needs the
9410
10139
  * stored call references afterwards. Do NOT add detachAll() here.
9411
10140
  *
10141
+ * Connect-time recovery only. A *request* refused on an already
10142
+ * authenticated session is never healed here: dropping the resume state
10143
+ * destroys the association between the socket and the previous session,
10144
+ * which is what reattach depends on. That path mints a fresh credential and
10145
+ * reauthenticates instead (see `SignalWire.recoverAndRetry`).
10146
+ *
9412
10147
  * For public teardown (disconnect/destroy), use {@link teardownSessionState}
9413
10148
  * instead, which clears the attach records as well.
9414
10149
  */
10150
+ async discardResumeStateAndReconnect() {
10151
+ try {
10152
+ await this.cleanupStoredConnectionParams();
10153
+ } catch (cleanupError) {
10154
+ logger$9.error("Failed to cleanup stored connection params:", cleanupError);
10155
+ } finally {
10156
+ this.transport.reconnect();
10157
+ }
10158
+ }
9415
10159
  async cleanupStoredConnectionParams() {
9416
10160
  await this.transport.setProtocol(void 0);
9417
10161
  await this.updateAuthorizationStateInStorage(void 0);
@@ -9485,11 +10229,15 @@ var ClientSessionManager = class extends Destroyable {
9485
10229
  const isReconnect = hasReconnectState && storedToken;
9486
10230
  let dpopToken;
9487
10231
  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();
10232
+ else {
10233
+ const credential = this.getCredential();
10234
+ const credentialExpired = credential.expiry_at !== void 0 && credential.expiry_at <= Date.now() + require_operators.CREDENTIAL_EXPIRY_SKEW_MS;
10235
+ if (this.onBeforeReconnect && (this.clientBound || credentialExpired)) {
10236
+ logger$9.debug("[Session] Refreshing credentials before fresh connect");
10237
+ await this.onBeforeReconnect();
10238
+ }
9491
10239
  }
9492
- if ((!isReconnect || this.clientBound) && this.dpopManager?.initialized) try {
10240
+ if (this.dpopManager?.initialized) try {
9493
10241
  dpopToken = await this.dpopManager.createRpcProof({ method: "signalwire.connect" });
9494
10242
  } catch (error) {
9495
10243
  if (this.clientBound) throw error;
@@ -9509,7 +10257,9 @@ var ClientSessionManager = class extends Destroyable {
9509
10257
  protocol: persistedParams.protocol
9510
10258
  } : {}
9511
10259
  });
9512
- const response = await (0, rxjs.lastValueFrom)((0, rxjs.from)(this.transport.execute(rpcConnectRequest)).pipe(require_operators.throwOnRPCError(), (0, rxjs.map)((res) => res.result), (0, rxjs.filter)(isRPCConnectResult), (0, rxjs.tap)(() => {
10260
+ if (this.transport.connectionStatus !== "connected") throw new require_operators.TransportConnectionError("WebSocket closed during authentication");
10261
+ const socketClosed$ = this.transport.connectionStatus$.pipe((0, rxjs.skip)(1), (0, rxjs.filter)((status) => status === "reconnecting" || status === "disconnected"), (0, rxjs.take)(1), (0, rxjs.switchMap)(() => (0, rxjs.throwError)(() => new require_operators.TransportConnectionError("WebSocket closed during authentication"))));
10262
+ const response = await (0, rxjs.lastValueFrom)((0, rxjs.race)((0, rxjs.from)(this.transport.execute(rpcConnectRequest)), socketClosed$).pipe(require_operators.throwOnRPCError(), (0, rxjs.map)((res) => res.result), (0, rxjs.filter)(isRPCConnectResult), (0, rxjs.tap)(() => {
9513
10263
  logger$9.debug("[Session] Response passed filter, processing authentication result");
9514
10264
  }), (0, rxjs.take)(1), (0, rxjs.catchError)((err) => {
9515
10265
  logger$9.error("[Session] Authentication RPC failed:", err);
@@ -9522,7 +10272,9 @@ var ClientSessionManager = class extends Destroyable {
9522
10272
  });
9523
10273
  if (response.protocol) await this.transport.setProtocol(response.protocol);
9524
10274
  this._authorization$.next(response.authorization);
10275
+ if (response.authorization.cnf?.jkt) this._wasClientBound = true;
9525
10276
  this._iceServers$.next(response.ice_servers ?? []);
10277
+ this.transport.setAuthenticated();
9526
10278
  this._authState$.next({ kind: "authenticated" });
9527
10279
  logger$9.debug("[Session] Authentication completed successfully");
9528
10280
  }
@@ -9588,7 +10340,7 @@ var ClientSessionManager = class extends Destroyable {
9588
10340
  to: destinationURI,
9589
10341
  ...options
9590
10342
  });
9591
- await (0, rxjs.firstValueFrom)((0, rxjs.race)(callSession.selfId$.pipe((0, rxjs.filter)((id) => Boolean(id)), (0, rxjs.take)(1), (0, rxjs.timeout)(this.callCreateTimeout)), callSession.errors$.pipe((0, rxjs.filter)(shouldAbortDial), (0, rxjs.take)(1), (0, rxjs.switchMap)((callError) => (0, rxjs.throwError)(() => callError.error)))));
10343
+ await awaitDialReady(callSession, require_operators.DEFAULT_CALL_SIGNALING_TIMEOUT_MS);
9592
10344
  this._calls$.next({
9593
10345
  [`${callSession.id}`]: callSession,
9594
10346
  ...this._calls$.value
@@ -9641,12 +10393,23 @@ var ClientSessionWrapper = class {
9641
10393
  get authenticated() {
9642
10394
  return this.clientSessionManager.authenticated;
9643
10395
  }
10396
+ /**
10397
+ * Whether the session is using a Client Bound SAT (DPoP). Sticky — set
10398
+ * when the binding is established or restored from a resumed session's
10399
+ * server authorization.
10400
+ */
10401
+ get clientBound() {
10402
+ return this.clientSessionManager.clientBound;
10403
+ }
9644
10404
  get signalingEvent$() {
9645
10405
  return this.clientSessionManager.signalingEvent$;
9646
10406
  }
9647
10407
  get iceServers() {
9648
10408
  return this.clientSessionManager.iceServers;
9649
10409
  }
10410
+ get callControl() {
10411
+ return this.clientSessionManager.callControl;
10412
+ }
9650
10413
  async execute(request, options) {
9651
10414
  return this.clientSessionManager.execute(request, options);
9652
10415
  }
@@ -9841,7 +10604,7 @@ var DeviceTokenManager = class extends Destroyable {
9841
10604
  await session.reauthenticate(tokenData.token, rpcProof, { clientBound: true });
9842
10605
  updateCredential({ token: tokenData.token });
9843
10606
  logger$7.info("[DeviceToken] Client Bound SAT activated successfully");
9844
- this._currentToken$.next(tokenData);
10607
+ this.emitCurrentToken(tokenData);
9845
10608
  return { activated: true };
9846
10609
  } catch (error) {
9847
10610
  logger$7.error("[DeviceToken] Failed to activate Client Bound SAT:", error);
@@ -9853,6 +10616,21 @@ var DeviceTokenManager = class extends Destroyable {
9853
10616
  }
9854
10617
  }
9855
10618
  /**
10619
+ * Emit a freshly received token to the reactive pipeline, stamping an
10620
+ * absolute `expires_at` when the response carried only `expires_in`.
10621
+ * Resolving the expiry at RECEIVE time (not at read time) is what lets
10622
+ * {@link refreshNowIfDue} detect due-ness on resume: a bare `expires_in`
10623
+ * re-resolved later would always compute a full TTL from "now" and never
10624
+ * cross the refresh buffer.
10625
+ */
10626
+ emitCurrentToken(token) {
10627
+ const stamped = token.expires_at ? token : {
10628
+ ...token,
10629
+ expires_at: resolveExpiresAt(token)
10630
+ };
10631
+ this._currentToken$.next(stamped);
10632
+ }
10633
+ /**
9856
10634
  * Returns true when the cached token has enough headroom before expiry to
9857
10635
  * be safely reused on reactivation. The headroom matches the refresh
9858
10636
  * buffer, so a token within the refresh window is treated as stale (the
@@ -9870,7 +10648,7 @@ var DeviceTokenManager = class extends Destroyable {
9870
10648
  method: "POST",
9871
10649
  uri: require_operators.DEVICE_TOKEN_ENDPOINT
9872
10650
  });
9873
- const response = await this.http.request({
10651
+ const response = await this.http().request({
9874
10652
  url: require_operators.DEVICE_TOKEN_ENDPOINT,
9875
10653
  ...POST_PARAMS,
9876
10654
  body: JSON.stringify({
@@ -9897,7 +10675,7 @@ var DeviceTokenManager = class extends Destroyable {
9897
10675
  uri: require_operators.DEVICE_REFRESH_ENDPOINT,
9898
10676
  accessToken: currentToken
9899
10677
  });
9900
- const response = await this.http.request({
10678
+ const response = await this.http().request({
9901
10679
  url: require_operators.DEVICE_REFRESH_ENDPOINT,
9902
10680
  ...POST_PARAMS,
9903
10681
  body: JSON.stringify({
@@ -9945,7 +10723,7 @@ var DeviceTokenManager = class extends Destroyable {
9945
10723
  const currentToken = this.getCredential().token;
9946
10724
  if (!currentToken) throw new require_operators.TokenRefreshError("No current token available for refresh");
9947
10725
  const newTokenData = await this.retryRefresh(session, currentToken, updateCredential);
9948
- this._currentToken$.next(newTokenData);
10726
+ this.emitCurrentToken(newTokenData);
9949
10727
  } catch (error) {
9950
10728
  logger$7.error("[DeviceToken] Automatic Client Bound SAT refresh failed:", error);
9951
10729
  this.errorHandler(error instanceof require_operators.TokenRefreshError ? error : new require_operators.TokenRefreshError("Automatic token refresh failed", error));
@@ -9972,6 +10750,22 @@ var DeviceTokenManager = class extends Destroyable {
9972
10750
  throw lastError instanceof Error ? lastError : new require_operators.TokenRefreshError("All refresh retries exhausted", lastError);
9973
10751
  }
9974
10752
  /**
10753
+ * Force an immediate refresh when the cached Client Bound SAT is already
10754
+ * past its refresh window. Called on resume from suspension where
10755
+ * background-tab throttling can delay the reactive timer past the buffer.
10756
+ * A no-op when no token is cached or it still has headroom; the normal
10757
+ * {@link executeRefresh} guards (paused / in-progress / unauthenticated)
10758
+ * still apply.
10759
+ */
10760
+ refreshNowIfDue() {
10761
+ const token = this._currentToken$.value;
10762
+ if (!token) return;
10763
+ if (resolveExpiresAt(token) * 1e3 - Date.now() <= require_operators.DEVICE_TOKEN_REFRESH_BUFFER_MS) {
10764
+ logger$7.debug("[DeviceToken] Resume: cached SAT past refresh window; refreshing now");
10765
+ this.executeRefresh();
10766
+ }
10767
+ }
10768
+ /**
9975
10769
  * Stops the reactive refresh pipeline from firing. Use when the underlying
9976
10770
  * session is being torn down (e.g., during {@link SignalWire.disconnect})
9977
10771
  * so a scheduled refresh cannot fire against a destroyed session.
@@ -10022,6 +10816,7 @@ var CredentialRefreshCoordinator = class extends Destroyable {
10022
10816
  this.deps = deps;
10023
10817
  this._activating = false;
10024
10818
  this._activationGeneration = 0;
10819
+ this._developerRefreshInProgress = false;
10025
10820
  if (dpopManager?.initialized) this._deviceTokenManager = (deps.deviceTokenManagerFactory ?? defaultDeviceTokenManagerFactory)(dpopManager, deps.http, (error) => deps.notifier.onError(error), () => deps.store.read());
10026
10821
  }
10027
10822
  /** True when the Client Bound SAT path is available (DPoP initialized). */
@@ -10040,28 +10835,120 @@ var CredentialRefreshCoordinator = class extends Destroyable {
10040
10835
  * invokes `deps.onRefreshExhausted` so the orchestrator can disconnect.
10041
10836
  */
10042
10837
  scheduleDeveloperRefresh(provider, expiresAt, attempt = 0) {
10838
+ this._activeProvider = provider;
10043
10839
  if (this._developerTimerId !== void 0) clearTimeout(this._developerTimerId);
10044
10840
  const refreshInterval = attempt === 0 ? Math.max(expiresAt - Date.now() - require_operators.CREDENTIAL_REFRESH_BUFFER_MS, 1e3) : Math.min(require_operators.CREDENTIAL_REFRESH_RETRY_BASE_MS * Math.pow(2, attempt) * (.5 + Math.random() * .5), require_operators.CREDENTIAL_REFRESH_MAX_DELAY_MS);
10045
- this._developerTimerId = setTimeout(async () => {
10841
+ this._developerTimerId = setTimeout(() => {
10842
+ this._developerTimerId = void 0;
10843
+ this.executeDeveloperRefresh(provider, expiresAt, attempt);
10844
+ }, refreshInterval);
10845
+ }
10846
+ /**
10847
+ * Runs the developer-provided refresh once: mints a new credential, stores
10848
+ * and persists it, reauthenticates the live session (via the notifier), and
10849
+ * reschedules against the new expiry. On failure retries with backoff up to
10850
+ * {@link CREDENTIAL_REFRESH_MAX_RETRIES}, then signals exhaustion.
10851
+ *
10852
+ * Shared by the scheduled timer tick and {@link forceRefreshIfDue}. The
10853
+ * `_developerRefreshInProgress` guard prevents the two from overlapping.
10854
+ */
10855
+ async executeDeveloperRefresh(provider, expiresAt, attempt) {
10856
+ if (this._developerRefreshInProgress) {
10857
+ logger$6.debug("[Coordinator] Developer refresh already in progress; skipping");
10858
+ return;
10859
+ }
10860
+ this._developerRefreshInProgress = true;
10861
+ try {
10862
+ const newCredentials = await this.refreshCredential(provider);
10863
+ this.deps.store.write(newCredentials);
10864
+ this.deps.store.persist(newCredentials);
10046
10865
  try {
10047
- if (!provider.refresh) throw new require_operators.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}/${require_operators.CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
10056
- this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
10057
- if (nextAttempt < require_operators.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 require_operators.TokenRefreshError("Credential refresh failed after max retries"));
10061
- this.deps.notifier.onRefreshExhausted();
10062
- }
10866
+ await this.deps.notifier.onCredentialRefreshed(newCredentials);
10867
+ } catch (reauthError) {
10868
+ logger$6.warn("[Coordinator] onCredentialRefreshed rejected (non-fatal):", reauthError);
10063
10869
  }
10064
- }, refreshInterval);
10870
+ logger$6.info("[Coordinator] Credentials refreshed successfully.");
10871
+ if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
10872
+ } catch (error) {
10873
+ const nextAttempt = attempt + 1;
10874
+ logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${require_operators.CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
10875
+ this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
10876
+ if (nextAttempt < require_operators.CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
10877
+ else {
10878
+ logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
10879
+ this.deps.notifier.onError(new require_operators.TokenRefreshError("Credential refresh failed after max retries"));
10880
+ this.deps.notifier.onRefreshExhausted();
10881
+ }
10882
+ } finally {
10883
+ this._developerRefreshInProgress = false;
10884
+ }
10885
+ }
10886
+ /**
10887
+ * Force an immediate refresh when the current credential is already past its
10888
+ * scheduled refresh window. Called on resume from suspension, where
10889
+ * background-tab timer throttling can delay the armed refresh well past
10890
+ * expiry, leaving the live session stale.
10891
+ *
10892
+ * Routes to whichever mechanism is armed: the developer timer if armed,
10893
+ * otherwise the Client Bound SAT pipeline. A no-op when nothing is due.
10894
+ */
10895
+ forceRefreshIfDue() {
10896
+ if (this._developerTimerId !== void 0 && this._activeProvider) {
10897
+ const expiry = this.deps.store.read().expiry_at;
10898
+ if (expiry !== void 0 && Date.now() >= expiry - require_operators.CREDENTIAL_REFRESH_BUFFER_MS) {
10899
+ logger$6.debug("[Coordinator] Resume: credential past refresh window; forcing refresh");
10900
+ clearTimeout(this._developerTimerId);
10901
+ this._developerTimerId = void 0;
10902
+ this.executeDeveloperRefresh(this._activeProvider, expiry, 0);
10903
+ }
10904
+ return;
10905
+ }
10906
+ this._deviceTokenManager?.refreshNowIfDue();
10907
+ }
10908
+ /**
10909
+ * Sync the credential's expiry from the server-provided authorization (the
10910
+ * `signalwire.connect` result). SATs are opaque JWE, so
10911
+ * `fabric_subscriber.expires_at` is the authoritative expiry of the token
10912
+ * the session actually connected with — the provider-reported `expiry_at`
10913
+ * is only a hint (and may be wrong or absent). Corrects the stored
10914
+ * credential and re-arms the developer refresh timer against the real
10915
+ * deadline when the provider supports `refresh()`.
10916
+ */
10917
+ syncExpiryFromAuthorization(authorization, provider) {
10918
+ const expiresAtSec = authorization?.fabric_subscriber?.expires_at;
10919
+ if (!expiresAtSec) return;
10920
+ const expiryAt = expiresAtSec * 1e3;
10921
+ const credential = this.deps.store.read();
10922
+ if (credential.expiry_at === expiryAt) return;
10923
+ logger$6.debug(`[Coordinator] Correcting credential expiry from server authorization: ${new Date(expiryAt).toISOString()}`);
10924
+ const updated = {
10925
+ ...credential,
10926
+ expiry_at: expiryAt
10927
+ };
10928
+ this.deps.store.write(updated);
10929
+ this.deps.store.persist(updated);
10930
+ if (provider?.refresh) this.scheduleDeveloperRefresh(provider, expiryAt);
10931
+ }
10932
+ /**
10933
+ * Invoke `provider.refresh()` deduped against any concurrent developer
10934
+ * refresh. Concurrent callers — the scheduled tick, a resume-forced refresh,
10935
+ * and the orchestrator's -32003 recovery / reconnect re-mint — share one
10936
+ * in-flight promise, so a provider backed by one-time-use rotating refresh
10937
+ * tokens is never invoked twice in parallel.
10938
+ *
10939
+ * The caller owns applying the returned credential (store write, session
10940
+ * reauth, rescheduling); this method only serializes the network call.
10941
+ */
10942
+ async refreshCredential(provider) {
10943
+ if (this._refreshInFlight) return this._refreshInFlight;
10944
+ if (!provider.refresh) throw new require_operators.InvalidCredentialsError("Credential provider does not support refresh");
10945
+ const run = provider.refresh();
10946
+ this._refreshInFlight = run;
10947
+ const clear = () => {
10948
+ if (this._refreshInFlight === run) this._refreshInFlight = void 0;
10949
+ };
10950
+ run.then(clear, clear);
10951
+ return run;
10065
10952
  }
10066
10953
  /**
10067
10954
  * Cancels any scheduled developer-provided refresh. Idempotent.
@@ -10580,6 +11467,7 @@ var WebSocketController = class WebSocketController extends Destroyable {
10580
11467
  }
10581
11468
  handleClose(_event) {
10582
11469
  this.clearConnectionTimeout();
11470
+ this.messageQueue = [];
10583
11471
  if (this.shouldReconnect) {
10584
11472
  this._status$.next("reconnecting");
10585
11473
  this.scheduleReconnection();
@@ -10649,6 +11537,9 @@ var WebSocketController = class WebSocketController extends Destroyable {
10649
11537
  function isSignalwirePingRequest(value) {
10650
11538
  return isJSONRPCRequest(value) && value.method === "signalwire.ping";
10651
11539
  }
11540
+ function isSignalwireConnectRequest(value) {
11541
+ return isJSONRPCRequest(value) && value.method === "signalwire.connect";
11542
+ }
10652
11543
 
10653
11544
  //#endregion
10654
11545
  //#region src/managers/TransportManager.ts
@@ -10662,6 +11553,10 @@ var TransportManager = class extends Destroyable {
10662
11553
  this.protocol$ = this.createReplaySubject(1);
10663
11554
  this.isConnecting = false;
10664
11555
  this.isConnected = false;
11556
+ this.socketStatus = "disconnected";
11557
+ this.sessionAuthenticated = false;
11558
+ this.heldRequests = [];
11559
+ this.serverPingWatch = 0;
10665
11560
  this.ackEvent = () => {
10666
11561
  return (0, rxjs.tap)((message) => {
10667
11562
  if (isSignalwireRequest(message)) try {
@@ -10677,6 +11572,7 @@ var TransportManager = class extends Destroyable {
10677
11572
  if (isSignalwirePingRequest(message)) {
10678
11573
  try {
10679
11574
  logger$2.debug("[Transport] Received ping, sending pong", { pingId: message.id });
11575
+ if (this.sessionAuthenticated) this.armServerPingWatchdog();
10680
11576
  this.send(RPCPingResponse(message.id));
10681
11577
  } catch (error) {
10682
11578
  logger$2.error("[Transport] Failed to send ping response:", error);
@@ -10714,6 +11610,14 @@ var TransportManager = class extends Destroyable {
10714
11610
  this.subscribeTo(this._webSocketConnections.errors$, (error) => {
10715
11611
  this.onError?.(error);
10716
11612
  });
11613
+ this.subscribeTo(this._webSocketConnections.status$, (status) => {
11614
+ this.socketStatus = status;
11615
+ if (status !== "connected") {
11616
+ this.sessionAuthenticated = false;
11617
+ this.clearServerPingWatchdog();
11618
+ }
11619
+ if (status === "disconnected") this.heldRequests = [];
11620
+ });
10717
11621
  this.initialized$ = (0, rxjs.defer)(() => (0, rxjs.from)(this._init())).pipe((0, rxjs.shareReplay)(1), (0, rxjs.takeUntil)(this.destroyed$));
10718
11622
  this._jsonRPCMessage$ = this._webSocketConnections.incomingMessages$.pipe((0, rxjs.map)((event) => {
10719
11623
  try {
@@ -10742,6 +11646,9 @@ var TransportManager = class extends Destroyable {
10742
11646
  get connectionStatus$() {
10743
11647
  return this._webSocketConnections.status$;
10744
11648
  }
11649
+ get connectionStatus() {
11650
+ return this.socketStatus;
11651
+ }
10745
11652
  async connect() {
10746
11653
  if (this.isConnecting || this.isConnected) {
10747
11654
  logger$2.warn("[Transport] Already connecting or connected");
@@ -10786,16 +11693,45 @@ var TransportManager = class extends Destroyable {
10786
11693
  }
10787
11694
  async execute(request, options) {
10788
11695
  this.send(request);
10789
- return new PendingRPC(request, this._jsonRPCResponse$, options).promise;
11696
+ const pending = new PendingRPC(request, this._jsonRPCResponse$, options).promise;
11697
+ pending.catch(() => {
11698
+ this.heldRequests = this.heldRequests.filter((held) => held.id !== request.id);
11699
+ });
11700
+ return pending;
10790
11701
  }
10791
11702
  send(message) {
10792
11703
  const payload = JSON.stringify(message);
11704
+ if (isJSONRPCResponse(message)) {
11705
+ if (this.socketStatus !== "connected") {
11706
+ logger$2.debug("[Transport] Dropping response while disconnected", { id: message.id });
11707
+ return;
11708
+ }
11709
+ } else if (!this.sessionAuthenticated && !isSignalwireConnectRequest(message)) {
11710
+ this.heldRequests.push({
11711
+ id: message.id,
11712
+ payload
11713
+ });
11714
+ return;
11715
+ }
10793
11716
  this._outgoingMessages$.next(payload);
10794
11717
  }
11718
+ /**
11719
+ * Called by the session when signalwire.connect succeeds on the current
11720
+ * socket. Sends the requests held since the socket opened, in order.
11721
+ */
11722
+ setAuthenticated() {
11723
+ if (this.socketStatus !== "connected") return;
11724
+ this.sessionAuthenticated = true;
11725
+ this.armServerPingWatchdog();
11726
+ const held = this.heldRequests;
11727
+ this.heldRequests = [];
11728
+ held.forEach(({ payload }) => this._outgoingMessages$.next(payload));
11729
+ }
10795
11730
  disconnect() {
10796
11731
  logger$2.debug("[Transport] Disconnecting");
10797
11732
  this.isConnected = false;
10798
11733
  this.isConnecting = false;
11734
+ this.clearServerPingWatchdog();
10799
11735
  this._webSocketConnections.disconnect();
10800
11736
  }
10801
11737
  destroy() {
@@ -10804,6 +11740,28 @@ var TransportManager = class extends Destroyable {
10804
11740
  super.destroy();
10805
11741
  this._webSocketConnections.destroy();
10806
11742
  }
11743
+ armServerPingWatchdog() {
11744
+ this.clearServerPingWatchdog();
11745
+ const watch = this.serverPingWatch;
11746
+ this.serverPingTimer = setTimeout(() => void this.probeServer(watch), require_operators.SERVER_PING_TIMEOUT_MS);
11747
+ }
11748
+ /** Bumps the watch so a probe still in flight no longer acts on its result. */
11749
+ clearServerPingWatchdog() {
11750
+ clearTimeout(this.serverPingTimer);
11751
+ this.serverPingTimer = void 0;
11752
+ this.serverPingWatch += 1;
11753
+ }
11754
+ async probeServer(watch) {
11755
+ logger$2.debug(`[Transport] No signalwire.ping for ${require_operators.SERVER_PING_TIMEOUT_MS}ms, probing the server`);
11756
+ try {
11757
+ await this.execute(RPCPing(), { timeoutMs: require_operators.SERVER_PING_PROBE_TIMEOUT_MS });
11758
+ if (watch === this.serverPingWatch) this.armServerPingWatchdog();
11759
+ } catch (error) {
11760
+ if (watch !== this.serverPingWatch) return;
11761
+ logger$2.warn("[Transport] Server did not answer the ping probe, replacing the socket", error);
11762
+ this.reconnect();
11763
+ }
11764
+ }
10807
11765
  async _loadProtocolFromStorage() {
10808
11766
  try {
10809
11767
  const storedProtocol = await this.storage.getItem(this.protocolKey);
@@ -10841,6 +11799,13 @@ var TransportManager = class extends Destroyable {
10841
11799
  //#endregion
10842
11800
  //#region src/clients/SignalWire.ts
10843
11801
  const logger$1 = require_operators.getLogger();
11802
+ /**
11803
+ * Storage key for the client-bound marker. The SAT and authorization_state are
11804
+ * both opaque to the SDK, so on a page reload the preflight recovery — which
11805
+ * runs before any session exists — has no other way to know the session was
11806
+ * client-bound. See {@link SignalWire.persistClientBoundMarker}.
11807
+ */
11808
+ const CLIENT_BOUND_STORAGE_KEY = "sw:client_bound";
10844
11809
  const buildOptionsFromDestination = (destination) => {
10845
11810
  if (typeof destination === "string") {
10846
11811
  const queryStartIndex = destination.indexOf("?");
@@ -10884,6 +11849,7 @@ var SignalWire = class extends Destroyable {
10884
11849
  this.preferences = new ClientPreferences();
10885
11850
  this._user$ = this.createBehaviorSubject(void 0);
10886
11851
  this._directory$ = this.createBehaviorSubject(void 0);
11852
+ this._credentialRecovered = false;
10887
11853
  this._isConnected$ = this.createBehaviorSubject(false);
10888
11854
  this._isRegistered$ = this.createBehaviorSubject(false);
10889
11855
  this._errors$ = this.createReplaySubject(1);
@@ -10922,6 +11888,16 @@ var SignalWire = class extends Destroyable {
10922
11888
  });
10923
11889
  }
10924
11890
  /**
11891
+ * Build the refresh path's own HTTP controller, against whatever host is current.
11892
+ *
11893
+ * Called on first use rather than up front, so `apiHost` already reflects the
11894
+ * token's `ch` claim. Same credential source as the container's controller — only
11895
+ * the instance, and therefore its observable streams, is separate.
11896
+ */
11897
+ createRefreshHttpController() {
11898
+ return new HTTPRequestController(this._deps.apiHost, () => this._deps.credential);
11899
+ }
11900
+ /**
10925
11901
  * Initializes DPoP if not already set up. Returns the fingerprint on success.
10926
11902
  */
10927
11903
  async initDPoP() {
@@ -10948,11 +11924,19 @@ var SignalWire = class extends Destroyable {
10948
11924
  async resolveCredentials() {
10949
11925
  const fingerprint = await this.initDPoP();
10950
11926
  this._refreshCoordinator = new CredentialRefreshCoordinator(this._dpopManager, {
10951
- http: this._deps.http,
11927
+ http: () => {
11928
+ if (!this._refreshHttp || this._refreshHttpHost !== this._deps.apiHost) {
11929
+ this._refreshHttp?.destroy();
11930
+ this._refreshHttp = this.createRefreshHttpController();
11931
+ this._refreshHttpHost = this._deps.apiHost;
11932
+ }
11933
+ return this._refreshHttp;
11934
+ },
10952
11935
  notifier: {
10953
11936
  onError: (error) => this._errors$.next(error),
10954
11937
  onWarning: (warning) => this._warnings$.next(warning),
10955
- onRefreshExhausted: () => void this.disconnect()
11938
+ onRefreshExhausted: () => void this.disconnect(),
11939
+ onCredentialRefreshed: async (credential) => this.reauthenticateLiveSession(credential)
10956
11940
  },
10957
11941
  store: {
10958
11942
  read: () => this._deps.credential,
@@ -10964,6 +11948,7 @@ var SignalWire = class extends Destroyable {
10964
11948
  ...this._deps.credential,
10965
11949
  ...partial
10966
11950
  };
11951
+ this.persistCredential(this._deps.credential);
10967
11952
  },
10968
11953
  persist: (credential) => this.persistCredential(credential)
10969
11954
  }
@@ -11009,20 +11994,181 @@ var SignalWire = class extends Destroyable {
11009
11994
  }
11010
11995
  this._deps.credential = _credentials;
11011
11996
  this.persistCredential(_credentials);
11012
- if (this.isConnected && this._clientSession.authenticated && _credentials.token) try {
11013
- await this._clientSession.reauthenticate(_credentials.token);
11997
+ await this.reauthenticateLiveSession(_credentials);
11998
+ }
11999
+ /**
12000
+ * Reauthenticate the currently-open session with a freshly obtained
12001
+ * credential so the new token takes effect on the live socket immediately —
12002
+ * not just on the next reconnect. No-op when the session is not
12003
+ * connected/authenticated or the credential carries no token (e.g. an
12004
+ * authorization-state-only refresh). Non-fatal: reauth failures surface on
12005
+ * `errors$` without aborting the refresh that triggered this.
12006
+ */
12007
+ async reauthenticateLiveSession(credential) {
12008
+ if (!this.isConnected || !this._clientSession.authenticated || !credential.token) return;
12009
+ try {
12010
+ await this._clientSession.reauthenticate(credential.token);
11014
12011
  logger$1.info("[SignalWire] Session refreshed with new credentials.");
11015
12012
  } catch (error) {
11016
12013
  logger$1.error("[SignalWire] Failed to refresh session with new credentials:", error);
11017
12014
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
11018
12015
  }
11019
12016
  }
12017
+ /**
12018
+ * Recover a session the server is refusing: mint a fresh credential,
12019
+ * reauthenticate the live session with it, and retry the operation.
12020
+ *
12021
+ * The connection is deliberately kept. A reload authenticates the new socket
12022
+ * against the persisted `authorization_state`, and that handshake is what
12023
+ * associates the socket with the previous session — the association reattach
12024
+ * depends on. `signalwire.reauthenticate` swaps the credential *on that same
12025
+ * session*, so recovery never touches the resume state. Discarding it would
12026
+ * heal the credential by destroying the very thing the caller is trying to
12027
+ * get back to.
12028
+ *
12029
+ * The operation is still the verdict, never the RPC. Reauthenticating with
12030
+ * the in-memory token is accepted by a resume even while requests stay
12031
+ * refused, because the persisted `authorization_state` short-circuits token
12032
+ * validation — and `signalwire.reauthenticate` with a *freshly minted* token
12033
+ * has also been observed accepted while `subscriber.online` keeps being
12034
+ * refused (staging run 33826974634). Both look like success and are not.
12035
+ *
12036
+ * @returns the operation's value, or the reason recovery could not deliver
12037
+ * one. `error` is undefined when there was no way to mint at all.
12038
+ */
12039
+ async recoverAndRetry(operation) {
12040
+ if (!await this.remintAndReauthenticate()) return { ok: false };
12041
+ try {
12042
+ const value = await operation();
12043
+ this._credentialRecovered = true;
12044
+ return {
12045
+ ok: true,
12046
+ value
12047
+ };
12048
+ } catch (error) {
12049
+ logger$1.warn("[SignalWire] Reauthentication was accepted but the operation is still refused:", error);
12050
+ return {
12051
+ ok: false,
12052
+ error
12053
+ };
12054
+ }
12055
+ }
12056
+ /**
12057
+ * Re-mint a credential and adopt it only if the live session accepts it.
12058
+ *
12059
+ * The mechanism follows the binding: a client-bound session re-mints a bound
12060
+ * base SAT through `authenticate()` with the DPoP fingerprint, because the
12061
+ * developer refresh handler would hand back an unbound token and silently
12062
+ * degrade the session. An unbound session uses the refresh handler. Rotation
12063
+ * cost is not a reason to skip this — the only reason is having no mechanism.
12064
+ *
12065
+ * @returns whether the session is now running on a freshly accepted credential.
12066
+ */
12067
+ async remintAndReauthenticate() {
12068
+ const provider = this._credentialProvider;
12069
+ if (!provider) return false;
12070
+ const { clientBound } = this._clientSession;
12071
+ if (!clientBound && !provider.refresh) {
12072
+ logger$1.debug("[SignalWire] [SW-NO-REFRESH-HANDLER] Unbound session with no refresh handler; cannot re-mint.");
12073
+ return false;
12074
+ }
12075
+ try {
12076
+ const newCredentials = clientBound ? await provider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0) : await this.remintCredential(provider);
12077
+ if (!newCredentials.token) {
12078
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the current one.");
12079
+ return false;
12080
+ }
12081
+ await this._clientSession.reauthenticate(newCredentials.token);
12082
+ this._deps.credential = newCredentials;
12083
+ this.persistCredential(newCredentials);
12084
+ if (newCredentials.expiry_at && provider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(provider, newCredentials.expiry_at);
12085
+ return true;
12086
+ } catch (error) {
12087
+ logger$1.warn("[SignalWire] Re-mint recovery failed:", error);
12088
+ return false;
12089
+ }
12090
+ }
12091
+ /**
12092
+ * Re-mint a credential via `provider.refresh()`, routed through the
12093
+ * coordinator's shared in-flight guard so concurrent re-mint paths (a
12094
+ * scheduled/resume refresh, -32003 recovery, and reconnect) never fire a
12095
+ * second `provider.refresh()` in parallel — which rotating one-time-use
12096
+ * refresh tokens reject. Falls back to a direct call only if the coordinator
12097
+ * has not been constructed yet.
12098
+ */
12099
+ async remintCredential(provider) {
12100
+ if (this._refreshCoordinator) return this._refreshCoordinator.refreshCredential(provider);
12101
+ if (!provider.refresh) throw new require_operators.InvalidCredentialsError("Credential provider does not support refresh");
12102
+ return provider.refresh();
12103
+ }
12104
+ /**
12105
+ * Re-mint credentials before a fresh (re)connect (`onBeforeReconnect` hook).
12106
+ * The session invokes this only when it is client-bound OR the in-memory
12107
+ * token is expired. The re-mint mechanism depends on the binding:
12108
+ * - Client-bound: `authenticate()` with the DPoP fingerprint to obtain a
12109
+ * fresh base SAT the upcoming reconnect can re-bind (the
12110
+ * DeviceTokenManager re-activates afterwards).
12111
+ * - Unbound: the developer's non-interactive `refresh()` handler.
12112
+ * `authenticate()` is deliberately NOT used here — it may be interactive
12113
+ * (a login prompt) and must not fire on a background reconnect.
12114
+ *
12115
+ * Rejects on failure so the session aborts the reconnect rather than
12116
+ * replaying a stale token.
12117
+ */
12118
+ async refreshCredentialForReconnect() {
12119
+ if (!this._credentialProvider) return;
12120
+ try {
12121
+ let newCredentials;
12122
+ if (this._clientSession?.clientBound ?? await this.wasClientBound()) {
12123
+ logger$1.debug("[SignalWire] Re-minting client-bound base SAT before reconnect");
12124
+ newCredentials = await this._credentialProvider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0);
12125
+ } else if (this._credentialProvider.refresh) {
12126
+ logger$1.debug("[SignalWire] Refreshing unbound credential before reconnect");
12127
+ newCredentials = await this.remintCredential(this._credentialProvider);
12128
+ } else {
12129
+ logger$1.warn("[SignalWire] [SW-NO-REFRESH-HANDLER] Token expired on reconnect but no refresh handler; reconnecting with the existing token.");
12130
+ return;
12131
+ }
12132
+ if (!newCredentials.token) {
12133
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the existing credential for reconnect.");
12134
+ return;
12135
+ }
12136
+ this._deps.credential = newCredentials;
12137
+ this.persistCredential(newCredentials);
12138
+ if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
12139
+ logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
12140
+ } catch (error) {
12141
+ logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
12142
+ this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
12143
+ throw error;
12144
+ }
12145
+ }
11020
12146
  /** Persist credential to localStorage when persistSession is enabled. */
11021
12147
  persistCredential(credential) {
11022
12148
  if (!credential.token) return;
11023
12149
  this._deps.storage.setItem("sw:cached_credential", credential);
11024
12150
  if (this._deps.persistSession) this._deps.storage.setItem("sw:cached_credential", credential, "local");
11025
12151
  }
12152
+ /**
12153
+ * Persist whether the session is client-bound, mirroring the credential's
12154
+ * storage scopes so it survives a reload. The preflight recovery reads it
12155
+ * before any session exists to decide whether to re-bind via `authenticate()`
12156
+ * or refresh an unbound token; the marker tracks the latest binding, so an
12157
+ * unbound reconnect clears a stale marker from an earlier client-bound login.
12158
+ */
12159
+ persistClientBoundMarker(bound) {
12160
+ const scopes = this._deps.persistSession ? ["session", "local"] : ["session"];
12161
+ for (const scope of scopes) if (bound) this._deps.storage.setItem(CLIENT_BOUND_STORAGE_KEY, true, scope);
12162
+ else this._deps.storage.removeItem(CLIENT_BOUND_STORAGE_KEY, scope);
12163
+ }
12164
+ /** Read the persisted client-bound marker (see {@link persistClientBoundMarker}). */
12165
+ async wasClientBound() {
12166
+ const scopes = this._deps.persistSession ? ["local", "session"] : ["session"];
12167
+ for (const scope of scopes) try {
12168
+ if (await this._deps.storage.getItem(CLIENT_BOUND_STORAGE_KEY, scope)) return true;
12169
+ } catch {}
12170
+ return false;
12171
+ }
11026
12172
  async init() {
11027
12173
  this._user$.next(new User(this._deps.http));
11028
12174
  if (!this._options.skipConnection) await this.connect();
@@ -11046,6 +12192,42 @@ var SignalWire = class extends Destroyable {
11046
12192
  } catch (error) {
11047
12193
  logger$1.error("[SignalWire] Failed to reattach calls:", error);
11048
12194
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
12195
+ } finally {
12196
+ this._credentialRecovered = false;
12197
+ }
12198
+ }
12199
+ /**
12200
+ * Fetch the authenticated user profile, recovering a stale credential.
12201
+ *
12202
+ * On a reload the persisted credential can be expired. Unlike the WS resume —
12203
+ * which the server accepts against the persisted `authorization_state` even
12204
+ * with an expired token — this REST preflight has no such short-circuit and is
12205
+ * refused (401). There is no session yet to reauthenticate, so recovery
12206
+ * re-mints the credential through the provider ({@link refreshCredentialForReconnect})
12207
+ * and retries with a FRESH {@link User}: Fetchable memoizes its result
12208
+ * (shareReplay), so reusing the instance would replay the 401 instead of
12209
+ * re-fetching with the new token. Without the user id the transport/session —
12210
+ * and the reattach a reload is trying to preserve — cannot even be addressed.
12211
+ */
12212
+ async fetchUserOrRecover() {
12213
+ const fetchUser = async (user$1) => {
12214
+ if (!await (0, rxjs.firstValueFrom)(user$1.fetched$)) throw new require_operators.UnexpectedError("Failed to fetch user information - fetched$ emitted false");
12215
+ this._deps.user = user$1;
12216
+ };
12217
+ const user = this._user$.value;
12218
+ if (!user) throw new require_operators.UnexpectedError("User not initialized before connect");
12219
+ try {
12220
+ await fetchUser(user);
12221
+ } catch (firstError) {
12222
+ 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.`);
12223
+ try {
12224
+ await this.refreshCredentialForReconnect();
12225
+ const refetched = new User(this._deps.http);
12226
+ await fetchUser(refetched);
12227
+ this._user$.next(refetched);
12228
+ } catch (retryError) {
12229
+ throw new require_operators.UnexpectedError("Error fetching user information", { cause: retryError });
12230
+ }
11049
12231
  }
11050
12232
  }
11051
12233
  /**
@@ -11087,40 +12269,23 @@ var SignalWire = class extends Destroyable {
11087
12269
  */
11088
12270
  async connect() {
11089
12271
  await this.teardownTransportAndSession();
11090
- try {
11091
- const user = this._user$.value;
11092
- if (!user) throw new require_operators.UnexpectedError("User not initialized before connect");
11093
- if (!await (0, rxjs.firstValueFrom)(user.fetched$)) throw new require_operators.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 require_operators.UnexpectedError("Error fetching user information", { cause: error });
11098
- }
12272
+ await this.fetchUserOrRecover();
11099
12273
  const errorHandler = (error) => {
11100
12274
  this._errors$.next(error);
11101
12275
  };
11102
12276
  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);
12277
+ this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey, () => this._credentialRecovered);
11104
12278
  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
12279
  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
- };
12280
+ this._clientSession.callControl = this._options.callControl ?? "routed";
12281
+ this._clientSession.onBeforeReconnect = async () => this.refreshCredentialForReconnect();
11121
12282
  this.subscribeTo(this._clientSession.errors$, (error) => {
11122
12283
  this._errors$.next(error);
11123
12284
  });
12285
+ this.subscribeTo(this._clientSession.authorization$, (authorization) => {
12286
+ this._refreshCoordinator?.syncExpiryFromAuthorization(authorization, this._credentialProvider);
12287
+ this.persistClientBoundMarker(Boolean(authorization?.cnf?.jkt));
12288
+ });
11124
12289
  await this._clientSession.connect();
11125
12290
  await this._refreshCoordinator?.activate(this._deps.user, this._clientSession);
11126
12291
  this.subscribeTo(this._clientSession.authenticated$.pipe((0, rxjs.skip)(1), (0, rxjs.filter)(Boolean)), async () => {
@@ -11283,6 +12448,13 @@ var SignalWire = class extends Destroyable {
11283
12448
  }
11284
12449
  try {
11285
12450
  this._visibilityController = new VisibilityController();
12451
+ this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, rxjs.filter)((event) => event.to === "visible")), () => {
12452
+ try {
12453
+ this._refreshCoordinator?.forceRefreshIfDue();
12454
+ } catch (error) {
12455
+ logger$1.warn("[SignalWire] Resume credential revalidation failed (non-fatal):", error);
12456
+ }
12457
+ });
11286
12458
  this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, rxjs.filter)((event) => event.to === "visible" && PreferencesContainer.instance.refreshDevicesOnVisible)), () => {
11287
12459
  logger$1.debug("[SignalWire] Page visible, re-enumerating devices");
11288
12460
  try {
@@ -11294,7 +12466,7 @@ var SignalWire = class extends Destroyable {
11294
12466
  logger$1.warn("[SignalWire] Failed to initialize VisibilityController:", error);
11295
12467
  }
11296
12468
  try {
11297
- this._diagnosticsCollector = new DiagnosticsCollector({ sdkVersion: "4.0.0-rc.2" });
12469
+ this._diagnosticsCollector = new DiagnosticsCollector({ sdkVersion: "4.0.0-rc.4" });
11298
12470
  } catch (error) {
11299
12471
  logger$1.warn("[SignalWire] Failed to initialize DiagnosticsCollector:", error);
11300
12472
  }
@@ -11365,21 +12537,22 @@ var SignalWire = class extends Destroyable {
11365
12537
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
11366
12538
  throw error;
11367
12539
  }
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()");
12540
+ logger$1.debug("[SignalWire] Failed to register user, attempting credential recovery...");
12541
+ const outcome = await this.recoverAndRetry(async () => {
11372
12542
  await this._transport.execute(RPCExecute({
11373
12543
  method: "subscriber.online",
11374
12544
  params: {}
11375
12545
  }));
12546
+ });
12547
+ if (outcome.ok) {
12548
+ logger$1.debug("[SignalWire] Recovery restored registration");
11376
12549
  this._isRegistered$.next(true);
11377
- } catch (reauthError) {
11378
- logger$1.error("[SignalWire] Reauthentication failed during register():", reauthError);
11379
- const registerError = new require_operators.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;
12550
+ return;
11382
12551
  }
12552
+ const failureCause = outcome.error ?? error;
12553
+ const registerError = new require_operators.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 }) });
12554
+ this._errors$.next(registerError);
12555
+ throw registerError;
11383
12556
  }
11384
12557
  }
11385
12558
  /**
@@ -11410,6 +12583,11 @@ var SignalWire = class extends Destroyable {
11410
12583
  * Returns a {@link Call} in `'ringing'` state. Subscribe to {@link Call.status$}
11411
12584
  * to track progression through `'connected'` → `'disconnected'`.
11412
12585
  *
12586
+ * Local media acquisition is deliberately unbounded: an unanswered permission
12587
+ * prompt leaves this promise pending indefinitely, so apply your own bound if
12588
+ * your UI needs one. The 12 s signaling budget starts only once acquisition
12589
+ * settles.
12590
+ *
11413
12591
  * @param destination - Address URI string (e.g. `'/public/my-room'`) or {@link Address} instance.
11414
12592
  * @param options - Media and dial options (audio/video, device constraints). Overrides defaults.
11415
12593
  * @returns The created {@link Call} instance.
@@ -11432,7 +12610,18 @@ var SignalWire = class extends Destroyable {
11432
12610
  };
11433
12611
  await this.waitAuthentication();
11434
12612
  logger$1.debug("[SignalWire] Dialing with options:", computed_options);
11435
- return this._clientSession.createOutboundCall(destination, computed_options);
12613
+ try {
12614
+ return await this._clientSession.createOutboundCall(destination, computed_options);
12615
+ } catch (error) {
12616
+ if (!isRecoverableAuthError(error)) throw error;
12617
+ logger$1.debug("[SignalWire] Dial hit a recoverable auth error; recovering the session and retrying");
12618
+ const outcome = await this.recoverAndRetry(async () => {
12619
+ await this.waitAuthentication();
12620
+ return this._clientSession.createOutboundCall(destination, computed_options);
12621
+ });
12622
+ if (outcome.ok) return outcome.value;
12623
+ throw outcome.error ?? error;
12624
+ }
11436
12625
  }
11437
12626
  /**
11438
12627
  * Runs a multi-phase connectivity test against the given destination.
@@ -11713,10 +12902,13 @@ var SignalWire = class extends Destroyable {
11713
12902
  destroy() {
11714
12903
  this._refreshCoordinator?.destroy();
11715
12904
  this._refreshCoordinator = void 0;
12905
+ this._refreshHttp?.destroy();
12906
+ this._refreshHttp = void 0;
11716
12907
  this._dpopManager?.destroy();
11717
- this._clientSession.teardownSessionState();
11718
- this._transport.destroy();
11719
- this._clientSession.destroy();
12908
+ const session = this._clientSession;
12909
+ session?.teardownSessionState();
12910
+ this._transport?.destroy();
12911
+ session?.destroy();
11720
12912
  try {
11721
12913
  this._networkMonitor?.destroy();
11722
12914
  } catch {}
@@ -11828,7 +13020,7 @@ var StaticCredentialProvider = class {
11828
13020
  /**
11829
13021
  * Library version from package.json, injected at build time.
11830
13022
  */
11831
- const version = "4.0.0-rc.2";
13023
+ const version = "4.0.0-rc.4";
11832
13024
  /**
11833
13025
  * Flag indicating the library has been loaded and is ready to use.
11834
13026
  * For UMD builds: `window.SignalWire.ready`
@@ -11850,7 +13042,7 @@ const ready = true;
11850
13042
  */
11851
13043
  const emitReadyEvent = () => {
11852
13044
  if (typeof window !== "undefined") {
11853
- const event = new CustomEvent("signalwire:js:ready", { detail: { version: "4.0.0-rc.2" } });
13045
+ const event = new CustomEvent("signalwire:js:ready", { detail: { version: "4.0.0-rc.4" } });
11854
13046
  window.dispatchEvent(event);
11855
13047
  }
11856
13048
  };
@@ -11858,7 +13050,10 @@ emitReadyEvent();
11858
13050
 
11859
13051
  //#endregion
11860
13052
  exports.Address = Address;
13053
+ exports.AuxiliaryLegCancelledError = require_operators.AuxiliaryLegCancelledError;
13054
+ exports.AuxiliaryLegTimeoutError = require_operators.AuxiliaryLegTimeoutError;
11861
13055
  exports.CallCreateError = require_operators.CallCreateError;
13056
+ exports.CallNotReadyError = require_operators.CallNotReadyError;
11862
13057
  exports.ClientPreferences = ClientPreferences;
11863
13058
  exports.CollectionFetchError = require_operators.CollectionFetchError;
11864
13059
  exports.DPoPInitError = require_operators.DPoPInitError;
@@ -11870,8 +13065,10 @@ exports.MediaTrackError = require_operators.MediaTrackError;
11870
13065
  exports.MessageParseError = require_operators.MessageParseError;
11871
13066
  exports.OverconstrainedFallbackError = require_operators.OverconstrainedFallbackError;
11872
13067
  exports.Participant = Participant;
13068
+ exports.ParticipantNotReadyError = require_operators.ParticipantNotReadyError;
11873
13069
  exports.PreflightError = require_operators.PreflightError;
11874
13070
  exports.RecoveryError = require_operators.RecoveryError;
13071
+ exports.ScreenShareAlreadyActiveError = require_operators.ScreenShareAlreadyActiveError;
11875
13072
  exports.SelfCapabilities = SelfCapabilities;
11876
13073
  exports.SelfParticipant = SelfParticipant;
11877
13074
  exports.SignalWire = SignalWire;