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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.mjs CHANGED
@@ -4186,7 +4186,7 @@ var require_merge$1 = /* @__PURE__ */ __commonJSMin(((exports) => {
4186
4186
  var empty_1$7 = require_empty();
4187
4187
  var args_1$8 = require_args();
4188
4188
  var from_1$4 = require_from();
4189
- function merge$6() {
4189
+ function merge$7() {
4190
4190
  var args = [];
4191
4191
  for (var _i = 0; _i < arguments.length; _i++) args[_i] = arguments[_i];
4192
4192
  var scheduler = args_1$8.popScheduler(args);
@@ -4194,7 +4194,7 @@ var require_merge$1 = /* @__PURE__ */ __commonJSMin(((exports) => {
4194
4194
  var sources = args;
4195
4195
  return !sources.length ? empty_1$7.EMPTY : sources.length === 1 ? innerFrom_1$31.innerFrom(sources[0]) : mergeAll_1$3.mergeAll(concurrent)(from_1$4.from(sources, scheduler));
4196
4196
  }
4197
- exports.merge = merge$6;
4197
+ exports.merge = merge$7;
4198
4198
  }));
4199
4199
 
4200
4200
  //#endregion
@@ -4325,13 +4325,13 @@ var require_race$1 = /* @__PURE__ */ __commonJSMin(((exports) => {
4325
4325
  var innerFrom_1$28 = require_innerFrom();
4326
4326
  var argsOrArgArray_1$4 = require_argsOrArgArray();
4327
4327
  var OperatorSubscriber_1$48 = require_OperatorSubscriber();
4328
- function race$5() {
4328
+ function race$4() {
4329
4329
  var sources = [];
4330
4330
  for (var _i = 0; _i < arguments.length; _i++) sources[_i] = arguments[_i];
4331
4331
  sources = argsOrArgArray_1$4.argsOrArgArray(sources);
4332
4332
  return sources.length === 1 ? innerFrom_1$28.innerFrom(sources[0]) : new Observable_1$6.Observable(raceInit(sources));
4333
4333
  }
4334
- exports.race = race$5;
4334
+ exports.race = race$4;
4335
4335
  function raceInit(sources) {
4336
4336
  return function(subscriber) {
4337
4337
  var subscriptions = [];
@@ -6076,7 +6076,7 @@ var require_merge = /* @__PURE__ */ __commonJSMin(((exports) => {
6076
6076
  var mergeAll_1$2 = require_mergeAll();
6077
6077
  var args_1$3 = require_args();
6078
6078
  var from_1$1 = require_from();
6079
- function merge$5() {
6079
+ function merge$6() {
6080
6080
  var args = [];
6081
6081
  for (var _i = 0; _i < arguments.length; _i++) args[_i] = arguments[_i];
6082
6082
  var scheduler = args_1$3.popScheduler(args);
@@ -6085,7 +6085,7 @@ var require_merge = /* @__PURE__ */ __commonJSMin(((exports) => {
6085
6085
  mergeAll_1$2.mergeAll(concurrent)(from_1$1.from(__spreadArray$8([source], __read$8(args)), scheduler)).subscribe(subscriber);
6086
6086
  });
6087
6087
  }
6088
- exports.merge = merge$5;
6088
+ exports.merge = merge$6;
6089
6089
  }));
6090
6090
 
6091
6091
  //#endregion
@@ -6932,7 +6932,7 @@ var require_switchMap = /* @__PURE__ */ __commonJSMin(((exports) => {
6932
6932
  var innerFrom_1$6 = require_innerFrom();
6933
6933
  var lift_1$13 = require_lift();
6934
6934
  var OperatorSubscriber_1$11 = require_OperatorSubscriber();
6935
- function switchMap$7(project, resultSelector) {
6935
+ function switchMap$8(project, resultSelector) {
6936
6936
  return lift_1$13.operate(function(source, subscriber) {
6937
6937
  var innerSubscriber = null;
6938
6938
  var index = 0;
@@ -6956,7 +6956,7 @@ var require_switchMap = /* @__PURE__ */ __commonJSMin(((exports) => {
6956
6956
  }));
6957
6957
  });
6958
6958
  }
6959
- exports.switchMap = switchMap$7;
6959
+ exports.switchMap = switchMap$8;
6960
6960
  }));
6961
6961
 
6962
6962
  //#endregion
@@ -9022,6 +9022,25 @@ const DEFAULT_ICE_CANDIDATE_TIMEOUT_MS = 600;
9022
9022
  const DEFAULT_ICE_GATHERING_TIMEOUT_MS = 6e3;
9023
9023
  const DEFAULT_RECONNECT_CALLS_TIMEOUT_MS = 300 * 1e3;
9024
9024
  const DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
9025
+ /**
9026
+ * How long call setup may wait for the network, measured from local media
9027
+ * settling until the member id arrives.
9028
+ *
9029
+ * Local media acquisition is deliberately NOT inside this: a permission prompt
9030
+ * or a device picker is human time, and a human may take as long as they like
9031
+ * without spending the server's budget. The clock starts when acquisition ends.
9032
+ *
9033
+ * Must exceed `iceGatheringTimeout + the RPC timeout`, which both run inside it.
9034
+ */
9035
+ const DEFAULT_CALL_SIGNALING_TIMEOUT_MS = 12e3;
9036
+ /**
9037
+ * How long an auxiliary leg (screen share, additional device) may take to
9038
+ * connect once its media is in hand.
9039
+ *
9040
+ * One bound for both: the picker is human time and sits outside it. What remains
9041
+ * — offer, ICE, invite, answer, DTLS — is the same work for either leg kind.
9042
+ */
9043
+ const DEFAULT_AUX_LEG_CONNECT_TIMEOUT_MS = 15e3;
9025
9044
  const DEFAULT_RECONNECT_DELAY_MIN_MS = 100;
9026
9045
  const DEFAULT_RECONNECT_DELAY_MAX_MS = 3e3;
9027
9046
  const DEFAULT_DEVICE_DEBOUNCE_TIME_MS = 1500;
@@ -9049,6 +9068,14 @@ const CREDENTIAL_REFRESH_MAX_DELAY_MS = 3e4;
9049
9068
  /** Buffer in milliseconds before token expiry to trigger refresh. */
9050
9069
  const CREDENTIAL_REFRESH_BUFFER_MS = 5e3;
9051
9070
  /**
9071
+ * Clock-skew allowance (ms) for treating an in-memory credential as expired
9072
+ * when deciding whether a fresh (re)connect must re-mint the token before
9073
+ * authenticating. A token within this window of its `expiry_at` is treated as
9074
+ * stale so the reconnect re-mints via the credential provider instead of
9075
+ * replaying a dead token (which the server rejects with -32003).
9076
+ */
9077
+ const CREDENTIAL_EXPIRY_SKEW_MS = 3e4;
9078
+ /**
9052
9079
  * Maximum time the coordinator will wait for `DeviceTokenManager.activate()`
9053
9080
  * to resolve before treating the activation as failed and falling back to
9054
9081
  * the developer-provided refresh path. Prevents a wedged HTTP layer from
@@ -9067,6 +9094,8 @@ const MEDIA_ACCESS_DENIAL_NAMES = [
9067
9094
  "SecurityError",
9068
9095
  "PermissionDeniedError"
9069
9096
  ];
9097
+ /** Error names browsers use when the capture hardware is already held exclusively. */
9098
+ const MEDIA_DEVICE_IN_USE_NAMES = ["NotReadableError", "TrackStartError"];
9070
9099
  /** Default polling interval for RTCPeerConnection.getStats() in milliseconds. */
9071
9100
  const DEFAULT_STATS_POLLING_INTERVAL_MS = 1e3;
9072
9101
  /** Number of initial samples used to build a baseline for spike detection. */
@@ -9281,6 +9310,20 @@ var CallCreateError = class extends Error {
9281
9310
  this.name = "CallCreateError";
9282
9311
  }
9283
9312
  };
9313
+ var CallNotReadyError = class extends Error {
9314
+ constructor(callId, options) {
9315
+ super(`Call "${callId}" has no self member context yet: selfId/nodeId have not been received from the server`, options);
9316
+ this.callId = callId;
9317
+ this.name = "CallNotReadyError";
9318
+ }
9319
+ };
9320
+ var ParticipantNotReadyError = class extends Error {
9321
+ constructor(memberId, options) {
9322
+ super(`Participant "${memberId}" has no call context yet: its member state (call_id/node_id) has not been received from the server`, options);
9323
+ this.memberId = memberId;
9324
+ this.name = "ParticipantNotReadyError";
9325
+ }
9326
+ };
9284
9327
  var JSONRPCError = class extends Error {
9285
9328
  constructor(code, message, data, options, requestId) {
9286
9329
  super(message, options);
@@ -9352,6 +9395,32 @@ var CollectionFetchError = class extends Error {
9352
9395
  this.name = "CollectionFetchError";
9353
9396
  }
9354
9397
  };
9398
+ /**
9399
+ * An auxiliary leg did not connect within its budget. Typed rather than a bare
9400
+ * RxJS `TimeoutError` so the leg and cause survive.
9401
+ */
9402
+ var AuxiliaryLegTimeoutError = class extends Error {
9403
+ constructor(leg, originalError) {
9404
+ super(`Timed out waiting for the ${leg} connection to be established`, { cause: originalError });
9405
+ this.leg = leg;
9406
+ this.originalError = originalError;
9407
+ this.name = "AuxiliaryLegTimeoutError";
9408
+ }
9409
+ };
9410
+ /**
9411
+ * An auxiliary leg was removed before it finished connecting.
9412
+ *
9413
+ * Typed rather than a bare resolve so a caller awaiting the start can tell a
9414
+ * cancel apart from a share that actually came up — the public methods return
9415
+ * `void`, so the promise is the only signal they have.
9416
+ */
9417
+ var AuxiliaryLegCancelledError = class extends Error {
9418
+ constructor(leg) {
9419
+ super(`The ${leg} leg was removed before it finished connecting`);
9420
+ this.leg = leg;
9421
+ this.name = "AuxiliaryLegCancelledError";
9422
+ }
9423
+ };
9355
9424
  var MediaTrackError = class extends Error {
9356
9425
  constructor(operation, kind, originalError) {
9357
9426
  super(`Media track ${operation} failed for ${kind}`, { cause: originalError instanceof Error ? originalError : void 0 });
@@ -9365,6 +9434,10 @@ var MediaTrackError = class extends Error {
9365
9434
  function isMediaAccessDenial(originalError) {
9366
9435
  return originalError instanceof Error && MEDIA_ACCESS_DENIAL_NAMES.includes(originalError.name);
9367
9436
  }
9437
+ /** True when a `getUserMedia` rejection means the hardware is already held exclusively. */
9438
+ function isMediaDeviceInUse(originalError) {
9439
+ return originalError instanceof Error && MEDIA_DEVICE_IN_USE_NAMES.includes(originalError.name);
9440
+ }
9368
9441
  /**
9369
9442
  * Failure to acquire local media (camera, microphone, or screen capture)
9370
9443
  * via `getUserMedia`/`getDisplayMedia`.
@@ -9388,6 +9461,22 @@ var MediaAccessError = class extends Error {
9388
9461
  return isMediaAccessDenial(this.originalError);
9389
9462
  }
9390
9463
  };
9464
+ /**
9465
+ * Thrown by `startScreenShare()` when the call is already sharing a screen.
9466
+ *
9467
+ * A call carries at most one screen share. Accepting a second one would
9468
+ * overwrite the only reference the SDK holds to the first, leaving it
9469
+ * capturing and sending with no way to stop it — so the second request is
9470
+ * rejected and the live share is left untouched. Call `stopScreenShare()`
9471
+ * first to replace it.
9472
+ */
9473
+ var ScreenShareAlreadyActiveError = class extends Error {
9474
+ constructor(screenShareId, options) {
9475
+ super(`A screen share is already active on this call (${screenShareId}). Call stopScreenShare() before starting another one.`, options);
9476
+ this.screenShareId = screenShareId;
9477
+ this.name = "ScreenShareAlreadyActiveError";
9478
+ }
9479
+ };
9391
9480
  var DPoPInitError = class extends Error {
9392
9481
  constructor(originalError, message = "Failed to initialize DPoP key pair") {
9393
9482
  super(message, { cause: originalError instanceof Error ? originalError : void 0 });
@@ -9628,9 +9717,9 @@ var require_loglevel = /* @__PURE__ */ __commonJSMin(((exports, module) => {
9628
9717
  defaultLogger$1 = new Logger();
9629
9718
  defaultLogger$1.getLogger = function getLogger$1(name) {
9630
9719
  if (typeof name !== "symbol" && typeof name !== "string" || name === "") throw new TypeError("You must supply a name when creating a logger.");
9631
- var logger$33 = _loggersByName[name];
9632
- if (!logger$33) logger$33 = _loggersByName[name] = new Logger(name, defaultLogger$1.methodFactory);
9633
- return logger$33;
9720
+ var logger$34 = _loggersByName[name];
9721
+ if (!logger$34) logger$34 = _loggersByName[name] = new Logger(name, defaultLogger$1.methodFactory);
9722
+ return logger$34;
9634
9723
  };
9635
9724
  var _log = typeof window !== undefinedType ? window.log : void 0;
9636
9725
  defaultLogger$1.noConflict = function() {
@@ -9666,8 +9755,8 @@ const defaultLoggerLevel = defaultLogger.levels.WARN;
9666
9755
  defaultLogger.setLevel(defaultLoggerLevel);
9667
9756
  let userLogger = null;
9668
9757
  /** Replace the built-in logger with a custom implementation. Pass `null` to restore defaults. */
9669
- const setLogger = (logger$33) => {
9670
- userLogger = logger$33;
9758
+ const setLogger = (logger$34) => {
9759
+ userLogger = logger$34;
9671
9760
  };
9672
9761
  let debugOptions = {};
9673
9762
  /** Configure debug options (e.g., `{ logWsTraffic: true }`). */
@@ -9711,8 +9800,8 @@ const wsTraffic = (options) => {
9711
9800
  loggerInstance.debug(`${options.type.toUpperCase()}: \n`, msg, "\n");
9712
9801
  };
9713
9802
  const getLogger = () => {
9714
- const logger$33 = getLoggerInstance();
9715
- return new Proxy(logger$33, { get(_target, prop, _receiver) {
9803
+ const logger$34 = getLoggerInstance();
9804
+ return new Proxy(logger$34, { get(_target, prop, _receiver) {
9716
9805
  if (prop === "wsTraffic") return wsTraffic;
9717
9806
  const instance = getLoggerInstance();
9718
9807
  const value = Reflect.get(instance, prop);
@@ -9764,7 +9853,7 @@ const asyncRetry = async ({ asyncCallable, maxRetries: retries = DEFAULT_MAX_RET
9764
9853
 
9765
9854
  //#endregion
9766
9855
  //#region src/controllers/HTTPRequestController.ts
9767
- const logger$32 = getLogger();
9856
+ const logger$33 = getLogger();
9768
9857
  const GET_PARAMS = {
9769
9858
  method: "GET",
9770
9859
  headers: { Accept: "application/json" }
@@ -9828,7 +9917,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9828
9917
  this._responses$.next(response);
9829
9918
  return response;
9830
9919
  } catch (error) {
9831
- logger$32.error("[HTTPRequestController] Request error:", error);
9920
+ logger$33.error("[HTTPRequestController] Request error:", error);
9832
9921
  this._status$.next("error");
9833
9922
  const err = error instanceof Error ? error : new Error("HTTP request failed", { cause: error });
9834
9923
  this._errors$.next(err);
@@ -9855,7 +9944,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9855
9944
  const url = this.buildURL(request.url);
9856
9945
  const headers = this.buildHeaders(request.headers);
9857
9946
  const timeout$5 = request.timeout ?? this.requestTimeout;
9858
- logger$32.debug("[HTTPRequestController] Executing request:", {
9947
+ logger$33.debug("[HTTPRequestController] Executing request:", {
9859
9948
  method: request.method,
9860
9949
  url,
9861
9950
  headers: Object.keys(headers).reduce((acc, key) => {
@@ -9875,7 +9964,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9875
9964
  });
9876
9965
  clearTimeout(timeoutId);
9877
9966
  const httpResponse = await this.convertResponse(response);
9878
- logger$32.debug("[HTTPRequestController] Response received:", {
9967
+ logger$33.debug("[HTTPRequestController] Response received:", {
9879
9968
  status: response.status,
9880
9969
  statusText: response.statusText,
9881
9970
  headers: [...response.headers.entries()],
@@ -9885,7 +9974,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9885
9974
  } catch (error) {
9886
9975
  clearTimeout(timeoutId);
9887
9976
  if (error instanceof Error && error.name === "AbortError") throw new RequestTimeoutError(`Request timeout after ${timeout$5}ms`, { cause: error });
9888
- logger$32.error("[HTTPRequestController] Request failed:", error);
9977
+ logger$33.error("[HTTPRequestController] Request failed:", error);
9889
9978
  throw error;
9890
9979
  }
9891
9980
  }
@@ -9899,8 +9988,8 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9899
9988
  const credential = this.getCredential();
9900
9989
  if (credential.token) {
9901
9990
  headers.Authorization = `Bearer ${credential.token}`;
9902
- logger$32.debug("[HTTPRequestController] Using Bearer token auth, token length:", credential.token.length);
9903
- } else logger$32.warn("[HTTPRequestController] No credentials available for authentication");
9991
+ logger$33.debug("[HTTPRequestController] Using Bearer token auth, token length:", credential.token.length);
9992
+ } else logger$33.warn("[HTTPRequestController] No credentials available for authentication");
9904
9993
  return headers;
9905
9994
  }
9906
9995
  /**
@@ -10048,7 +10137,7 @@ function fromMsToSec(milliseconds) {
10048
10137
 
10049
10138
  //#endregion
10050
10139
  //#region src/containers/PreferencesContainer.ts
10051
- const logger$31 = getLogger();
10140
+ const logger$32 = getLogger();
10052
10141
  var PreferencesContainer = class PreferencesContainer {
10053
10142
  static get instance() {
10054
10143
  this._instance ??= new PreferencesContainer();
@@ -10710,7 +10799,7 @@ var ClientPreferences = class {
10710
10799
  if (!this._storage) return;
10711
10800
  const data = collectStoredPreferences();
10712
10801
  this._storage.setItem(PREFERENCES_STORAGE_KEY, data, "local").catch((error) => {
10713
- logger$31.error(`[ClientPreferences] Failed to save preferences: ${String(error)}`);
10802
+ logger$32.error(`[ClientPreferences] Failed to save preferences: ${String(error)}`);
10714
10803
  });
10715
10804
  }
10716
10805
  /** Loads preferences from storage and applies them to the container. */
@@ -10719,7 +10808,7 @@ var ClientPreferences = class {
10719
10808
  this._storage.getItem(PREFERENCES_STORAGE_KEY, "local").then((stored) => {
10720
10809
  if (stored) applyStoredPreferences(stored);
10721
10810
  }).catch((error) => {
10722
- logger$31.error(`[ClientPreferences] Failed to load preferences: ${String(error)}`);
10811
+ logger$32.error(`[ClientPreferences] Failed to load preferences: ${String(error)}`);
10723
10812
  });
10724
10813
  }
10725
10814
  };
@@ -10741,7 +10830,7 @@ function toError(value) {
10741
10830
  //#endregion
10742
10831
  //#region src/controllers/NavigatorDeviceController.ts
10743
10832
  var import_cjs$29 = require_cjs();
10744
- const logger$30 = getLogger();
10833
+ const logger$31 = getLogger();
10745
10834
  /** Maps a device kind to its storage key. */
10746
10835
  const DEVICE_STORAGE_KEYS = {
10747
10836
  audioinput: DEVICE_STORAGE_KEY_AUDIO_INPUT,
@@ -10763,7 +10852,7 @@ var NavigatorDeviceController = class extends Destroyable {
10763
10852
  super();
10764
10853
  this.webRTCApiProvider = webRTCApiProvider;
10765
10854
  this.deviceChangeHandler = () => {
10766
- logger$30.debug("[DeviceController] Device change detected");
10855
+ logger$31.debug("[DeviceController] Device change detected");
10767
10856
  this.enumerateDevices();
10768
10857
  };
10769
10858
  this._devicesState$ = this.createBehaviorSubject(initialDevicesState);
@@ -10828,13 +10917,13 @@ var NavigatorDeviceController = class extends Destroyable {
10828
10917
  return this.cachedObservable("videoInputDevices$", () => this._devicesState$.pipe((0, import_cjs$29.map)((state) => state.videoinput), (0, import_cjs$29.distinctUntilChanged)(), (0, import_cjs$29.takeUntil)(this.destroyed$)));
10829
10918
  }
10830
10919
  get selectedAudioInputDevice$() {
10831
- return this.cachedObservable("selectedAudioInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, import_cjs$29.map)((state) => state.audioinput), (0, import_cjs$29.distinctUntilChanged)(), (0, import_cjs$29.takeUntil)(this.destroyed$), (0, import_cjs$29.tap)((info) => logger$30.debug("[DeviceController] Selected audio input device changed:", info))));
10920
+ return this.cachedObservable("selectedAudioInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, import_cjs$29.map)((state) => state.audioinput), (0, import_cjs$29.distinctUntilChanged)(), (0, import_cjs$29.takeUntil)(this.destroyed$), (0, import_cjs$29.tap)((info) => logger$31.debug("[DeviceController] Selected audio input device changed:", info))));
10832
10921
  }
10833
10922
  get selectedAudioOutputDevice$() {
10834
- return this.cachedObservable("selectedAudioOutputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, import_cjs$29.map)((state) => state.audiooutput), (0, import_cjs$29.distinctUntilChanged)(), (0, import_cjs$29.takeUntil)(this.destroyed$), (0, import_cjs$29.tap)((info) => logger$30.debug("[DeviceController] Selected audio output device changed:", info))));
10923
+ return this.cachedObservable("selectedAudioOutputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, import_cjs$29.map)((state) => state.audiooutput), (0, import_cjs$29.distinctUntilChanged)(), (0, import_cjs$29.takeUntil)(this.destroyed$), (0, import_cjs$29.tap)((info) => logger$31.debug("[DeviceController] Selected audio output device changed:", info))));
10835
10924
  }
10836
10925
  get selectedVideoInputDevice$() {
10837
- return this.cachedObservable("selectedVideoInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, import_cjs$29.map)((state) => state.videoinput), (0, import_cjs$29.distinctUntilChanged)(), (0, import_cjs$29.takeUntil)(this.destroyed$), (0, import_cjs$29.tap)((info) => logger$30.debug("[DeviceController] Selected video input device changed:", info))));
10926
+ return this.cachedObservable("selectedVideoInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, import_cjs$29.map)((state) => state.videoinput), (0, import_cjs$29.distinctUntilChanged)(), (0, import_cjs$29.takeUntil)(this.destroyed$), (0, import_cjs$29.tap)((info) => logger$31.debug("[DeviceController] Selected video input device changed:", info))));
10838
10927
  }
10839
10928
  get selectedAudioInputDevice() {
10840
10929
  if (this._audioInputDisabled$.value) return null;
@@ -10909,7 +10998,7 @@ var NavigatorDeviceController = class extends Destroyable {
10909
10998
  if (device) this.persistDeviceSelection("audioinput", device);
10910
10999
  }
10911
11000
  selectVideoInputDevice(device) {
10912
- logger$30.debug("[DeviceController] Setting selected video input device:", device);
11001
+ logger$31.debug("[DeviceController] Setting selected video input device:", device);
10913
11002
  if (this._videoInputDisabled$.value && device) this._videoInputDisabled$.next(false);
10914
11003
  const previous = this._selectedDevicesState$.value.videoinput;
10915
11004
  if (previous && previous.deviceId !== device?.deviceId) this._deviceHistory.push("videoinput", previous);
@@ -10966,7 +11055,7 @@ var NavigatorDeviceController = class extends Destroyable {
10966
11055
  }
10967
11056
  const fromHistory = this._deviceHistory.findInHistory(kind, devices);
10968
11057
  if (fromHistory) {
10969
- logger$30.debug(`[DeviceController] Device disappeared, falling back to history: ${fromHistory.label}`);
11058
+ logger$31.debug(`[DeviceController] Device disappeared, falling back to history: ${fromHistory.label}`);
10970
11059
  this.emitDeviceRecovered(kind, selected, fromHistory, "device_disconnected");
10971
11060
  return fromHistory;
10972
11061
  }
@@ -11019,7 +11108,7 @@ var NavigatorDeviceController = class extends Destroyable {
11019
11108
  try {
11020
11109
  await this._storageManager.setItem(DEVICE_STORAGE_KEYS[kind], stored, "local");
11021
11110
  } catch (error) {
11022
- logger$30.error(`[DeviceController] Failed to persist device selection for ${kind}:`, error);
11111
+ logger$31.error(`[DeviceController] Failed to persist device selection for ${kind}:`, error);
11023
11112
  }
11024
11113
  }
11025
11114
  async loadPersistedDevices() {
@@ -11035,7 +11124,7 @@ var NavigatorDeviceController = class extends Destroyable {
11035
11124
  [kind]: stored
11036
11125
  };
11037
11126
  } catch (error) {
11038
- logger$30.error(`[DeviceController] Failed to load persisted device for ${kind}:`, error);
11127
+ logger$31.error(`[DeviceController] Failed to load persisted device for ${kind}:`, error);
11039
11128
  }
11040
11129
  }
11041
11130
  /** Clears device history, persisted selections, and re-enumerates devices. */
@@ -11053,7 +11142,7 @@ var NavigatorDeviceController = class extends Destroyable {
11053
11142
  this.disableDeviceMonitoring();
11054
11143
  this.webRTCApiProvider.mediaDevices.addEventListener("devicechange", this.deviceChangeHandler);
11055
11144
  if (PreferencesContainer.instance.devicePollingInterval > 0) this._devicesPoolingSubscription = (0, import_cjs$29.interval)(PreferencesContainer.instance.devicePollingInterval).subscribe(() => {
11056
- logger$30.debug("[DeviceController] Polling devices due to interval");
11145
+ logger$31.debug("[DeviceController] Polling devices due to interval");
11057
11146
  this.enumerateDevices();
11058
11147
  });
11059
11148
  this.enumerateDevices();
@@ -11079,13 +11168,13 @@ var NavigatorDeviceController = class extends Destroyable {
11079
11168
  videoinput: []
11080
11169
  });
11081
11170
  this._devicesState$.next(devicesByKind);
11082
- logger$30.debug("[DeviceController] Devices enumerated:", {
11171
+ logger$31.debug("[DeviceController] Devices enumerated:", {
11083
11172
  audioInputs: devicesByKind.audioinput.length,
11084
11173
  audioOutputs: devicesByKind.audiooutput.length,
11085
11174
  videoInputs: devicesByKind.videoinput.length
11086
11175
  });
11087
11176
  } catch (error) {
11088
- logger$30.error("[DeviceController] Failed to enumerate devices:", error);
11177
+ logger$31.error("[DeviceController] Failed to enumerate devices:", error);
11089
11178
  this._errors$.next(toError(error));
11090
11179
  }
11091
11180
  }
@@ -11101,7 +11190,7 @@ var NavigatorDeviceController = class extends Destroyable {
11101
11190
  stream.getTracks().forEach((t) => t.stop());
11102
11191
  return capabilities;
11103
11192
  } catch (error) {
11104
- logger$30.error("[DeviceController] Failed to get device capabilities:", error);
11193
+ logger$31.error("[DeviceController] Failed to get device capabilities:", error);
11105
11194
  this._errors$.next(toError(error));
11106
11195
  throw error;
11107
11196
  }
@@ -11352,7 +11441,7 @@ var DependencyContainer = class {
11352
11441
 
11353
11442
  //#endregion
11354
11443
  //#region src/controllers/CryptoController.ts
11355
- const logger$29 = getLogger();
11444
+ const logger$30 = getLogger();
11356
11445
  const DPOP_DB_NAME = "sw-dpop";
11357
11446
  const DPOP_DB_VERSION = 1;
11358
11447
  const DPOP_STORE_NAME = "keys";
@@ -11411,7 +11500,7 @@ async function loadKeyPairFromDB() {
11411
11500
  tx.oncomplete = () => db.close();
11412
11501
  });
11413
11502
  } catch (error) {
11414
- logger$29.warn("[DPoP] Failed to load key pair from IndexedDB:", error);
11503
+ logger$30.warn("[DPoP] Failed to load key pair from IndexedDB:", error);
11415
11504
  return null;
11416
11505
  }
11417
11506
  }
@@ -11431,7 +11520,7 @@ async function saveKeyPairToDB(keyPair) {
11431
11520
  };
11432
11521
  });
11433
11522
  } catch (error) {
11434
- logger$29.warn("[DPoP] Failed to save key pair to IndexedDB:", error);
11523
+ logger$30.warn("[DPoP] Failed to save key pair to IndexedDB:", error);
11435
11524
  }
11436
11525
  }
11437
11526
  async function deleteKeyPairFromDB() {
@@ -11450,7 +11539,7 @@ async function deleteKeyPairFromDB() {
11450
11539
  };
11451
11540
  });
11452
11541
  } catch (error) {
11453
- logger$29.warn("[DPoP] Failed to delete key pair from IndexedDB:", error);
11542
+ logger$30.warn("[DPoP] Failed to delete key pair from IndexedDB:", error);
11454
11543
  }
11455
11544
  }
11456
11545
  /**
@@ -11510,13 +11599,13 @@ var CryptoController = class {
11510
11599
  this._publicJwk = await crypto.subtle.exportKey("jwk", stored.publicKey);
11511
11600
  this._fingerprint = await computeJwkThumbprint(this._publicJwk);
11512
11601
  this._initialized = true;
11513
- logger$29.debug("[DPoP] Key pair restored from IndexedDB, fingerprint:", this._fingerprint);
11602
+ logger$30.debug("[DPoP] Key pair restored from IndexedDB, fingerprint:", this._fingerprint);
11514
11603
  return this._fingerprint;
11515
11604
  } catch (error) {
11516
- logger$29.warn("[DPoP] Stored key pair unusable, generating new one:", error);
11605
+ logger$30.warn("[DPoP] Stored key pair unusable, generating new one:", error);
11517
11606
  await deleteKeyPairFromDB();
11518
11607
  }
11519
- logger$29.debug("[DPoP] Generating RSA key pair");
11608
+ logger$30.debug("[DPoP] Generating RSA key pair");
11520
11609
  this._keyPair = await crypto.subtle.generateKey({
11521
11610
  name: "RSASSA-PKCS1-v1_5",
11522
11611
  modulusLength: 2048,
@@ -11531,7 +11620,7 @@ var CryptoController = class {
11531
11620
  this._fingerprint = await computeJwkThumbprint(this._publicJwk);
11532
11621
  this._initialized = true;
11533
11622
  await saveKeyPairToDB(this._keyPair);
11534
- logger$29.debug("[DPoP] Key pair generated and persisted, fingerprint:", this._fingerprint);
11623
+ logger$30.debug("[DPoP] Key pair generated and persisted, fingerprint:", this._fingerprint);
11535
11624
  return this._fingerprint;
11536
11625
  }
11537
11626
  /**
@@ -11597,7 +11686,7 @@ var CryptoController = class {
11597
11686
  this._fingerprint = null;
11598
11687
  this._initialized = false;
11599
11688
  deleteKeyPairFromDB();
11600
- logger$29.debug("[DPoP] Controller destroyed");
11689
+ logger$30.debug("[DPoP] Controller destroyed");
11601
11690
  }
11602
11691
  get publicJwk() {
11603
11692
  if (!this._publicJwk) throw new DPoPInitError("CryptoController not initialized. Call init() first.");
@@ -11621,7 +11710,7 @@ var CryptoController = class {
11621
11710
  //#endregion
11622
11711
  //#region src/controllers/NetworkMonitor.ts
11623
11712
  var import_cjs$28 = require_cjs();
11624
- const logger$28 = getLogger();
11713
+ const logger$29 = getLogger();
11625
11714
  /**
11626
11715
  * Safely check whether we are running in a browser environment
11627
11716
  * with `window` and the relevant event targets.
@@ -11678,7 +11767,7 @@ var NetworkMonitor = class extends Destroyable {
11678
11767
  }
11679
11768
  attachListeners() {
11680
11769
  if (!hasBrowserNetworkEvents()) {
11681
- logger$28.debug("NetworkMonitor: no browser environment detected, skipping event listeners");
11770
+ logger$29.debug("NetworkMonitor: no browser environment detected, skipping event listeners");
11682
11771
  return;
11683
11772
  }
11684
11773
  window.addEventListener("online", this._onOnline);
@@ -11686,7 +11775,7 @@ var NetworkMonitor = class extends Destroyable {
11686
11775
  const connection = getNetworkConnection();
11687
11776
  if (connection) connection.addEventListener("change", this._onConnectionChange);
11688
11777
  this._listenersAttached = true;
11689
- logger$28.debug("NetworkMonitor: event listeners attached");
11778
+ logger$29.debug("NetworkMonitor: event listeners attached");
11690
11779
  }
11691
11780
  removeListeners() {
11692
11781
  if (!this._listenersAttached) return;
@@ -11697,10 +11786,10 @@ var NetworkMonitor = class extends Destroyable {
11697
11786
  if (connection) connection.removeEventListener("change", this._onConnectionChange);
11698
11787
  }
11699
11788
  this._listenersAttached = false;
11700
- logger$28.debug("NetworkMonitor: event listeners removed");
11789
+ logger$29.debug("NetworkMonitor: event listeners removed");
11701
11790
  }
11702
11791
  handleOnline() {
11703
- logger$28.info("NetworkMonitor: browser went online");
11792
+ logger$29.info("NetworkMonitor: browser went online");
11704
11793
  this._isOnline$.next(true);
11705
11794
  this._networkChange$.next({
11706
11795
  type: "online",
@@ -11709,7 +11798,7 @@ var NetworkMonitor = class extends Destroyable {
11709
11798
  });
11710
11799
  }
11711
11800
  handleOffline() {
11712
- logger$28.info("NetworkMonitor: browser went offline");
11801
+ logger$29.info("NetworkMonitor: browser went offline");
11713
11802
  this._isOnline$.next(false);
11714
11803
  this._networkChange$.next({
11715
11804
  type: "offline",
@@ -11718,7 +11807,7 @@ var NetworkMonitor = class extends Destroyable {
11718
11807
  }
11719
11808
  handleConnectionChange() {
11720
11809
  const networkType = getNetworkType();
11721
- logger$28.info(`NetworkMonitor: connection changed — effectiveType=${networkType ?? "unknown"}`);
11810
+ logger$29.info(`NetworkMonitor: connection changed — effectiveType=${networkType ?? "unknown"}`);
11722
11811
  this._networkChange$.next({
11723
11812
  type: "connection_change",
11724
11813
  timestamp: Date.now(),
@@ -11834,7 +11923,7 @@ function getNavigatorMediaDevices() {
11834
11923
  //#endregion
11835
11924
  //#region src/controllers/PreflightRunner.ts
11836
11925
  var import_cjs$27 = require_cjs();
11837
- const logger$27 = getLogger();
11926
+ const logger$28 = getLogger();
11838
11927
  const DEFAULT_MEDIA_TEST_DURATION_S = 10;
11839
11928
  const ICE_GATHERING_TIMEOUT_MS = 1e4;
11840
11929
  const SIGNALING_RTT_TIMEOUT_MS = 5e3;
@@ -11883,7 +11972,7 @@ var PreflightRunner = class extends Destroyable {
11883
11972
  if (!this._options.skipMediaTest) try {
11884
11973
  bandwidth = await this.testMediaBandwidth(destination);
11885
11974
  } catch (error) {
11886
- logger$27.warn("[PreflightRunner] Media bandwidth test failed:", error);
11975
+ logger$28.warn("[PreflightRunner] Media bandwidth test failed:", error);
11887
11976
  warnings.push("Media bandwidth test failed");
11888
11977
  }
11889
11978
  return {
@@ -11895,7 +11984,7 @@ var PreflightRunner = class extends Destroyable {
11895
11984
  warnings
11896
11985
  };
11897
11986
  } catch (error) {
11898
- logger$27.error("[PreflightRunner] Preflight test failed:", error);
11987
+ logger$28.error("[PreflightRunner] Preflight test failed:", error);
11899
11988
  throw new PreflightError("preflight", error instanceof Error ? error : new Error(String(error)));
11900
11989
  } finally {
11901
11990
  this.destroy();
@@ -11926,7 +12015,7 @@ var PreflightRunner = class extends Destroyable {
11926
12015
  if (track.kind === "video" && track.readyState === "live") videoWorking = true;
11927
12016
  }
11928
12017
  } catch (error) {
11929
- logger$27.warn("[PreflightRunner] Device test failed:", error);
12018
+ logger$28.warn("[PreflightRunner] Device test failed:", error);
11930
12019
  } finally {
11931
12020
  if (audioStream) audioStream.getTracks().forEach((t) => t.stop());
11932
12021
  }
@@ -11984,7 +12073,7 @@ var PreflightRunner = class extends Destroyable {
11984
12073
  rttMs
11985
12074
  };
11986
12075
  } catch (error) {
11987
- logger$27.warn("[PreflightRunner] ICE connectivity test failed:", error);
12076
+ logger$28.warn("[PreflightRunner] ICE connectivity test failed:", error);
11988
12077
  return {
11989
12078
  type: "failed",
11990
12079
  turnReachable: false,
@@ -12032,7 +12121,7 @@ var PreflightRunner = class extends Destroyable {
12032
12121
  //#endregion
12033
12122
  //#region src/controllers/VisibilityController.ts
12034
12123
  var import_cjs$26 = require_cjs();
12035
- const logger$26 = getLogger();
12124
+ const logger$27 = getLogger();
12036
12125
  /**
12037
12126
  * Checks whether the document visibility API is available.
12038
12127
  */
@@ -12069,8 +12158,8 @@ var VisibilityController = class extends Destroyable {
12069
12158
  this._boundHandler = this._handleVisibilityChange.bind(this);
12070
12159
  if (this._hasVisibilityApi) {
12071
12160
  document.addEventListener("visibilitychange", this._boundHandler);
12072
- logger$26.debug("VisibilityController: listening for visibilitychange events");
12073
- } else logger$26.debug("VisibilityController: document visibility API not available, defaulting to visible");
12161
+ logger$27.debug("VisibilityController: listening for visibilitychange events");
12162
+ } else logger$27.debug("VisibilityController: document visibility API not available, defaulting to visible");
12074
12163
  }
12075
12164
  /**
12076
12165
  * Observable of the current visibility state.
@@ -12095,7 +12184,7 @@ var VisibilityController = class extends Destroyable {
12095
12184
  destroy() {
12096
12185
  if (this._hasVisibilityApi) {
12097
12186
  document.removeEventListener("visibilitychange", this._boundHandler);
12098
- logger$26.debug("VisibilityController: removed visibilitychange listener");
12187
+ logger$27.debug("VisibilityController: removed visibilitychange listener");
12099
12188
  }
12100
12189
  super.destroy();
12101
12190
  }
@@ -12113,7 +12202,7 @@ var VisibilityController = class extends Destroyable {
12113
12202
  timestamp: Date.now()
12114
12203
  };
12115
12204
  this._visibilityChange$.next(changeEvent);
12116
- logger$26.debug("VisibilityController: visibility changed", {
12205
+ logger$27.debug("VisibilityController: visibility changed", {
12117
12206
  from: previousState,
12118
12207
  to: newState
12119
12208
  });
@@ -12352,15 +12441,57 @@ const RPCEventAckResponse = (id) => makeRPCResponse({
12352
12441
  result: {}
12353
12442
  });
12354
12443
 
12444
+ //#endregion
12445
+ //#region src/utils/authRecovery.ts
12446
+ /**
12447
+ * Walk an error's `error`/`cause` chain looking for a {@link JSONRPCError}.
12448
+ * Errors thrown by call creation are wrapped (e.g. `CallCreateError`), so the
12449
+ * underlying signaling error is nested. Bounded by a visited set to guard
12450
+ * against cyclic causes.
12451
+ */
12452
+ function findJSONRPCError(error) {
12453
+ const seen = /* @__PURE__ */ new Set();
12454
+ let current = error;
12455
+ while (current instanceof Error && !seen.has(current)) {
12456
+ seen.add(current);
12457
+ if (current instanceof JSONRPCError) return current;
12458
+ current = current.error ?? current.cause;
12459
+ }
12460
+ }
12461
+ /**
12462
+ * Whether an error is a session-recoverable authentication failure
12463
+ * (`-32002` authentication failed or `-32003` requester validation failed)
12464
+ * that a credential re-mint + retry can heal.
12465
+ */
12466
+ function isRecoverableAuthError(error) {
12467
+ const rpcError = findJSONRPCError(error);
12468
+ return rpcError !== void 0 && (rpcError.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || rpcError.code === RPC_ERROR_AUTHENTICATION_FAILED);
12469
+ }
12470
+ /**
12471
+ * Whether an error is specifically a requester-validation rejection
12472
+ * (`-32003`) — the server refusing the session's credential.
12473
+ *
12474
+ * Narrower than {@link isRecoverableAuthError} on purpose. `-32002` is
12475
+ * overloaded server-side: a rejected reattach arrives as `-32002` with
12476
+ * `cause: INVALID_MSG_UNSPECIFIED` and message `CALL ERROR`, which is a
12477
+ * call-level rejection and says nothing about the credential. Use this where
12478
+ * the decision must not be fooled by that, such as deciding whether retrying
12479
+ * an operation could possibly succeed.
12480
+ */
12481
+ function isRequesterValidationError(error) {
12482
+ return findJSONRPCError(error)?.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED;
12483
+ }
12484
+
12355
12485
  //#endregion
12356
12486
  //#region src/managers/AttachManager.ts
12357
- const logger$25 = getLogger();
12487
+ const logger$26 = getLogger();
12358
12488
  var AttachManager = class {
12359
- constructor(storage, deviceController, reconnectCallsTimeout, attachKey) {
12489
+ constructor(storage, deviceController, reconnectCallsTimeout, attachKey, credentialRecovered) {
12360
12490
  this.storage = storage;
12361
12491
  this.deviceController = deviceController;
12362
12492
  this.reconnectCallsTimeout = reconnectCallsTimeout;
12363
12493
  this.attachKey = attachKey;
12494
+ this.credentialRecovered = credentialRecovered;
12364
12495
  this.writeQueue = Promise.resolve();
12365
12496
  }
12366
12497
  async detachAll() {
@@ -12375,7 +12506,7 @@ var AttachManager = class {
12375
12506
  try {
12376
12507
  return await this.storage.getItem(this.attachKey) ?? {};
12377
12508
  } catch (error) {
12378
- logger$25.warn("[AttachManager] Failed to retrieve attached calls from storage", error);
12509
+ logger$26.warn("[AttachManager] Failed to retrieve attached calls from storage", error);
12379
12510
  return {};
12380
12511
  }
12381
12512
  }
@@ -12383,7 +12514,7 @@ var AttachManager = class {
12383
12514
  try {
12384
12515
  await this.storage.setItem(this.attachKey, attached);
12385
12516
  } catch (error) {
12386
- logger$25.warn("[AttachManager] Failed to write attached calls to storage", error);
12517
+ logger$26.warn("[AttachManager] Failed to write attached calls to storage", error);
12387
12518
  }
12388
12519
  }
12389
12520
  /**
@@ -12402,11 +12533,39 @@ var AttachManager = class {
12402
12533
  }
12403
12534
  async attach(call) {
12404
12535
  if (!call.to) {
12405
- logger$25.warn("[AttachManager] Skip attach for calls with no destination");
12536
+ logger$26.warn("[AttachManager] Skip attach for calls with no destination");
12406
12537
  return;
12407
12538
  }
12539
+ const attachment = this.buildAttachment(call, call.to);
12540
+ await this.mutate((attached) => ({
12541
+ ...attached,
12542
+ [call.id]: attachment
12543
+ }));
12544
+ }
12545
+ /**
12546
+ * Keep an already-stored call's reference alive and current — the periodic
12547
+ * refresh the `verto.ping` keepalive drives.
12548
+ *
12549
+ * Only ever updates: a call with no record is one nothing wants reattached,
12550
+ * and re-creating it here would undo a `detach`. That matters because a ping
12551
+ * can land in the window between `bye()` detaching and the call being torn
12552
+ * down, and a record revived there survives the hangup — so the next page
12553
+ * load dials a call nobody is on. The existence check and the write share
12554
+ * one {@link mutate} turn, so a concurrent detach cannot slip between them.
12555
+ */
12556
+ async refresh(call) {
12557
+ if (!call.to) return;
12408
12558
  const destination = call.to;
12409
- const attachment = {
12559
+ await this.mutate((attached) => {
12560
+ if (!Object.hasOwn(attached, call.id)) return attached;
12561
+ return {
12562
+ ...attached,
12563
+ [call.id]: this.buildAttachment(call, destination)
12564
+ };
12565
+ });
12566
+ }
12567
+ buildAttachment(call, destination) {
12568
+ return {
12410
12569
  nodeId: call.nodeId,
12411
12570
  destination,
12412
12571
  mediaDirections: call.mediaDirections,
@@ -12414,10 +12573,6 @@ var AttachManager = class {
12414
12573
  videoInputDevice: call.mediaDirections.video !== "inactive" ? this.deviceController.selectedVideoInputDevice : null,
12415
12574
  attachedAt: Date.now()
12416
12575
  };
12417
- await this.mutate((attached) => ({
12418
- ...attached,
12419
- [call.id]: attachment
12420
- }));
12421
12576
  }
12422
12577
  async detach(call) {
12423
12578
  await this.mutate((attached) => {
@@ -12440,8 +12595,14 @@ var AttachManager = class {
12440
12595
  * rejecting. Once that fix is deployed, this will work for both
12441
12596
  * page reloads and WebSocket reconnects.
12442
12597
  *
12443
- * Failed reattach attempts are handled gracefully — the stale call
12444
- * reference is cleaned up from storage.
12598
+ * A failed reattach does NOT generally cost the stored reference. It is
12599
+ * discarded only when the server denied the reattach on a session whose
12600
+ * credential it had already accepted — a verified reauthentication followed
12601
+ * by a refusal is the server saying the call is gone, and that is the one
12602
+ * refusal worth acting on. Until then the credential may be what is being
12603
+ * refused, and the record is the only way a later reload can try again;
12604
+ * keeping it costs nothing, since `detachExpired` reaps it once it is older
12605
+ * than `reconnectCallsTimeout`.
12445
12606
  */
12446
12607
  async reattachCalls() {
12447
12608
  const attached = await this.readAttached();
@@ -12450,25 +12611,31 @@ var AttachManager = class {
12450
12611
  const { destination } = attachment;
12451
12612
  const options = this.buildCallOptions(attachment);
12452
12613
  let succeeded = false;
12614
+ let refusedOnCredentials = false;
12453
12615
  for (let attempt = 1; attempt <= 3; attempt++) try {
12454
12616
  await this.session.createOutboundCall(destination, {
12455
12617
  callId,
12456
12618
  ...options
12457
12619
  });
12458
- logger$25.info(`[AttachManager] Reattached call ${callId} (attempt ${attempt})`);
12620
+ logger$26.info(`[AttachManager] Reattached call ${callId} (attempt ${attempt})`);
12459
12621
  succeeded = true;
12460
12622
  break;
12461
12623
  } catch (error) {
12462
- logger$25.warn(`[AttachManager] Reattach attempt ${attempt}/3 failed for call ${callId}:`, error);
12624
+ logger$26.warn(`[AttachManager] Reattach attempt ${attempt}/3 failed for call ${callId}:`, error);
12625
+ if (isRequesterValidationError(error)) {
12626
+ refusedOnCredentials = true;
12627
+ logger$26.warn(`[AttachManager] Reattach of ${callId} was refused on credentials; not retrying.`);
12628
+ break;
12629
+ }
12463
12630
  if (attempt < 3) await new Promise((r) => setTimeout(r, (attempt + 1) * 1e3));
12464
12631
  }
12465
- if (!succeeded) {
12466
- logger$25.warn(`[AttachManager] Reattach failed after 3 attempts for call ${callId}, removing reference`);
12632
+ if (!succeeded) if (this.credentialRecovered() && !refusedOnCredentials) {
12633
+ logger$26.warn(`[AttachManager] Reattach of ${callId} was denied after a verified reauthentication, removing reference`);
12467
12634
  await this.detach({
12468
12635
  id: callId,
12469
12636
  mediaDirections: attachment.mediaDirections
12470
12637
  });
12471
- }
12638
+ } else logger$26.warn(`[AttachManager] Reattach failed for call ${callId}; keeping the reference (credential refused or never proven good)`);
12472
12639
  }
12473
12640
  }
12474
12641
  /**
@@ -12569,12 +12736,12 @@ var require_race = /* @__PURE__ */ __commonJSMin(((exports) => {
12569
12736
  exports.race = void 0;
12570
12737
  var argsOrArgArray_1 = require_argsOrArgArray();
12571
12738
  var raceWith_1$1 = require_raceWith();
12572
- function race$4() {
12739
+ function race$3() {
12573
12740
  var args = [];
12574
12741
  for (var _i = 0; _i < arguments.length; _i++) args[_i] = arguments[_i];
12575
12742
  return raceWith_1$1.raceWith.apply(void 0, __spreadArray([], __read(argsOrArgArray_1.argsOrArgArray(args))));
12576
12743
  }
12577
- exports.race = race$4;
12744
+ exports.race = race$3;
12578
12745
  }));
12579
12746
 
12580
12747
  //#endregion
@@ -13634,7 +13801,7 @@ function toggleHandraiseMethod(is) {
13634
13801
 
13635
13802
  //#endregion
13636
13803
  //#region src/core/entities/Participant.ts
13637
- const logger$24 = getLogger();
13804
+ const logger$25 = getLogger();
13638
13805
  const initialState = {};
13639
13806
  /**
13640
13807
  * Represents a participant in a call.
@@ -13644,9 +13811,9 @@ const initialState = {};
13644
13811
  * the local participant with additional device control.
13645
13812
  */
13646
13813
  var Participant = class extends Destroyable {
13647
- constructor(id, executeMethod, deviceController) {
13814
+ constructor(id, callExecuteMethod, deviceController) {
13648
13815
  super();
13649
- this.executeMethod = executeMethod;
13816
+ this.callExecuteMethod = callExecuteMethod;
13650
13817
  this.deviceController = deviceController;
13651
13818
  this._state$ = this.createBehaviorSubject(initialState);
13652
13819
  this.id = id;
@@ -13868,22 +14035,55 @@ var Participant = class extends Destroyable {
13868
14035
  get value() {
13869
14036
  return this._state$.value;
13870
14037
  }
14038
+ /**
14039
+ * Target triple for member RPCs, built from the participant's own state.
14040
+ * The backend locates the member's session by the target `call_id`/`node_id`,
14041
+ * so this must always be the participant's own call context — never the
14042
+ * local call's id (issue #19400).
14043
+ *
14044
+ * Reading it doubles as a readiness probe: it throws until the first full
14045
+ * member event (`member.joined`/`member.updated` or the `call.joined`
14046
+ * roster) arrives, and never regresses afterwards.
14047
+ *
14048
+ * @throws {ParticipantNotReadyError} If the member state has not been
14049
+ * received yet (e.g. a participant first seen via `member.talking`) — an
14050
+ * empty call context can never address the member, so fail fast instead of
14051
+ * sending a doomed RPC.
14052
+ */
14053
+ get target() {
14054
+ const { call_id, node_id } = this._state$.value;
14055
+ if (!call_id || !node_id) throw new ParticipantNotReadyError(this.id);
14056
+ return {
14057
+ member_id: this.id,
14058
+ call_id,
14059
+ node_id
14060
+ };
14061
+ }
14062
+ /**
14063
+ * Executes a member RPC against this participant, injecting its own
14064
+ * {@link target} as the target.
14065
+ *
14066
+ * @throws {ParticipantNotReadyError} Via {@link target}, when the
14067
+ * member state has not been received yet.
14068
+ */
14069
+ async executeMethod(method, args) {
14070
+ return this.callExecuteMethod(this.target, method, args);
14071
+ }
13871
14072
  /** Toggles the deafened state (mutes/unmutes incoming audio). */
13872
14073
  async toggleDeaf() {
13873
- const method = toggleDeafMethod(this.deaf);
13874
- await this.executeMethod(this.id, method, {});
14074
+ await this.executeMethod(toggleDeafMethod(this.deaf), {});
13875
14075
  }
13876
14076
  /** Toggles the hand-raised state. */
13877
14077
  async toggleHandraise() {
13878
- await this.executeMethod(this.id, toggleHandraiseMethod(this.handraised), {});
14078
+ await this.executeMethod(toggleHandraiseMethod(this.handraised), {});
13879
14079
  }
13880
14080
  /** Mutes the participant's audio. */
13881
14081
  async mute() {
13882
- await this.executeMethod(this.id, "call.mute", { channels: ["audio"] });
14082
+ await this.executeMethod("call.mute", { channels: ["audio"] });
13883
14083
  }
13884
14084
  /** Unmutes the participant's audio. */
13885
14085
  async unmute() {
13886
- await this.executeMethod(this.id, "call.unmute", { channels: ["audio"] });
14086
+ await this.executeMethod("call.unmute", { channels: ["audio"] });
13887
14087
  }
13888
14088
  /** Toggles the participant's audio mute state. */
13889
14089
  async toggleMute() {
@@ -13891,11 +14091,11 @@ var Participant = class extends Destroyable {
13891
14091
  }
13892
14092
  /** Mutes the participant's video. */
13893
14093
  async muteVideo() {
13894
- await this.executeMethod(this.id, "call.mute", { channels: ["video"] });
14094
+ await this.executeMethod("call.mute", { channels: ["video"] });
13895
14095
  }
13896
14096
  /** Unmutes the participant's video. */
13897
14097
  async unmuteVideo() {
13898
- await this.executeMethod(this.id, "call.unmute", { channels: ["video"] });
14098
+ await this.executeMethod("call.unmute", { channels: ["video"] });
13899
14099
  }
13900
14100
  /** Toggles the participant's video mute state. */
13901
14101
  async toggleMuteVideo() {
@@ -13903,7 +14103,7 @@ var Participant = class extends Destroyable {
13903
14103
  }
13904
14104
  /** Toggles echo cancellation on the audio input. */
13905
14105
  async toggleEchoCancellation() {
13906
- await this.executeMethod(this.id, "call.audioflags.set", {
14106
+ await this.executeMethod("call.audioflags.set", {
13907
14107
  echo_cancellation: !this.echoCancellation,
13908
14108
  auto_gain: this.autoGain,
13909
14109
  noise_suppression: this.noiseSuppression
@@ -13911,7 +14111,7 @@ var Participant = class extends Destroyable {
13911
14111
  }
13912
14112
  /** Toggles automatic gain control on the audio input. */
13913
14113
  async toggleAudioInputAutoGain() {
13914
- await this.executeMethod(this.id, "call.audioflags.set", {
14114
+ await this.executeMethod("call.audioflags.set", {
13915
14115
  echo_cancellation: this.echoCancellation,
13916
14116
  auto_gain: !this.autoGain,
13917
14117
  noise_suppression: this.noiseSuppression
@@ -13919,7 +14119,7 @@ var Participant = class extends Destroyable {
13919
14119
  }
13920
14120
  /** Toggles noise suppression on the audio input. */
13921
14121
  async toggleNoiseSuppression() {
13922
- await this.executeMethod(this.id, "call.audioflags.set", {
14122
+ await this.executeMethod("call.audioflags.set", {
13923
14123
  echo_cancellation: this.echoCancellation,
13924
14124
  auto_gain: this.autoGain,
13925
14125
  noise_suppression: !this.noiseSuppression
@@ -13927,7 +14127,7 @@ var Participant = class extends Destroyable {
13927
14127
  }
13928
14128
  /** Toggles low-bitrate mode for this participant's media. */
13929
14129
  async toggleLowbitrate() {
13930
- await this.executeMethod(this.id, "call.lowbitrate.set", { lowbitrate: !this.lowbitrate });
14130
+ await this.executeMethod("call.lowbitrate.set", { lowbitrate: !this.lowbitrate });
13931
14131
  }
13932
14132
  /**
13933
14133
  * Adjusts the **conference-only** microphone energy gate / sensitivity level
@@ -13943,7 +14143,7 @@ var Participant = class extends Destroyable {
13943
14143
  * (integer, larger values are more sensitive).
13944
14144
  */
13945
14145
  async setAudioInputSensitivity(value) {
13946
- await this.executeMethod(this.id, "call.microphone.sensitivity.set", { sensitivity: value });
14146
+ await this.executeMethod("call.microphone.sensitivity.set", { sensitivity: value });
13947
14147
  }
13948
14148
  /**
13949
14149
  * Sets the **server-side** microphone volume on this participant's bridged
@@ -13956,7 +14156,7 @@ var Participant = class extends Destroyable {
13956
14156
  * @param value - Volume level (0-100).
13957
14157
  */
13958
14158
  async setAudioInputVolume(value) {
13959
- await this.executeMethod(this.id, "call.microphone.volume.set", { volume: value });
14159
+ await this.executeMethod("call.microphone.volume.set", { volume: value });
13960
14160
  }
13961
14161
  /**
13962
14162
  * Sets the **server-side** speaker volume on this participant's bridged call
@@ -13970,45 +14170,31 @@ var Participant = class extends Destroyable {
13970
14170
  * @param value - Volume level (0-100).
13971
14171
  */
13972
14172
  async setAudioOutputVolume(value) {
13973
- await this.executeMethod(this.id, "call.speaker.volume.set", { volume: value });
14173
+ await this.executeMethod("call.speaker.volume.set", { volume: value });
13974
14174
  }
13975
14175
  /**
13976
14176
  * Sets the participant's position in the video layout.
13977
14177
  *
13978
- * Requires the `member.position` capability. The gateway keys positions by the
13979
- * **target member's own** `call_id`/`node_id` (see issue #19400 and the legacy
13980
- * `setPositions` implementation), so this sends the participant's own call
13981
- * context — matching {@link Participant.remove}. A resolved promise does not
13982
- * guarantee a visible change: the backend silently returns `200` (no-op) for
13983
- * non-conference targets.
14178
+ * Requires the `member.position` capability. The gateway requires a
14179
+ * `targets` array of `{ target, position }` entries (issue #19400). A
14180
+ * resolved promise does not guarantee a visible change: the backend silently
14181
+ * returns `200` (no-op) for non-conference targets.
13984
14182
  *
13985
14183
  * @param value - The {@link VideoPosition} to assign (e.g. `'auto'`, `'reserved-0'`).
13986
14184
  */
13987
14185
  async setPosition(value) {
13988
- const state = this._state$.value;
13989
- const target = {
13990
- member_id: this.id,
13991
- call_id: state.call_id ?? "",
13992
- node_id: state.node_id ?? ""
13993
- };
13994
- await this.executeMethod(target, "call.member.position.set", { targets: [{
13995
- target,
14186
+ await this.executeMethod("call.member.position.set", { targets: [{
14187
+ target: this.target,
13996
14188
  position: value
13997
14189
  }] });
13998
14190
  }
13999
14191
  /** Removes this participant from the call. */
14000
14192
  async remove() {
14001
- const state = this._state$.value;
14002
- const target = {
14003
- member_id: this.id,
14004
- call_id: state.call_id ?? "",
14005
- node_id: state.node_id ?? ""
14006
- };
14007
- await this.executeMethod(target, "call.member.remove", {});
14193
+ await this.executeMethod("call.member.remove", { targets: [this.target] });
14008
14194
  }
14009
14195
  /** Ends the call for this participant. */
14010
14196
  async end() {
14011
- await this.executeMethod(this.id, "call.end", {});
14197
+ await this.executeMethod("call.end", {});
14012
14198
  }
14013
14199
  /**
14014
14200
  * Replaces custom metadata for this participant.
@@ -14028,7 +14214,7 @@ var Participant = class extends Destroyable {
14028
14214
  }
14029
14215
  /** Destroys the participant, releasing all subscriptions and references. */
14030
14216
  destroy() {
14031
- this.executeMethod = void 0;
14217
+ this.callExecuteMethod = void 0;
14032
14218
  super.destroy();
14033
14219
  }
14034
14220
  };
@@ -14040,8 +14226,8 @@ var Participant = class extends Destroyable {
14040
14226
  */
14041
14227
  var SelfParticipant = class extends Participant {
14042
14228
  /** @internal */
14043
- constructor(id, executeMethod, vertoManager, deviceController) {
14044
- super(id, executeMethod, deviceController);
14229
+ constructor(id, callExecuteMethod, vertoManager, deviceController) {
14230
+ super(id, callExecuteMethod, deviceController);
14045
14231
  this.vertoManager = vertoManager;
14046
14232
  this._studioAudio$ = this.createBehaviorSubject(false);
14047
14233
  this.capabilities = new SelfCapabilities();
@@ -14065,7 +14251,7 @@ var SelfParticipant = class extends Participant {
14065
14251
  async enableStudioAudio() {
14066
14252
  if (this._studioAudio$.value) return;
14067
14253
  this._studioAudio$.next(true);
14068
- await this.executeMethod(this.id, "call.audioflags.set", {
14254
+ await this.executeMethod("call.audioflags.set", {
14069
14255
  echo_cancellation: false,
14070
14256
  auto_gain: false,
14071
14257
  noise_suppression: false
@@ -14078,7 +14264,7 @@ var SelfParticipant = class extends Participant {
14078
14264
  async disableStudioAudio() {
14079
14265
  if (!this._studioAudio$.value) return;
14080
14266
  this._studioAudio$.next(false);
14081
- await this.executeMethod(this.id, "call.audioflags.set", {
14267
+ await this.executeMethod("call.audioflags.set", {
14082
14268
  echo_cancellation: true,
14083
14269
  auto_gain: true,
14084
14270
  noise_suppression: true
@@ -14087,17 +14273,27 @@ var SelfParticipant = class extends Participant {
14087
14273
  /**
14088
14274
  * Starts sharing the local screen.
14089
14275
  *
14276
+ * A call carries at most one screen share. Read `screenShareStatus` before
14277
+ * calling and treat `'starting'`/`'stopping'` as busy.
14278
+ *
14090
14279
  * The call is unaffected when acquisition fails.
14091
14280
  *
14281
+ * @param options - Pass `{ audio: true }` to also request the shared
14282
+ * surface's audio. Defaults to video only.
14283
+ * @throws {ScreenShareAlreadyActiveError} When this call is already
14284
+ * sharing a screen. Call {@link stopScreenShare} before starting another.
14285
+ * @throws {AuxiliaryLegCancelledError} When {@link stopScreenShare} removes
14286
+ * the share before its leg finishes connecting.
14092
14287
  * @throws The raw `getDisplayMedia` error. A dismissed picker or a
14093
14288
  * permission denial rejects with a `NotAllowedError` `DOMException` —
14094
14289
  * inspect `error.name` to tell benign cancels apart from real failures.
14095
14290
  */
14096
- async startScreenShare() {
14291
+ async startScreenShare(options) {
14097
14292
  try {
14098
- await this.vertoManager.addScreenMedia();
14293
+ await this.vertoManager.addScreenMedia(options);
14099
14294
  } catch (error) {
14100
- logger$24.error("[Participant.startScreenShare] Screen share error:", error);
14295
+ if (error instanceof AuxiliaryLegCancelledError) logger$25.debug("[Participant.startScreenShare] Screen share cancelled before connecting.");
14296
+ else logger$25.error("[Participant.startScreenShare] Screen share error:", error);
14101
14297
  throw error;
14102
14298
  }
14103
14299
  }
@@ -14118,14 +14314,18 @@ var SelfParticipant = class extends Participant {
14118
14314
  *
14119
14315
  * The call is unaffected when acquisition fails.
14120
14316
  *
14317
+ * @throws {AuxiliaryLegCancelledError} When {@link removeAdditionalDevice}
14318
+ * removes the device before its leg finishes connecting.
14121
14319
  * @throws The raw `getUserMedia` error (e.g. `NotAllowedError` on
14122
- * permission denial) — inspect `error.name` to decide how to react.
14320
+ * permission denial) — inspect `error.name` to decide how to react — or
14321
+ * `AuxiliaryLegTimeoutError` if the leg does not connect in time.
14123
14322
  */
14124
14323
  async addAdditionalDevice(options) {
14125
14324
  try {
14126
14325
  await this.vertoManager.addInputDevice(options);
14127
14326
  } catch (error) {
14128
- logger$24.error("[Participant.addAdditionalDevice] Additional device error:", error);
14327
+ if (error instanceof AuxiliaryLegCancelledError) logger$25.debug("[Participant.addAdditionalDevice] Device removed before connecting.");
14328
+ else logger$25.error("[Participant.addAdditionalDevice] Additional device error:", error);
14129
14329
  throw error;
14130
14330
  }
14131
14331
  }
@@ -14160,22 +14360,31 @@ var SelfParticipant = class extends Participant {
14160
14360
  this.deviceController.selectAudioInputDevice(device);
14161
14361
  if (options.savePreference) PreferencesContainer.instance.preferredAudioInput = device;
14162
14362
  }
14163
- /** Updates the audio input track constraints for the active call. */
14363
+ /**
14364
+ * Updates the audio input track constraints for the active call.
14365
+ * @returns whether the constraints reached the media the call is sending.
14366
+ */
14164
14367
  async setAudioInputDeviceConstraints(constraints) {
14165
- await this.vertoManager.updateMediaConstraints({ audio: constraints });
14368
+ return this.vertoManager.updateMediaConstraints({ audio: constraints });
14166
14369
  }
14167
- /** Updates both audio and video input track constraints for the active call. */
14370
+ /**
14371
+ * Updates both audio and video input track constraints for the active call.
14372
+ * @returns whether both kinds took the constraints.
14373
+ */
14168
14374
  async setInputDevicesConstraints(constraints) {
14169
- await this.vertoManager.updateMediaConstraints(constraints);
14375
+ return this.vertoManager.updateMediaConstraints(constraints);
14170
14376
  }
14171
14377
  /** Selects the video input device for future calls. Optionally saves as a preference. */
14172
14378
  selectVideoInputDevice(device, options = {}) {
14173
14379
  this.deviceController.selectVideoInputDevice(device);
14174
14380
  if (options.savePreference) PreferencesContainer.instance.preferredVideoInput = device;
14175
14381
  }
14176
- /** Updates the video input track constraints for the active call. */
14382
+ /**
14383
+ * Updates the video input track constraints for the active call.
14384
+ * @returns whether the constraints reached the media the call is sending.
14385
+ */
14177
14386
  async setVideoInputDeviceConstraints(constraints) {
14178
- await this.vertoManager.updateMediaConstraints({ video: constraints });
14387
+ return this.vertoManager.updateMediaConstraints({ video: constraints });
14179
14388
  }
14180
14389
  /** Selects the audio output device. Optionally saves as a preference. */
14181
14390
  selectAudioOutputDevice(device, options = {}) {
@@ -14188,7 +14397,7 @@ var SelfParticipant = class extends Participant {
14188
14397
  */
14189
14398
  exitStudioModeIfActive() {
14190
14399
  if (this._studioAudio$.value) {
14191
- logger$24.debug("[SelfParticipant] Exiting studio audio mode due to individual flag toggle");
14400
+ logger$25.debug("[SelfParticipant] Exiting studio audio mode due to individual flag toggle");
14192
14401
  this._studioAudio$.next(false);
14193
14402
  }
14194
14403
  }
@@ -14212,7 +14421,7 @@ var SelfParticipant = class extends Participant {
14212
14421
  try {
14213
14422
  await super.mute();
14214
14423
  } catch (error) {
14215
- logger$24.warn("[Participant.toggleAudioInput] Server Error while muting audio input, proceeding with local toggle anyway", error);
14424
+ logger$25.warn("[Participant.toggleAudioInput] Server Error while muting audio input, proceeding with local toggle anyway", error);
14216
14425
  } finally {
14217
14426
  this.vertoManager.muteMainAudioInputDevice();
14218
14427
  }
@@ -14222,7 +14431,7 @@ var SelfParticipant = class extends Participant {
14222
14431
  try {
14223
14432
  await super.unmute();
14224
14433
  } catch (error) {
14225
- logger$24.warn("[Participant.toggleAudioInput] Server Error while unmuting audio input, proceeding with local toggle anyway", error);
14434
+ logger$25.warn("[Participant.toggleAudioInput] Server Error while unmuting audio input, proceeding with local toggle anyway", error);
14226
14435
  } finally {
14227
14436
  await this.vertoManager.unmuteMainAudioInputDevice();
14228
14437
  }
@@ -14232,7 +14441,7 @@ var SelfParticipant = class extends Participant {
14232
14441
  try {
14233
14442
  await super.muteVideo();
14234
14443
  } catch (error) {
14235
- logger$24.warn("[Participant.toggleVideoInput] Server Error while muting video input, proceeding with local toggle anyway", error);
14444
+ logger$25.warn("[Participant.toggleVideoInput] Server Error while muting video input, proceeding with local toggle anyway", error);
14236
14445
  } finally {
14237
14446
  this.vertoManager.muteMainVideoInputDevice();
14238
14447
  }
@@ -14242,7 +14451,7 @@ var SelfParticipant = class extends Participant {
14242
14451
  try {
14243
14452
  await super.unmuteVideo();
14244
14453
  } catch (error) {
14245
- logger$24.warn("[Participant.toggleVideoInput] Server Error while unmuting video input, proceeding with local toggle anyway", error);
14454
+ logger$25.warn("[Participant.toggleVideoInput] Server Error while unmuting video input, proceeding with local toggle anyway", error);
14246
14455
  } finally {
14247
14456
  await this.vertoManager.unmuteMainVideoInputDevice();
14248
14457
  }
@@ -14447,7 +14656,7 @@ function filterAs(predicate, resultPath) {
14447
14656
  //#endregion
14448
14657
  //#region src/operators/throwOnRPCError.ts
14449
14658
  var import_cjs$21 = require_cjs();
14450
- const logger$23 = getLogger();
14659
+ const logger$24 = getLogger();
14451
14660
  /**
14452
14661
  * RxJS operator that throws a {@link JSONRPCError} when the RPC response contains an error.
14453
14662
  * Passes successful responses through unchanged.
@@ -14455,14 +14664,14 @@ const logger$23 = getLogger();
14455
14664
  function throwOnRPCError() {
14456
14665
  return (0, import_cjs$21.map)((response) => {
14457
14666
  if (response.error) {
14458
- logger$23.error("[throwOnRPCError] RPC error response:", {
14667
+ logger$24.error("[throwOnRPCError] RPC error response:", {
14459
14668
  code: response.error.code,
14460
14669
  message: response.error.message,
14461
14670
  data: response.error.data
14462
14671
  });
14463
14672
  throw new JSONRPCError(response.error.code, response.error.message, response.error.data);
14464
14673
  }
14465
- logger$23.debug("[throwOnRPCError] RPC successful response:", response);
14674
+ logger$24.debug("[throwOnRPCError] RPC successful response:", response);
14466
14675
  return response;
14467
14676
  });
14468
14677
  }
@@ -14470,7 +14679,7 @@ function throwOnRPCError() {
14470
14679
  //#endregion
14471
14680
  //#region src/managers/CallEventsManager.ts
14472
14681
  var import_cjs$20 = require_cjs();
14473
- const logger$22 = getLogger();
14682
+ const logger$23 = getLogger();
14474
14683
  const initialSessionState = {};
14475
14684
  /** @internal */
14476
14685
  var CallEventsManager = class extends Destroyable {
@@ -14574,7 +14783,7 @@ var CallEventsManager = class extends Destroyable {
14574
14783
  }
14575
14784
  initSubscriptions() {
14576
14785
  this.subscribeTo(this.callJoinedEvent$, (callJoinedEvent) => {
14577
- logger$22.debug("[CallEventsManager] Handling call.joined event for call/session IDs:", {
14786
+ logger$23.debug("[CallEventsManager] Handling call.joined event for call/session IDs:", {
14578
14787
  callId: callJoinedEvent.call_id,
14579
14788
  roomSessionId: callJoinedEvent.room_session_id
14580
14789
  });
@@ -14601,19 +14810,19 @@ var CallEventsManager = class extends Destroyable {
14601
14810
  if (this._self$.value?.capabilities.setLayout) this.updateLayouts();
14602
14811
  });
14603
14812
  this.subscribeTo(this.memberUpdates$, (member) => {
14604
- logger$22.debug("[CallEventsManager] Handling member update event for member ID:", member);
14813
+ logger$23.debug("[CallEventsManager] Handling member update event for member ID:", member);
14605
14814
  this.upsertParticipant(member);
14606
14815
  });
14607
14816
  this.subscribeTo(this.webRtcCallSession.memberLeft$, (memberLeftEvent) => {
14608
- logger$22.debug("[CallEventsManager] Handling member.left event for member ID:", memberLeftEvent.member.member_id);
14817
+ logger$23.debug("[CallEventsManager] Handling member.left event for member ID:", memberLeftEvent.member.member_id);
14609
14818
  const participants = { ...this._participants$.value };
14610
14819
  if (memberLeftEvent.member.member_id in participants) {
14611
14820
  delete participants[memberLeftEvent.member.member_id];
14612
14821
  this._participants$.next(participants);
14613
- } else logger$22.warn(`[CallEventsManager] Received member.left event for unknown member ID: ${memberLeftEvent.member.member_id}`);
14822
+ } else logger$23.warn(`[CallEventsManager] Received member.left event for unknown member ID: ${memberLeftEvent.member.member_id}`);
14614
14823
  });
14615
14824
  this.subscribeTo(this.webRtcCallSession.callUpdated$, (callUpdatedEvent) => {
14616
- logger$22.debug("[CallEventsManager] Handling call.updated event:", callUpdatedEvent);
14825
+ logger$23.debug("[CallEventsManager] Handling call.updated event:", callUpdatedEvent);
14617
14826
  const roomSession = callUpdatedEvent.room_session;
14618
14827
  this._sessionState$.next({
14619
14828
  ...this._sessionState$.value,
@@ -14628,7 +14837,7 @@ var CallEventsManager = class extends Destroyable {
14628
14837
  });
14629
14838
  });
14630
14839
  this.subscribeTo(this.layoutChangedEvent$, (layoutChangedEvent) => {
14631
- logger$22.debug("[CallEventsManager] Handling layout.changed event:", layoutChangedEvent);
14840
+ logger$23.debug("[CallEventsManager] Handling layout.changed event:", layoutChangedEvent);
14632
14841
  this._sessionState$.next({
14633
14842
  ...this._sessionState$.value,
14634
14843
  layout_name: layoutChangedEvent.id,
@@ -14638,10 +14847,10 @@ var CallEventsManager = class extends Destroyable {
14638
14847
  });
14639
14848
  }
14640
14849
  updateParticipantPositions(layoutChangedEvent) {
14641
- 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.");
14850
+ 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.");
14642
14851
  layoutChangedEvent.layers.filter((layer) => !!layer.member_id).filter((layer) => {
14643
14852
  if (!(layer.member_id in this._participants$.value)) {
14644
- logger$22.warn(`[CallEventsManager] Skipping layout layer for unknown member_id: ${layer.member_id}`);
14853
+ logger$23.warn(`[CallEventsManager] Skipping layout layer for unknown member_id: ${layer.member_id}`);
14645
14854
  return false;
14646
14855
  }
14647
14856
  return true;
@@ -14659,12 +14868,17 @@ var CallEventsManager = class extends Destroyable {
14659
14868
  updateLayouts() {
14660
14869
  if (!this.selfId) return;
14661
14870
  this.webRtcCallSession.executeMethod(this.selfId, "call.layout.list", {}).then((response) => {
14871
+ const layouts = response.result?.layouts;
14872
+ if (!layouts) {
14873
+ logger$23.warn("[CallEventsManager] Layout list response carried no layouts; keeping current layouts");
14874
+ return;
14875
+ }
14662
14876
  this._sessionState$.next({
14663
14877
  ...this._sessionState$.value,
14664
- layouts: response.result.layouts
14878
+ layouts
14665
14879
  });
14666
14880
  }).catch((error) => {
14667
- logger$22.error("[CallEventsManager] Error fetching layouts:", error);
14881
+ logger$23.error("[CallEventsManager] Error fetching layouts:", error);
14668
14882
  });
14669
14883
  }
14670
14884
  updateParticipants(members) {
@@ -14680,7 +14894,7 @@ var CallEventsManager = class extends Destroyable {
14680
14894
  }
14681
14895
  const participant = this._participants$.value[member.member_id];
14682
14896
  const oldValue = participant.value;
14683
- logger$22.debug("[CallEventsManager] Updating participant:", member.member_id, {
14897
+ logger$23.debug("[CallEventsManager] Updating participant:", member.member_id, {
14684
14898
  oldValue,
14685
14899
  newValue: member
14686
14900
  });
@@ -14693,17 +14907,17 @@ var CallEventsManager = class extends Destroyable {
14693
14907
  }
14694
14908
  get callJoinedEvent$() {
14695
14909
  return this.cachedObservable("callJoinedEvent$", () => this.webRtcCallSession.callEvent$.pipe((0, import_cjs$20.filter)(isCallJoinedPayload), (0, import_cjs$20.tap)((event) => {
14696
- logger$22.debug("[CallEventsManager] Call joined event:", event);
14910
+ logger$23.debug("[CallEventsManager] Call joined event:", event);
14697
14911
  })));
14698
14912
  }
14699
14913
  get layoutChangedEvent$() {
14700
14914
  return this.cachedObservable("layoutChangedEvent$", () => this.webRtcCallSession.callEvent$.pipe(filterAs(isLayoutChangedPayload, "layout"), (0, import_cjs$20.tap)((event) => {
14701
- logger$22.debug("[CallEventsManager] Layout changed event:", event);
14915
+ logger$23.debug("[CallEventsManager] Layout changed event:", event);
14702
14916
  })));
14703
14917
  }
14704
14918
  get memberUpdates$() {
14705
14919
  return this.cachedObservable("memberUpdates$", () => (0, import_cjs$20.merge)(this.webRtcCallSession.memberJoined$, this.webRtcCallSession.memberUpdated$, this.webRtcCallSession.memberTalking$).pipe((0, import_cjs$20.map)((event) => event.member), (0, import_cjs$20.tap)((event) => {
14706
- logger$22.debug("[CallEventsManager] Member update event:", event);
14920
+ logger$23.debug("[CallEventsManager] Member update event:", event);
14707
14921
  })));
14708
14922
  }
14709
14923
  destroy() {
@@ -14722,6 +14936,111 @@ var CallEventsManager = class extends Destroyable {
14722
14936
  }
14723
14937
  };
14724
14938
 
14939
+ //#endregion
14940
+ //#region src/controllers/ConstraintFallbackHelper.ts
14941
+ /**
14942
+ * ConstraintFallbackHelper - Provides getUserMedia with automatic constraint
14943
+ * fallback on OverconstrainedError.
14944
+ *
14945
+ * When a specific device ID is requested, the helper tries progressively
14946
+ * looser constraints:
14947
+ * 1. `{ deviceId: { exact: deviceId } }` -- exact match
14948
+ * 2. `{ deviceId: deviceId }` -- preferred (browser may pick another)
14949
+ * 3. `{}` -- no constraint, browser default
14950
+ *
14951
+ * This prevents stale device IDs from blocking call setup.
14952
+ *
14953
+ * @see Section 5.8 and Section 11 of the Implementation Guide
14954
+ */
14955
+ const logger$22 = getLogger();
14956
+ /**
14957
+ * Attempts getUserMedia with progressively looser constraints.
14958
+ *
14959
+ * The function tries three levels of constraint specificity for the given
14960
+ * device kind. Each level is only attempted if the previous one fails with
14961
+ * an OverconstrainedError. Non-OverconstrainedError failures (e.g.,
14962
+ * NotAllowedError) are thrown immediately without fallback.
14963
+ *
14964
+ * @param mediaDevices - Anything exposing `getUserMedia` (a full
14965
+ * `WebRTCMediaDevices`, or a shim wrapping one)
14966
+ * @param constraints - The full MediaStreamConstraints to use as a base
14967
+ * @param kind - Which track kind to apply fallback to ('audio' | 'video')
14968
+ * @param deviceId - The device ID to try (if undefined, calls getUserMedia as-is)
14969
+ * @returns The stream and the fallback level that succeeded
14970
+ * @throws When all fallback levels fail, or when a non-OverconstrainedError occurs
14971
+ */
14972
+ async function getUserMediaWithFallback(mediaDevices, constraints, kind, deviceId) {
14973
+ if (!deviceId) return {
14974
+ stream: await mediaDevices.getUserMedia(constraints),
14975
+ fallbackLevel: "default"
14976
+ };
14977
+ const baseConstraints = typeof constraints[kind] === "object" ? constraints[kind] : {};
14978
+ try {
14979
+ const exactConstraints = {
14980
+ ...constraints,
14981
+ [kind]: {
14982
+ ...baseConstraints,
14983
+ deviceId: { exact: deviceId }
14984
+ }
14985
+ };
14986
+ return {
14987
+ stream: await mediaDevices.getUserMedia(exactConstraints),
14988
+ fallbackLevel: "exact"
14989
+ };
14990
+ } catch (error) {
14991
+ if (!isOverconstrainedError(error)) throw error;
14992
+ logger$22.debug(`[ConstraintFallbackHelper] Exact constraint failed for ${kind}, trying preferred`, { deviceId });
14993
+ }
14994
+ try {
14995
+ const preferredConstraints = {
14996
+ ...constraints,
14997
+ [kind]: {
14998
+ ...baseConstraints,
14999
+ deviceId
15000
+ }
15001
+ };
15002
+ return {
15003
+ stream: await mediaDevices.getUserMedia(preferredConstraints),
15004
+ fallbackLevel: "preferred"
15005
+ };
15006
+ } catch (error) {
15007
+ if (!isOverconstrainedError(error)) throw error;
15008
+ logger$22.debug(`[ConstraintFallbackHelper] Preferred constraint failed for ${kind}, trying default`, { deviceId });
15009
+ }
15010
+ try {
15011
+ const defaultConstraints = {
15012
+ ...constraints,
15013
+ [kind]: { ...baseConstraints }
15014
+ };
15015
+ if (typeof defaultConstraints[kind] === "object") {
15016
+ const { deviceId: _removed, ...rest } = defaultConstraints[kind];
15017
+ defaultConstraints[kind] = rest;
15018
+ }
15019
+ const stream = await mediaDevices.getUserMedia(defaultConstraints);
15020
+ logger$22.warn(`[ConstraintFallbackHelper] Fell back to browser default for ${kind}`, { requestedDeviceId: deviceId });
15021
+ return {
15022
+ stream,
15023
+ fallbackLevel: "default"
15024
+ };
15025
+ } catch (error) {
15026
+ logger$22.error(`[ConstraintFallbackHelper] All fallback levels exhausted for ${kind}`, {
15027
+ deviceId,
15028
+ error
15029
+ });
15030
+ throw error;
15031
+ }
15032
+ }
15033
+ /**
15034
+ * Checks whether an error is an OverconstrainedError.
15035
+ *
15036
+ * Browsers may throw either a native OverconstrainedError or a DOMException
15037
+ * with a specific name.
15038
+ */
15039
+ function isOverconstrainedError(error) {
15040
+ if (error instanceof Error) return error.name === "OverconstrainedError" || error.name === "ConstraintNotSatisfiedError";
15041
+ return false;
15042
+ }
15043
+
14725
15044
  //#endregion
14726
15045
  //#region src/helpers/SDPHelper.ts
14727
15046
  /**
@@ -15280,6 +15599,7 @@ var LocalStreamController = class extends Destroyable {
15280
15599
  this._localAudioTracks$ = this.createBehaviorSubject([]);
15281
15600
  this._localVideoTracks$ = this.createBehaviorSubject([]);
15282
15601
  this._mediaTrackEnded$ = this.createSubject();
15602
+ this._trackOrigins = /* @__PURE__ */ new WeakMap();
15283
15603
  }
15284
15604
  get localStream$() {
15285
15605
  return this._localStream$.asObservable().pipe((0, import_cjs$17.takeUntil)(this.destroyed$));
@@ -15302,6 +15622,22 @@ var LocalStreamController = class extends Destroyable {
15302
15622
  get localVideoTracks() {
15303
15623
  return this._localVideoTracks$.value;
15304
15624
  }
15625
+ tagTracks(tracks, origin) {
15626
+ for (const track of tracks) this._trackOrigins.set(track, origin);
15627
+ }
15628
+ setTrackOrigin(track, origin) {
15629
+ this._trackOrigins.set(track, origin);
15630
+ }
15631
+ getTrackOrigin(track) {
15632
+ return this._trackOrigins.get(track);
15633
+ }
15634
+ /**
15635
+ * Fail-safe: an unrecorded track reads as not-a-device-capture, so a missed
15636
+ * tagging site leaves media alone rather than destroying it.
15637
+ */
15638
+ isDeviceCapture(track) {
15639
+ return this._trackOrigins.get(track) === "device";
15640
+ }
15305
15641
  /**
15306
15642
  * Build the local media stream based on the provided options.
15307
15643
  */
@@ -15311,13 +15647,16 @@ var LocalStreamController = class extends Destroyable {
15311
15647
  if (this.options.inputAudioStream ?? this.options.inputVideoStream) {
15312
15648
  const tracks = [...this.options.inputAudioStream?.getTracks() ?? [], ...this.options.inputVideoStream?.getTracks() ?? []];
15313
15649
  stream = new MediaStream(tracks);
15650
+ this.tagTracks(tracks, "application");
15314
15651
  } else if (this.options.propose === "screenshare") {
15315
- logger$19.debug("[LocalStreamController] Requesting display media for screen sharing with audio:", Boolean(this.options.inputAudioDeviceConstraints));
15652
+ const audio = this.options.screenShareAudio ?? false;
15653
+ logger$19.debug("[LocalStreamController] Requesting display media for screen sharing with audio:", audio);
15316
15654
  stream = await this.options.getDisplayMedia({
15317
15655
  video: true,
15318
- audio: Boolean(this.options.inputAudioDeviceConstraints)
15656
+ audio
15319
15657
  });
15320
15658
  logger$19.debug("[LocalStreamController] Screen share media obtained:", stream);
15659
+ this.tagTracks(stream.getTracks(), "display");
15321
15660
  } else {
15322
15661
  const constraints = {
15323
15662
  audio: this.options.inputAudioDeviceConstraints,
@@ -15326,6 +15665,7 @@ var LocalStreamController = class extends Destroyable {
15326
15665
  logger$19.debug("[LocalStreamController] Requesting user media with constraints:", constraints);
15327
15666
  stream = await this.options.getUserMedia(constraints);
15328
15667
  logger$19.debug("[LocalStreamController] User media obtained:", stream);
15668
+ this.tagTracks(stream.getTracks(), "device");
15329
15669
  }
15330
15670
  this._localStream$.next(stream);
15331
15671
  this._localAudioTracks$.next(stream.getAudioTracks());
@@ -15335,10 +15675,13 @@ var LocalStreamController = class extends Destroyable {
15335
15675
  /**
15336
15676
  * Add a local media track to the local stream.
15337
15677
  * @param track - The MediaStreamTrack to add
15678
+ * @param origin - Defaults to `'device'`; every internal caller passes a
15679
+ * fresh `getUserMedia` capture.
15338
15680
  * @returns The MediaStream (either existing or newly created)
15339
15681
  */
15340
- addTrack(track) {
15682
+ addTrack(track, origin = "device") {
15341
15683
  const localStream = this._localStream$.value ?? new MediaStream();
15684
+ this._trackOrigins.set(track, origin);
15342
15685
  track.addEventListener("ended", this.mediaTrackEndedHandler);
15343
15686
  localStream.addTrack(track);
15344
15687
  this._localStream$.next(localStream);
@@ -15608,15 +15951,27 @@ var TransceiverController = class extends Destroyable {
15608
15951
  for (let i = 0; i < Number(msStreamsNumber); i++) this.peerConnection.addTransceiver("video", { direction: "recvonly" });
15609
15952
  }
15610
15953
  }
15954
+ /**
15955
+ * @returns whether every live sender of the kind took the constraints. A
15956
+ * skipped non-device sender, an exhausted fallback, and having no live sender
15957
+ * at all all report `false` — `mediaParamsUpdated.applied` is built from this,
15958
+ * and an application told `true` cannot tell a working push from a no-op.
15959
+ */
15611
15960
  async updateSendersConstraints(kind, constraints) {
15612
15961
  if (!constraints) {
15613
15962
  this.stopTrackSender(kind);
15614
- return Promise.resolve();
15963
+ return false;
15615
15964
  }
15616
15965
  const senders = this.peerConnection.getSenders().filter((sender) => sender.track?.kind === kind && sender.track.readyState === "live");
15966
+ let applied = senders.length > 0;
15617
15967
  for (const sender of senders) {
15618
15968
  const { track } = sender;
15619
15969
  if (track) {
15970
+ if (!this.options.localStreamController.isDeviceCapture(track)) {
15971
+ logger$18.debug(`[TransceiverController] Skipping ${kind} constraints for a non-device track (origin: ${this.options.localStreamController.getTrackOrigin(track) ?? "unrecorded"}), track ${track.id}`);
15972
+ applied = false;
15973
+ continue;
15974
+ }
15620
15975
  const constraintsToApply = {
15621
15976
  ...track.getConstraints(),
15622
15977
  ...constraints
@@ -15632,33 +15987,39 @@ var TransceiverController = class extends Destroyable {
15632
15987
  } catch (fallbackError) {
15633
15988
  logger$18.warn(`[TransceiverController] Track replacement fallback also failed for ${kind} track:`, fallbackError);
15634
15989
  this.options.onError?.(new MediaTrackError("updateSendersConstraints", kind, fallbackError));
15990
+ applied = false;
15635
15991
  }
15636
15992
  }
15637
15993
  }
15638
15994
  }
15995
+ return applied;
15639
15996
  }
15640
15997
  /**
15641
- * Fallback when applyConstraints fails: stop the current track, acquire a new
15642
- * one via getUserMedia with the merged constraints (preserving the current
15643
- * deviceId), replace the sender track, and update the localStream.
15998
+ * Fallback when applyConstraints fails, which on iOS Safari it silently does.
15644
15999
  *
15645
- * This is critical for iOS Safari where applyConstraints on audio tracks
15646
- * silently fails or throws.
16000
+ * Order matters: acquiring before stopping means a failed acquisition leaves
16001
+ * the existing media playing. The deviceId goes through the fallback ladder
16002
+ * rather than pinned `{ exact }`, so a stale id degrades instead of failing.
15647
16003
  */
15648
16004
  async replaceTrackFallback(sender, oldTrack, kind, mergedConstraints) {
15649
16005
  const { deviceId } = oldTrack.getSettings();
15650
- const constraintsWithDevice = {
15651
- ...mergedConstraints,
15652
- ...deviceId ? { deviceId: { exact: deviceId } } : {}
15653
- };
15654
- const trackId = oldTrack.id;
16006
+ const { stream, fallbackLevel } = await getUserMediaWithFallback({ getUserMedia: this.options.getUserMedia }, { [kind]: mergedConstraints }, kind, deviceId);
16007
+ const newTrack = stream.getTracks().find((t) => t.kind === kind);
16008
+ if (!newTrack) {
16009
+ stream.getTracks().forEach((t) => t.stop());
16010
+ throw new MediaTrackError("replaceTrackFallback", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
16011
+ }
16012
+ try {
16013
+ await sender.replaceTrack(newTrack);
16014
+ } catch (error) {
16015
+ stream.getTracks().forEach((t) => t.stop());
16016
+ throw error;
16017
+ }
16018
+ const oldTrackId = oldTrack.id;
16019
+ this.options.localStreamController.removeTrack(oldTrackId);
15655
16020
  oldTrack.stop();
15656
- this.options.localStreamController.removeTrack(trackId);
15657
- const newTrack = (await this.options.getUserMedia({ [kind]: constraintsWithDevice })).getTracks().find((t) => t.kind === kind);
15658
- if (!newTrack) throw new MediaTrackError("replaceTrackFallback", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
15659
- await sender.replaceTrack(newTrack);
15660
16021
  this.options.localStreamController.addTrack(newTrack);
15661
- logger$18.debug(`[TransceiverController] Track replacement fallback succeeded for ${kind}. New track: ${newTrack.id}`);
16022
+ logger$18.debug(`[TransceiverController] Track replacement fallback succeeded for ${kind} (deviceId fallback level: ${fallbackLevel}). New track: ${newTrack.id}`);
15662
16023
  }
15663
16024
  getMediaDirections() {
15664
16025
  if (this.peerConnection.connectionState === "connected") return this.peerConnection.getTransceivers().reduce((acc, transceiver) => {
@@ -15728,48 +16089,44 @@ var RTCPeerConnectionController = class extends Destroyable {
15728
16089
  this.negotiationNeeded$.next();
15729
16090
  };
15730
16091
  this.updateSelectedInputDevice = async (kind, deviceInfo) => {
16092
+ const { localStream } = this;
16093
+ if (!localStream) {
16094
+ logger$17.warn("[RTCPeerConnectionController] No local stream available to update input device.");
16095
+ return;
16096
+ }
16097
+ const currentTrack = localStream.getTracks().find((track) => track.kind === kind);
16098
+ if (!currentTrack) {
16099
+ logger$17.debug(`[RTCPeerConnectionController] No ${kind} track to switch.`);
16100
+ return;
16101
+ }
16102
+ if (!deviceInfo) {
16103
+ logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected: none`);
16104
+ this.stopTrackSender(kind);
16105
+ return;
16106
+ }
16107
+ const constraints = {
16108
+ ...currentTrack.getConstraints(),
16109
+ ...this.deviceController.deviceInfoToConstraints(deviceInfo)
16110
+ };
15731
16111
  try {
15732
- const { localStream } = this;
15733
- if (!localStream) {
15734
- logger$17.warn("[RTCPeerConnectionController] No local stream available to update input device.");
15735
- return;
15736
- }
15737
- logger$17.debug(`[RTCPeerConnectionController] Updating selected ${kind} input device:`, localStream.getTracks());
15738
- const track = localStream.getTracks().find((track$1) => track$1.kind === kind);
15739
- if (track) {
15740
- this.transceiverController?.stopTrackSender(kind);
15741
- this.localStreamController.removeTrack(track.id);
15742
- logger$17.debug(`[RTCPeerConnectionController] Stopped existing ${kind} track: ${track.id}`, localStream.getTracks());
15743
- if (!deviceInfo) {
15744
- logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected: none`);
15745
- return;
15746
- }
15747
- const streamTrack = (await this.getUserMedia({ [kind]: {
15748
- ...track.getConstraints(),
15749
- ...this.deviceController.deviceInfoToConstraints(deviceInfo)
15750
- } })).getTracks().find((t) => t.kind === kind);
15751
- if (streamTrack) {
15752
- logger$17.debug(`[RTCPeerConnectionController] Adding new ${kind} track: ${streamTrack.id}`);
15753
- this.localStreamController.addTrack(streamTrack);
15754
- await this.transceiverController?.replaceSenderTrack(kind, streamTrack);
15755
- logger$17.debug(`[RTCPeerConnectionController] Added new ${kind} track: ${streamTrack.id}`, this.localStream?.getTracks());
15756
- }
15757
- }
15758
- logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected:`, deviceInfo?.label);
16112
+ const newTrack = await this.acquireInputTrack(kind, constraints, deviceInfo, currentTrack);
16113
+ await this.attachInputTrack(kind, newTrack, currentTrack);
16114
+ logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected:`, deviceInfo.label, newTrack.id);
15759
16115
  } catch (error) {
15760
16116
  logger$17.error(`[RTCPeerConnectionController] Failed to select ${kind} input device:`, error);
15761
16117
  this._errors$.next(new MediaTrackError("updateSelectedInputDevice", kind, error));
15762
- throw error;
15763
16118
  }
15764
16119
  };
15765
16120
  this._isNegotiating$ = this.createBehaviorSubject(false);
15766
16121
  this._memberId = null;
16122
+ this._nodeId = null;
15767
16123
  this._iceConnectionState$ = this.createReplaySubject(1);
15768
16124
  this._connectionState$ = this.createReplaySubject(1);
15769
16125
  this._signalingState$ = this.createReplaySubject(1);
15770
16126
  this._iceGatheringState$ = this.createReplaySubject(1);
15771
16127
  this._errors$ = this.createReplaySubject(1);
15772
16128
  this._iceCandidates$ = this.createReplaySubject(1);
16129
+ this._localMediaSettled$ = this.createReplaySubject(1);
15773
16130
  this._initialized$ = this.createReplaySubject(1);
15774
16131
  this._remoteDescription$ = this.createReplaySubject(1);
15775
16132
  this._remoteStream$ = this.createBehaviorSubject(null);
@@ -15802,6 +16159,7 @@ var RTCPeerConnectionController = class extends Destroyable {
15802
16159
  inputVideoStream: this.options.inputVideoStream,
15803
16160
  inputAudioDeviceConstraints: this.inputAudioDeviceConstraints,
15804
16161
  inputVideoDeviceConstraints: this.inputVideoDeviceConstraints,
16162
+ screenShareAudio: this.options.screenShareAudio,
15805
16163
  getUserMedia: async (constraints) => this.getUserMedia(constraints),
15806
16164
  getDisplayMedia: async (options$1) => this.getDisplayMedia(options$1)
15807
16165
  });
@@ -15828,6 +16186,13 @@ var RTCPeerConnectionController = class extends Destroyable {
15828
16186
  get memberId() {
15829
16187
  return this._memberId;
15830
16188
  }
16189
+ /** The node this leg's invite landed on — auxiliary legs are placed independently. */
16190
+ setNodeId(nodeId) {
16191
+ this._nodeId = nodeId;
16192
+ }
16193
+ get nodeId() {
16194
+ return this._nodeId;
16195
+ }
15831
16196
  stopTrackSender(kind, options = { updateTransceiverDirection: false }) {
15832
16197
  const audioCovered = kind === "audio" || kind === "both";
15833
16198
  if (audioCovered && this._localAudioPipeline) this.stopRawAudioInputForPipeline();
@@ -15837,10 +16202,7 @@ var RTCPeerConnectionController = class extends Destroyable {
15837
16202
  }
15838
16203
  stopRawAudioInputForPipeline() {
15839
16204
  const rawTracks = this.localStreamController.localAudioTracks;
15840
- for (const track of rawTracks) if (track.readyState === "live") {
15841
- track.stop();
15842
- this.localStreamController.removeTrack(track.id);
15843
- }
16205
+ for (const track of rawTracks) if (track.readyState === "live") this.localStreamController.removeTrack(track.id);
15844
16206
  this._localAudioPipeline?.setInputTrack(null);
15845
16207
  }
15846
16208
  get isNegotiating$() {
@@ -15873,6 +16235,10 @@ var RTCPeerConnectionController = class extends Destroyable {
15873
16235
  get remoteDescription$() {
15874
16236
  return this.cachedObservable("remoteDescription$", () => this._remoteDescription$.asObservable().pipe((0, import_cjs$16.takeUntil)(this.destroyed$)));
15875
16237
  }
16238
+ /** Emits once local media is settled — acquired, or knowingly receive-only. */
16239
+ get localMediaSettled$() {
16240
+ return this.cachedObservable("localMediaSettled$", () => this._localMediaSettled$.asObservable().pipe((0, import_cjs$16.takeUntil)(this.destroyed$)));
16241
+ }
15876
16242
  get localStream$() {
15877
16243
  return this.cachedObservable("localStream$", () => this.localStreamController.localStream$.pipe((0, import_cjs$16.takeUntil)(this.destroyed$)));
15878
16244
  }
@@ -15900,6 +16266,9 @@ var RTCPeerConnectionController = class extends Destroyable {
15900
16266
  get propose() {
15901
16267
  return this.options.propose ?? "main";
15902
16268
  }
16269
+ get connectionState() {
16270
+ return this.peerConnection?.connectionState;
16271
+ }
15903
16272
  get isAdditionalDevice() {
15904
16273
  return this.propose === "additional-device";
15905
16274
  }
@@ -16028,7 +16397,7 @@ var RTCPeerConnectionController = class extends Destroyable {
16028
16397
  this._isNegotiating$.next(true);
16029
16398
  await this._setRemoteDescription(this.sdpInit);
16030
16399
  } else {
16031
- await this.setupTrackHandling();
16400
+ if (!await this.setupTrackHandling()) return;
16032
16401
  this._initialized$.next(true);
16033
16402
  }
16034
16403
  } catch (error) {
@@ -16152,7 +16521,10 @@ var RTCPeerConnectionController = class extends Destroyable {
16152
16521
  inputVideoDeviceConstraints: this.inputVideoDeviceConstraints
16153
16522
  });
16154
16523
  }
16155
- await this.setupLocalTracks();
16524
+ if (!await this.setupLocalTracks()) {
16525
+ logger$17.debug("[RTCPeerConnectionController] Inbound answer abandoned; the connection went away.");
16526
+ return;
16527
+ }
16156
16528
  const { answerOptions } = this;
16157
16529
  logger$17.debug("[RTCPeerConnectionController] Creating inbound answer with options:", answerOptions);
16158
16530
  await this.createAnswer(answerOptions);
@@ -16290,35 +16662,52 @@ var RTCPeerConnectionController = class extends Destroyable {
16290
16662
  }
16291
16663
  /**
16292
16664
  * Setup track handling for remote tracks.
16665
+ *
16666
+ * @returns `false` when the connection went away while local media was being
16667
+ * acquired — see {@link setupLocalTracks}.
16293
16668
  */
16294
16669
  async setupTrackHandling() {
16295
16670
  if (!this.peerConnection) throw new DependencyError("RTCPeerConnection is not initialized");
16296
- await this.setupLocalTracks();
16671
+ if (!await this.setupLocalTracks()) return false;
16297
16672
  await this.setupRemoteTracks();
16673
+ return true;
16298
16674
  }
16675
+ /**
16676
+ * @returns `false` when the connection was torn down while getUserMedia was
16677
+ * in flight. The acquisition is not cancellable, so the caller must stop
16678
+ * rather than go on to touch a peer connection that is closed or gone.
16679
+ */
16299
16680
  async setupLocalTracks() {
16300
16681
  logger$17.debug("[RTCPeerConnectionController] Setting up local tracks/transceivers.");
16301
16682
  if (this.hasNoLocalMediaToSend()) {
16302
16683
  if (!this.receiveAudio && !this.receiveVideo) throw new InvalidParams("Call requests no media: enable audio/video or receiveAudio/receiveVideo");
16303
16684
  logger$17.debug("[RTCPeerConnectionController] No local media requested; negotiating receive-only.");
16304
16685
  this.setupReceiveOnlyTransceivers();
16305
- return;
16686
+ this._localMediaSettled$.next();
16687
+ return true;
16306
16688
  }
16307
16689
  let localStream;
16308
16690
  try {
16309
16691
  localStream = this.localStream ?? await this.localStreamController.buildLocalStream();
16310
16692
  } catch (error) {
16311
16693
  this.handleLocalMediaFailure(error);
16312
- return;
16694
+ this._localMediaSettled$.next();
16695
+ return true;
16313
16696
  }
16697
+ if (!this.peerConnection || this.peerConnection.signalingState === "closed") {
16698
+ logger$17.debug("[RTCPeerConnectionController] Local media arrived after teardown; releasing it.");
16699
+ localStream.getTracks().forEach((track) => track.stop());
16700
+ return false;
16701
+ }
16702
+ this._localMediaSettled$.next();
16314
16703
  if (this.transceiverController?.useAddStream ?? false) {
16315
16704
  logger$17.warn("[RTCPeerConnectionController] Using deprecated addStream API to add local stream.");
16316
- this.peerConnection?.addStream(localStream);
16705
+ this.peerConnection.addStream(localStream);
16317
16706
  if (!this.isNegotiating) {
16318
16707
  logger$17.debug("[RTCPeerConnectionController] Forcing negotiationneeded after local tracks setup.");
16319
16708
  this.negotiationNeeded$.next();
16320
16709
  }
16321
- return;
16710
+ return true;
16322
16711
  }
16323
16712
  for (const kind of ["audio", "video"]) {
16324
16713
  const tracks = (kind === "audio" ? localStream.getAudioTracks() : localStream.getVideoTracks()).map((track, index) => ({
@@ -16332,10 +16721,11 @@ var RTCPeerConnectionController = class extends Destroyable {
16332
16721
  await this.transceiverController?.setupTransceiverSender(track, localStream, transceivers[index]);
16333
16722
  } else {
16334
16723
  logger$17.debug(`[RTCPeerConnectionController] Using addTrack for local ${kind} track:`, track.id);
16335
- this.peerConnection?.addTrack(track, localStream);
16724
+ this.peerConnection.addTrack(track, localStream);
16336
16725
  }
16337
16726
  }
16338
16727
  }
16728
+ return true;
16339
16729
  }
16340
16730
  /** True for a main connection with no local media to send. */
16341
16731
  hasNoLocalMediaToSend() {
@@ -16424,6 +16814,61 @@ var RTCPeerConnectionController = class extends Destroyable {
16424
16814
  this._localAudioPipeline.setInputTrack(newTrack);
16425
16815
  }
16426
16816
  /**
16817
+ * Capture the newly selected device, leaving the current capture running.
16818
+ *
16819
+ * A rejection must leave the current track sending, so nothing is released
16820
+ * until the replacement is in hand. The one exception is hardware that admits
16821
+ * a single opener — a phone's front and back cameras, typically — which
16822
+ * rejects the second capture until the first is closed.
16823
+ */
16824
+ async acquireInputTrack(kind, constraints, deviceInfo, currentTrack) {
16825
+ try {
16826
+ return await this.captureTrack(kind, constraints, deviceInfo.deviceId);
16827
+ } catch (error) {
16828
+ if (!isMediaDeviceInUse(error)) throw error;
16829
+ logger$17.warn(`[RTCPeerConnectionController] ${kind} device is held exclusively; releasing the current capture to retry:`, error);
16830
+ const previousDeviceId = currentTrack.getSettings().deviceId;
16831
+ this.stopTrackSender(kind);
16832
+ try {
16833
+ return await this.captureTrack(kind, constraints, deviceInfo.deviceId);
16834
+ } catch (retryError) {
16835
+ await this.restorePreviousInputTrack(kind, constraints, previousDeviceId, currentTrack);
16836
+ throw retryError;
16837
+ }
16838
+ }
16839
+ }
16840
+ async captureTrack(kind, constraints, deviceId) {
16841
+ const { stream, fallbackLevel } = await getUserMediaWithFallback({ getUserMedia: async (c) => this.getUserMedia(c) }, { [kind]: constraints }, kind, deviceId);
16842
+ const track = stream.getTracks().find((t) => t.kind === kind);
16843
+ if (!track) {
16844
+ stream.getTracks().forEach((t) => t.stop());
16845
+ throw new MediaTrackError("updateSelectedInputDevice", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
16846
+ }
16847
+ if (fallbackLevel !== "exact") logger$17.warn(`[RTCPeerConnectionController] ${kind} device acquired at fallback level '${fallbackLevel}'; the capture may not be the requested device.`);
16848
+ return track;
16849
+ }
16850
+ /** Best-effort return to the device that was released for an exclusive retry. */
16851
+ async restorePreviousInputTrack(kind, constraints, previousDeviceId, releasedTrack) {
16852
+ try {
16853
+ const restored = await this.captureTrack(kind, constraints, previousDeviceId);
16854
+ await this.attachInputTrack(kind, restored, releasedTrack);
16855
+ } catch (error) {
16856
+ logger$17.error(`[RTCPeerConnectionController] Failed to restore the previous ${kind} device:`, error);
16857
+ }
16858
+ }
16859
+ async attachInputTrack(kind, newTrack, oldTrack) {
16860
+ const pipelineOwnsAudio = kind === "audio" && this._localAudioPipeline;
16861
+ if (!pipelineOwnsAudio) try {
16862
+ await this.transceiverController?.replaceSenderTrack(kind, newTrack);
16863
+ } catch (error) {
16864
+ newTrack.stop();
16865
+ throw error;
16866
+ }
16867
+ this.localStreamController.removeTrack(oldTrack.id);
16868
+ this.localStreamController.addTrack(newTrack);
16869
+ if (pipelineOwnsAudio) this._localAudioPipeline?.setInputTrack(newTrack);
16870
+ }
16871
+ /**
16427
16872
  * Return the lazily-created {@link LocalAudioPipeline}, constructing it on
16428
16873
  * first access. On creation the current audio sender's track is routed
16429
16874
  * through the pipeline (input → gain → analyser → destination) and the
@@ -16456,6 +16901,7 @@ var RTCPeerConnectionController = class extends Destroyable {
16456
16901
  const sender = (this.transceiverController?.audioTransceivers.at(0))?.sender ?? this.peerConnection.getSenders().find((s) => s.track?.kind === "audio");
16457
16902
  if (!sender || !raw) return;
16458
16903
  try {
16904
+ this.localStreamController.setTrackOrigin(this._localAudioPipeline.outputTrack, "processed");
16459
16905
  await sender.replaceTrack(this._localAudioPipeline.outputTrack);
16460
16906
  } catch (error) {
16461
16907
  logger$17.warn("[RTCPeerConnectionController] Failed to route audio sender through pipeline:", error);
@@ -16516,42 +16962,73 @@ var RTCPeerConnectionController = class extends Destroyable {
16516
16962
  for (const existingTrack of existingTracks) this.removeLocalTrack(existingTrack.id);
16517
16963
  this.addLocalTrack(track);
16518
16964
  }
16519
- async updateSendersConstraints(kind, constraints) {
16520
- await this.transceiverController?.updateSendersConstraints(kind, constraints);
16521
- }
16522
16965
  /**
16523
- * Replace the current audio track with a new one using the given constraints.
16524
- * Used for server-pushed audio constraint changes where applyConstraints
16525
- * fails on iOS Safari. Stops the current track, acquires a new one via
16526
- * getUserMedia, and replaces the sender track.
16966
+ * @returns whether the constraints reached the media the leg is sending.
16967
+ *
16968
+ * With the pipeline engaged the audio sender carries the processed
16969
+ * destination track, so the sender scan would find nothing it may touch and
16970
+ * every audio constraint API would silently no-op. The constraints belong to
16971
+ * the pipeline's device source, which is the capture that sender ultimately
16972
+ * carries.
16527
16973
  */
16528
- async replaceAudioTrackWithConstraints(constraints) {
16529
- const senders = this.peerConnection?.getSenders().filter((s) => s.track?.kind === "audio" && s.track.readyState === "live");
16530
- if (!senders || senders.length === 0) {
16531
- logger$17.warn("[RTCPeerConnectionController] No live audio sender to replace");
16532
- return;
16533
- }
16534
- for (const sender of senders) {
16535
- const oldTrack = sender.track;
16536
- if (!oldTrack) continue;
16537
- const { deviceId } = oldTrack.getSettings();
16538
- const mergedConstraints = {
16539
- ...oldTrack.getConstraints(),
16540
- ...constraints,
16541
- ...deviceId ? { deviceId: { exact: deviceId } } : {}
16542
- };
16543
- const trackId = oldTrack.id;
16544
- oldTrack.stop();
16545
- this.localStreamController.removeTrack(trackId);
16546
- const newTrack = (await this.getUserMedia({ audio: mergedConstraints })).getAudioTracks()[0];
16547
- await sender.replaceTrack(newTrack);
16548
- this.localStreamController.addTrack(newTrack);
16549
- logger$17.debug(`[RTCPeerConnectionController] Audio track replaced for server-pushed params. New track: ${newTrack.id}`);
16974
+ async updateSendersConstraints(kind, constraints) {
16975
+ if (kind === "audio" && this._localAudioPipeline) {
16976
+ if (!constraints) {
16977
+ this.stopTrackSender("audio");
16978
+ return false;
16979
+ }
16980
+ return this.applyPipelineSourceConstraints(constraints);
16550
16981
  }
16982
+ return await this.transceiverController?.updateSendersConstraints(kind, constraints) ?? false;
16551
16983
  }
16552
16984
  /**
16553
- * Clean up resources and close the peer connection.
16554
- * Completes all observables to prevent memory leaks.
16985
+ * Mirror of the sender path for a piped audio leg: same merge, same fallback
16986
+ * ladder, same device-capture invariant — but the swap target is the pipeline
16987
+ * input, so the sender keeps emitting the pipeline's output track and its
16988
+ * identity survives the change.
16989
+ */
16990
+ async applyPipelineSourceConstraints(constraints) {
16991
+ const pipeline = this._localAudioPipeline;
16992
+ const source = this.localStreamController.localAudioTracks.at(0);
16993
+ if (!pipeline || !source) {
16994
+ logger$17.debug("[RTCPeerConnectionController] No pipeline input to constrain.");
16995
+ return false;
16996
+ }
16997
+ if (!this.localStreamController.isDeviceCapture(source)) {
16998
+ logger$17.debug(`[RTCPeerConnectionController] Skipping audio constraints for a non-device pipeline input (origin: ${this.localStreamController.getTrackOrigin(source) ?? "unrecorded"}).`);
16999
+ return false;
17000
+ }
17001
+ const merged = {
17002
+ ...source.getConstraints(),
17003
+ ...constraints
17004
+ };
17005
+ try {
17006
+ await source.applyConstraints(merged);
17007
+ logger$17.debug("[RTCPeerConnectionController] Pipeline input constraints updated:", merged);
17008
+ return true;
17009
+ } catch (error) {
17010
+ logger$17.warn("[RTCPeerConnectionController] applyConstraints failed on the pipeline input, re-acquiring:", error);
17011
+ }
17012
+ try {
17013
+ const { stream } = await getUserMediaWithFallback({ getUserMedia: async (c) => this.getUserMedia(c) }, { audio: merged }, "audio", source.getSettings().deviceId);
17014
+ const newTrack = stream.getAudioTracks().at(0);
17015
+ if (!newTrack) {
17016
+ stream.getTracks().forEach((track) => track.stop());
17017
+ throw new Error("getUserMedia returned no audio track");
17018
+ }
17019
+ this.localStreamController.removeTrack(source.id);
17020
+ this.localStreamController.addTrack(newTrack);
17021
+ pipeline.setInputTrack(newTrack);
17022
+ return true;
17023
+ } catch (error) {
17024
+ logger$17.warn("[RTCPeerConnectionController] Failed to re-acquire the pipeline input for constraints:", error);
17025
+ this._errors$.next(new MediaTrackError("updateSendersConstraints", "audio", error));
17026
+ return false;
17027
+ }
17028
+ }
17029
+ /**
17030
+ * Clean up resources and close the peer connection.
17031
+ * Completes all observables to prevent memory leaks.
16555
17032
  */
16556
17033
  destroy() {
16557
17034
  logger$17.debug(`[RTCPeerConnectionController] Destroying RTCPeerConnectionController. ${this.propose}`);
@@ -16666,6 +17143,48 @@ const logger$16 = getLogger();
16666
17143
  function resolveInviteNodeId(args) {
16667
17144
  return args.isInvite && !args.reattach && !args.explicitNodeId ? "" : args.currentNodeId ?? "";
16668
17145
  }
17146
+ /**
17147
+ * Surface the real outcome of a `webrtc.verto` reply.
17148
+ *
17149
+ * A webrtc.verto response nests several envelopes, each keyed by a verto-style
17150
+ * string `code` ("200" ok, "400"/etc. fail) rather than a JSON-RPC `error`. An outer
17151
+ * layer reports only whether the frame was delivered; an inner layer carries the op's
17152
+ * own outcome:
17153
+ *
17154
+ * response.result = { code:"200", result:{…} } ← delivery acknowledgement
17155
+ * .result = { jsonrpc, id, result:{…} } ← the reply payload
17156
+ * .result = { code:"400", message:"Bad request" } ← the actual op outcome
17157
+ *
17158
+ * A failure can appear at any layer (delivery refused, or the op itself rejected
17159
+ * deeper down), so walk every nested `.result` object and return the FIRST non-2xx
17160
+ * `code` with its message. Returns null when every `code` seen is 2xx or absent —
17161
+ * i.e. the op succeeded. This is the only way to detect that e.g. a mute/kick was
17162
+ * rejected, since the outer delivery `code` is "200" (delivered) even then.
17163
+ *
17164
+ * Pure function — exported for unit testing.
17165
+ */
17166
+ function findNestedVertoFailure(response) {
17167
+ let node = response;
17168
+ while (node !== null && typeof node === "object") {
17169
+ const obj = node;
17170
+ const err = obj.error;
17171
+ if (err !== null && typeof err === "object") {
17172
+ const e = err;
17173
+ const errCode = typeof e.code === "string" || typeof e.code === "number" ? String(e.code) : void 0;
17174
+ if (errCode !== void 0 && !/^2\d\d$/.test(errCode)) return {
17175
+ code: errCode,
17176
+ message: typeof e.message === "string" ? e.message : void 0
17177
+ };
17178
+ }
17179
+ const code = typeof obj.code === "string" || typeof obj.code === "number" ? String(obj.code) : void 0;
17180
+ if (code !== void 0 && !/^2\d\d$/.test(code)) return {
17181
+ code,
17182
+ message: typeof obj.message === "string" ? obj.message : void 0
17183
+ };
17184
+ node = obj.result !== null && typeof obj.result === "object" ? obj.result : null;
17185
+ }
17186
+ return null;
17187
+ }
16669
17188
  var VertoManager = class extends Destroyable {
16670
17189
  constructor(callSession) {
16671
17190
  super();
@@ -16688,7 +17207,7 @@ var WebRTCVertoManager = class extends VertoManager {
16688
17207
  this._signalingStatus$ = this.createReplaySubject(1);
16689
17208
  this._screenShareStatus$ = this.createBehaviorSubject("none");
16690
17209
  this._rtcPeerConnectionsMap = /* @__PURE__ */ new Map();
16691
- this._screenShareTimeoutMs = 5e4;
17210
+ this._legErrors$ = this.createSubject();
16692
17211
  this._nodeId$ = this.createBehaviorSubject(options.nodeId ?? null);
16693
17212
  this.onError = options.onError;
16694
17213
  this.onModifyFailed = options.onModifyFailed;
@@ -16736,6 +17255,10 @@ var WebRTCVertoManager = class extends VertoManager {
16736
17255
  get selfId$() {
16737
17256
  return this._selfId$.asObservable();
16738
17257
  }
17258
+ /** Separates the media phase of call creation from the signalling phase. */
17259
+ get localMediaSettled$() {
17260
+ return this.mainPeerConnection.localMediaSettled$;
17261
+ }
16739
17262
  get localStream() {
16740
17263
  return this._rtcPeerConnectionsMap.get(this.webRtcCallSession.id)?.localStream ?? null;
16741
17264
  }
@@ -16792,35 +17315,95 @@ var WebRTCVertoManager = class extends VertoManager {
16792
17315
  const { mediaParams, callID } = event;
16793
17316
  const rtcPeerConnController = this._rtcPeerConnectionsMap.get(callID);
16794
17317
  const { audio, video } = mediaParams;
16795
- (async () => {
16796
- try {
16797
- if (audio && rtcPeerConnController) await rtcPeerConnController.replaceAudioTrackWithConstraints(audio);
16798
- if (video) await rtcPeerConnController?.updateSendersConstraints("video", video);
16799
- this.webRtcCallSession.emitMediaParamsUpdated({
16800
- audio,
16801
- video,
16802
- timestamp: Date.now()
16803
- });
16804
- } catch (error) {
16805
- logger$16.warn("[WebRTCManager] Error applying server-pushed media params:", error);
16806
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
16807
- }
16808
- })();
17318
+ if (!rtcPeerConnController) {
17319
+ logger$16.warn(`[WebRTCManager] Ignoring server-pushed media params for unknown leg ${callID}`);
17320
+ return;
17321
+ }
17322
+ this.applyServerMediaParams(rtcPeerConnController, audio, video);
16809
17323
  });
16810
17324
  this.subscribeTo(this.vertoPing$, (vertoPing) => {
16811
- this.attachManager.attach(this.buildAttachableCall());
17325
+ this.attachManager.refresh(this.buildAttachableCall());
16812
17326
  this.sendVertoPong(vertoPing);
16813
17327
  });
16814
17328
  }
16815
17329
  /**
17330
+ * An auxiliary-leg failure must never destroy the call; main-leg and
17331
+ * call-level errors keep `CallFactory.isFatalError`'s classification.
17332
+ *
17333
+ * Every site holding a peer connection reports through here, so the invariant
17334
+ * is structural rather than per-call-site — which is how cloud-product#20523
17335
+ * happened, with only one of fourteen sites passing `{ fatal: false }`.
17336
+ *
17337
+ * `override` composes rather than replaces: a caller may force non-fatal for a
17338
+ * reason of its own (a `verto.info` frame is best-effort whichever leg carries
17339
+ * it), and an auxiliary leg stays non-fatal regardless.
17340
+ */
17341
+ reportLegError(error, rtcPeerConnController, override) {
17342
+ const leg = rtcPeerConnController?.propose;
17343
+ const legId = rtcPeerConnController?.id;
17344
+ const auxiliary = Boolean(rtcPeerConnController) && !rtcPeerConnController?.isMainDevice;
17345
+ this.onError?.(error, {
17346
+ ...override?.fatal === false || auxiliary ? { fatal: false } : {},
17347
+ ...leg ? { leg } : {},
17348
+ ...legId ? { legId } : {}
17349
+ });
17350
+ if (legId) this._legErrors$.next({
17351
+ legId,
17352
+ error
17353
+ });
17354
+ }
17355
+ /**
17356
+ * Errors reported for one leg, as a stream that fails with them.
17357
+ *
17358
+ * Signaling failures are reported, never thrown — so nothing that waits on a
17359
+ * leg's progress would otherwise learn of a rejected invite. Merging this in
17360
+ * lets the wait end with the reason the server gave.
17361
+ */
17362
+ legError$(legId) {
17363
+ return this._legErrors$.pipe((0, import_cjs$15.filter)((report) => report.legId === legId), (0, import_cjs$15.map)((report) => {
17364
+ throw report.error;
17365
+ }));
17366
+ }
17367
+ /**
17368
+ * Audio and video are applied independently so a failure in one cannot
17369
+ * suppress the other, and `mediaParamsUpdated` is emitted whatever happens —
17370
+ * an application should not be starved of the params by a constraint failure.
17371
+ */
17372
+ async applyServerMediaParams(rtcPeerConnController, audio, video) {
17373
+ const failures = [];
17374
+ let applied = true;
17375
+ if (audio) try {
17376
+ applied = await rtcPeerConnController.updateSendersConstraints("audio", audio) && applied;
17377
+ } catch (error) {
17378
+ applied = false;
17379
+ failures.push(toError(error));
17380
+ }
17381
+ if (video) try {
17382
+ applied = await rtcPeerConnController.updateSendersConstraints("video", video) && applied;
17383
+ } catch (error) {
17384
+ applied = false;
17385
+ failures.push(toError(error));
17386
+ }
17387
+ this.webRtcCallSession.emitMediaParamsUpdated({
17388
+ audio,
17389
+ video,
17390
+ timestamp: Date.now(),
17391
+ applied
17392
+ });
17393
+ for (const failure of failures) {
17394
+ logger$16.warn("[WebRTCManager] Error applying server-pushed media params:", failure);
17395
+ this.reportLegError(failure, rtcPeerConnController, { fatal: false });
17396
+ }
17397
+ }
17398
+ /**
16816
17399
  * Set node_id/selfId only when the current value is null.
16817
17400
  *
16818
17401
  * During reattach, `call.joined` and `verto.answer` events can deliver
16819
17402
  * these identifiers before the `verto.invite` RPC response (`CALL CREATED`)
16820
17403
  * arrives. These methods let early events populate them eagerly so that
16821
17404
  * downstream RPC calls (e.g. `call.layout.list`) don't fail with empty
16822
- * identifiers. `processInviteResponse()` remains the authoritative source
16823
- * and always overwrites unconditionally.
17405
+ * identifiers. `processInviteResponse()` remains the authoritative source and
17406
+ * overwrites unconditionally — for selfId, on the main leg only.
16824
17407
  */
16825
17408
  setNodeIdIfNull(nodeId) {
16826
17409
  if (!this._nodeId$.value && nodeId) {
@@ -16843,24 +17426,33 @@ var WebRTCVertoManager = class extends VertoManager {
16843
17426
  this.onError?.(new VertoPongError(error));
16844
17427
  }
16845
17428
  }
17429
+ /**
17430
+ * @returns whether the constraints reached the media the call is sending.
17431
+ * `false` is an outcome, not a failure: the leg may have no live sender of
17432
+ * the kind, or carry media the SDK did not capture and may not replace.
17433
+ * A failure behind it still reaches the call's `errors$`, so a caller that
17434
+ * ignores this value learns of it there.
17435
+ */
16846
17436
  async updateMediaConstraints(options = {}) {
16847
17437
  const { audio, video } = options;
17438
+ let applied = true;
16848
17439
  try {
16849
- if (audio) await this.mainPeerConnection.updateSendersConstraints("audio", audio);
16850
- if (video) await this.mainPeerConnection.updateSendersConstraints("video", video);
17440
+ if (audio) applied = await this.mainPeerConnection.updateSendersConstraints("audio", audio) && applied;
17441
+ if (video) applied = await this.mainPeerConnection.updateSendersConstraints("video", video) && applied;
16851
17442
  } catch (error) {
16852
17443
  logger$16.warn("[WebRTCManager] Error updating media constraints:", error);
16853
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
17444
+ this.reportLegError(toError(error), this.mainPeerConnection);
16854
17445
  throw error;
16855
17446
  }
17447
+ return applied;
16856
17448
  }
16857
17449
  get selfId() {
16858
17450
  return this._selfId$.value;
16859
17451
  }
16860
17452
  /** Build an AttachableCall from the current call state. */
16861
- buildAttachableCall(idOverride) {
17453
+ buildAttachableCall(idOverride, nodeIdOverride) {
16862
17454
  return {
16863
- nodeId: this.nodeId ?? void 0,
17455
+ nodeId: nodeIdOverride ?? this.nodeId ?? void 0,
16864
17456
  id: idOverride ?? this.webRtcCallSession.id,
16865
17457
  to: this.webRtcCallSession.to,
16866
17458
  mediaDirections: this.webRtcCallSession.mediaDirections
@@ -16990,24 +17582,49 @@ var WebRTCVertoManager = class extends VertoManager {
16990
17582
  get vertoPing$() {
16991
17583
  return this.cachedObservable("vertoPing$", () => this.webRtcCallSession.webrtcMessages$.pipe(filterAs(isVertoPingInnerParams, "params"), (0, import_cjs$15.takeUntil)(this.destroyed$)));
16992
17584
  }
17585
+ /**
17586
+ * Send a member-control op in-dialog via verto.info.
17587
+ *
17588
+ * The control payload rides in `params.command` — a sibling of `dialogParams`,
17589
+ * at the same level as `dtmf` in {@link sendDigits} — and the inner verto.info
17590
+ * is matched to this call's channel by `dialogParams.callID`, the in-dialog
17591
+ * convention for member-scoped frames. Because it is delivered on the dialog
17592
+ * itself, control lands on the call's own channel with no {node_id,call_id,member_id}
17593
+ * "self" tuple to get wrong. The outer webrtc.verto envelope (added by executeVerto)
17594
+ * still carries the own-leg callID + node_id for session routing.
17595
+ *
17596
+ * Keep `command` OUT of `dialogParams`: it is read at the params level, and
17597
+ * filterVertoParams rewrites/filters dialogParams keys but passes params-level
17598
+ * keys through verbatim.
17599
+ */
17600
+ async sendCallControl(method, params) {
17601
+ const response = await this.executeVerto(VertoInfo({
17602
+ dialogParams: { callID: this.webRtcCallSession.id },
17603
+ command: {
17604
+ method,
17605
+ params
17606
+ }
17607
+ }));
17608
+ const failure = findNestedVertoFailure(response);
17609
+ if (failure) throw new JSONRPCError(Number.parseInt(failure.code, 10) || 0, `Call control "${method}" failed (code ${failure.code})${failure.message ? `: ${failure.message}` : ""}`, void 0);
17610
+ return response;
17611
+ }
16993
17612
  async executeVerto(message, optionals = {}) {
16994
- const webrtcVertoMessage = WebrtcVerto({
17613
+ const params = {
16995
17614
  callID: optionals.callID ?? this.webRtcCallSession.id,
16996
17615
  node_id: optionals.node_id ?? this._nodeId$.value ?? "",
16997
17616
  message,
16998
17617
  subscribe: optionals.subscribe
16999
- });
17618
+ };
17619
+ const webrtcVertoMessage = WebrtcVerto(params);
17000
17620
  const response = await this.webRtcCallSession.execute(webrtcVertoMessage);
17001
- if (response.error) {
17002
- const error = new JSONRPCError(response.error.code, response.error.message, response.error.data);
17003
- this.onError?.(error);
17004
- return response;
17005
- }
17621
+ const nonFatal = message.method === "verto.info" ? { fatal: false } : void 0;
17006
17622
  const innerResult = getValueFrom(response, "result.result");
17007
- if (innerResult?.error) {
17008
- const error = new JSONRPCError(innerResult.error.code, innerResult.error.message, innerResult.error.data);
17009
- this.onError?.(error);
17010
- return response;
17623
+ const failure = response.error ?? innerResult?.error;
17624
+ if (failure) {
17625
+ const error = new JSONRPCError(failure.code, failure.message, failure.data);
17626
+ if (message.method === "verto.invite" || message.method === "verto.answer") throw error;
17627
+ this.reportLegError(error, this._rtcPeerConnectionsMap.get(params.callID), nonFatal);
17011
17628
  }
17012
17629
  return response;
17013
17630
  }
@@ -17026,8 +17643,9 @@ var WebRTCVertoManager = class extends VertoManager {
17026
17643
  default:
17027
17644
  }
17028
17645
  } catch (error) {
17646
+ if (vertoMethod === "verto.answer") throw error;
17029
17647
  logger$16.error(`[WebRTCManager] Error sending Verto ${vertoMethod}:`, error);
17030
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
17648
+ this.reportLegError(toError(error), rtcPeerConnController);
17031
17649
  if (vertoMethod === "verto.modify") this.onModifyFailed?.();
17032
17650
  }
17033
17651
  }
@@ -17043,7 +17661,7 @@ var WebRTCVertoManager = class extends VertoManager {
17043
17661
  } catch (error) {
17044
17662
  logger$16.warn("[WebRTCManager] Error processing modify response:", error);
17045
17663
  const modifyError = error instanceof Error ? error : new Error(String(error), { cause: error });
17046
- this.onError?.(modifyError);
17664
+ this.reportLegError(modifyError, rtcPeerConnController);
17047
17665
  }
17048
17666
  }
17049
17667
  }
@@ -17056,37 +17674,35 @@ var WebRTCVertoManager = class extends VertoManager {
17056
17674
  status,
17057
17675
  signalingError
17058
17676
  });
17059
- this.onError?.(signalingError);
17677
+ this.reportLegError(signalingError, null, { fatal: false });
17060
17678
  return;
17061
17679
  }
17062
17680
  if (rtcPeerConnController.isMainDevice) this._signalingStatus$.next(status);
17063
17681
  }
17064
17682
  processInviteResponse(response, rtcPeerConnController) {
17065
- if (!response.error && getValueFrom(response, "result.result.result.message") === "CALL CREATED") {
17683
+ if (getValueFrom(response, "result.result.result.message") === "CALL CREATED") {
17066
17684
  this.emitMainSignalingStatus(rtcPeerConnController.id, "trying");
17067
- this._nodeId$.next(getValueFrom(response, "result.node_id") ?? null);
17685
+ const nodeId = getValueFrom(response, "result.node_id") ?? null;
17068
17686
  const memberId = getValueFrom(response, "result.result.result.memberID") ?? null;
17069
- const callId = getValueFrom(response, "result.result.result.callID") ?? null;
17687
+ const callId = getValueFrom(response, "result.result.result.callID");
17070
17688
  logger$16.debug("[WebRTCManager] Verto invite response:", {
17071
17689
  callId,
17072
17690
  memberId,
17073
17691
  response
17074
17692
  });
17075
- this._selfId$.next(memberId);
17076
17693
  rtcPeerConnController.setMemberId(memberId);
17077
- if (callId) {
17078
- this.webRtcCallSession.addCallId(callId);
17079
- this.attachManager.attach(this.buildAttachableCall(callId));
17080
- } else logger$16.warn("[WebRTCManager] Cannot attach call, missing callId:", {
17081
- nodeId: this.nodeId,
17082
- callId
17083
- });
17694
+ rtcPeerConnController.setNodeId(nodeId);
17695
+ if (rtcPeerConnController.isMainDevice) {
17696
+ this._selfId$.next(memberId);
17697
+ this._nodeId$.next(nodeId);
17698
+ this.attachManager.attach(this.buildAttachableCall(callId, nodeId ?? void 0));
17699
+ }
17700
+ if (callId) this.webRtcCallSession.addCallId(callId);
17084
17701
  logger$16.info("[WebRTCManager] Verto invite successful");
17085
17702
  logger$16.debug(`[WebRTCManager] nodeid: ${this._nodeId$.value}, selfId: ${this._selfId$.value}`);
17086
17703
  } else {
17087
17704
  logger$16.error("[WebRTCManager] Verto invite failed:", response);
17088
- const inviteError = response.error ? new JSONRPCError(response.error.code, response.error.message, response.error.data) : /* @__PURE__ */ new Error("Verto invite failed: unexpected response");
17089
- this.onError?.(inviteError);
17705
+ this.reportLegError(/* @__PURE__ */ new Error("Verto invite failed: unexpected response"), rtcPeerConnController);
17090
17706
  }
17091
17707
  }
17092
17708
  get RTCPeerConnectionConfig() {
@@ -17125,7 +17741,7 @@ var WebRTCVertoManager = class extends VertoManager {
17125
17741
  this._rtcPeerConnectionsMap.set(rtcPeerConnController.id, rtcPeerConnController);
17126
17742
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
17127
17743
  this.subscribeTo(rtcPeerConnController.errors$, (error) => {
17128
- this.onError?.(error);
17744
+ this.reportLegError(error, rtcPeerConnController);
17129
17745
  });
17130
17746
  if (options.initOffer) this.handleInboundAnswer(rtcPeerConnController);
17131
17747
  }
@@ -17154,7 +17770,7 @@ var WebRTCVertoManager = class extends VertoManager {
17154
17770
  await rtcPeerConnController.acceptInbound(answerOptions);
17155
17771
  } catch (error) {
17156
17772
  logger$16.error("[WebRTCManager] Error creating inbound answer:", error);
17157
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
17773
+ this.reportLegError(toError(error), rtcPeerConnController);
17158
17774
  }
17159
17775
  }
17160
17776
  }
@@ -17226,7 +17842,7 @@ var WebRTCVertoManager = class extends VertoManager {
17226
17842
  isInvite: isVertoInviteMessage(vertoMessage),
17227
17843
  reattach: this.webRtcCallSession.options.reattach === true,
17228
17844
  explicitNodeId: this.webRtcCallSession.options.nodeId,
17229
- currentNodeId: this._nodeId$.value
17845
+ currentNodeId: rtcPeerConnController.nodeId ?? this._nodeId$.value
17230
17846
  }),
17231
17847
  subscribe
17232
17848
  };
@@ -17258,7 +17874,7 @@ var WebRTCVertoManager = class extends VertoManager {
17258
17874
  await this.attachManager.attach(this.buildAttachableCall());
17259
17875
  } catch (error) {
17260
17876
  logger$16.error("[WebRTCManager] Error sending Verto answer:", error);
17261
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
17877
+ this.reportLegError(toError(error), rtcPeerConnectionController);
17262
17878
  await rtcPeerConnectionController.updateAnswerStatus({ status: "failed" });
17263
17879
  }
17264
17880
  }
@@ -17330,15 +17946,19 @@ var WebRTCVertoManager = class extends VertoManager {
17330
17946
  await this.mainPeerConnection.restoreTrackSender(deviceKind);
17331
17947
  } else {
17332
17948
  const error = new InvalidParams("No valid device to be added");
17333
- this.onError?.(error);
17949
+ this.reportLegError(error, this.mainPeerConnection);
17334
17950
  throw error;
17335
17951
  }
17336
17952
  }
17337
- async addScreenMedia(options = { audio: false }) {
17338
- await this.initAdditionalPeerConnection("screenshare", options);
17953
+ async addScreenMedia(options = {}) {
17954
+ await this.initAdditionalPeerConnection("screenshare", {
17955
+ audio: false,
17956
+ screenShareAudio: options.audio ?? false
17957
+ });
17339
17958
  }
17340
17959
  async initAdditionalPeerConnection(propose, options) {
17341
17960
  const isScreenShare = propose === "screenshare";
17961
+ if (isScreenShare && this._screenShareId && this._rtcPeerConnectionsMap.has(this._screenShareId)) throw new ScreenShareAlreadyActiveError(this._screenShareId);
17342
17962
  let firstPeerConnectionError;
17343
17963
  let rtcPeerConnController = null;
17344
17964
  try {
@@ -17355,21 +17975,36 @@ var WebRTCVertoManager = class extends VertoManager {
17355
17975
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
17356
17976
  this.subscribeTo(rtcPeerConnController.errors$, (error) => {
17357
17977
  firstPeerConnectionError ??= error;
17358
- this.onError?.(error, { fatal: false });
17978
+ this.reportLegError(error, rtcPeerConnController);
17359
17979
  });
17360
- await (0, import_cjs$15.firstValueFrom)(rtcPeerConnController.connectionState$.pipe((0, import_cjs$15.filter)((state) => state === "connected"), (0, import_cjs$15.take)(1), (0, import_cjs$15.timeout)(this._screenShareTimeoutMs), (0, import_cjs$15.takeUntil)(this.destroyed$)));
17980
+ const pc = rtcPeerConnController;
17981
+ await (0, import_cjs$15.firstValueFrom)((0, import_cjs$15.merge)(pc.localMediaSettled$.pipe((0, import_cjs$15.take)(1), (0, import_cjs$15.switchMap)(() => pc.connectionState$.pipe((0, import_cjs$15.filter)((state) => state === "connected"), (0, import_cjs$15.take)(1), (0, import_cjs$15.timeout)(DEFAULT_AUX_LEG_CONNECT_TIMEOUT_MS)))), this.legError$(pc.id)).pipe((0, import_cjs$15.takeUntil)((0, import_cjs$15.merge)(this.destroyed$, pc.destroyed$))));
17361
17982
  if (isScreenShare) this._screenShareStatus$.next("started");
17362
17983
  logger$16.info(`[WebRTCManager] Additional peer connection connected (${propose}).`);
17363
17984
  return rtcPeerConnController.id;
17364
17985
  } catch (error) {
17365
- logger$16.warn("[WebRTCManager] Error initializing additional peer connection:", error);
17366
- if (rtcPeerConnController) rtcPeerConnController.destroy();
17367
- if (isScreenShare) this._screenShareStatus$.next("none");
17986
+ const cancelled = error instanceof AuxiliaryLegCancelledError;
17987
+ const aborted = error instanceof import_cjs$15.EmptyError && !firstPeerConnectionError;
17988
+ if (!cancelled && !aborted) logger$16.warn("[WebRTCManager] Error initializing additional peer connection:", error);
17989
+ if (rtcPeerConnController && this._rtcPeerConnectionsMap.has(rtcPeerConnController.id)) {
17990
+ rtcPeerConnController.destroy();
17991
+ this._rtcPeerConnectionsMap.delete(rtcPeerConnController.id);
17992
+ this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
17993
+ }
17994
+ if (isScreenShare) {
17995
+ this._screenShareId = void 0;
17996
+ this._screenShareStatus$.next("none");
17997
+ }
17998
+ if (cancelled) {
17999
+ logger$16.debug("[WebRTCManager] Additional peer connection removed before connecting.");
18000
+ throw error;
18001
+ }
17368
18002
  if (firstPeerConnectionError) throw firstPeerConnectionError instanceof MediaAccessError && firstPeerConnectionError.originalError instanceof Error ? firstPeerConnectionError.originalError : firstPeerConnectionError;
17369
18003
  if (error instanceof import_cjs$15.EmptyError) {
17370
18004
  logger$16.debug("[WebRTCManager] Additional peer connection aborted before connecting.");
17371
18005
  return;
17372
18006
  }
18007
+ if (error instanceof import_cjs$15.TimeoutError) throw new AuxiliaryLegTimeoutError(propose, error);
17373
18008
  throw error instanceof Error ? error : new Error(String(error), { cause: error });
17374
18009
  }
17375
18010
  }
@@ -17386,7 +18021,10 @@ var WebRTCVertoManager = class extends VertoManager {
17386
18021
  if (removeTrack) return this.mainPeerConnection.stopTrackSender(removeTrack, { updateTransceiverDirection: true });
17387
18022
  }
17388
18023
  async removeScreenMedia() {
17389
- if (!["starting", "started"].includes(this._screenShareStatus$.value)) logger$16.warn("[WebRTCManager] No active screen share to stop.");
18024
+ if (!["starting", "started"].includes(this._screenShareStatus$.value)) {
18025
+ logger$16.warn("[WebRTCManager] No active screen share to stop.");
18026
+ return;
18027
+ }
17390
18028
  if (!this._screenShareId) {
17391
18029
  logger$16.debug("[WebRTCManager] No screen share peer connection found.");
17392
18030
  return;
@@ -17401,6 +18039,10 @@ var WebRTCVertoManager = class extends VertoManager {
17401
18039
  try {
17402
18040
  if (rtcPeerConnController) await this.executeVertoBye(rtcPeerConnController);
17403
18041
  } finally {
18042
+ if (rtcPeerConnController && rtcPeerConnController.connectionState !== "connected") this._legErrors$.next({
18043
+ legId: id,
18044
+ error: new AuxiliaryLegCancelledError(rtcPeerConnController.propose)
18045
+ });
17404
18046
  rtcPeerConnController?.destroy();
17405
18047
  this._rtcPeerConnectionsMap.delete(id);
17406
18048
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
@@ -17415,7 +18057,10 @@ var WebRTCVertoManager = class extends VertoManager {
17415
18057
  await this.executeVerto(VertoBye({
17416
18058
  ...causeParams,
17417
18059
  dialogParams: this.dialogParams(rtcPeerConnController)
17418
- }));
18060
+ }), {
18061
+ callID: rtcPeerConnController.id,
18062
+ node_id: rtcPeerConnController.nodeId ?? void 0
18063
+ });
17419
18064
  } catch (error) {
17420
18065
  logger$16.warn("[WebRTCManager] Call might already be disconnected, error sending Verto bye:", error);
17421
18066
  throw error;
@@ -18294,17 +18939,38 @@ function mosToQualityLevel(mos) {
18294
18939
  return "critical";
18295
18940
  }
18296
18941
 
18942
+ //#endregion
18943
+ //#region src/utils/unwrapVertoReply.ts
18944
+ /**
18945
+ * Unwrap a method's own reply from a `webrtc.verto` envelope.
18946
+ *
18947
+ * A control verb sent in-dialog comes back nested two levels deep:
18948
+ *
18949
+ * ```
18950
+ * { result: { node_id, code, result: { jsonrpc, id, result: <method payload> } } }
18951
+ * ```
18952
+ *
18953
+ * whereas the routed transport resolves the method payload directly under
18954
+ * `.result`. Readers want the latter shape, and the difference is silent when it
18955
+ * is wrong — `response.result.layouts` simply evaluates to `undefined` against the
18956
+ * envelope, so the data arrives, nothing throws, and the caller sees an empty
18957
+ * value. That exact failure produced an empty layout dropdown with a successful
18958
+ * request behind it.
18959
+ *
18960
+ * Accepting both shapes here keeps every reader indifferent to which transport
18961
+ * produced the response. Anything that is not a nested envelope (a plain ack, or
18962
+ * an already-unwrapped reply) passes through untouched.
18963
+ */
18964
+ function unwrapVertoReply(response) {
18965
+ const inner = getValueFrom(response, "result.result");
18966
+ return inner && typeof inner === "object" && "result" in inner ? inner : response;
18967
+ }
18968
+
18297
18969
  //#endregion
18298
18970
  //#region src/core/entities/Call.ts
18299
18971
  var import_cjs$11 = require_cjs();
18300
18972
  const logger$12 = getLogger();
18301
18973
  /**
18302
- * Verto method for setting member layout positions. Its gateway DTO requires a
18303
- * `targets` array whose entries are `{ target, position }` (NOT bare targets),
18304
- * so {@link WebRTCCall.buildMethodParams} special-cases it. See issue #19400.
18305
- */
18306
- const POSITION_SET_METHOD = "call.member.position.set";
18307
- /**
18308
18974
  * Ratio between the critical and warning RTT spike multipliers.
18309
18975
  * Warning threshold = baseline * warningMultiplier (default 3x)
18310
18976
  * Critical threshold = baseline * warningMultiplier * RTT_CRITICAL_TO_WARNING_RATIO
@@ -18399,8 +19065,11 @@ var WebRTCCall = class extends Destroyable {
18399
19065
  emitError(callError) {
18400
19066
  if (this._status$.value === "destroyed" || this._status$.value === "failed") return;
18401
19067
  this._errors$.next(callError);
18402
- if (callError.fatal) {
19068
+ if (callError.fatal && this._status$.value !== "disconnecting") {
18403
19069
  this._status$.next("failed");
19070
+ this.vertoManager.bye().catch((error) => {
19071
+ logger$12.debug("[Call] fatal-teardown bye failed (signaling likely already dead):", error);
19072
+ });
18404
19073
  this.destroy();
18405
19074
  }
18406
19075
  }
@@ -18455,7 +19124,7 @@ var WebRTCCall = class extends Destroyable {
18455
19124
  /** Toggles the call lock state, preventing or allowing new participants from joining. */
18456
19125
  async toggleLock() {
18457
19126
  const method = this.locked ? "call.unlock" : "call.lock";
18458
- await this.executeMethod(this.selfId ?? "", method, {});
19127
+ await this.executeMethod(this.callSelf, method, {});
18459
19128
  }
18460
19129
  /**
18461
19130
  * Toggles the hold state of the call (pauses/resumes local media transmission).
@@ -18504,14 +19173,25 @@ var WebRTCCall = class extends Destroyable {
18504
19173
  *
18505
19174
  * Constructs call context (node_id, call_id, member_id) and sends the RPC request.
18506
19175
  *
18507
- * @param target - Target member ID string, or a {@link MemberTarget} object.
19176
+ * @param target - Target {@link MemberTarget} triple, or the local member's
19177
+ * ID string for self-operations (any other string is rejected — a bare
19178
+ * member id cannot carry the remote member's own call context).
18508
19179
  * @param method - Verto method name (e.g. `'call.mute'`, `'call.member.remove'`).
18509
19180
  * @param args - Parameters for the RPC method.
18510
19181
  * @returns The RPC response.
19182
+ * @throws {CallNotReadyError} If the call has no self member context yet.
19183
+ * @throws {InvalidParams} If a string target is not the local member's ID.
18511
19184
  * @throws {JSONRPCError} If the RPC call returns an error.
18512
19185
  */
18513
19186
  async executeMethod(target, method, args) {
18514
- const params = this.buildMethodParams(target, args, method);
19187
+ const self = this.callSelf;
19188
+ if (typeof target === "string" && target !== self.member_id) throw new InvalidParams(`Target member ID ${target} does not match call's self member ID ${self.member_id}`);
19189
+ if (this.clientSession.callControl === "in-dialog") return this.executeMethodInDialog(target, method, args);
19190
+ const params = {
19191
+ ...args,
19192
+ self,
19193
+ target: typeof target === "string" ? self : target
19194
+ };
18515
19195
  const request = buildRPCRequest({
18516
19196
  method,
18517
19197
  params
@@ -18525,29 +19205,98 @@ var WebRTCCall = class extends Destroyable {
18525
19205
  throw error;
18526
19206
  }
18527
19207
  }
18528
- buildMethodParams(target, args, method) {
18529
- const self = {
18530
- node_id: this.nodeId ?? "",
18531
- call_id: this.id,
18532
- member_id: this.vertoManager.selfId ?? ""
18533
- };
18534
- if (method === POSITION_SET_METHOD) return {
18535
- ...args,
18536
- self
18537
- };
18538
- if (typeof target === "object") return {
18539
- ...args,
18540
- self,
18541
- targets: [target]
18542
- };
18543
- return {
18544
- ...args,
18545
- self,
18546
- target: {
18547
- node_id: this.nodeId ?? "",
19208
+ /**
19209
+ * `executeMethod` for a call opened with `callControl: 'in-dialog'`.
19210
+ *
19211
+ * Translates the routed transport's calling convention into the in-dialog one. No
19212
+ * `self` tuple is sent, but a `target` is — the same {call_id, member_id} the routed
19213
+ * transport puts in `target` (minus node_id), for self-ops and cross-member ops alike.
19214
+ *
19215
+ * Target shapes are per-verb and irregular, so they are centralised here rather
19216
+ * than left to callers: most verbs take a singular `target`, `call.member.remove`
19217
+ * takes a plural `targets` array, and `call.member.position.set` takes a flat
19218
+ * `targets` of `{call_id, position}` — the one verb keyed on call_id rather than
19219
+ * member_id, so the member triple `Participant.setPosition` built is unwrapped.
19220
+ */
19221
+ async executeMethodInDialog(target, method, args) {
19222
+ const control = { ...args };
19223
+ if (method === "call.member.position.set") control.targets = (args.targets ?? []).map((entry) => ({
19224
+ call_id: entry.target?.call_id ?? entry.call_id,
19225
+ position: entry.position
19226
+ }));
19227
+ else {
19228
+ const member = typeof target === "object" ? {
19229
+ call_id: target.call_id,
19230
+ member_id: target.member_id
19231
+ } : {
18548
19232
  call_id: this.id,
18549
19233
  member_id: target
18550
- }
19234
+ };
19235
+ if (method === "call.member.remove") control.targets = [member];
19236
+ else control.target = member;
19237
+ }
19238
+ return this.sendCommand(method, control);
19239
+ }
19240
+ /**
19241
+ * Sends a `call.*` control verb **in-dialog** via `verto.info`, as an alternative
19242
+ * to the routed {@link executeMethod} transport.
19243
+ *
19244
+ * Why both exist: `executeMethod` addresses the member with an explicit
19245
+ * `{node_id, call_id, member_id}` tuple, which does not resolve for every conference,
19246
+ * so the op can fail. An in-dialog frame carries the verb on the member's own
19247
+ * signaling channel instead, so control works without the client needing to know how
19248
+ * the conference is hosted.
19249
+ *
19250
+ * The trade-off is reach: the in-dialog transport is only accepted for calls that
19251
+ * join a conference over SWML (e.g. an SWML `join_conference`); use the routed
19252
+ * default otherwise.
19253
+ *
19254
+ * `params` are sent verbatim — nothing is built for you, which includes the target.
19255
+ * **A self-directed op still needs one**, or it is refused; name yourself explicitly:
19256
+ *
19257
+ * ```ts
19258
+ * const { call_id, member_id } = call.self.target;
19259
+ * await call.sendCommand('call.mute', { channels: ['audio'], target: { call_id, member_id } });
19260
+ * ```
19261
+ *
19262
+ * Never include `node_id` — only the two ids. The shapes are per-verb: most take a
19263
+ * singular `target`, `call.member.remove` takes a plural `targets` array, and
19264
+ * `call.member.position.set` takes a flat `targets: [{call_id, position}]` (the one
19265
+ * verb keyed on `call_id` rather than `member_id`). Verbs that act on the call as a
19266
+ * whole, or that the SDK does not wrap at all, take no target.
19267
+ *
19268
+ * For the typed alternative that handles all of this, create the client with
19269
+ * `callControl: 'in-dialog'` and use the ordinary `Call`/`Participant` methods.
19270
+ *
19271
+ * @internal Not part of the supported surface while the in-dialog transport is still
19272
+ * rolling out. `WebRTCCall` is exported from the package entry, so without this tag
19273
+ * TypeDoc publishes the method — and the example above — as public API.
19274
+ *
19275
+ * @param method - A `call.*` method name (e.g. `'call.mute'`).
19276
+ * @param params - Method parameters, sent verbatim.
19277
+ * @returns The method's own reply, unwrapped from the `verto.info` envelope.
19278
+ * @throws {JSONRPCError} If the control op fails.
19279
+ */
19280
+ async sendCommand(method, params = {}) {
19281
+ return unwrapVertoReply(await this.vertoManager.sendCallControl(method, params));
19282
+ }
19283
+ /**
19284
+ * The local leg's member triple — sent as `self` in every member RPC
19285
+ * envelope, and as the `target` of call-scoped self-operations (e.g. lock,
19286
+ * layout).
19287
+ *
19288
+ * @throws {CallNotReadyError} Before `call.joined` delivers the self member
19289
+ * context (`selfId`/`nodeId`) — an RPC without it cannot be routed, so fail
19290
+ * fast instead of sending a doomed request.
19291
+ */
19292
+ get callSelf() {
19293
+ const node_id = this.nodeId;
19294
+ const member_id = this.vertoManager.selfId;
19295
+ if (!node_id || !member_id) throw new CallNotReadyError(this.id);
19296
+ return {
19297
+ node_id,
19298
+ call_id: this.id,
19299
+ member_id
18551
19300
  };
18552
19301
  }
18553
19302
  /** Observable of the current call status (e.g. `'ringing'`, `'connected'`). */
@@ -18703,13 +19452,13 @@ var WebRTCCall = class extends Destroyable {
18703
19452
  get bandwidthConstrained$() {
18704
19453
  return this.deferEmission(this._bandwidthConstrained$.asObservable());
18705
19454
  }
18706
- /** Observable that emits when server-pushed media params are applied. */
19455
+ /** Observable that emits when the server pushes media params. */
18707
19456
  get mediaParamsUpdated$() {
18708
19457
  return this.deferEmission(this._mediaParamsUpdated$.asObservable());
18709
19458
  }
18710
19459
  /**
18711
19460
  * @internal Emit a media params update event.
18712
- * Called by the VertoManager when server-pushed media params are applied.
19461
+ * Called by the VertoManager when the server pushes media params.
18713
19462
  */
18714
19463
  emitMediaParamsUpdated(event) {
18715
19464
  this._mediaParamsUpdated$.next(event);
@@ -18917,6 +19666,10 @@ var WebRTCCall = class extends Destroyable {
18917
19666
  get selfId$() {
18918
19667
  return this.vertoManager.selfId$;
18919
19668
  }
19669
+ /** @internal Lets call creation bound the media and signalling phases apart. */
19670
+ get localMediaSettled$() {
19671
+ return this.vertoManager.localMediaSettled$;
19672
+ }
18920
19673
  /** Local participant's member ID, or `null` if not joined. */
18921
19674
  get selfId() {
18922
19675
  return this.vertoManager.selfId;
@@ -19110,12 +19863,17 @@ var WebRTCCall = class extends Destroyable {
19110
19863
  *
19111
19864
  * **These operations are NOT atomic.** The layout is applied first, then each
19112
19865
  * member position sequentially, so members may briefly flash into their
19113
- * default slots before being moved to the requested positions.
19866
+ * default slots before being moved to the requested positions. Targeted
19867
+ * members are validated upfront, though: when any of them has no
19868
+ * {@link Participant.target | member call context} yet, the whole call
19869
+ * rejects before any request is sent and the layout is left unchanged.
19114
19870
  *
19115
19871
  * @param layout - Layout name (must be one of {@link layouts}).
19116
19872
  * @param positions - Optional map of member IDs to {@link VideoPosition} values.
19117
19873
  * When omitted or empty, only the layout is changed.
19118
19874
  * @throws {InvalidParams} If the layout is not in the available {@link layouts}.
19875
+ * @throws {ParticipantNotReadyError} If a targeted member's call context has
19876
+ * not been received yet — thrown before any request is sent.
19119
19877
  *
19120
19878
  * @example
19121
19879
  * ```ts
@@ -19126,18 +19884,19 @@ var WebRTCCall = class extends Destroyable {
19126
19884
  */
19127
19885
  async setLayout(layout, positions) {
19128
19886
  if (!this.layouts.includes(layout)) throw new InvalidParams(`Layout ${layout} is not available in the current call layouts: ${this.layouts.join(", ")}`);
19129
- const selfId = await (0, import_cjs$11.firstValueFrom)(this.selfId$.pipe((0, import_cjs$11.filter)((id) => id !== null)));
19130
- await this.executeMethod(selfId, "call.layout.set", { layout });
19131
- const positionEntries = Object.entries(positions ?? {});
19132
- if (positionEntries.length === 0) return;
19133
- for (const [memberId, position] of positionEntries) {
19887
+ const targets = [];
19888
+ for (const [memberId, position] of Object.entries(positions ?? {})) {
19134
19889
  const participant = this.participants.find((p) => p.id === memberId);
19135
19890
  if (!participant) {
19136
19891
  logger$12.warn(`[Call] setLayout: member ${memberId} not found in participants; skipping position ${position}`);
19137
19892
  continue;
19138
19893
  }
19139
- await participant.setPosition(position);
19894
+ participant.target;
19895
+ targets.push([participant, position]);
19140
19896
  }
19897
+ const selfId = await (0, import_cjs$11.firstValueFrom)(this.selfId$.pipe((0, import_cjs$11.filter)((id) => id !== null)));
19898
+ await this.executeMethod(selfId, "call.layout.set", { layout });
19899
+ for (const [participant, position] of targets) await participant.setPosition(position);
19141
19900
  }
19142
19901
  /**
19143
19902
  * Transfers the call to another destination.
@@ -19239,17 +19998,28 @@ var WebRTCCall = class extends Destroyable {
19239
19998
  * (notably iOS Safari) fall back to re-acquiring the track with the new
19240
19999
  * constraint set and plumbing the replacement through the local audio
19241
20000
  * pipeline if one is active.
20001
+ *
20002
+ * @returns whether the constraint reached the microphone. `false` is an
20003
+ * outcome rather than an error — a leg sending media the SDK did not capture
20004
+ * is left alone — so a UI that reflects the toggle must read it. Any failure
20005
+ * behind a `false` is also reported on {@link errors$}.
19242
20006
  */
19243
20007
  async setEchoCancellation(enabled) {
19244
- await this.vertoManager.updateMediaConstraints({ audio: { echoCancellation: enabled } });
20008
+ return this.vertoManager.updateMediaConstraints({ audio: { echoCancellation: enabled } });
19245
20009
  }
19246
- /** Toggle browser noise suppression on the local mic at runtime. */
20010
+ /**
20011
+ * Toggle browser noise suppression on the local mic at runtime.
20012
+ * @returns whether the constraint reached the microphone.
20013
+ */
19247
20014
  async setNoiseSuppression(enabled) {
19248
- await this.vertoManager.updateMediaConstraints({ audio: { noiseSuppression: enabled } });
20015
+ return this.vertoManager.updateMediaConstraints({ audio: { noiseSuppression: enabled } });
19249
20016
  }
19250
- /** Toggle browser automatic gain control on the local mic at runtime. */
20017
+ /**
20018
+ * Toggle browser automatic gain control on the local mic at runtime.
20019
+ * @returns whether the constraint reached the microphone.
20020
+ */
19251
20021
  async setAutoGainControl(enabled) {
19252
- await this.vertoManager.updateMediaConstraints({ audio: { autoGainControl: enabled } });
20022
+ return this.vertoManager.updateMediaConstraints({ audio: { autoGainControl: enabled } });
19253
20023
  }
19254
20024
  /**
19255
20025
  * Observable of the aggregate remote audio level, 0..1 RMS. The server
@@ -19306,6 +20076,9 @@ var WebRTCCall = class extends Destroyable {
19306
20076
  /**
19307
20077
  * Infers the semantic error category from a raw Error thrown by VertoManager
19308
20078
  * or an RTCPeerConnection layer.
20079
+ *
20080
+ * Pure function — exported for unit testing.
20081
+ * @internal
19309
20082
  */
19310
20083
  function inferCallErrorKind(error) {
19311
20084
  if (error instanceof RPCTimeoutError) return "timeout";
@@ -19324,7 +20097,14 @@ const RECOVERABLE_RPC_CODES = new Set([
19324
20097
  RPC_ERROR_AUTHENTICATION_FAILED,
19325
20098
  RPC_ERROR_INVALID_PARAMS
19326
20099
  ]);
19327
- /** Determines whether an error should be fatal (destroy the call). */
20100
+ /**
20101
+ * A *fallback*: callers knowing which leg failed pass an explicit `fatal` and
20102
+ * never reach here, so the default-fatal branch only sees call- and main-leg
20103
+ * errors. Auxiliary legs go through `WebRTCVertoManager.reportLegError`.
20104
+ *
20105
+ * Pure function — exported for unit testing.
20106
+ * @internal
20107
+ */
19328
20108
  function isFatalError(error) {
19329
20109
  if (error instanceof VertoPongError) return false;
19330
20110
  if (error instanceof MediaTrackError) return false;
@@ -19359,7 +20139,9 @@ var CallFactory = class {
19359
20139
  kind: inferCallErrorKind(error),
19360
20140
  fatal: options$1?.fatal ?? isFatalError(error),
19361
20141
  error,
19362
- callId: callInstance.id
20142
+ callId: callInstance.id,
20143
+ ...options$1?.leg ? { leg: options$1.leg } : {},
20144
+ ...options$1?.legId ? { legId: options$1.legId } : {}
19363
20145
  };
19364
20146
  callInstance.emitError(callError);
19365
20147
  },
@@ -19831,6 +20613,30 @@ const logger$9 = getLogger();
19831
20613
  function shouldAbortDial(callError) {
19832
20614
  return callError.fatal || !(callError.error instanceof MediaAccessError);
19833
20615
  }
20616
+ /**
20617
+ * Wait for a dialed call to be ready, or for the failure that stops it.
20618
+ *
20619
+ * Local media acquisition is deliberately unbounded: a permission prompt or a
20620
+ * device picker is human time, and `getUserMedia` cannot be cancelled anyway.
20621
+ * The clock starts only once acquisition settles, so a slow human never spends
20622
+ * the server's budget.
20623
+ *
20624
+ * `merge` rather than `race`, because the two legs settle asymmetrically. A
20625
+ * fatal acquisition failure reports the error and then destroys the call in the
20626
+ * same synchronous step; `errors$` defers delivery by a microtask while
20627
+ * `localMediaSettled$` completes immediately. Under `race` that bare completion
20628
+ * ended the wait first and `dial()` rejected with an RxJS `EmptyError`, burying
20629
+ * the `NotAllowedError` applications are told to inspect. Under `merge` the
20630
+ * completed leg is simply spent, and the queued error — enqueued before the
20631
+ * completion, so delivered before it — arrives to reject the wait. A dial
20632
+ * abandoned with no error at all still ends both legs, and the resulting
20633
+ * `EmptyError` remains the benign-cancel signal.
20634
+ *
20635
+ * Exported for unit testing.
20636
+ */
20637
+ async function awaitDialReady(session, signalingTimeoutMs) {
20638
+ return (0, import_cjs$7.firstValueFrom)((0, import_cjs$7.merge)(session.localMediaSettled$.pipe((0, import_cjs$7.take)(1), (0, import_cjs$7.switchMap)(() => session.selfId$.pipe((0, import_cjs$7.filter)((id) => Boolean(id)), (0, import_cjs$7.take)(1), (0, import_cjs$7.timeout)(signalingTimeoutMs)))), session.errors$.pipe((0, import_cjs$7.filter)(shouldAbortDial), (0, import_cjs$7.take)(1), (0, import_cjs$7.switchMap)((callError) => (0, import_cjs$7.throwError)(() => callError.error)))));
20639
+ }
19834
20640
  const getAddressSearchURI = (options) => {
19835
20641
  const to = options.to?.split("?")[0];
19836
20642
  const from$9 = options.from?.startsWith("subscriber://") ? options.from.replace("subscriber://", "") : options.from;
@@ -19847,7 +20653,6 @@ var ClientSessionManager = class extends Destroyable {
19847
20653
  this.authorizationStateKey = authorizationStateKey;
19848
20654
  this.attachManager = attachManager;
19849
20655
  this.dpopManager = dpopManager;
19850
- this.callCreateTimeout = 6e3;
19851
20656
  this.agent = `signalwire-js/4.0.0`;
19852
20657
  this.eventAcks = true;
19853
20658
  this.authorizationState$ = this.createReplaySubject(1);
@@ -19856,6 +20661,7 @@ var ClientSessionManager = class extends Destroyable {
19856
20661
  minor: 0,
19857
20662
  revision: 0
19858
20663
  };
20664
+ this.callControl = "routed";
19859
20665
  this._authorization$ = this.createBehaviorSubject(void 0);
19860
20666
  this._errors$ = this.createReplaySubject(1);
19861
20667
  this._authState$ = this.createBehaviorSubject({ kind: "unauthenticated" });
@@ -20056,21 +20862,18 @@ var ClientSessionManager = class extends Destroyable {
20056
20862
  }
20057
20863
  async handleAuthenticationError(error) {
20058
20864
  logger$9.error("Authentication error:", error);
20059
- const isRecoverableAuthError = error instanceof JSONRPCError && (error.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === RPC_ERROR_INVALID_PARAMS || error.code === RPC_ERROR_AUTHENTICATION_FAILED);
20865
+ const isRecoverableAuthError$1 = error instanceof JSONRPCError && (error.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === RPC_ERROR_INVALID_PARAMS || error.code === RPC_ERROR_AUTHENTICATION_FAILED);
20060
20866
  const hasStoredState = await (0, import_cjs$7.firstValueFrom)(this.authorizationState$.pipe((0, import_cjs$7.take)(1))) !== void 0;
20061
- if (isRecoverableAuthError && hasStoredState) {
20867
+ if (isRecoverableAuthError$1 && hasStoredState) {
20062
20868
  logger$9.debug("[Session] Recoverable auth error — cleaning up stored state and reconnecting fresh");
20063
- try {
20064
- await this.cleanupStoredConnectionParams();
20065
- } catch (cleanupError) {
20066
- logger$9.error("Failed to cleanup stored connection params:", cleanupError);
20067
- } finally {
20068
- this.transport.reconnect();
20069
- }
20869
+ await this.discardResumeStateAndReconnect();
20070
20870
  } else this._errors$.next(error);
20071
20871
  }
20072
20872
  /**
20073
- * Clear the resume state (authorization_state + protocol) only.
20873
+ * Clear the resume state (authorization_state + protocol) and ask the
20874
+ * transport to reconnect. The `connected` event re-triggers
20875
+ * `authenticate()`, which now has no stored state and so performs a fresh
20876
+ * connect.
20074
20877
  *
20075
20878
  * This is the stale-auth-state recovery helper used by handleAuthError:
20076
20879
  * the server rejected a reconnect, so the resume state is discarded and a
@@ -20078,9 +20881,24 @@ var ClientSessionManager = class extends Destroyable {
20078
20881
  * session lives on through the reconnect and reattachCalls() needs the
20079
20882
  * stored call references afterwards. Do NOT add detachAll() here.
20080
20883
  *
20884
+ * Connect-time recovery only. A *request* refused on an already
20885
+ * authenticated session is never healed here: dropping the resume state
20886
+ * destroys the association between the socket and the previous session,
20887
+ * which is what reattach depends on. That path mints a fresh credential and
20888
+ * reauthenticates instead (see `SignalWire.recoverAndRetry`).
20889
+ *
20081
20890
  * For public teardown (disconnect/destroy), use {@link teardownSessionState}
20082
20891
  * instead, which clears the attach records as well.
20083
20892
  */
20893
+ async discardResumeStateAndReconnect() {
20894
+ try {
20895
+ await this.cleanupStoredConnectionParams();
20896
+ } catch (cleanupError) {
20897
+ logger$9.error("Failed to cleanup stored connection params:", cleanupError);
20898
+ } finally {
20899
+ this.transport.reconnect();
20900
+ }
20901
+ }
20084
20902
  async cleanupStoredConnectionParams() {
20085
20903
  await this.transport.setProtocol(void 0);
20086
20904
  await this.updateAuthorizationStateInStorage(void 0);
@@ -20154,11 +20972,15 @@ var ClientSessionManager = class extends Destroyable {
20154
20972
  const isReconnect = hasReconnectState && storedToken;
20155
20973
  let dpopToken;
20156
20974
  if (isReconnect) logger$9.debug("[Session] Reconnecting with stored jwt_token + authorization_state");
20157
- else if (this.onBeforeReconnect && this.clientBound) {
20158
- logger$9.debug("[Session] Refreshing credentials before fresh connect");
20159
- await this.onBeforeReconnect();
20975
+ else {
20976
+ const credential = this.getCredential();
20977
+ const credentialExpired = credential.expiry_at !== void 0 && credential.expiry_at <= Date.now() + CREDENTIAL_EXPIRY_SKEW_MS;
20978
+ if (this.onBeforeReconnect && (this.clientBound || credentialExpired)) {
20979
+ logger$9.debug("[Session] Refreshing credentials before fresh connect");
20980
+ await this.onBeforeReconnect();
20981
+ }
20160
20982
  }
20161
- if ((!isReconnect || this.clientBound) && this.dpopManager?.initialized) try {
20983
+ if (this.dpopManager?.initialized) try {
20162
20984
  dpopToken = await this.dpopManager.createRpcProof({ method: "signalwire.connect" });
20163
20985
  } catch (error) {
20164
20986
  if (this.clientBound) throw error;
@@ -20191,6 +21013,7 @@ var ClientSessionManager = class extends Destroyable {
20191
21013
  });
20192
21014
  if (response.protocol) await this.transport.setProtocol(response.protocol);
20193
21015
  this._authorization$.next(response.authorization);
21016
+ if (response.authorization.cnf?.jkt) this._wasClientBound = true;
20194
21017
  this._iceServers$.next(response.ice_servers ?? []);
20195
21018
  this._authState$.next({ kind: "authenticated" });
20196
21019
  logger$9.debug("[Session] Authentication completed successfully");
@@ -20257,7 +21080,7 @@ var ClientSessionManager = class extends Destroyable {
20257
21080
  to: destinationURI,
20258
21081
  ...options
20259
21082
  });
20260
- await (0, import_cjs$7.firstValueFrom)((0, import_cjs$7.race)(callSession.selfId$.pipe((0, import_cjs$7.filter)((id) => Boolean(id)), (0, import_cjs$7.take)(1), (0, import_cjs$7.timeout)(this.callCreateTimeout)), callSession.errors$.pipe((0, import_cjs$7.filter)(shouldAbortDial), (0, import_cjs$7.take)(1), (0, import_cjs$7.switchMap)((callError) => (0, import_cjs$7.throwError)(() => callError.error)))));
21083
+ await awaitDialReady(callSession, DEFAULT_CALL_SIGNALING_TIMEOUT_MS);
20261
21084
  this._calls$.next({
20262
21085
  [`${callSession.id}`]: callSession,
20263
21086
  ...this._calls$.value
@@ -20310,12 +21133,23 @@ var ClientSessionWrapper = class {
20310
21133
  get authenticated() {
20311
21134
  return this.clientSessionManager.authenticated;
20312
21135
  }
21136
+ /**
21137
+ * Whether the session is using a Client Bound SAT (DPoP). Sticky — set
21138
+ * when the binding is established or restored from a resumed session's
21139
+ * server authorization.
21140
+ */
21141
+ get clientBound() {
21142
+ return this.clientSessionManager.clientBound;
21143
+ }
20313
21144
  get signalingEvent$() {
20314
21145
  return this.clientSessionManager.signalingEvent$;
20315
21146
  }
20316
21147
  get iceServers() {
20317
21148
  return this.clientSessionManager.iceServers;
20318
21149
  }
21150
+ get callControl() {
21151
+ return this.clientSessionManager.callControl;
21152
+ }
20319
21153
  async execute(request, options) {
20320
21154
  return this.clientSessionManager.execute(request, options);
20321
21155
  }
@@ -20512,7 +21346,7 @@ var DeviceTokenManager = class extends Destroyable {
20512
21346
  await session.reauthenticate(tokenData.token, rpcProof, { clientBound: true });
20513
21347
  updateCredential({ token: tokenData.token });
20514
21348
  logger$7.info("[DeviceToken] Client Bound SAT activated successfully");
20515
- this._currentToken$.next(tokenData);
21349
+ this.emitCurrentToken(tokenData);
20516
21350
  return { activated: true };
20517
21351
  } catch (error) {
20518
21352
  logger$7.error("[DeviceToken] Failed to activate Client Bound SAT:", error);
@@ -20524,6 +21358,21 @@ var DeviceTokenManager = class extends Destroyable {
20524
21358
  }
20525
21359
  }
20526
21360
  /**
21361
+ * Emit a freshly received token to the reactive pipeline, stamping an
21362
+ * absolute `expires_at` when the response carried only `expires_in`.
21363
+ * Resolving the expiry at RECEIVE time (not at read time) is what lets
21364
+ * {@link refreshNowIfDue} detect due-ness on resume: a bare `expires_in`
21365
+ * re-resolved later would always compute a full TTL from "now" and never
21366
+ * cross the refresh buffer.
21367
+ */
21368
+ emitCurrentToken(token) {
21369
+ const stamped = token.expires_at ? token : {
21370
+ ...token,
21371
+ expires_at: resolveExpiresAt(token)
21372
+ };
21373
+ this._currentToken$.next(stamped);
21374
+ }
21375
+ /**
20527
21376
  * Returns true when the cached token has enough headroom before expiry to
20528
21377
  * be safely reused on reactivation. The headroom matches the refresh
20529
21378
  * buffer, so a token within the refresh window is treated as stale (the
@@ -20541,7 +21390,7 @@ var DeviceTokenManager = class extends Destroyable {
20541
21390
  method: "POST",
20542
21391
  uri: DEVICE_TOKEN_ENDPOINT
20543
21392
  });
20544
- const response = await this.http.request({
21393
+ const response = await this.http().request({
20545
21394
  url: DEVICE_TOKEN_ENDPOINT,
20546
21395
  ...POST_PARAMS,
20547
21396
  body: JSON.stringify({
@@ -20568,7 +21417,7 @@ var DeviceTokenManager = class extends Destroyable {
20568
21417
  uri: DEVICE_REFRESH_ENDPOINT,
20569
21418
  accessToken: currentToken
20570
21419
  });
20571
- const response = await this.http.request({
21420
+ const response = await this.http().request({
20572
21421
  url: DEVICE_REFRESH_ENDPOINT,
20573
21422
  ...POST_PARAMS,
20574
21423
  body: JSON.stringify({
@@ -20616,7 +21465,7 @@ var DeviceTokenManager = class extends Destroyable {
20616
21465
  const currentToken = this.getCredential().token;
20617
21466
  if (!currentToken) throw new TokenRefreshError("No current token available for refresh");
20618
21467
  const newTokenData = await this.retryRefresh(session, currentToken, updateCredential);
20619
- this._currentToken$.next(newTokenData);
21468
+ this.emitCurrentToken(newTokenData);
20620
21469
  } catch (error) {
20621
21470
  logger$7.error("[DeviceToken] Automatic Client Bound SAT refresh failed:", error);
20622
21471
  this.errorHandler(error instanceof TokenRefreshError ? error : new TokenRefreshError("Automatic token refresh failed", error));
@@ -20643,6 +21492,22 @@ var DeviceTokenManager = class extends Destroyable {
20643
21492
  throw lastError instanceof Error ? lastError : new TokenRefreshError("All refresh retries exhausted", lastError);
20644
21493
  }
20645
21494
  /**
21495
+ * Force an immediate refresh when the cached Client Bound SAT is already
21496
+ * past its refresh window. Called on resume from suspension where
21497
+ * background-tab throttling can delay the reactive timer past the buffer.
21498
+ * A no-op when no token is cached or it still has headroom; the normal
21499
+ * {@link executeRefresh} guards (paused / in-progress / unauthenticated)
21500
+ * still apply.
21501
+ */
21502
+ refreshNowIfDue() {
21503
+ const token = this._currentToken$.value;
21504
+ if (!token) return;
21505
+ if (resolveExpiresAt(token) * 1e3 - Date.now() <= DEVICE_TOKEN_REFRESH_BUFFER_MS) {
21506
+ logger$7.debug("[DeviceToken] Resume: cached SAT past refresh window; refreshing now");
21507
+ this.executeRefresh();
21508
+ }
21509
+ }
21510
+ /**
20646
21511
  * Stops the reactive refresh pipeline from firing. Use when the underlying
20647
21512
  * session is being torn down (e.g., during {@link SignalWire.disconnect})
20648
21513
  * so a scheduled refresh cannot fire against a destroyed session.
@@ -20693,6 +21558,7 @@ var CredentialRefreshCoordinator = class extends Destroyable {
20693
21558
  this.deps = deps;
20694
21559
  this._activating = false;
20695
21560
  this._activationGeneration = 0;
21561
+ this._developerRefreshInProgress = false;
20696
21562
  if (dpopManager?.initialized) this._deviceTokenManager = (deps.deviceTokenManagerFactory ?? defaultDeviceTokenManagerFactory)(dpopManager, deps.http, (error) => deps.notifier.onError(error), () => deps.store.read());
20697
21563
  }
20698
21564
  /** True when the Client Bound SAT path is available (DPoP initialized). */
@@ -20711,28 +21577,120 @@ var CredentialRefreshCoordinator = class extends Destroyable {
20711
21577
  * invokes `deps.onRefreshExhausted` so the orchestrator can disconnect.
20712
21578
  */
20713
21579
  scheduleDeveloperRefresh(provider, expiresAt, attempt = 0) {
21580
+ this._activeProvider = provider;
20714
21581
  if (this._developerTimerId !== void 0) clearTimeout(this._developerTimerId);
20715
21582
  const refreshInterval = attempt === 0 ? Math.max(expiresAt - Date.now() - CREDENTIAL_REFRESH_BUFFER_MS, 1e3) : Math.min(CREDENTIAL_REFRESH_RETRY_BASE_MS * Math.pow(2, attempt) * (.5 + Math.random() * .5), CREDENTIAL_REFRESH_MAX_DELAY_MS);
20716
- this._developerTimerId = setTimeout(async () => {
21583
+ this._developerTimerId = setTimeout(() => {
21584
+ this._developerTimerId = void 0;
21585
+ this.executeDeveloperRefresh(provider, expiresAt, attempt);
21586
+ }, refreshInterval);
21587
+ }
21588
+ /**
21589
+ * Runs the developer-provided refresh once: mints a new credential, stores
21590
+ * and persists it, reauthenticates the live session (via the notifier), and
21591
+ * reschedules against the new expiry. On failure retries with backoff up to
21592
+ * {@link CREDENTIAL_REFRESH_MAX_RETRIES}, then signals exhaustion.
21593
+ *
21594
+ * Shared by the scheduled timer tick and {@link forceRefreshIfDue}. The
21595
+ * `_developerRefreshInProgress` guard prevents the two from overlapping.
21596
+ */
21597
+ async executeDeveloperRefresh(provider, expiresAt, attempt) {
21598
+ if (this._developerRefreshInProgress) {
21599
+ logger$6.debug("[Coordinator] Developer refresh already in progress; skipping");
21600
+ return;
21601
+ }
21602
+ this._developerRefreshInProgress = true;
21603
+ try {
21604
+ const newCredentials = await this.refreshCredential(provider);
21605
+ this.deps.store.write(newCredentials);
21606
+ this.deps.store.persist(newCredentials);
20717
21607
  try {
20718
- if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
20719
- const newCredentials = await provider.refresh();
20720
- this.deps.store.write(newCredentials);
20721
- this.deps.store.persist(newCredentials);
20722
- logger$6.info("[Coordinator] Credentials refreshed successfully.");
20723
- if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
20724
- } catch (error) {
20725
- const nextAttempt = attempt + 1;
20726
- logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
20727
- this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
20728
- if (nextAttempt < CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
20729
- else {
20730
- logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
20731
- this.deps.notifier.onError(new TokenRefreshError("Credential refresh failed after max retries"));
20732
- this.deps.notifier.onRefreshExhausted();
20733
- }
21608
+ await this.deps.notifier.onCredentialRefreshed(newCredentials);
21609
+ } catch (reauthError) {
21610
+ logger$6.warn("[Coordinator] onCredentialRefreshed rejected (non-fatal):", reauthError);
20734
21611
  }
20735
- }, refreshInterval);
21612
+ logger$6.info("[Coordinator] Credentials refreshed successfully.");
21613
+ if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
21614
+ } catch (error) {
21615
+ const nextAttempt = attempt + 1;
21616
+ logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
21617
+ this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
21618
+ if (nextAttempt < CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
21619
+ else {
21620
+ logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
21621
+ this.deps.notifier.onError(new TokenRefreshError("Credential refresh failed after max retries"));
21622
+ this.deps.notifier.onRefreshExhausted();
21623
+ }
21624
+ } finally {
21625
+ this._developerRefreshInProgress = false;
21626
+ }
21627
+ }
21628
+ /**
21629
+ * Force an immediate refresh when the current credential is already past its
21630
+ * scheduled refresh window. Called on resume from suspension, where
21631
+ * background-tab timer throttling can delay the armed refresh well past
21632
+ * expiry, leaving the live session stale.
21633
+ *
21634
+ * Routes to whichever mechanism is armed: the developer timer if armed,
21635
+ * otherwise the Client Bound SAT pipeline. A no-op when nothing is due.
21636
+ */
21637
+ forceRefreshIfDue() {
21638
+ if (this._developerTimerId !== void 0 && this._activeProvider) {
21639
+ const expiry = this.deps.store.read().expiry_at;
21640
+ if (expiry !== void 0 && Date.now() >= expiry - CREDENTIAL_REFRESH_BUFFER_MS) {
21641
+ logger$6.debug("[Coordinator] Resume: credential past refresh window; forcing refresh");
21642
+ clearTimeout(this._developerTimerId);
21643
+ this._developerTimerId = void 0;
21644
+ this.executeDeveloperRefresh(this._activeProvider, expiry, 0);
21645
+ }
21646
+ return;
21647
+ }
21648
+ this._deviceTokenManager?.refreshNowIfDue();
21649
+ }
21650
+ /**
21651
+ * Sync the credential's expiry from the server-provided authorization (the
21652
+ * `signalwire.connect` result). SATs are opaque JWE, so
21653
+ * `fabric_subscriber.expires_at` is the authoritative expiry of the token
21654
+ * the session actually connected with — the provider-reported `expiry_at`
21655
+ * is only a hint (and may be wrong or absent). Corrects the stored
21656
+ * credential and re-arms the developer refresh timer against the real
21657
+ * deadline when the provider supports `refresh()`.
21658
+ */
21659
+ syncExpiryFromAuthorization(authorization, provider) {
21660
+ const expiresAtSec = authorization?.fabric_subscriber?.expires_at;
21661
+ if (!expiresAtSec) return;
21662
+ const expiryAt = expiresAtSec * 1e3;
21663
+ const credential = this.deps.store.read();
21664
+ if (credential.expiry_at === expiryAt) return;
21665
+ logger$6.debug(`[Coordinator] Correcting credential expiry from server authorization: ${new Date(expiryAt).toISOString()}`);
21666
+ const updated = {
21667
+ ...credential,
21668
+ expiry_at: expiryAt
21669
+ };
21670
+ this.deps.store.write(updated);
21671
+ this.deps.store.persist(updated);
21672
+ if (provider?.refresh) this.scheduleDeveloperRefresh(provider, expiryAt);
21673
+ }
21674
+ /**
21675
+ * Invoke `provider.refresh()` deduped against any concurrent developer
21676
+ * refresh. Concurrent callers — the scheduled tick, a resume-forced refresh,
21677
+ * and the orchestrator's -32003 recovery / reconnect re-mint — share one
21678
+ * in-flight promise, so a provider backed by one-time-use rotating refresh
21679
+ * tokens is never invoked twice in parallel.
21680
+ *
21681
+ * The caller owns applying the returned credential (store write, session
21682
+ * reauth, rescheduling); this method only serializes the network call.
21683
+ */
21684
+ async refreshCredential(provider) {
21685
+ if (this._refreshInFlight) return this._refreshInFlight;
21686
+ if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
21687
+ const run = provider.refresh();
21688
+ this._refreshInFlight = run;
21689
+ const clear = () => {
21690
+ if (this._refreshInFlight === run) this._refreshInFlight = void 0;
21691
+ };
21692
+ run.then(clear, clear);
21693
+ return run;
20736
21694
  }
20737
21695
  /**
20738
21696
  * Cancels any scheduled developer-provided refresh. Idempotent.
@@ -21516,6 +22474,13 @@ var TransportManager = class extends Destroyable {
21516
22474
  //#region src/clients/SignalWire.ts
21517
22475
  var import_cjs$1 = require_cjs();
21518
22476
  const logger$1 = getLogger();
22477
+ /**
22478
+ * Storage key for the client-bound marker. The SAT and authorization_state are
22479
+ * both opaque to the SDK, so on a page reload the preflight recovery — which
22480
+ * runs before any session exists — has no other way to know the session was
22481
+ * client-bound. See {@link SignalWire.persistClientBoundMarker}.
22482
+ */
22483
+ const CLIENT_BOUND_STORAGE_KEY = "sw:client_bound";
21519
22484
  const buildOptionsFromDestination = (destination) => {
21520
22485
  if (typeof destination === "string") {
21521
22486
  const queryStartIndex = destination.indexOf("?");
@@ -21559,6 +22524,7 @@ var SignalWire = class extends Destroyable {
21559
22524
  this.preferences = new ClientPreferences();
21560
22525
  this._user$ = this.createBehaviorSubject(void 0);
21561
22526
  this._directory$ = this.createBehaviorSubject(void 0);
22527
+ this._credentialRecovered = false;
21562
22528
  this._isConnected$ = this.createBehaviorSubject(false);
21563
22529
  this._isRegistered$ = this.createBehaviorSubject(false);
21564
22530
  this._errors$ = this.createReplaySubject(1);
@@ -21597,6 +22563,16 @@ var SignalWire = class extends Destroyable {
21597
22563
  });
21598
22564
  }
21599
22565
  /**
22566
+ * Build the refresh path's own HTTP controller, against whatever host is current.
22567
+ *
22568
+ * Called on first use rather than up front, so `apiHost` already reflects the
22569
+ * token's `ch` claim. Same credential source as the container's controller — only
22570
+ * the instance, and therefore its observable streams, is separate.
22571
+ */
22572
+ createRefreshHttpController() {
22573
+ return new HTTPRequestController(this._deps.apiHost, () => this._deps.credential);
22574
+ }
22575
+ /**
21600
22576
  * Initializes DPoP if not already set up. Returns the fingerprint on success.
21601
22577
  */
21602
22578
  async initDPoP() {
@@ -21623,11 +22599,19 @@ var SignalWire = class extends Destroyable {
21623
22599
  async resolveCredentials() {
21624
22600
  const fingerprint = await this.initDPoP();
21625
22601
  this._refreshCoordinator = new CredentialRefreshCoordinator(this._dpopManager, {
21626
- http: this._deps.http,
22602
+ http: () => {
22603
+ if (!this._refreshHttp || this._refreshHttpHost !== this._deps.apiHost) {
22604
+ this._refreshHttp?.destroy();
22605
+ this._refreshHttp = this.createRefreshHttpController();
22606
+ this._refreshHttpHost = this._deps.apiHost;
22607
+ }
22608
+ return this._refreshHttp;
22609
+ },
21627
22610
  notifier: {
21628
22611
  onError: (error) => this._errors$.next(error),
21629
22612
  onWarning: (warning) => this._warnings$.next(warning),
21630
- onRefreshExhausted: () => void this.disconnect()
22613
+ onRefreshExhausted: () => void this.disconnect(),
22614
+ onCredentialRefreshed: async (credential) => this.reauthenticateLiveSession(credential)
21631
22615
  },
21632
22616
  store: {
21633
22617
  read: () => this._deps.credential,
@@ -21639,6 +22623,7 @@ var SignalWire = class extends Destroyable {
21639
22623
  ...this._deps.credential,
21640
22624
  ...partial
21641
22625
  };
22626
+ this.persistCredential(this._deps.credential);
21642
22627
  },
21643
22628
  persist: (credential) => this.persistCredential(credential)
21644
22629
  }
@@ -21684,20 +22669,181 @@ var SignalWire = class extends Destroyable {
21684
22669
  }
21685
22670
  this._deps.credential = _credentials;
21686
22671
  this.persistCredential(_credentials);
21687
- if (this.isConnected && this._clientSession.authenticated && _credentials.token) try {
21688
- await this._clientSession.reauthenticate(_credentials.token);
22672
+ await this.reauthenticateLiveSession(_credentials);
22673
+ }
22674
+ /**
22675
+ * Reauthenticate the currently-open session with a freshly obtained
22676
+ * credential so the new token takes effect on the live socket immediately —
22677
+ * not just on the next reconnect. No-op when the session is not
22678
+ * connected/authenticated or the credential carries no token (e.g. an
22679
+ * authorization-state-only refresh). Non-fatal: reauth failures surface on
22680
+ * `errors$` without aborting the refresh that triggered this.
22681
+ */
22682
+ async reauthenticateLiveSession(credential) {
22683
+ if (!this.isConnected || !this._clientSession.authenticated || !credential.token) return;
22684
+ try {
22685
+ await this._clientSession.reauthenticate(credential.token);
21689
22686
  logger$1.info("[SignalWire] Session refreshed with new credentials.");
21690
22687
  } catch (error) {
21691
22688
  logger$1.error("[SignalWire] Failed to refresh session with new credentials:", error);
21692
22689
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
21693
22690
  }
21694
22691
  }
22692
+ /**
22693
+ * Recover a session the server is refusing: mint a fresh credential,
22694
+ * reauthenticate the live session with it, and retry the operation.
22695
+ *
22696
+ * The connection is deliberately kept. A reload authenticates the new socket
22697
+ * against the persisted `authorization_state`, and that handshake is what
22698
+ * associates the socket with the previous session — the association reattach
22699
+ * depends on. `signalwire.reauthenticate` swaps the credential *on that same
22700
+ * session*, so recovery never touches the resume state. Discarding it would
22701
+ * heal the credential by destroying the very thing the caller is trying to
22702
+ * get back to.
22703
+ *
22704
+ * The operation is still the verdict, never the RPC. Reauthenticating with
22705
+ * the in-memory token is accepted by a resume even while requests stay
22706
+ * refused, because the persisted `authorization_state` short-circuits token
22707
+ * validation — and `signalwire.reauthenticate` with a *freshly minted* token
22708
+ * has also been observed accepted while `subscriber.online` keeps being
22709
+ * refused (staging run 33826974634). Both look like success and are not.
22710
+ *
22711
+ * @returns the operation's value, or the reason recovery could not deliver
22712
+ * one. `error` is undefined when there was no way to mint at all.
22713
+ */
22714
+ async recoverAndRetry(operation) {
22715
+ if (!await this.remintAndReauthenticate()) return { ok: false };
22716
+ try {
22717
+ const value = await operation();
22718
+ this._credentialRecovered = true;
22719
+ return {
22720
+ ok: true,
22721
+ value
22722
+ };
22723
+ } catch (error) {
22724
+ logger$1.warn("[SignalWire] Reauthentication was accepted but the operation is still refused:", error);
22725
+ return {
22726
+ ok: false,
22727
+ error
22728
+ };
22729
+ }
22730
+ }
22731
+ /**
22732
+ * Re-mint a credential and adopt it only if the live session accepts it.
22733
+ *
22734
+ * The mechanism follows the binding: a client-bound session re-mints a bound
22735
+ * base SAT through `authenticate()` with the DPoP fingerprint, because the
22736
+ * developer refresh handler would hand back an unbound token and silently
22737
+ * degrade the session. An unbound session uses the refresh handler. Rotation
22738
+ * cost is not a reason to skip this — the only reason is having no mechanism.
22739
+ *
22740
+ * @returns whether the session is now running on a freshly accepted credential.
22741
+ */
22742
+ async remintAndReauthenticate() {
22743
+ const provider = this._credentialProvider;
22744
+ if (!provider) return false;
22745
+ const { clientBound } = this._clientSession;
22746
+ if (!clientBound && !provider.refresh) {
22747
+ logger$1.debug("[SignalWire] [SW-NO-REFRESH-HANDLER] Unbound session with no refresh handler; cannot re-mint.");
22748
+ return false;
22749
+ }
22750
+ try {
22751
+ const newCredentials = clientBound ? await provider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0) : await this.remintCredential(provider);
22752
+ if (!newCredentials.token) {
22753
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the current one.");
22754
+ return false;
22755
+ }
22756
+ await this._clientSession.reauthenticate(newCredentials.token);
22757
+ this._deps.credential = newCredentials;
22758
+ this.persistCredential(newCredentials);
22759
+ if (newCredentials.expiry_at && provider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(provider, newCredentials.expiry_at);
22760
+ return true;
22761
+ } catch (error) {
22762
+ logger$1.warn("[SignalWire] Re-mint recovery failed:", error);
22763
+ return false;
22764
+ }
22765
+ }
22766
+ /**
22767
+ * Re-mint a credential via `provider.refresh()`, routed through the
22768
+ * coordinator's shared in-flight guard so concurrent re-mint paths (a
22769
+ * scheduled/resume refresh, -32003 recovery, and reconnect) never fire a
22770
+ * second `provider.refresh()` in parallel — which rotating one-time-use
22771
+ * refresh tokens reject. Falls back to a direct call only if the coordinator
22772
+ * has not been constructed yet.
22773
+ */
22774
+ async remintCredential(provider) {
22775
+ if (this._refreshCoordinator) return this._refreshCoordinator.refreshCredential(provider);
22776
+ if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
22777
+ return provider.refresh();
22778
+ }
22779
+ /**
22780
+ * Re-mint credentials before a fresh (re)connect (`onBeforeReconnect` hook).
22781
+ * The session invokes this only when it is client-bound OR the in-memory
22782
+ * token is expired. The re-mint mechanism depends on the binding:
22783
+ * - Client-bound: `authenticate()` with the DPoP fingerprint to obtain a
22784
+ * fresh base SAT the upcoming reconnect can re-bind (the
22785
+ * DeviceTokenManager re-activates afterwards).
22786
+ * - Unbound: the developer's non-interactive `refresh()` handler.
22787
+ * `authenticate()` is deliberately NOT used here — it may be interactive
22788
+ * (a login prompt) and must not fire on a background reconnect.
22789
+ *
22790
+ * Rejects on failure so the session aborts the reconnect rather than
22791
+ * replaying a stale token.
22792
+ */
22793
+ async refreshCredentialForReconnect() {
22794
+ if (!this._credentialProvider) return;
22795
+ try {
22796
+ let newCredentials;
22797
+ if (this._clientSession?.clientBound ?? await this.wasClientBound()) {
22798
+ logger$1.debug("[SignalWire] Re-minting client-bound base SAT before reconnect");
22799
+ newCredentials = await this._credentialProvider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0);
22800
+ } else if (this._credentialProvider.refresh) {
22801
+ logger$1.debug("[SignalWire] Refreshing unbound credential before reconnect");
22802
+ newCredentials = await this.remintCredential(this._credentialProvider);
22803
+ } else {
22804
+ logger$1.warn("[SignalWire] [SW-NO-REFRESH-HANDLER] Token expired on reconnect but no refresh handler; reconnecting with the existing token.");
22805
+ return;
22806
+ }
22807
+ if (!newCredentials.token) {
22808
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the existing credential for reconnect.");
22809
+ return;
22810
+ }
22811
+ this._deps.credential = newCredentials;
22812
+ this.persistCredential(newCredentials);
22813
+ if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
22814
+ logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
22815
+ } catch (error) {
22816
+ logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
22817
+ this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22818
+ throw error;
22819
+ }
22820
+ }
21695
22821
  /** Persist credential to localStorage when persistSession is enabled. */
21696
22822
  persistCredential(credential) {
21697
22823
  if (!credential.token) return;
21698
22824
  this._deps.storage.setItem("sw:cached_credential", credential);
21699
22825
  if (this._deps.persistSession) this._deps.storage.setItem("sw:cached_credential", credential, "local");
21700
22826
  }
22827
+ /**
22828
+ * Persist whether the session is client-bound, mirroring the credential's
22829
+ * storage scopes so it survives a reload. The preflight recovery reads it
22830
+ * before any session exists to decide whether to re-bind via `authenticate()`
22831
+ * or refresh an unbound token; the marker tracks the latest binding, so an
22832
+ * unbound reconnect clears a stale marker from an earlier client-bound login.
22833
+ */
22834
+ persistClientBoundMarker(bound) {
22835
+ const scopes = this._deps.persistSession ? ["session", "local"] : ["session"];
22836
+ for (const scope of scopes) if (bound) this._deps.storage.setItem(CLIENT_BOUND_STORAGE_KEY, true, scope);
22837
+ else this._deps.storage.removeItem(CLIENT_BOUND_STORAGE_KEY, scope);
22838
+ }
22839
+ /** Read the persisted client-bound marker (see {@link persistClientBoundMarker}). */
22840
+ async wasClientBound() {
22841
+ const scopes = this._deps.persistSession ? ["local", "session"] : ["session"];
22842
+ for (const scope of scopes) try {
22843
+ if (await this._deps.storage.getItem(CLIENT_BOUND_STORAGE_KEY, scope)) return true;
22844
+ } catch {}
22845
+ return false;
22846
+ }
21701
22847
  async init() {
21702
22848
  this._user$.next(new User(this._deps.http));
21703
22849
  if (!this._options.skipConnection) await this.connect();
@@ -21721,6 +22867,42 @@ var SignalWire = class extends Destroyable {
21721
22867
  } catch (error) {
21722
22868
  logger$1.error("[SignalWire] Failed to reattach calls:", error);
21723
22869
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22870
+ } finally {
22871
+ this._credentialRecovered = false;
22872
+ }
22873
+ }
22874
+ /**
22875
+ * Fetch the authenticated user profile, recovering a stale credential.
22876
+ *
22877
+ * On a reload the persisted credential can be expired. Unlike the WS resume —
22878
+ * which the server accepts against the persisted `authorization_state` even
22879
+ * with an expired token — this REST preflight has no such short-circuit and is
22880
+ * refused (401). There is no session yet to reauthenticate, so recovery
22881
+ * re-mints the credential through the provider ({@link refreshCredentialForReconnect})
22882
+ * and retries with a FRESH {@link User}: Fetchable memoizes its result
22883
+ * (shareReplay), so reusing the instance would replay the 401 instead of
22884
+ * re-fetching with the new token. Without the user id the transport/session —
22885
+ * and the reattach a reload is trying to preserve — cannot even be addressed.
22886
+ */
22887
+ async fetchUserOrRecover() {
22888
+ const fetchUser = async (user$1) => {
22889
+ if (!await (0, import_cjs$1.firstValueFrom)(user$1.fetched$)) throw new UnexpectedError("Failed to fetch user information - fetched$ emitted false");
22890
+ this._deps.user = user$1;
22891
+ };
22892
+ const user = this._user$.value;
22893
+ if (!user) throw new UnexpectedError("User not initialized before connect");
22894
+ try {
22895
+ await fetchUser(user);
22896
+ } catch (firstError) {
22897
+ 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.`);
22898
+ try {
22899
+ await this.refreshCredentialForReconnect();
22900
+ const refetched = new User(this._deps.http);
22901
+ await fetchUser(refetched);
22902
+ this._user$.next(refetched);
22903
+ } catch (retryError) {
22904
+ throw new UnexpectedError("Error fetching user information", { cause: retryError });
22905
+ }
21724
22906
  }
21725
22907
  }
21726
22908
  /**
@@ -21762,40 +22944,23 @@ var SignalWire = class extends Destroyable {
21762
22944
  */
21763
22945
  async connect() {
21764
22946
  await this.teardownTransportAndSession();
21765
- try {
21766
- const user = this._user$.value;
21767
- if (!user) throw new UnexpectedError("User not initialized before connect");
21768
- if (!await (0, import_cjs$1.firstValueFrom)(user.fetched$)) throw new UnexpectedError("Failed to fetch user information - fetched$ emitted false");
21769
- this._deps.user = user;
21770
- } catch (error) {
21771
- 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.`);
21772
- throw new UnexpectedError("Error fetching user information", { cause: error });
21773
- }
22947
+ await this.fetchUserOrRecover();
21774
22948
  const errorHandler = (error) => {
21775
22949
  this._errors$.next(error);
21776
22950
  };
21777
22951
  this._transport = new TransportManager(this._deps.storage, this._deps.protocolKey, this._deps.WebSocket, PreferencesContainer.instance.relayHost ?? this._deps.relayHost, errorHandler);
21778
- this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey);
22952
+ this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey, () => this._credentialRecovered);
21779
22953
  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$);
21780
22954
  this._publicSession = new ClientSessionWrapper(this._clientSession);
21781
- this._clientSession.onBeforeReconnect = async () => {
21782
- if (!this._credentialProvider) return;
21783
- try {
21784
- const fingerprint = this._dpopManager?.initialized ? this._dpopManager.fingerprint : void 0;
21785
- logger$1.debug("[SignalWire] Credential expired, refreshing before reconnect");
21786
- const newCredentials = await this._credentialProvider.authenticate(fingerprint ? { fingerprint } : void 0);
21787
- this._deps.credential = newCredentials;
21788
- if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
21789
- logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
21790
- } catch (error) {
21791
- logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
21792
- this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
21793
- throw error;
21794
- }
21795
- };
22955
+ this._clientSession.callControl = this._options.callControl ?? "routed";
22956
+ this._clientSession.onBeforeReconnect = async () => this.refreshCredentialForReconnect();
21796
22957
  this.subscribeTo(this._clientSession.errors$, (error) => {
21797
22958
  this._errors$.next(error);
21798
22959
  });
22960
+ this.subscribeTo(this._clientSession.authorization$, (authorization) => {
22961
+ this._refreshCoordinator?.syncExpiryFromAuthorization(authorization, this._credentialProvider);
22962
+ this.persistClientBoundMarker(Boolean(authorization?.cnf?.jkt));
22963
+ });
21799
22964
  await this._clientSession.connect();
21800
22965
  await this._refreshCoordinator?.activate(this._deps.user, this._clientSession);
21801
22966
  this.subscribeTo(this._clientSession.authenticated$.pipe((0, import_cjs$1.skip)(1), (0, import_cjs$1.filter)(Boolean)), async () => {
@@ -21958,6 +23123,13 @@ var SignalWire = class extends Destroyable {
21958
23123
  }
21959
23124
  try {
21960
23125
  this._visibilityController = new VisibilityController();
23126
+ this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, import_cjs$1.filter)((event) => event.to === "visible")), () => {
23127
+ try {
23128
+ this._refreshCoordinator?.forceRefreshIfDue();
23129
+ } catch (error) {
23130
+ logger$1.warn("[SignalWire] Resume credential revalidation failed (non-fatal):", error);
23131
+ }
23132
+ });
21961
23133
  this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, import_cjs$1.filter)((event) => event.to === "visible" && PreferencesContainer.instance.refreshDevicesOnVisible)), () => {
21962
23134
  logger$1.debug("[SignalWire] Page visible, re-enumerating devices");
21963
23135
  try {
@@ -21969,7 +23141,7 @@ var SignalWire = class extends Destroyable {
21969
23141
  logger$1.warn("[SignalWire] Failed to initialize VisibilityController:", error);
21970
23142
  }
21971
23143
  try {
21972
- this._diagnosticsCollector = new DiagnosticsCollector({ sdkVersion: "4.0.0-rc.2" });
23144
+ this._diagnosticsCollector = new DiagnosticsCollector({ sdkVersion: "4.0.0-rc.3" });
21973
23145
  } catch (error) {
21974
23146
  logger$1.warn("[SignalWire] Failed to initialize DiagnosticsCollector:", error);
21975
23147
  }
@@ -22040,21 +23212,22 @@ var SignalWire = class extends Destroyable {
22040
23212
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22041
23213
  throw error;
22042
23214
  }
22043
- logger$1.debug("[SignalWire] Failed to register user, trying reauthentication...");
22044
- try {
22045
- await this._clientSession.reauthenticate(this._deps.credential.token);
22046
- logger$1.debug("[SignalWire] Reauthentication successful, retrying register()");
23215
+ logger$1.debug("[SignalWire] Failed to register user, attempting credential recovery...");
23216
+ const outcome = await this.recoverAndRetry(async () => {
22047
23217
  await this._transport.execute(RPCExecute({
22048
23218
  method: "subscriber.online",
22049
23219
  params: {}
22050
23220
  }));
23221
+ });
23222
+ if (outcome.ok) {
23223
+ logger$1.debug("[SignalWire] Recovery restored registration");
22051
23224
  this._isRegistered$.next(true);
22052
- } catch (reauthError) {
22053
- logger$1.error("[SignalWire] Reauthentication failed during register():", reauthError);
22054
- const registerError = new InvalidCredentialsError("Failed to register user, and reauthentication attempt also failed. Please check your credentials.", { cause: reauthError instanceof Error ? reauthError : new Error(String(reauthError), { cause: reauthError }) });
22055
- this._errors$.next(registerError);
22056
- throw registerError;
23225
+ return;
22057
23226
  }
23227
+ const failureCause = outcome.error ?? error;
23228
+ const registerError = new InvalidCredentialsError("Failed to register user, and credential recovery also failed. Please check your credentials.", { cause: failureCause instanceof Error ? failureCause : new Error(String(failureCause), { cause: failureCause }) });
23229
+ this._errors$.next(registerError);
23230
+ throw registerError;
22058
23231
  }
22059
23232
  }
22060
23233
  /**
@@ -22085,6 +23258,11 @@ var SignalWire = class extends Destroyable {
22085
23258
  * Returns a {@link Call} in `'ringing'` state. Subscribe to {@link Call.status$}
22086
23259
  * to track progression through `'connected'` → `'disconnected'`.
22087
23260
  *
23261
+ * Local media acquisition is deliberately unbounded: an unanswered permission
23262
+ * prompt leaves this promise pending indefinitely, so apply your own bound if
23263
+ * your UI needs one. The 12 s signaling budget starts only once acquisition
23264
+ * settles.
23265
+ *
22088
23266
  * @param destination - Address URI string (e.g. `'/public/my-room'`) or {@link Address} instance.
22089
23267
  * @param options - Media and dial options (audio/video, device constraints). Overrides defaults.
22090
23268
  * @returns The created {@link Call} instance.
@@ -22107,7 +23285,18 @@ var SignalWire = class extends Destroyable {
22107
23285
  };
22108
23286
  await this.waitAuthentication();
22109
23287
  logger$1.debug("[SignalWire] Dialing with options:", computed_options);
22110
- return this._clientSession.createOutboundCall(destination, computed_options);
23288
+ try {
23289
+ return await this._clientSession.createOutboundCall(destination, computed_options);
23290
+ } catch (error) {
23291
+ if (!isRecoverableAuthError(error)) throw error;
23292
+ logger$1.debug("[SignalWire] Dial hit a recoverable auth error; recovering the session and retrying");
23293
+ const outcome = await this.recoverAndRetry(async () => {
23294
+ await this.waitAuthentication();
23295
+ return this._clientSession.createOutboundCall(destination, computed_options);
23296
+ });
23297
+ if (outcome.ok) return outcome.value;
23298
+ throw outcome.error ?? error;
23299
+ }
22111
23300
  }
22112
23301
  /**
22113
23302
  * Runs a multi-phase connectivity test against the given destination.
@@ -22388,10 +23577,13 @@ var SignalWire = class extends Destroyable {
22388
23577
  destroy() {
22389
23578
  this._refreshCoordinator?.destroy();
22390
23579
  this._refreshCoordinator = void 0;
23580
+ this._refreshHttp?.destroy();
23581
+ this._refreshHttp = void 0;
22391
23582
  this._dpopManager?.destroy();
22392
- this._clientSession.teardownSessionState();
22393
- this._transport.destroy();
22394
- this._clientSession.destroy();
23583
+ const session = this._clientSession;
23584
+ session?.teardownSessionState();
23585
+ this._transport?.destroy();
23586
+ session?.destroy();
22395
23587
  try {
22396
23588
  this._networkMonitor?.destroy();
22397
23589
  } catch {}
@@ -22504,7 +23696,7 @@ var StaticCredentialProvider = class {
22504
23696
  /**
22505
23697
  * Library version from package.json, injected at build time.
22506
23698
  */
22507
- const version = "4.0.0-rc.2";
23699
+ const version = "4.0.0-rc.3";
22508
23700
  /**
22509
23701
  * Flag indicating the library has been loaded and is ready to use.
22510
23702
  * For UMD builds: `window.SignalWire.ready`
@@ -22526,7 +23718,7 @@ const ready = true;
22526
23718
  */
22527
23719
  const emitReadyEvent = () => {
22528
23720
  if (typeof window !== "undefined") {
22529
- const event = new CustomEvent("signalwire:js:ready", { detail: { version: "4.0.0-rc.2" } });
23721
+ const event = new CustomEvent("signalwire:js:ready", { detail: { version: "4.0.0-rc.3" } });
22530
23722
  window.dispatchEvent(event);
22531
23723
  }
22532
23724
  };
@@ -22550,5 +23742,5 @@ emitReadyEvent();
22550
23742
  if (typeof process === "undefined") globalThis.process = { env: { NODE_ENV: "production" } };
22551
23743
 
22552
23744
  //#endregion
22553
- export { Address, CallCreateError, ClientPreferences, CollectionFetchError, DPoPInitError, DeviceTokenError, EmbedTokenCredentialProvider, InvalidCredentialsError, MediaAccessError, MediaTrackError, MessageParseError, OverconstrainedFallbackError, Participant, PreflightError, RecoveryError, SelfCapabilities, SelfParticipant, SignalWire, StaticCredentialProvider, TokenRefreshError, UnexpectedError, User, VertoPongError, WebRTCCall, embeddableCall, getLogger, isSelfParticipant, ready, setDebugOptions, setLogLevel, setLogger, version };
23745
+ export { Address, AuxiliaryLegCancelledError, AuxiliaryLegTimeoutError, CallCreateError, CallNotReadyError, ClientPreferences, CollectionFetchError, DPoPInitError, DeviceTokenError, EmbedTokenCredentialProvider, InvalidCredentialsError, MediaAccessError, MediaTrackError, MessageParseError, OverconstrainedFallbackError, Participant, ParticipantNotReadyError, PreflightError, RecoveryError, ScreenShareAlreadyActiveError, SelfCapabilities, SelfParticipant, SignalWire, StaticCredentialProvider, TokenRefreshError, UnexpectedError, User, VertoPongError, WebRTCCall, embeddableCall, getLogger, isSelfParticipant, ready, setDebugOptions, setLogLevel, setLogger, version };
22554
23746
  //# sourceMappingURL=browser.mjs.map