@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.
@@ -4192,7 +4192,7 @@ var require_merge$1 = /* @__PURE__ */ __commonJSMin(((exports) => {
4192
4192
  var empty_1$7 = require_empty();
4193
4193
  var args_1$8 = require_args();
4194
4194
  var from_1$4 = require_from();
4195
- function merge$6() {
4195
+ function merge$7() {
4196
4196
  var args = [];
4197
4197
  for (var _i = 0; _i < arguments.length; _i++) args[_i] = arguments[_i];
4198
4198
  var scheduler = args_1$8.popScheduler(args);
@@ -4200,7 +4200,7 @@ var require_merge$1 = /* @__PURE__ */ __commonJSMin(((exports) => {
4200
4200
  var sources = args;
4201
4201
  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));
4202
4202
  }
4203
- exports.merge = merge$6;
4203
+ exports.merge = merge$7;
4204
4204
  }));
4205
4205
 
4206
4206
  //#endregion
@@ -4331,13 +4331,13 @@ var require_race$1 = /* @__PURE__ */ __commonJSMin(((exports) => {
4331
4331
  var innerFrom_1$28 = require_innerFrom();
4332
4332
  var argsOrArgArray_1$4 = require_argsOrArgArray();
4333
4333
  var OperatorSubscriber_1$48 = require_OperatorSubscriber();
4334
- function race$5() {
4334
+ function race$4() {
4335
4335
  var sources = [];
4336
4336
  for (var _i = 0; _i < arguments.length; _i++) sources[_i] = arguments[_i];
4337
4337
  sources = argsOrArgArray_1$4.argsOrArgArray(sources);
4338
4338
  return sources.length === 1 ? innerFrom_1$28.innerFrom(sources[0]) : new Observable_1$6.Observable(raceInit(sources));
4339
4339
  }
4340
- exports.race = race$5;
4340
+ exports.race = race$4;
4341
4341
  function raceInit(sources) {
4342
4342
  return function(subscriber) {
4343
4343
  var subscriptions = [];
@@ -6082,7 +6082,7 @@ var require_merge = /* @__PURE__ */ __commonJSMin(((exports) => {
6082
6082
  var mergeAll_1$2 = require_mergeAll();
6083
6083
  var args_1$3 = require_args();
6084
6084
  var from_1$1 = require_from();
6085
- function merge$5() {
6085
+ function merge$6() {
6086
6086
  var args = [];
6087
6087
  for (var _i = 0; _i < arguments.length; _i++) args[_i] = arguments[_i];
6088
6088
  var scheduler = args_1$3.popScheduler(args);
@@ -6091,7 +6091,7 @@ var require_merge = /* @__PURE__ */ __commonJSMin(((exports) => {
6091
6091
  mergeAll_1$2.mergeAll(concurrent)(from_1$1.from(__spreadArray$8([source], __read$8(args)), scheduler)).subscribe(subscriber);
6092
6092
  });
6093
6093
  }
6094
- exports.merge = merge$5;
6094
+ exports.merge = merge$6;
6095
6095
  }));
6096
6096
 
6097
6097
  //#endregion
@@ -6938,7 +6938,7 @@ var require_switchMap = /* @__PURE__ */ __commonJSMin(((exports) => {
6938
6938
  var innerFrom_1$6 = require_innerFrom();
6939
6939
  var lift_1$13 = require_lift();
6940
6940
  var OperatorSubscriber_1$11 = require_OperatorSubscriber();
6941
- function switchMap$7(project, resultSelector) {
6941
+ function switchMap$8(project, resultSelector) {
6942
6942
  return lift_1$13.operate(function(source, subscriber) {
6943
6943
  var innerSubscriber = null;
6944
6944
  var index = 0;
@@ -6962,7 +6962,7 @@ var require_switchMap = /* @__PURE__ */ __commonJSMin(((exports) => {
6962
6962
  }));
6963
6963
  });
6964
6964
  }
6965
- exports.switchMap = switchMap$7;
6965
+ exports.switchMap = switchMap$8;
6966
6966
  }));
6967
6967
 
6968
6968
  //#endregion
@@ -9028,6 +9028,25 @@ const DEFAULT_ICE_CANDIDATE_TIMEOUT_MS = 600;
9028
9028
  const DEFAULT_ICE_GATHERING_TIMEOUT_MS = 6e3;
9029
9029
  const DEFAULT_RECONNECT_CALLS_TIMEOUT_MS = 300 * 1e3;
9030
9030
  const DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
9031
+ /**
9032
+ * How long call setup may wait for the network, measured from local media
9033
+ * settling until the member id arrives.
9034
+ *
9035
+ * Local media acquisition is deliberately NOT inside this: a permission prompt
9036
+ * or a device picker is human time, and a human may take as long as they like
9037
+ * without spending the server's budget. The clock starts when acquisition ends.
9038
+ *
9039
+ * Must exceed `iceGatheringTimeout + the RPC timeout`, which both run inside it.
9040
+ */
9041
+ const DEFAULT_CALL_SIGNALING_TIMEOUT_MS = 12e3;
9042
+ /**
9043
+ * How long an auxiliary leg (screen share, additional device) may take to
9044
+ * connect once its media is in hand.
9045
+ *
9046
+ * One bound for both: the picker is human time and sits outside it. What remains
9047
+ * — offer, ICE, invite, answer, DTLS — is the same work for either leg kind.
9048
+ */
9049
+ const DEFAULT_AUX_LEG_CONNECT_TIMEOUT_MS = 15e3;
9031
9050
  const DEFAULT_RECONNECT_DELAY_MIN_MS = 100;
9032
9051
  const DEFAULT_RECONNECT_DELAY_MAX_MS = 3e3;
9033
9052
  const DEFAULT_DEVICE_DEBOUNCE_TIME_MS = 1500;
@@ -9055,6 +9074,14 @@ const CREDENTIAL_REFRESH_MAX_DELAY_MS = 3e4;
9055
9074
  /** Buffer in milliseconds before token expiry to trigger refresh. */
9056
9075
  const CREDENTIAL_REFRESH_BUFFER_MS = 5e3;
9057
9076
  /**
9077
+ * Clock-skew allowance (ms) for treating an in-memory credential as expired
9078
+ * when deciding whether a fresh (re)connect must re-mint the token before
9079
+ * authenticating. A token within this window of its `expiry_at` is treated as
9080
+ * stale so the reconnect re-mints via the credential provider instead of
9081
+ * replaying a dead token (which the server rejects with -32003).
9082
+ */
9083
+ const CREDENTIAL_EXPIRY_SKEW_MS = 3e4;
9084
+ /**
9058
9085
  * Maximum time the coordinator will wait for `DeviceTokenManager.activate()`
9059
9086
  * to resolve before treating the activation as failed and falling back to
9060
9087
  * the developer-provided refresh path. Prevents a wedged HTTP layer from
@@ -9073,6 +9100,8 @@ const MEDIA_ACCESS_DENIAL_NAMES = [
9073
9100
  "SecurityError",
9074
9101
  "PermissionDeniedError"
9075
9102
  ];
9103
+ /** Error names browsers use when the capture hardware is already held exclusively. */
9104
+ const MEDIA_DEVICE_IN_USE_NAMES = ["NotReadableError", "TrackStartError"];
9076
9105
  /** Default polling interval for RTCPeerConnection.getStats() in milliseconds. */
9077
9106
  const DEFAULT_STATS_POLLING_INTERVAL_MS = 1e3;
9078
9107
  /** Number of initial samples used to build a baseline for spike detection. */
@@ -9287,6 +9316,20 @@ var CallCreateError = class extends Error {
9287
9316
  this.name = "CallCreateError";
9288
9317
  }
9289
9318
  };
9319
+ var CallNotReadyError = class extends Error {
9320
+ constructor(callId, options) {
9321
+ super(`Call "${callId}" has no self member context yet: selfId/nodeId have not been received from the server`, options);
9322
+ this.callId = callId;
9323
+ this.name = "CallNotReadyError";
9324
+ }
9325
+ };
9326
+ var ParticipantNotReadyError = class extends Error {
9327
+ constructor(memberId, options) {
9328
+ super(`Participant "${memberId}" has no call context yet: its member state (call_id/node_id) has not been received from the server`, options);
9329
+ this.memberId = memberId;
9330
+ this.name = "ParticipantNotReadyError";
9331
+ }
9332
+ };
9290
9333
  var JSONRPCError = class extends Error {
9291
9334
  constructor(code, message, data, options, requestId) {
9292
9335
  super(message, options);
@@ -9358,6 +9401,32 @@ var CollectionFetchError = class extends Error {
9358
9401
  this.name = "CollectionFetchError";
9359
9402
  }
9360
9403
  };
9404
+ /**
9405
+ * An auxiliary leg did not connect within its budget. Typed rather than a bare
9406
+ * RxJS `TimeoutError` so the leg and cause survive.
9407
+ */
9408
+ var AuxiliaryLegTimeoutError = class extends Error {
9409
+ constructor(leg, originalError) {
9410
+ super(`Timed out waiting for the ${leg} connection to be established`, { cause: originalError });
9411
+ this.leg = leg;
9412
+ this.originalError = originalError;
9413
+ this.name = "AuxiliaryLegTimeoutError";
9414
+ }
9415
+ };
9416
+ /**
9417
+ * An auxiliary leg was removed before it finished connecting.
9418
+ *
9419
+ * Typed rather than a bare resolve so a caller awaiting the start can tell a
9420
+ * cancel apart from a share that actually came up — the public methods return
9421
+ * `void`, so the promise is the only signal they have.
9422
+ */
9423
+ var AuxiliaryLegCancelledError = class extends Error {
9424
+ constructor(leg) {
9425
+ super(`The ${leg} leg was removed before it finished connecting`);
9426
+ this.leg = leg;
9427
+ this.name = "AuxiliaryLegCancelledError";
9428
+ }
9429
+ };
9361
9430
  var MediaTrackError = class extends Error {
9362
9431
  constructor(operation, kind, originalError) {
9363
9432
  super(`Media track ${operation} failed for ${kind}`, { cause: originalError instanceof Error ? originalError : void 0 });
@@ -9371,6 +9440,10 @@ var MediaTrackError = class extends Error {
9371
9440
  function isMediaAccessDenial(originalError) {
9372
9441
  return originalError instanceof Error && MEDIA_ACCESS_DENIAL_NAMES.includes(originalError.name);
9373
9442
  }
9443
+ /** True when a `getUserMedia` rejection means the hardware is already held exclusively. */
9444
+ function isMediaDeviceInUse(originalError) {
9445
+ return originalError instanceof Error && MEDIA_DEVICE_IN_USE_NAMES.includes(originalError.name);
9446
+ }
9374
9447
  /**
9375
9448
  * Failure to acquire local media (camera, microphone, or screen capture)
9376
9449
  * via `getUserMedia`/`getDisplayMedia`.
@@ -9394,6 +9467,22 @@ var MediaAccessError = class extends Error {
9394
9467
  return isMediaAccessDenial(this.originalError);
9395
9468
  }
9396
9469
  };
9470
+ /**
9471
+ * Thrown by `startScreenShare()` when the call is already sharing a screen.
9472
+ *
9473
+ * A call carries at most one screen share. Accepting a second one would
9474
+ * overwrite the only reference the SDK holds to the first, leaving it
9475
+ * capturing and sending with no way to stop it — so the second request is
9476
+ * rejected and the live share is left untouched. Call `stopScreenShare()`
9477
+ * first to replace it.
9478
+ */
9479
+ var ScreenShareAlreadyActiveError = class extends Error {
9480
+ constructor(screenShareId, options) {
9481
+ super(`A screen share is already active on this call (${screenShareId}). Call stopScreenShare() before starting another one.`, options);
9482
+ this.screenShareId = screenShareId;
9483
+ this.name = "ScreenShareAlreadyActiveError";
9484
+ }
9485
+ };
9397
9486
  var DPoPInitError = class extends Error {
9398
9487
  constructor(originalError, message = "Failed to initialize DPoP key pair") {
9399
9488
  super(message, { cause: originalError instanceof Error ? originalError : void 0 });
@@ -9634,9 +9723,9 @@ var require_loglevel = /* @__PURE__ */ __commonJSMin(((exports, module) => {
9634
9723
  defaultLogger$1 = new Logger();
9635
9724
  defaultLogger$1.getLogger = function getLogger$1(name) {
9636
9725
  if (typeof name !== "symbol" && typeof name !== "string" || name === "") throw new TypeError("You must supply a name when creating a logger.");
9637
- var logger$33 = _loggersByName[name];
9638
- if (!logger$33) logger$33 = _loggersByName[name] = new Logger(name, defaultLogger$1.methodFactory);
9639
- return logger$33;
9726
+ var logger$34 = _loggersByName[name];
9727
+ if (!logger$34) logger$34 = _loggersByName[name] = new Logger(name, defaultLogger$1.methodFactory);
9728
+ return logger$34;
9640
9729
  };
9641
9730
  var _log = typeof window !== undefinedType ? window.log : void 0;
9642
9731
  defaultLogger$1.noConflict = function() {
@@ -9672,8 +9761,8 @@ const defaultLoggerLevel = defaultLogger.levels.WARN;
9672
9761
  defaultLogger.setLevel(defaultLoggerLevel);
9673
9762
  let userLogger = null;
9674
9763
  /** Replace the built-in logger with a custom implementation. Pass `null` to restore defaults. */
9675
- const setLogger = (logger$33) => {
9676
- userLogger = logger$33;
9764
+ const setLogger = (logger$34) => {
9765
+ userLogger = logger$34;
9677
9766
  };
9678
9767
  let debugOptions = {};
9679
9768
  /** Configure debug options (e.g., `{ logWsTraffic: true }`). */
@@ -9717,8 +9806,8 @@ const wsTraffic = (options) => {
9717
9806
  loggerInstance.debug(`${options.type.toUpperCase()}: \n`, msg, "\n");
9718
9807
  };
9719
9808
  const getLogger = () => {
9720
- const logger$33 = getLoggerInstance();
9721
- return new Proxy(logger$33, { get(_target, prop, _receiver) {
9809
+ const logger$34 = getLoggerInstance();
9810
+ return new Proxy(logger$34, { get(_target, prop, _receiver) {
9722
9811
  if (prop === "wsTraffic") return wsTraffic;
9723
9812
  const instance = getLoggerInstance();
9724
9813
  const value = Reflect.get(instance, prop);
@@ -9770,7 +9859,7 @@ const asyncRetry = async ({ asyncCallable, maxRetries: retries = DEFAULT_MAX_RET
9770
9859
 
9771
9860
  //#endregion
9772
9861
  //#region src/controllers/HTTPRequestController.ts
9773
- const logger$32 = getLogger();
9862
+ const logger$33 = getLogger();
9774
9863
  const GET_PARAMS = {
9775
9864
  method: "GET",
9776
9865
  headers: { Accept: "application/json" }
@@ -9834,7 +9923,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9834
9923
  this._responses$.next(response);
9835
9924
  return response;
9836
9925
  } catch (error) {
9837
- logger$32.error("[HTTPRequestController] Request error:", error);
9926
+ logger$33.error("[HTTPRequestController] Request error:", error);
9838
9927
  this._status$.next("error");
9839
9928
  const err = error instanceof Error ? error : new Error("HTTP request failed", { cause: error });
9840
9929
  this._errors$.next(err);
@@ -9861,7 +9950,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9861
9950
  const url = this.buildURL(request.url);
9862
9951
  const headers = this.buildHeaders(request.headers);
9863
9952
  const timeout$5 = request.timeout ?? this.requestTimeout;
9864
- logger$32.debug("[HTTPRequestController] Executing request:", {
9953
+ logger$33.debug("[HTTPRequestController] Executing request:", {
9865
9954
  method: request.method,
9866
9955
  url,
9867
9956
  headers: Object.keys(headers).reduce((acc, key) => {
@@ -9881,7 +9970,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9881
9970
  });
9882
9971
  clearTimeout(timeoutId);
9883
9972
  const httpResponse = await this.convertResponse(response);
9884
- logger$32.debug("[HTTPRequestController] Response received:", {
9973
+ logger$33.debug("[HTTPRequestController] Response received:", {
9885
9974
  status: response.status,
9886
9975
  statusText: response.statusText,
9887
9976
  headers: [...response.headers.entries()],
@@ -9891,7 +9980,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9891
9980
  } catch (error) {
9892
9981
  clearTimeout(timeoutId);
9893
9982
  if (error instanceof Error && error.name === "AbortError") throw new RequestTimeoutError(`Request timeout after ${timeout$5}ms`, { cause: error });
9894
- logger$32.error("[HTTPRequestController] Request failed:", error);
9983
+ logger$33.error("[HTTPRequestController] Request failed:", error);
9895
9984
  throw error;
9896
9985
  }
9897
9986
  }
@@ -9905,8 +9994,8 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9905
9994
  const credential = this.getCredential();
9906
9995
  if (credential.token) {
9907
9996
  headers.Authorization = `Bearer ${credential.token}`;
9908
- logger$32.debug("[HTTPRequestController] Using Bearer token auth, token length:", credential.token.length);
9909
- } else logger$32.warn("[HTTPRequestController] No credentials available for authentication");
9997
+ logger$33.debug("[HTTPRequestController] Using Bearer token auth, token length:", credential.token.length);
9998
+ } else logger$33.warn("[HTTPRequestController] No credentials available for authentication");
9910
9999
  return headers;
9911
10000
  }
9912
10001
  /**
@@ -10054,7 +10143,7 @@ function fromMsToSec(milliseconds) {
10054
10143
 
10055
10144
  //#endregion
10056
10145
  //#region src/containers/PreferencesContainer.ts
10057
- const logger$31 = getLogger();
10146
+ const logger$32 = getLogger();
10058
10147
  var PreferencesContainer = class PreferencesContainer {
10059
10148
  static get instance() {
10060
10149
  this._instance ??= new PreferencesContainer();
@@ -10716,7 +10805,7 @@ var ClientPreferences = class {
10716
10805
  if (!this._storage) return;
10717
10806
  const data = collectStoredPreferences();
10718
10807
  this._storage.setItem(PREFERENCES_STORAGE_KEY, data, "local").catch((error) => {
10719
- logger$31.error(`[ClientPreferences] Failed to save preferences: ${String(error)}`);
10808
+ logger$32.error(`[ClientPreferences] Failed to save preferences: ${String(error)}`);
10720
10809
  });
10721
10810
  }
10722
10811
  /** Loads preferences from storage and applies them to the container. */
@@ -10725,7 +10814,7 @@ var ClientPreferences = class {
10725
10814
  this._storage.getItem(PREFERENCES_STORAGE_KEY, "local").then((stored) => {
10726
10815
  if (stored) applyStoredPreferences(stored);
10727
10816
  }).catch((error) => {
10728
- logger$31.error(`[ClientPreferences] Failed to load preferences: ${String(error)}`);
10817
+ logger$32.error(`[ClientPreferences] Failed to load preferences: ${String(error)}`);
10729
10818
  });
10730
10819
  }
10731
10820
  };
@@ -10747,7 +10836,7 @@ function toError(value) {
10747
10836
  //#endregion
10748
10837
  //#region src/controllers/NavigatorDeviceController.ts
10749
10838
  var import_cjs$29 = require_cjs();
10750
- const logger$30 = getLogger();
10839
+ const logger$31 = getLogger();
10751
10840
  /** Maps a device kind to its storage key. */
10752
10841
  const DEVICE_STORAGE_KEYS = {
10753
10842
  audioinput: DEVICE_STORAGE_KEY_AUDIO_INPUT,
@@ -10769,7 +10858,7 @@ var NavigatorDeviceController = class extends Destroyable {
10769
10858
  super();
10770
10859
  this.webRTCApiProvider = webRTCApiProvider;
10771
10860
  this.deviceChangeHandler = () => {
10772
- logger$30.debug("[DeviceController] Device change detected");
10861
+ logger$31.debug("[DeviceController] Device change detected");
10773
10862
  this.enumerateDevices();
10774
10863
  };
10775
10864
  this._devicesState$ = this.createBehaviorSubject(initialDevicesState);
@@ -10834,13 +10923,13 @@ var NavigatorDeviceController = class extends Destroyable {
10834
10923
  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$)));
10835
10924
  }
10836
10925
  get selectedAudioInputDevice$() {
10837
- 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))));
10926
+ 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))));
10838
10927
  }
10839
10928
  get selectedAudioOutputDevice$() {
10840
- 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))));
10929
+ 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))));
10841
10930
  }
10842
10931
  get selectedVideoInputDevice$() {
10843
- 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))));
10932
+ 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))));
10844
10933
  }
10845
10934
  get selectedAudioInputDevice() {
10846
10935
  if (this._audioInputDisabled$.value) return null;
@@ -10915,7 +11004,7 @@ var NavigatorDeviceController = class extends Destroyable {
10915
11004
  if (device) this.persistDeviceSelection("audioinput", device);
10916
11005
  }
10917
11006
  selectVideoInputDevice(device) {
10918
- logger$30.debug("[DeviceController] Setting selected video input device:", device);
11007
+ logger$31.debug("[DeviceController] Setting selected video input device:", device);
10919
11008
  if (this._videoInputDisabled$.value && device) this._videoInputDisabled$.next(false);
10920
11009
  const previous = this._selectedDevicesState$.value.videoinput;
10921
11010
  if (previous && previous.deviceId !== device?.deviceId) this._deviceHistory.push("videoinput", previous);
@@ -10972,7 +11061,7 @@ var NavigatorDeviceController = class extends Destroyable {
10972
11061
  }
10973
11062
  const fromHistory = this._deviceHistory.findInHistory(kind, devices);
10974
11063
  if (fromHistory) {
10975
- logger$30.debug(`[DeviceController] Device disappeared, falling back to history: ${fromHistory.label}`);
11064
+ logger$31.debug(`[DeviceController] Device disappeared, falling back to history: ${fromHistory.label}`);
10976
11065
  this.emitDeviceRecovered(kind, selected, fromHistory, "device_disconnected");
10977
11066
  return fromHistory;
10978
11067
  }
@@ -11025,7 +11114,7 @@ var NavigatorDeviceController = class extends Destroyable {
11025
11114
  try {
11026
11115
  await this._storageManager.setItem(DEVICE_STORAGE_KEYS[kind], stored, "local");
11027
11116
  } catch (error) {
11028
- logger$30.error(`[DeviceController] Failed to persist device selection for ${kind}:`, error);
11117
+ logger$31.error(`[DeviceController] Failed to persist device selection for ${kind}:`, error);
11029
11118
  }
11030
11119
  }
11031
11120
  async loadPersistedDevices() {
@@ -11041,7 +11130,7 @@ var NavigatorDeviceController = class extends Destroyable {
11041
11130
  [kind]: stored
11042
11131
  };
11043
11132
  } catch (error) {
11044
- logger$30.error(`[DeviceController] Failed to load persisted device for ${kind}:`, error);
11133
+ logger$31.error(`[DeviceController] Failed to load persisted device for ${kind}:`, error);
11045
11134
  }
11046
11135
  }
11047
11136
  /** Clears device history, persisted selections, and re-enumerates devices. */
@@ -11059,7 +11148,7 @@ var NavigatorDeviceController = class extends Destroyable {
11059
11148
  this.disableDeviceMonitoring();
11060
11149
  this.webRTCApiProvider.mediaDevices.addEventListener("devicechange", this.deviceChangeHandler);
11061
11150
  if (PreferencesContainer.instance.devicePollingInterval > 0) this._devicesPoolingSubscription = (0, import_cjs$29.interval)(PreferencesContainer.instance.devicePollingInterval).subscribe(() => {
11062
- logger$30.debug("[DeviceController] Polling devices due to interval");
11151
+ logger$31.debug("[DeviceController] Polling devices due to interval");
11063
11152
  this.enumerateDevices();
11064
11153
  });
11065
11154
  this.enumerateDevices();
@@ -11085,13 +11174,13 @@ var NavigatorDeviceController = class extends Destroyable {
11085
11174
  videoinput: []
11086
11175
  });
11087
11176
  this._devicesState$.next(devicesByKind);
11088
- logger$30.debug("[DeviceController] Devices enumerated:", {
11177
+ logger$31.debug("[DeviceController] Devices enumerated:", {
11089
11178
  audioInputs: devicesByKind.audioinput.length,
11090
11179
  audioOutputs: devicesByKind.audiooutput.length,
11091
11180
  videoInputs: devicesByKind.videoinput.length
11092
11181
  });
11093
11182
  } catch (error) {
11094
- logger$30.error("[DeviceController] Failed to enumerate devices:", error);
11183
+ logger$31.error("[DeviceController] Failed to enumerate devices:", error);
11095
11184
  this._errors$.next(toError(error));
11096
11185
  }
11097
11186
  }
@@ -11107,7 +11196,7 @@ var NavigatorDeviceController = class extends Destroyable {
11107
11196
  stream.getTracks().forEach((t) => t.stop());
11108
11197
  return capabilities;
11109
11198
  } catch (error) {
11110
- logger$30.error("[DeviceController] Failed to get device capabilities:", error);
11199
+ logger$31.error("[DeviceController] Failed to get device capabilities:", error);
11111
11200
  this._errors$.next(toError(error));
11112
11201
  throw error;
11113
11202
  }
@@ -11358,7 +11447,7 @@ var DependencyContainer = class {
11358
11447
 
11359
11448
  //#endregion
11360
11449
  //#region src/controllers/CryptoController.ts
11361
- const logger$29 = getLogger();
11450
+ const logger$30 = getLogger();
11362
11451
  const DPOP_DB_NAME = "sw-dpop";
11363
11452
  const DPOP_DB_VERSION = 1;
11364
11453
  const DPOP_STORE_NAME = "keys";
@@ -11417,7 +11506,7 @@ async function loadKeyPairFromDB() {
11417
11506
  tx.oncomplete = () => db.close();
11418
11507
  });
11419
11508
  } catch (error) {
11420
- logger$29.warn("[DPoP] Failed to load key pair from IndexedDB:", error);
11509
+ logger$30.warn("[DPoP] Failed to load key pair from IndexedDB:", error);
11421
11510
  return null;
11422
11511
  }
11423
11512
  }
@@ -11437,7 +11526,7 @@ async function saveKeyPairToDB(keyPair) {
11437
11526
  };
11438
11527
  });
11439
11528
  } catch (error) {
11440
- logger$29.warn("[DPoP] Failed to save key pair to IndexedDB:", error);
11529
+ logger$30.warn("[DPoP] Failed to save key pair to IndexedDB:", error);
11441
11530
  }
11442
11531
  }
11443
11532
  async function deleteKeyPairFromDB() {
@@ -11456,7 +11545,7 @@ async function deleteKeyPairFromDB() {
11456
11545
  };
11457
11546
  });
11458
11547
  } catch (error) {
11459
- logger$29.warn("[DPoP] Failed to delete key pair from IndexedDB:", error);
11548
+ logger$30.warn("[DPoP] Failed to delete key pair from IndexedDB:", error);
11460
11549
  }
11461
11550
  }
11462
11551
  /**
@@ -11516,13 +11605,13 @@ var CryptoController = class {
11516
11605
  this._publicJwk = await crypto.subtle.exportKey("jwk", stored.publicKey);
11517
11606
  this._fingerprint = await computeJwkThumbprint(this._publicJwk);
11518
11607
  this._initialized = true;
11519
- logger$29.debug("[DPoP] Key pair restored from IndexedDB, fingerprint:", this._fingerprint);
11608
+ logger$30.debug("[DPoP] Key pair restored from IndexedDB, fingerprint:", this._fingerprint);
11520
11609
  return this._fingerprint;
11521
11610
  } catch (error) {
11522
- logger$29.warn("[DPoP] Stored key pair unusable, generating new one:", error);
11611
+ logger$30.warn("[DPoP] Stored key pair unusable, generating new one:", error);
11523
11612
  await deleteKeyPairFromDB();
11524
11613
  }
11525
- logger$29.debug("[DPoP] Generating RSA key pair");
11614
+ logger$30.debug("[DPoP] Generating RSA key pair");
11526
11615
  this._keyPair = await crypto.subtle.generateKey({
11527
11616
  name: "RSASSA-PKCS1-v1_5",
11528
11617
  modulusLength: 2048,
@@ -11537,7 +11626,7 @@ var CryptoController = class {
11537
11626
  this._fingerprint = await computeJwkThumbprint(this._publicJwk);
11538
11627
  this._initialized = true;
11539
11628
  await saveKeyPairToDB(this._keyPair);
11540
- logger$29.debug("[DPoP] Key pair generated and persisted, fingerprint:", this._fingerprint);
11629
+ logger$30.debug("[DPoP] Key pair generated and persisted, fingerprint:", this._fingerprint);
11541
11630
  return this._fingerprint;
11542
11631
  }
11543
11632
  /**
@@ -11603,7 +11692,7 @@ var CryptoController = class {
11603
11692
  this._fingerprint = null;
11604
11693
  this._initialized = false;
11605
11694
  deleteKeyPairFromDB();
11606
- logger$29.debug("[DPoP] Controller destroyed");
11695
+ logger$30.debug("[DPoP] Controller destroyed");
11607
11696
  }
11608
11697
  get publicJwk() {
11609
11698
  if (!this._publicJwk) throw new DPoPInitError("CryptoController not initialized. Call init() first.");
@@ -11627,7 +11716,7 @@ var CryptoController = class {
11627
11716
  //#endregion
11628
11717
  //#region src/controllers/NetworkMonitor.ts
11629
11718
  var import_cjs$28 = require_cjs();
11630
- const logger$28 = getLogger();
11719
+ const logger$29 = getLogger();
11631
11720
  /**
11632
11721
  * Safely check whether we are running in a browser environment
11633
11722
  * with `window` and the relevant event targets.
@@ -11684,7 +11773,7 @@ var NetworkMonitor = class extends Destroyable {
11684
11773
  }
11685
11774
  attachListeners() {
11686
11775
  if (!hasBrowserNetworkEvents()) {
11687
- logger$28.debug("NetworkMonitor: no browser environment detected, skipping event listeners");
11776
+ logger$29.debug("NetworkMonitor: no browser environment detected, skipping event listeners");
11688
11777
  return;
11689
11778
  }
11690
11779
  window.addEventListener("online", this._onOnline);
@@ -11692,7 +11781,7 @@ var NetworkMonitor = class extends Destroyable {
11692
11781
  const connection = getNetworkConnection();
11693
11782
  if (connection) connection.addEventListener("change", this._onConnectionChange);
11694
11783
  this._listenersAttached = true;
11695
- logger$28.debug("NetworkMonitor: event listeners attached");
11784
+ logger$29.debug("NetworkMonitor: event listeners attached");
11696
11785
  }
11697
11786
  removeListeners() {
11698
11787
  if (!this._listenersAttached) return;
@@ -11703,10 +11792,10 @@ var NetworkMonitor = class extends Destroyable {
11703
11792
  if (connection) connection.removeEventListener("change", this._onConnectionChange);
11704
11793
  }
11705
11794
  this._listenersAttached = false;
11706
- logger$28.debug("NetworkMonitor: event listeners removed");
11795
+ logger$29.debug("NetworkMonitor: event listeners removed");
11707
11796
  }
11708
11797
  handleOnline() {
11709
- logger$28.info("NetworkMonitor: browser went online");
11798
+ logger$29.info("NetworkMonitor: browser went online");
11710
11799
  this._isOnline$.next(true);
11711
11800
  this._networkChange$.next({
11712
11801
  type: "online",
@@ -11715,7 +11804,7 @@ var NetworkMonitor = class extends Destroyable {
11715
11804
  });
11716
11805
  }
11717
11806
  handleOffline() {
11718
- logger$28.info("NetworkMonitor: browser went offline");
11807
+ logger$29.info("NetworkMonitor: browser went offline");
11719
11808
  this._isOnline$.next(false);
11720
11809
  this._networkChange$.next({
11721
11810
  type: "offline",
@@ -11724,7 +11813,7 @@ var NetworkMonitor = class extends Destroyable {
11724
11813
  }
11725
11814
  handleConnectionChange() {
11726
11815
  const networkType = getNetworkType();
11727
- logger$28.info(`NetworkMonitor: connection changed — effectiveType=${networkType ?? "unknown"}`);
11816
+ logger$29.info(`NetworkMonitor: connection changed — effectiveType=${networkType ?? "unknown"}`);
11728
11817
  this._networkChange$.next({
11729
11818
  type: "connection_change",
11730
11819
  timestamp: Date.now(),
@@ -11840,7 +11929,7 @@ function getNavigatorMediaDevices() {
11840
11929
  //#endregion
11841
11930
  //#region src/controllers/PreflightRunner.ts
11842
11931
  var import_cjs$27 = require_cjs();
11843
- const logger$27 = getLogger();
11932
+ const logger$28 = getLogger();
11844
11933
  const DEFAULT_MEDIA_TEST_DURATION_S = 10;
11845
11934
  const ICE_GATHERING_TIMEOUT_MS = 1e4;
11846
11935
  const SIGNALING_RTT_TIMEOUT_MS = 5e3;
@@ -11889,7 +11978,7 @@ var PreflightRunner = class extends Destroyable {
11889
11978
  if (!this._options.skipMediaTest) try {
11890
11979
  bandwidth = await this.testMediaBandwidth(destination);
11891
11980
  } catch (error) {
11892
- logger$27.warn("[PreflightRunner] Media bandwidth test failed:", error);
11981
+ logger$28.warn("[PreflightRunner] Media bandwidth test failed:", error);
11893
11982
  warnings.push("Media bandwidth test failed");
11894
11983
  }
11895
11984
  return {
@@ -11901,7 +11990,7 @@ var PreflightRunner = class extends Destroyable {
11901
11990
  warnings
11902
11991
  };
11903
11992
  } catch (error) {
11904
- logger$27.error("[PreflightRunner] Preflight test failed:", error);
11993
+ logger$28.error("[PreflightRunner] Preflight test failed:", error);
11905
11994
  throw new PreflightError("preflight", error instanceof Error ? error : new Error(String(error)));
11906
11995
  } finally {
11907
11996
  this.destroy();
@@ -11932,7 +12021,7 @@ var PreflightRunner = class extends Destroyable {
11932
12021
  if (track.kind === "video" && track.readyState === "live") videoWorking = true;
11933
12022
  }
11934
12023
  } catch (error) {
11935
- logger$27.warn("[PreflightRunner] Device test failed:", error);
12024
+ logger$28.warn("[PreflightRunner] Device test failed:", error);
11936
12025
  } finally {
11937
12026
  if (audioStream) audioStream.getTracks().forEach((t) => t.stop());
11938
12027
  }
@@ -11990,7 +12079,7 @@ var PreflightRunner = class extends Destroyable {
11990
12079
  rttMs
11991
12080
  };
11992
12081
  } catch (error) {
11993
- logger$27.warn("[PreflightRunner] ICE connectivity test failed:", error);
12082
+ logger$28.warn("[PreflightRunner] ICE connectivity test failed:", error);
11994
12083
  return {
11995
12084
  type: "failed",
11996
12085
  turnReachable: false,
@@ -12038,7 +12127,7 @@ var PreflightRunner = class extends Destroyable {
12038
12127
  //#endregion
12039
12128
  //#region src/controllers/VisibilityController.ts
12040
12129
  var import_cjs$26 = require_cjs();
12041
- const logger$26 = getLogger();
12130
+ const logger$27 = getLogger();
12042
12131
  /**
12043
12132
  * Checks whether the document visibility API is available.
12044
12133
  */
@@ -12075,8 +12164,8 @@ var VisibilityController = class extends Destroyable {
12075
12164
  this._boundHandler = this._handleVisibilityChange.bind(this);
12076
12165
  if (this._hasVisibilityApi) {
12077
12166
  document.addEventListener("visibilitychange", this._boundHandler);
12078
- logger$26.debug("VisibilityController: listening for visibilitychange events");
12079
- } else logger$26.debug("VisibilityController: document visibility API not available, defaulting to visible");
12167
+ logger$27.debug("VisibilityController: listening for visibilitychange events");
12168
+ } else logger$27.debug("VisibilityController: document visibility API not available, defaulting to visible");
12080
12169
  }
12081
12170
  /**
12082
12171
  * Observable of the current visibility state.
@@ -12101,7 +12190,7 @@ var VisibilityController = class extends Destroyable {
12101
12190
  destroy() {
12102
12191
  if (this._hasVisibilityApi) {
12103
12192
  document.removeEventListener("visibilitychange", this._boundHandler);
12104
- logger$26.debug("VisibilityController: removed visibilitychange listener");
12193
+ logger$27.debug("VisibilityController: removed visibilitychange listener");
12105
12194
  }
12106
12195
  super.destroy();
12107
12196
  }
@@ -12119,7 +12208,7 @@ var VisibilityController = class extends Destroyable {
12119
12208
  timestamp: Date.now()
12120
12209
  };
12121
12210
  this._visibilityChange$.next(changeEvent);
12122
- logger$26.debug("VisibilityController: visibility changed", {
12211
+ logger$27.debug("VisibilityController: visibility changed", {
12123
12212
  from: previousState,
12124
12213
  to: newState
12125
12214
  });
@@ -12358,15 +12447,57 @@ const RPCEventAckResponse = (id) => makeRPCResponse({
12358
12447
  result: {}
12359
12448
  });
12360
12449
 
12450
+ //#endregion
12451
+ //#region src/utils/authRecovery.ts
12452
+ /**
12453
+ * Walk an error's `error`/`cause` chain looking for a {@link JSONRPCError}.
12454
+ * Errors thrown by call creation are wrapped (e.g. `CallCreateError`), so the
12455
+ * underlying signaling error is nested. Bounded by a visited set to guard
12456
+ * against cyclic causes.
12457
+ */
12458
+ function findJSONRPCError(error) {
12459
+ const seen = /* @__PURE__ */ new Set();
12460
+ let current = error;
12461
+ while (current instanceof Error && !seen.has(current)) {
12462
+ seen.add(current);
12463
+ if (current instanceof JSONRPCError) return current;
12464
+ current = current.error ?? current.cause;
12465
+ }
12466
+ }
12467
+ /**
12468
+ * Whether an error is a session-recoverable authentication failure
12469
+ * (`-32002` authentication failed or `-32003` requester validation failed)
12470
+ * that a credential re-mint + retry can heal.
12471
+ */
12472
+ function isRecoverableAuthError(error) {
12473
+ const rpcError = findJSONRPCError(error);
12474
+ return rpcError !== void 0 && (rpcError.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || rpcError.code === RPC_ERROR_AUTHENTICATION_FAILED);
12475
+ }
12476
+ /**
12477
+ * Whether an error is specifically a requester-validation rejection
12478
+ * (`-32003`) — the server refusing the session's credential.
12479
+ *
12480
+ * Narrower than {@link isRecoverableAuthError} on purpose. `-32002` is
12481
+ * overloaded server-side: a rejected reattach arrives as `-32002` with
12482
+ * `cause: INVALID_MSG_UNSPECIFIED` and message `CALL ERROR`, which is a
12483
+ * call-level rejection and says nothing about the credential. Use this where
12484
+ * the decision must not be fooled by that, such as deciding whether retrying
12485
+ * an operation could possibly succeed.
12486
+ */
12487
+ function isRequesterValidationError(error) {
12488
+ return findJSONRPCError(error)?.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED;
12489
+ }
12490
+
12361
12491
  //#endregion
12362
12492
  //#region src/managers/AttachManager.ts
12363
- const logger$25 = getLogger();
12493
+ const logger$26 = getLogger();
12364
12494
  var AttachManager = class {
12365
- constructor(storage, deviceController, reconnectCallsTimeout, attachKey) {
12495
+ constructor(storage, deviceController, reconnectCallsTimeout, attachKey, credentialRecovered) {
12366
12496
  this.storage = storage;
12367
12497
  this.deviceController = deviceController;
12368
12498
  this.reconnectCallsTimeout = reconnectCallsTimeout;
12369
12499
  this.attachKey = attachKey;
12500
+ this.credentialRecovered = credentialRecovered;
12370
12501
  this.writeQueue = Promise.resolve();
12371
12502
  }
12372
12503
  async detachAll() {
@@ -12381,7 +12512,7 @@ var AttachManager = class {
12381
12512
  try {
12382
12513
  return await this.storage.getItem(this.attachKey) ?? {};
12383
12514
  } catch (error) {
12384
- logger$25.warn("[AttachManager] Failed to retrieve attached calls from storage", error);
12515
+ logger$26.warn("[AttachManager] Failed to retrieve attached calls from storage", error);
12385
12516
  return {};
12386
12517
  }
12387
12518
  }
@@ -12389,7 +12520,7 @@ var AttachManager = class {
12389
12520
  try {
12390
12521
  await this.storage.setItem(this.attachKey, attached);
12391
12522
  } catch (error) {
12392
- logger$25.warn("[AttachManager] Failed to write attached calls to storage", error);
12523
+ logger$26.warn("[AttachManager] Failed to write attached calls to storage", error);
12393
12524
  }
12394
12525
  }
12395
12526
  /**
@@ -12408,11 +12539,39 @@ var AttachManager = class {
12408
12539
  }
12409
12540
  async attach(call) {
12410
12541
  if (!call.to) {
12411
- logger$25.warn("[AttachManager] Skip attach for calls with no destination");
12542
+ logger$26.warn("[AttachManager] Skip attach for calls with no destination");
12412
12543
  return;
12413
12544
  }
12545
+ const attachment = this.buildAttachment(call, call.to);
12546
+ await this.mutate((attached) => ({
12547
+ ...attached,
12548
+ [call.id]: attachment
12549
+ }));
12550
+ }
12551
+ /**
12552
+ * Keep an already-stored call's reference alive and current — the periodic
12553
+ * refresh the `verto.ping` keepalive drives.
12554
+ *
12555
+ * Only ever updates: a call with no record is one nothing wants reattached,
12556
+ * and re-creating it here would undo a `detach`. That matters because a ping
12557
+ * can land in the window between `bye()` detaching and the call being torn
12558
+ * down, and a record revived there survives the hangup — so the next page
12559
+ * load dials a call nobody is on. The existence check and the write share
12560
+ * one {@link mutate} turn, so a concurrent detach cannot slip between them.
12561
+ */
12562
+ async refresh(call) {
12563
+ if (!call.to) return;
12414
12564
  const destination = call.to;
12415
- const attachment = {
12565
+ await this.mutate((attached) => {
12566
+ if (!Object.hasOwn(attached, call.id)) return attached;
12567
+ return {
12568
+ ...attached,
12569
+ [call.id]: this.buildAttachment(call, destination)
12570
+ };
12571
+ });
12572
+ }
12573
+ buildAttachment(call, destination) {
12574
+ return {
12416
12575
  nodeId: call.nodeId,
12417
12576
  destination,
12418
12577
  mediaDirections: call.mediaDirections,
@@ -12420,10 +12579,6 @@ var AttachManager = class {
12420
12579
  videoInputDevice: call.mediaDirections.video !== "inactive" ? this.deviceController.selectedVideoInputDevice : null,
12421
12580
  attachedAt: Date.now()
12422
12581
  };
12423
- await this.mutate((attached) => ({
12424
- ...attached,
12425
- [call.id]: attachment
12426
- }));
12427
12582
  }
12428
12583
  async detach(call) {
12429
12584
  await this.mutate((attached) => {
@@ -12446,8 +12601,14 @@ var AttachManager = class {
12446
12601
  * rejecting. Once that fix is deployed, this will work for both
12447
12602
  * page reloads and WebSocket reconnects.
12448
12603
  *
12449
- * Failed reattach attempts are handled gracefully — the stale call
12450
- * reference is cleaned up from storage.
12604
+ * A failed reattach does NOT generally cost the stored reference. It is
12605
+ * discarded only when the server denied the reattach on a session whose
12606
+ * credential it had already accepted — a verified reauthentication followed
12607
+ * by a refusal is the server saying the call is gone, and that is the one
12608
+ * refusal worth acting on. Until then the credential may be what is being
12609
+ * refused, and the record is the only way a later reload can try again;
12610
+ * keeping it costs nothing, since `detachExpired` reaps it once it is older
12611
+ * than `reconnectCallsTimeout`.
12451
12612
  */
12452
12613
  async reattachCalls() {
12453
12614
  const attached = await this.readAttached();
@@ -12456,25 +12617,31 @@ var AttachManager = class {
12456
12617
  const { destination } = attachment;
12457
12618
  const options = this.buildCallOptions(attachment);
12458
12619
  let succeeded = false;
12620
+ let refusedOnCredentials = false;
12459
12621
  for (let attempt = 1; attempt <= 3; attempt++) try {
12460
12622
  await this.session.createOutboundCall(destination, {
12461
12623
  callId,
12462
12624
  ...options
12463
12625
  });
12464
- logger$25.info(`[AttachManager] Reattached call ${callId} (attempt ${attempt})`);
12626
+ logger$26.info(`[AttachManager] Reattached call ${callId} (attempt ${attempt})`);
12465
12627
  succeeded = true;
12466
12628
  break;
12467
12629
  } catch (error) {
12468
- logger$25.warn(`[AttachManager] Reattach attempt ${attempt}/3 failed for call ${callId}:`, error);
12630
+ logger$26.warn(`[AttachManager] Reattach attempt ${attempt}/3 failed for call ${callId}:`, error);
12631
+ if (isRequesterValidationError(error)) {
12632
+ refusedOnCredentials = true;
12633
+ logger$26.warn(`[AttachManager] Reattach of ${callId} was refused on credentials; not retrying.`);
12634
+ break;
12635
+ }
12469
12636
  if (attempt < 3) await new Promise((r) => setTimeout(r, (attempt + 1) * 1e3));
12470
12637
  }
12471
- if (!succeeded) {
12472
- logger$25.warn(`[AttachManager] Reattach failed after 3 attempts for call ${callId}, removing reference`);
12638
+ if (!succeeded) if (this.credentialRecovered() && !refusedOnCredentials) {
12639
+ logger$26.warn(`[AttachManager] Reattach of ${callId} was denied after a verified reauthentication, removing reference`);
12473
12640
  await this.detach({
12474
12641
  id: callId,
12475
12642
  mediaDirections: attachment.mediaDirections
12476
12643
  });
12477
- }
12644
+ } else logger$26.warn(`[AttachManager] Reattach failed for call ${callId}; keeping the reference (credential refused or never proven good)`);
12478
12645
  }
12479
12646
  }
12480
12647
  /**
@@ -12575,12 +12742,12 @@ var require_race = /* @__PURE__ */ __commonJSMin(((exports) => {
12575
12742
  exports.race = void 0;
12576
12743
  var argsOrArgArray_1 = require_argsOrArgArray();
12577
12744
  var raceWith_1$1 = require_raceWith();
12578
- function race$4() {
12745
+ function race$3() {
12579
12746
  var args = [];
12580
12747
  for (var _i = 0; _i < arguments.length; _i++) args[_i] = arguments[_i];
12581
12748
  return raceWith_1$1.raceWith.apply(void 0, __spreadArray([], __read(argsOrArgArray_1.argsOrArgArray(args))));
12582
12749
  }
12583
- exports.race = race$4;
12750
+ exports.race = race$3;
12584
12751
  }));
12585
12752
 
12586
12753
  //#endregion
@@ -13640,7 +13807,7 @@ function toggleHandraiseMethod(is) {
13640
13807
 
13641
13808
  //#endregion
13642
13809
  //#region src/core/entities/Participant.ts
13643
- const logger$24 = getLogger();
13810
+ const logger$25 = getLogger();
13644
13811
  const initialState = {};
13645
13812
  /**
13646
13813
  * Represents a participant in a call.
@@ -13650,9 +13817,9 @@ const initialState = {};
13650
13817
  * the local participant with additional device control.
13651
13818
  */
13652
13819
  var Participant = class extends Destroyable {
13653
- constructor(id, executeMethod, deviceController) {
13820
+ constructor(id, callExecuteMethod, deviceController) {
13654
13821
  super();
13655
- this.executeMethod = executeMethod;
13822
+ this.callExecuteMethod = callExecuteMethod;
13656
13823
  this.deviceController = deviceController;
13657
13824
  this._state$ = this.createBehaviorSubject(initialState);
13658
13825
  this.id = id;
@@ -13874,22 +14041,55 @@ var Participant = class extends Destroyable {
13874
14041
  get value() {
13875
14042
  return this._state$.value;
13876
14043
  }
14044
+ /**
14045
+ * Target triple for member RPCs, built from the participant's own state.
14046
+ * The backend locates the member's session by the target `call_id`/`node_id`,
14047
+ * so this must always be the participant's own call context — never the
14048
+ * local call's id (issue #19400).
14049
+ *
14050
+ * Reading it doubles as a readiness probe: it throws until the first full
14051
+ * member event (`member.joined`/`member.updated` or the `call.joined`
14052
+ * roster) arrives, and never regresses afterwards.
14053
+ *
14054
+ * @throws {ParticipantNotReadyError} If the member state has not been
14055
+ * received yet (e.g. a participant first seen via `member.talking`) — an
14056
+ * empty call context can never address the member, so fail fast instead of
14057
+ * sending a doomed RPC.
14058
+ */
14059
+ get target() {
14060
+ const { call_id, node_id } = this._state$.value;
14061
+ if (!call_id || !node_id) throw new ParticipantNotReadyError(this.id);
14062
+ return {
14063
+ member_id: this.id,
14064
+ call_id,
14065
+ node_id
14066
+ };
14067
+ }
14068
+ /**
14069
+ * Executes a member RPC against this participant, injecting its own
14070
+ * {@link target} as the target.
14071
+ *
14072
+ * @throws {ParticipantNotReadyError} Via {@link target}, when the
14073
+ * member state has not been received yet.
14074
+ */
14075
+ async executeMethod(method, args) {
14076
+ return this.callExecuteMethod(this.target, method, args);
14077
+ }
13877
14078
  /** Toggles the deafened state (mutes/unmutes incoming audio). */
13878
14079
  async toggleDeaf() {
13879
- const method = toggleDeafMethod(this.deaf);
13880
- await this.executeMethod(this.id, method, {});
14080
+ await this.executeMethod(toggleDeafMethod(this.deaf), {});
13881
14081
  }
13882
14082
  /** Toggles the hand-raised state. */
13883
14083
  async toggleHandraise() {
13884
- await this.executeMethod(this.id, toggleHandraiseMethod(this.handraised), {});
14084
+ await this.executeMethod(toggleHandraiseMethod(this.handraised), {});
13885
14085
  }
13886
14086
  /** Mutes the participant's audio. */
13887
14087
  async mute() {
13888
- await this.executeMethod(this.id, "call.mute", { channels: ["audio"] });
14088
+ await this.executeMethod("call.mute", { channels: ["audio"] });
13889
14089
  }
13890
14090
  /** Unmutes the participant's audio. */
13891
14091
  async unmute() {
13892
- await this.executeMethod(this.id, "call.unmute", { channels: ["audio"] });
14092
+ await this.executeMethod("call.unmute", { channels: ["audio"] });
13893
14093
  }
13894
14094
  /** Toggles the participant's audio mute state. */
13895
14095
  async toggleMute() {
@@ -13897,11 +14097,11 @@ var Participant = class extends Destroyable {
13897
14097
  }
13898
14098
  /** Mutes the participant's video. */
13899
14099
  async muteVideo() {
13900
- await this.executeMethod(this.id, "call.mute", { channels: ["video"] });
14100
+ await this.executeMethod("call.mute", { channels: ["video"] });
13901
14101
  }
13902
14102
  /** Unmutes the participant's video. */
13903
14103
  async unmuteVideo() {
13904
- await this.executeMethod(this.id, "call.unmute", { channels: ["video"] });
14104
+ await this.executeMethod("call.unmute", { channels: ["video"] });
13905
14105
  }
13906
14106
  /** Toggles the participant's video mute state. */
13907
14107
  async toggleMuteVideo() {
@@ -13909,7 +14109,7 @@ var Participant = class extends Destroyable {
13909
14109
  }
13910
14110
  /** Toggles echo cancellation on the audio input. */
13911
14111
  async toggleEchoCancellation() {
13912
- await this.executeMethod(this.id, "call.audioflags.set", {
14112
+ await this.executeMethod("call.audioflags.set", {
13913
14113
  echo_cancellation: !this.echoCancellation,
13914
14114
  auto_gain: this.autoGain,
13915
14115
  noise_suppression: this.noiseSuppression
@@ -13917,7 +14117,7 @@ var Participant = class extends Destroyable {
13917
14117
  }
13918
14118
  /** Toggles automatic gain control on the audio input. */
13919
14119
  async toggleAudioInputAutoGain() {
13920
- await this.executeMethod(this.id, "call.audioflags.set", {
14120
+ await this.executeMethod("call.audioflags.set", {
13921
14121
  echo_cancellation: this.echoCancellation,
13922
14122
  auto_gain: !this.autoGain,
13923
14123
  noise_suppression: this.noiseSuppression
@@ -13925,7 +14125,7 @@ var Participant = class extends Destroyable {
13925
14125
  }
13926
14126
  /** Toggles noise suppression on the audio input. */
13927
14127
  async toggleNoiseSuppression() {
13928
- await this.executeMethod(this.id, "call.audioflags.set", {
14128
+ await this.executeMethod("call.audioflags.set", {
13929
14129
  echo_cancellation: this.echoCancellation,
13930
14130
  auto_gain: this.autoGain,
13931
14131
  noise_suppression: !this.noiseSuppression
@@ -13933,7 +14133,7 @@ var Participant = class extends Destroyable {
13933
14133
  }
13934
14134
  /** Toggles low-bitrate mode for this participant's media. */
13935
14135
  async toggleLowbitrate() {
13936
- await this.executeMethod(this.id, "call.lowbitrate.set", { lowbitrate: !this.lowbitrate });
14136
+ await this.executeMethod("call.lowbitrate.set", { lowbitrate: !this.lowbitrate });
13937
14137
  }
13938
14138
  /**
13939
14139
  * Adjusts the **conference-only** microphone energy gate / sensitivity level
@@ -13949,7 +14149,7 @@ var Participant = class extends Destroyable {
13949
14149
  * (integer, larger values are more sensitive).
13950
14150
  */
13951
14151
  async setAudioInputSensitivity(value) {
13952
- await this.executeMethod(this.id, "call.microphone.sensitivity.set", { sensitivity: value });
14152
+ await this.executeMethod("call.microphone.sensitivity.set", { sensitivity: value });
13953
14153
  }
13954
14154
  /**
13955
14155
  * Sets the **server-side** microphone volume on this participant's bridged
@@ -13962,7 +14162,7 @@ var Participant = class extends Destroyable {
13962
14162
  * @param value - Volume level (0-100).
13963
14163
  */
13964
14164
  async setAudioInputVolume(value) {
13965
- await this.executeMethod(this.id, "call.microphone.volume.set", { volume: value });
14165
+ await this.executeMethod("call.microphone.volume.set", { volume: value });
13966
14166
  }
13967
14167
  /**
13968
14168
  * Sets the **server-side** speaker volume on this participant's bridged call
@@ -13976,45 +14176,31 @@ var Participant = class extends Destroyable {
13976
14176
  * @param value - Volume level (0-100).
13977
14177
  */
13978
14178
  async setAudioOutputVolume(value) {
13979
- await this.executeMethod(this.id, "call.speaker.volume.set", { volume: value });
14179
+ await this.executeMethod("call.speaker.volume.set", { volume: value });
13980
14180
  }
13981
14181
  /**
13982
14182
  * Sets the participant's position in the video layout.
13983
14183
  *
13984
- * Requires the `member.position` capability. The gateway keys positions by the
13985
- * **target member's own** `call_id`/`node_id` (see issue #19400 and the legacy
13986
- * `setPositions` implementation), so this sends the participant's own call
13987
- * context — matching {@link Participant.remove}. A resolved promise does not
13988
- * guarantee a visible change: the backend silently returns `200` (no-op) for
13989
- * non-conference targets.
14184
+ * Requires the `member.position` capability. The gateway requires a
14185
+ * `targets` array of `{ target, position }` entries (issue #19400). A
14186
+ * resolved promise does not guarantee a visible change: the backend silently
14187
+ * returns `200` (no-op) for non-conference targets.
13990
14188
  *
13991
14189
  * @param value - The {@link VideoPosition} to assign (e.g. `'auto'`, `'reserved-0'`).
13992
14190
  */
13993
14191
  async setPosition(value) {
13994
- const state = this._state$.value;
13995
- const target = {
13996
- member_id: this.id,
13997
- call_id: state.call_id ?? "",
13998
- node_id: state.node_id ?? ""
13999
- };
14000
- await this.executeMethod(target, "call.member.position.set", { targets: [{
14001
- target,
14192
+ await this.executeMethod("call.member.position.set", { targets: [{
14193
+ target: this.target,
14002
14194
  position: value
14003
14195
  }] });
14004
14196
  }
14005
14197
  /** Removes this participant from the call. */
14006
14198
  async remove() {
14007
- const state = this._state$.value;
14008
- const target = {
14009
- member_id: this.id,
14010
- call_id: state.call_id ?? "",
14011
- node_id: state.node_id ?? ""
14012
- };
14013
- await this.executeMethod(target, "call.member.remove", {});
14199
+ await this.executeMethod("call.member.remove", { targets: [this.target] });
14014
14200
  }
14015
14201
  /** Ends the call for this participant. */
14016
14202
  async end() {
14017
- await this.executeMethod(this.id, "call.end", {});
14203
+ await this.executeMethod("call.end", {});
14018
14204
  }
14019
14205
  /**
14020
14206
  * Replaces custom metadata for this participant.
@@ -14034,7 +14220,7 @@ var Participant = class extends Destroyable {
14034
14220
  }
14035
14221
  /** Destroys the participant, releasing all subscriptions and references. */
14036
14222
  destroy() {
14037
- this.executeMethod = void 0;
14223
+ this.callExecuteMethod = void 0;
14038
14224
  super.destroy();
14039
14225
  }
14040
14226
  };
@@ -14046,8 +14232,8 @@ var Participant = class extends Destroyable {
14046
14232
  */
14047
14233
  var SelfParticipant = class extends Participant {
14048
14234
  /** @internal */
14049
- constructor(id, executeMethod, vertoManager, deviceController) {
14050
- super(id, executeMethod, deviceController);
14235
+ constructor(id, callExecuteMethod, vertoManager, deviceController) {
14236
+ super(id, callExecuteMethod, deviceController);
14051
14237
  this.vertoManager = vertoManager;
14052
14238
  this._studioAudio$ = this.createBehaviorSubject(false);
14053
14239
  this.capabilities = new SelfCapabilities();
@@ -14071,7 +14257,7 @@ var SelfParticipant = class extends Participant {
14071
14257
  async enableStudioAudio() {
14072
14258
  if (this._studioAudio$.value) return;
14073
14259
  this._studioAudio$.next(true);
14074
- await this.executeMethod(this.id, "call.audioflags.set", {
14260
+ await this.executeMethod("call.audioflags.set", {
14075
14261
  echo_cancellation: false,
14076
14262
  auto_gain: false,
14077
14263
  noise_suppression: false
@@ -14084,7 +14270,7 @@ var SelfParticipant = class extends Participant {
14084
14270
  async disableStudioAudio() {
14085
14271
  if (!this._studioAudio$.value) return;
14086
14272
  this._studioAudio$.next(false);
14087
- await this.executeMethod(this.id, "call.audioflags.set", {
14273
+ await this.executeMethod("call.audioflags.set", {
14088
14274
  echo_cancellation: true,
14089
14275
  auto_gain: true,
14090
14276
  noise_suppression: true
@@ -14093,17 +14279,27 @@ var SelfParticipant = class extends Participant {
14093
14279
  /**
14094
14280
  * Starts sharing the local screen.
14095
14281
  *
14282
+ * A call carries at most one screen share. Read `screenShareStatus` before
14283
+ * calling and treat `'starting'`/`'stopping'` as busy.
14284
+ *
14096
14285
  * The call is unaffected when acquisition fails.
14097
14286
  *
14287
+ * @param options - Pass `{ audio: true }` to also request the shared
14288
+ * surface's audio. Defaults to video only.
14289
+ * @throws {ScreenShareAlreadyActiveError} When this call is already
14290
+ * sharing a screen. Call {@link stopScreenShare} before starting another.
14291
+ * @throws {AuxiliaryLegCancelledError} When {@link stopScreenShare} removes
14292
+ * the share before its leg finishes connecting.
14098
14293
  * @throws The raw `getDisplayMedia` error. A dismissed picker or a
14099
14294
  * permission denial rejects with a `NotAllowedError` `DOMException` —
14100
14295
  * inspect `error.name` to tell benign cancels apart from real failures.
14101
14296
  */
14102
- async startScreenShare() {
14297
+ async startScreenShare(options) {
14103
14298
  try {
14104
- await this.vertoManager.addScreenMedia();
14299
+ await this.vertoManager.addScreenMedia(options);
14105
14300
  } catch (error) {
14106
- logger$24.error("[Participant.startScreenShare] Screen share error:", error);
14301
+ if (error instanceof AuxiliaryLegCancelledError) logger$25.debug("[Participant.startScreenShare] Screen share cancelled before connecting.");
14302
+ else logger$25.error("[Participant.startScreenShare] Screen share error:", error);
14107
14303
  throw error;
14108
14304
  }
14109
14305
  }
@@ -14124,14 +14320,18 @@ var SelfParticipant = class extends Participant {
14124
14320
  *
14125
14321
  * The call is unaffected when acquisition fails.
14126
14322
  *
14323
+ * @throws {AuxiliaryLegCancelledError} When {@link removeAdditionalDevice}
14324
+ * removes the device before its leg finishes connecting.
14127
14325
  * @throws The raw `getUserMedia` error (e.g. `NotAllowedError` on
14128
- * permission denial) — inspect `error.name` to decide how to react.
14326
+ * permission denial) — inspect `error.name` to decide how to react — or
14327
+ * `AuxiliaryLegTimeoutError` if the leg does not connect in time.
14129
14328
  */
14130
14329
  async addAdditionalDevice(options) {
14131
14330
  try {
14132
14331
  await this.vertoManager.addInputDevice(options);
14133
14332
  } catch (error) {
14134
- logger$24.error("[Participant.addAdditionalDevice] Additional device error:", error);
14333
+ if (error instanceof AuxiliaryLegCancelledError) logger$25.debug("[Participant.addAdditionalDevice] Device removed before connecting.");
14334
+ else logger$25.error("[Participant.addAdditionalDevice] Additional device error:", error);
14135
14335
  throw error;
14136
14336
  }
14137
14337
  }
@@ -14166,22 +14366,31 @@ var SelfParticipant = class extends Participant {
14166
14366
  this.deviceController.selectAudioInputDevice(device);
14167
14367
  if (options.savePreference) PreferencesContainer.instance.preferredAudioInput = device;
14168
14368
  }
14169
- /** Updates the audio input track constraints for the active call. */
14369
+ /**
14370
+ * Updates the audio input track constraints for the active call.
14371
+ * @returns whether the constraints reached the media the call is sending.
14372
+ */
14170
14373
  async setAudioInputDeviceConstraints(constraints) {
14171
- await this.vertoManager.updateMediaConstraints({ audio: constraints });
14374
+ return this.vertoManager.updateMediaConstraints({ audio: constraints });
14172
14375
  }
14173
- /** Updates both audio and video input track constraints for the active call. */
14376
+ /**
14377
+ * Updates both audio and video input track constraints for the active call.
14378
+ * @returns whether both kinds took the constraints.
14379
+ */
14174
14380
  async setInputDevicesConstraints(constraints) {
14175
- await this.vertoManager.updateMediaConstraints(constraints);
14381
+ return this.vertoManager.updateMediaConstraints(constraints);
14176
14382
  }
14177
14383
  /** Selects the video input device for future calls. Optionally saves as a preference. */
14178
14384
  selectVideoInputDevice(device, options = {}) {
14179
14385
  this.deviceController.selectVideoInputDevice(device);
14180
14386
  if (options.savePreference) PreferencesContainer.instance.preferredVideoInput = device;
14181
14387
  }
14182
- /** Updates the video input track constraints for the active call. */
14388
+ /**
14389
+ * Updates the video input track constraints for the active call.
14390
+ * @returns whether the constraints reached the media the call is sending.
14391
+ */
14183
14392
  async setVideoInputDeviceConstraints(constraints) {
14184
- await this.vertoManager.updateMediaConstraints({ video: constraints });
14393
+ return this.vertoManager.updateMediaConstraints({ video: constraints });
14185
14394
  }
14186
14395
  /** Selects the audio output device. Optionally saves as a preference. */
14187
14396
  selectAudioOutputDevice(device, options = {}) {
@@ -14194,7 +14403,7 @@ var SelfParticipant = class extends Participant {
14194
14403
  */
14195
14404
  exitStudioModeIfActive() {
14196
14405
  if (this._studioAudio$.value) {
14197
- logger$24.debug("[SelfParticipant] Exiting studio audio mode due to individual flag toggle");
14406
+ logger$25.debug("[SelfParticipant] Exiting studio audio mode due to individual flag toggle");
14198
14407
  this._studioAudio$.next(false);
14199
14408
  }
14200
14409
  }
@@ -14218,7 +14427,7 @@ var SelfParticipant = class extends Participant {
14218
14427
  try {
14219
14428
  await super.mute();
14220
14429
  } catch (error) {
14221
- logger$24.warn("[Participant.toggleAudioInput] Server Error while muting audio input, proceeding with local toggle anyway", error);
14430
+ logger$25.warn("[Participant.toggleAudioInput] Server Error while muting audio input, proceeding with local toggle anyway", error);
14222
14431
  } finally {
14223
14432
  this.vertoManager.muteMainAudioInputDevice();
14224
14433
  }
@@ -14228,7 +14437,7 @@ var SelfParticipant = class extends Participant {
14228
14437
  try {
14229
14438
  await super.unmute();
14230
14439
  } catch (error) {
14231
- logger$24.warn("[Participant.toggleAudioInput] Server Error while unmuting audio input, proceeding with local toggle anyway", error);
14440
+ logger$25.warn("[Participant.toggleAudioInput] Server Error while unmuting audio input, proceeding with local toggle anyway", error);
14232
14441
  } finally {
14233
14442
  await this.vertoManager.unmuteMainAudioInputDevice();
14234
14443
  }
@@ -14238,7 +14447,7 @@ var SelfParticipant = class extends Participant {
14238
14447
  try {
14239
14448
  await super.muteVideo();
14240
14449
  } catch (error) {
14241
- logger$24.warn("[Participant.toggleVideoInput] Server Error while muting video input, proceeding with local toggle anyway", error);
14450
+ logger$25.warn("[Participant.toggleVideoInput] Server Error while muting video input, proceeding with local toggle anyway", error);
14242
14451
  } finally {
14243
14452
  this.vertoManager.muteMainVideoInputDevice();
14244
14453
  }
@@ -14248,7 +14457,7 @@ var SelfParticipant = class extends Participant {
14248
14457
  try {
14249
14458
  await super.unmuteVideo();
14250
14459
  } catch (error) {
14251
- logger$24.warn("[Participant.toggleVideoInput] Server Error while unmuting video input, proceeding with local toggle anyway", error);
14460
+ logger$25.warn("[Participant.toggleVideoInput] Server Error while unmuting video input, proceeding with local toggle anyway", error);
14252
14461
  } finally {
14253
14462
  await this.vertoManager.unmuteMainVideoInputDevice();
14254
14463
  }
@@ -14453,7 +14662,7 @@ function filterAs(predicate, resultPath) {
14453
14662
  //#endregion
14454
14663
  //#region src/operators/throwOnRPCError.ts
14455
14664
  var import_cjs$21 = require_cjs();
14456
- const logger$23 = getLogger();
14665
+ const logger$24 = getLogger();
14457
14666
  /**
14458
14667
  * RxJS operator that throws a {@link JSONRPCError} when the RPC response contains an error.
14459
14668
  * Passes successful responses through unchanged.
@@ -14461,14 +14670,14 @@ const logger$23 = getLogger();
14461
14670
  function throwOnRPCError() {
14462
14671
  return (0, import_cjs$21.map)((response) => {
14463
14672
  if (response.error) {
14464
- logger$23.error("[throwOnRPCError] RPC error response:", {
14673
+ logger$24.error("[throwOnRPCError] RPC error response:", {
14465
14674
  code: response.error.code,
14466
14675
  message: response.error.message,
14467
14676
  data: response.error.data
14468
14677
  });
14469
14678
  throw new JSONRPCError(response.error.code, response.error.message, response.error.data);
14470
14679
  }
14471
- logger$23.debug("[throwOnRPCError] RPC successful response:", response);
14680
+ logger$24.debug("[throwOnRPCError] RPC successful response:", response);
14472
14681
  return response;
14473
14682
  });
14474
14683
  }
@@ -14476,7 +14685,7 @@ function throwOnRPCError() {
14476
14685
  //#endregion
14477
14686
  //#region src/managers/CallEventsManager.ts
14478
14687
  var import_cjs$20 = require_cjs();
14479
- const logger$22 = getLogger();
14688
+ const logger$23 = getLogger();
14480
14689
  const initialSessionState = {};
14481
14690
  /** @internal */
14482
14691
  var CallEventsManager = class extends Destroyable {
@@ -14580,7 +14789,7 @@ var CallEventsManager = class extends Destroyable {
14580
14789
  }
14581
14790
  initSubscriptions() {
14582
14791
  this.subscribeTo(this.callJoinedEvent$, (callJoinedEvent) => {
14583
- logger$22.debug("[CallEventsManager] Handling call.joined event for call/session IDs:", {
14792
+ logger$23.debug("[CallEventsManager] Handling call.joined event for call/session IDs:", {
14584
14793
  callId: callJoinedEvent.call_id,
14585
14794
  roomSessionId: callJoinedEvent.room_session_id
14586
14795
  });
@@ -14607,19 +14816,19 @@ var CallEventsManager = class extends Destroyable {
14607
14816
  if (this._self$.value?.capabilities.setLayout) this.updateLayouts();
14608
14817
  });
14609
14818
  this.subscribeTo(this.memberUpdates$, (member) => {
14610
- logger$22.debug("[CallEventsManager] Handling member update event for member ID:", member);
14819
+ logger$23.debug("[CallEventsManager] Handling member update event for member ID:", member);
14611
14820
  this.upsertParticipant(member);
14612
14821
  });
14613
14822
  this.subscribeTo(this.webRtcCallSession.memberLeft$, (memberLeftEvent) => {
14614
- logger$22.debug("[CallEventsManager] Handling member.left event for member ID:", memberLeftEvent.member.member_id);
14823
+ logger$23.debug("[CallEventsManager] Handling member.left event for member ID:", memberLeftEvent.member.member_id);
14615
14824
  const participants = { ...this._participants$.value };
14616
14825
  if (memberLeftEvent.member.member_id in participants) {
14617
14826
  delete participants[memberLeftEvent.member.member_id];
14618
14827
  this._participants$.next(participants);
14619
- } else logger$22.warn(`[CallEventsManager] Received member.left event for unknown member ID: ${memberLeftEvent.member.member_id}`);
14828
+ } else logger$23.warn(`[CallEventsManager] Received member.left event for unknown member ID: ${memberLeftEvent.member.member_id}`);
14620
14829
  });
14621
14830
  this.subscribeTo(this.webRtcCallSession.callUpdated$, (callUpdatedEvent) => {
14622
- logger$22.debug("[CallEventsManager] Handling call.updated event:", callUpdatedEvent);
14831
+ logger$23.debug("[CallEventsManager] Handling call.updated event:", callUpdatedEvent);
14623
14832
  const roomSession = callUpdatedEvent.room_session;
14624
14833
  this._sessionState$.next({
14625
14834
  ...this._sessionState$.value,
@@ -14634,7 +14843,7 @@ var CallEventsManager = class extends Destroyable {
14634
14843
  });
14635
14844
  });
14636
14845
  this.subscribeTo(this.layoutChangedEvent$, (layoutChangedEvent) => {
14637
- logger$22.debug("[CallEventsManager] Handling layout.changed event:", layoutChangedEvent);
14846
+ logger$23.debug("[CallEventsManager] Handling layout.changed event:", layoutChangedEvent);
14638
14847
  this._sessionState$.next({
14639
14848
  ...this._sessionState$.value,
14640
14849
  layout_name: layoutChangedEvent.id,
@@ -14644,10 +14853,10 @@ var CallEventsManager = class extends Destroyable {
14644
14853
  });
14645
14854
  }
14646
14855
  updateParticipantPositions(layoutChangedEvent) {
14647
- 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.");
14856
+ 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.");
14648
14857
  layoutChangedEvent.layers.filter((layer) => !!layer.member_id).filter((layer) => {
14649
14858
  if (!(layer.member_id in this._participants$.value)) {
14650
- logger$22.warn(`[CallEventsManager] Skipping layout layer for unknown member_id: ${layer.member_id}`);
14859
+ logger$23.warn(`[CallEventsManager] Skipping layout layer for unknown member_id: ${layer.member_id}`);
14651
14860
  return false;
14652
14861
  }
14653
14862
  return true;
@@ -14665,12 +14874,17 @@ var CallEventsManager = class extends Destroyable {
14665
14874
  updateLayouts() {
14666
14875
  if (!this.selfId) return;
14667
14876
  this.webRtcCallSession.executeMethod(this.selfId, "call.layout.list", {}).then((response) => {
14877
+ const layouts = response.result?.layouts;
14878
+ if (!layouts) {
14879
+ logger$23.warn("[CallEventsManager] Layout list response carried no layouts; keeping current layouts");
14880
+ return;
14881
+ }
14668
14882
  this._sessionState$.next({
14669
14883
  ...this._sessionState$.value,
14670
- layouts: response.result.layouts
14884
+ layouts
14671
14885
  });
14672
14886
  }).catch((error) => {
14673
- logger$22.error("[CallEventsManager] Error fetching layouts:", error);
14887
+ logger$23.error("[CallEventsManager] Error fetching layouts:", error);
14674
14888
  });
14675
14889
  }
14676
14890
  updateParticipants(members) {
@@ -14686,7 +14900,7 @@ var CallEventsManager = class extends Destroyable {
14686
14900
  }
14687
14901
  const participant = this._participants$.value[member.member_id];
14688
14902
  const oldValue = participant.value;
14689
- logger$22.debug("[CallEventsManager] Updating participant:", member.member_id, {
14903
+ logger$23.debug("[CallEventsManager] Updating participant:", member.member_id, {
14690
14904
  oldValue,
14691
14905
  newValue: member
14692
14906
  });
@@ -14699,17 +14913,17 @@ var CallEventsManager = class extends Destroyable {
14699
14913
  }
14700
14914
  get callJoinedEvent$() {
14701
14915
  return this.cachedObservable("callJoinedEvent$", () => this.webRtcCallSession.callEvent$.pipe((0, import_cjs$20.filter)(isCallJoinedPayload), (0, import_cjs$20.tap)((event) => {
14702
- logger$22.debug("[CallEventsManager] Call joined event:", event);
14916
+ logger$23.debug("[CallEventsManager] Call joined event:", event);
14703
14917
  })));
14704
14918
  }
14705
14919
  get layoutChangedEvent$() {
14706
14920
  return this.cachedObservable("layoutChangedEvent$", () => this.webRtcCallSession.callEvent$.pipe(filterAs(isLayoutChangedPayload, "layout"), (0, import_cjs$20.tap)((event) => {
14707
- logger$22.debug("[CallEventsManager] Layout changed event:", event);
14921
+ logger$23.debug("[CallEventsManager] Layout changed event:", event);
14708
14922
  })));
14709
14923
  }
14710
14924
  get memberUpdates$() {
14711
14925
  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) => {
14712
- logger$22.debug("[CallEventsManager] Member update event:", event);
14926
+ logger$23.debug("[CallEventsManager] Member update event:", event);
14713
14927
  })));
14714
14928
  }
14715
14929
  destroy() {
@@ -14728,6 +14942,111 @@ var CallEventsManager = class extends Destroyable {
14728
14942
  }
14729
14943
  };
14730
14944
 
14945
+ //#endregion
14946
+ //#region src/controllers/ConstraintFallbackHelper.ts
14947
+ /**
14948
+ * ConstraintFallbackHelper - Provides getUserMedia with automatic constraint
14949
+ * fallback on OverconstrainedError.
14950
+ *
14951
+ * When a specific device ID is requested, the helper tries progressively
14952
+ * looser constraints:
14953
+ * 1. `{ deviceId: { exact: deviceId } }` -- exact match
14954
+ * 2. `{ deviceId: deviceId }` -- preferred (browser may pick another)
14955
+ * 3. `{}` -- no constraint, browser default
14956
+ *
14957
+ * This prevents stale device IDs from blocking call setup.
14958
+ *
14959
+ * @see Section 5.8 and Section 11 of the Implementation Guide
14960
+ */
14961
+ const logger$22 = getLogger();
14962
+ /**
14963
+ * Attempts getUserMedia with progressively looser constraints.
14964
+ *
14965
+ * The function tries three levels of constraint specificity for the given
14966
+ * device kind. Each level is only attempted if the previous one fails with
14967
+ * an OverconstrainedError. Non-OverconstrainedError failures (e.g.,
14968
+ * NotAllowedError) are thrown immediately without fallback.
14969
+ *
14970
+ * @param mediaDevices - Anything exposing `getUserMedia` (a full
14971
+ * `WebRTCMediaDevices`, or a shim wrapping one)
14972
+ * @param constraints - The full MediaStreamConstraints to use as a base
14973
+ * @param kind - Which track kind to apply fallback to ('audio' | 'video')
14974
+ * @param deviceId - The device ID to try (if undefined, calls getUserMedia as-is)
14975
+ * @returns The stream and the fallback level that succeeded
14976
+ * @throws When all fallback levels fail, or when a non-OverconstrainedError occurs
14977
+ */
14978
+ async function getUserMediaWithFallback(mediaDevices, constraints, kind, deviceId) {
14979
+ if (!deviceId) return {
14980
+ stream: await mediaDevices.getUserMedia(constraints),
14981
+ fallbackLevel: "default"
14982
+ };
14983
+ const baseConstraints = typeof constraints[kind] === "object" ? constraints[kind] : {};
14984
+ try {
14985
+ const exactConstraints = {
14986
+ ...constraints,
14987
+ [kind]: {
14988
+ ...baseConstraints,
14989
+ deviceId: { exact: deviceId }
14990
+ }
14991
+ };
14992
+ return {
14993
+ stream: await mediaDevices.getUserMedia(exactConstraints),
14994
+ fallbackLevel: "exact"
14995
+ };
14996
+ } catch (error) {
14997
+ if (!isOverconstrainedError(error)) throw error;
14998
+ logger$22.debug(`[ConstraintFallbackHelper] Exact constraint failed for ${kind}, trying preferred`, { deviceId });
14999
+ }
15000
+ try {
15001
+ const preferredConstraints = {
15002
+ ...constraints,
15003
+ [kind]: {
15004
+ ...baseConstraints,
15005
+ deviceId
15006
+ }
15007
+ };
15008
+ return {
15009
+ stream: await mediaDevices.getUserMedia(preferredConstraints),
15010
+ fallbackLevel: "preferred"
15011
+ };
15012
+ } catch (error) {
15013
+ if (!isOverconstrainedError(error)) throw error;
15014
+ logger$22.debug(`[ConstraintFallbackHelper] Preferred constraint failed for ${kind}, trying default`, { deviceId });
15015
+ }
15016
+ try {
15017
+ const defaultConstraints = {
15018
+ ...constraints,
15019
+ [kind]: { ...baseConstraints }
15020
+ };
15021
+ if (typeof defaultConstraints[kind] === "object") {
15022
+ const { deviceId: _removed, ...rest } = defaultConstraints[kind];
15023
+ defaultConstraints[kind] = rest;
15024
+ }
15025
+ const stream = await mediaDevices.getUserMedia(defaultConstraints);
15026
+ logger$22.warn(`[ConstraintFallbackHelper] Fell back to browser default for ${kind}`, { requestedDeviceId: deviceId });
15027
+ return {
15028
+ stream,
15029
+ fallbackLevel: "default"
15030
+ };
15031
+ } catch (error) {
15032
+ logger$22.error(`[ConstraintFallbackHelper] All fallback levels exhausted for ${kind}`, {
15033
+ deviceId,
15034
+ error
15035
+ });
15036
+ throw error;
15037
+ }
15038
+ }
15039
+ /**
15040
+ * Checks whether an error is an OverconstrainedError.
15041
+ *
15042
+ * Browsers may throw either a native OverconstrainedError or a DOMException
15043
+ * with a specific name.
15044
+ */
15045
+ function isOverconstrainedError(error) {
15046
+ if (error instanceof Error) return error.name === "OverconstrainedError" || error.name === "ConstraintNotSatisfiedError";
15047
+ return false;
15048
+ }
15049
+
14731
15050
  //#endregion
14732
15051
  //#region src/helpers/SDPHelper.ts
14733
15052
  /**
@@ -15286,6 +15605,7 @@ var LocalStreamController = class extends Destroyable {
15286
15605
  this._localAudioTracks$ = this.createBehaviorSubject([]);
15287
15606
  this._localVideoTracks$ = this.createBehaviorSubject([]);
15288
15607
  this._mediaTrackEnded$ = this.createSubject();
15608
+ this._trackOrigins = /* @__PURE__ */ new WeakMap();
15289
15609
  }
15290
15610
  get localStream$() {
15291
15611
  return this._localStream$.asObservable().pipe((0, import_cjs$17.takeUntil)(this.destroyed$));
@@ -15308,6 +15628,22 @@ var LocalStreamController = class extends Destroyable {
15308
15628
  get localVideoTracks() {
15309
15629
  return this._localVideoTracks$.value;
15310
15630
  }
15631
+ tagTracks(tracks, origin) {
15632
+ for (const track of tracks) this._trackOrigins.set(track, origin);
15633
+ }
15634
+ setTrackOrigin(track, origin) {
15635
+ this._trackOrigins.set(track, origin);
15636
+ }
15637
+ getTrackOrigin(track) {
15638
+ return this._trackOrigins.get(track);
15639
+ }
15640
+ /**
15641
+ * Fail-safe: an unrecorded track reads as not-a-device-capture, so a missed
15642
+ * tagging site leaves media alone rather than destroying it.
15643
+ */
15644
+ isDeviceCapture(track) {
15645
+ return this._trackOrigins.get(track) === "device";
15646
+ }
15311
15647
  /**
15312
15648
  * Build the local media stream based on the provided options.
15313
15649
  */
@@ -15317,13 +15653,16 @@ var LocalStreamController = class extends Destroyable {
15317
15653
  if (this.options.inputAudioStream ?? this.options.inputVideoStream) {
15318
15654
  const tracks = [...this.options.inputAudioStream?.getTracks() ?? [], ...this.options.inputVideoStream?.getTracks() ?? []];
15319
15655
  stream = new MediaStream(tracks);
15656
+ this.tagTracks(tracks, "application");
15320
15657
  } else if (this.options.propose === "screenshare") {
15321
- logger$19.debug("[LocalStreamController] Requesting display media for screen sharing with audio:", Boolean(this.options.inputAudioDeviceConstraints));
15658
+ const audio = this.options.screenShareAudio ?? false;
15659
+ logger$19.debug("[LocalStreamController] Requesting display media for screen sharing with audio:", audio);
15322
15660
  stream = await this.options.getDisplayMedia({
15323
15661
  video: true,
15324
- audio: Boolean(this.options.inputAudioDeviceConstraints)
15662
+ audio
15325
15663
  });
15326
15664
  logger$19.debug("[LocalStreamController] Screen share media obtained:", stream);
15665
+ this.tagTracks(stream.getTracks(), "display");
15327
15666
  } else {
15328
15667
  const constraints = {
15329
15668
  audio: this.options.inputAudioDeviceConstraints,
@@ -15332,6 +15671,7 @@ var LocalStreamController = class extends Destroyable {
15332
15671
  logger$19.debug("[LocalStreamController] Requesting user media with constraints:", constraints);
15333
15672
  stream = await this.options.getUserMedia(constraints);
15334
15673
  logger$19.debug("[LocalStreamController] User media obtained:", stream);
15674
+ this.tagTracks(stream.getTracks(), "device");
15335
15675
  }
15336
15676
  this._localStream$.next(stream);
15337
15677
  this._localAudioTracks$.next(stream.getAudioTracks());
@@ -15341,10 +15681,13 @@ var LocalStreamController = class extends Destroyable {
15341
15681
  /**
15342
15682
  * Add a local media track to the local stream.
15343
15683
  * @param track - The MediaStreamTrack to add
15684
+ * @param origin - Defaults to `'device'`; every internal caller passes a
15685
+ * fresh `getUserMedia` capture.
15344
15686
  * @returns The MediaStream (either existing or newly created)
15345
15687
  */
15346
- addTrack(track) {
15688
+ addTrack(track, origin = "device") {
15347
15689
  const localStream = this._localStream$.value ?? new MediaStream();
15690
+ this._trackOrigins.set(track, origin);
15348
15691
  track.addEventListener("ended", this.mediaTrackEndedHandler);
15349
15692
  localStream.addTrack(track);
15350
15693
  this._localStream$.next(localStream);
@@ -15614,15 +15957,27 @@ var TransceiverController = class extends Destroyable {
15614
15957
  for (let i = 0; i < Number(msStreamsNumber); i++) this.peerConnection.addTransceiver("video", { direction: "recvonly" });
15615
15958
  }
15616
15959
  }
15960
+ /**
15961
+ * @returns whether every live sender of the kind took the constraints. A
15962
+ * skipped non-device sender, an exhausted fallback, and having no live sender
15963
+ * at all all report `false` — `mediaParamsUpdated.applied` is built from this,
15964
+ * and an application told `true` cannot tell a working push from a no-op.
15965
+ */
15617
15966
  async updateSendersConstraints(kind, constraints) {
15618
15967
  if (!constraints) {
15619
15968
  this.stopTrackSender(kind);
15620
- return Promise.resolve();
15969
+ return false;
15621
15970
  }
15622
15971
  const senders = this.peerConnection.getSenders().filter((sender) => sender.track?.kind === kind && sender.track.readyState === "live");
15972
+ let applied = senders.length > 0;
15623
15973
  for (const sender of senders) {
15624
15974
  const { track } = sender;
15625
15975
  if (track) {
15976
+ if (!this.options.localStreamController.isDeviceCapture(track)) {
15977
+ logger$18.debug(`[TransceiverController] Skipping ${kind} constraints for a non-device track (origin: ${this.options.localStreamController.getTrackOrigin(track) ?? "unrecorded"}), track ${track.id}`);
15978
+ applied = false;
15979
+ continue;
15980
+ }
15626
15981
  const constraintsToApply = {
15627
15982
  ...track.getConstraints(),
15628
15983
  ...constraints
@@ -15638,33 +15993,39 @@ var TransceiverController = class extends Destroyable {
15638
15993
  } catch (fallbackError) {
15639
15994
  logger$18.warn(`[TransceiverController] Track replacement fallback also failed for ${kind} track:`, fallbackError);
15640
15995
  this.options.onError?.(new MediaTrackError("updateSendersConstraints", kind, fallbackError));
15996
+ applied = false;
15641
15997
  }
15642
15998
  }
15643
15999
  }
15644
16000
  }
16001
+ return applied;
15645
16002
  }
15646
16003
  /**
15647
- * Fallback when applyConstraints fails: stop the current track, acquire a new
15648
- * one via getUserMedia with the merged constraints (preserving the current
15649
- * deviceId), replace the sender track, and update the localStream.
16004
+ * Fallback when applyConstraints fails, which on iOS Safari it silently does.
15650
16005
  *
15651
- * This is critical for iOS Safari where applyConstraints on audio tracks
15652
- * silently fails or throws.
16006
+ * Order matters: acquiring before stopping means a failed acquisition leaves
16007
+ * the existing media playing. The deviceId goes through the fallback ladder
16008
+ * rather than pinned `{ exact }`, so a stale id degrades instead of failing.
15653
16009
  */
15654
16010
  async replaceTrackFallback(sender, oldTrack, kind, mergedConstraints) {
15655
16011
  const { deviceId } = oldTrack.getSettings();
15656
- const constraintsWithDevice = {
15657
- ...mergedConstraints,
15658
- ...deviceId ? { deviceId: { exact: deviceId } } : {}
15659
- };
15660
- const trackId = oldTrack.id;
16012
+ const { stream, fallbackLevel } = await getUserMediaWithFallback({ getUserMedia: this.options.getUserMedia }, { [kind]: mergedConstraints }, kind, deviceId);
16013
+ const newTrack = stream.getTracks().find((t) => t.kind === kind);
16014
+ if (!newTrack) {
16015
+ stream.getTracks().forEach((t) => t.stop());
16016
+ throw new MediaTrackError("replaceTrackFallback", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
16017
+ }
16018
+ try {
16019
+ await sender.replaceTrack(newTrack);
16020
+ } catch (error) {
16021
+ stream.getTracks().forEach((t) => t.stop());
16022
+ throw error;
16023
+ }
16024
+ const oldTrackId = oldTrack.id;
16025
+ this.options.localStreamController.removeTrack(oldTrackId);
15661
16026
  oldTrack.stop();
15662
- this.options.localStreamController.removeTrack(trackId);
15663
- const newTrack = (await this.options.getUserMedia({ [kind]: constraintsWithDevice })).getTracks().find((t) => t.kind === kind);
15664
- if (!newTrack) throw new MediaTrackError("replaceTrackFallback", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
15665
- await sender.replaceTrack(newTrack);
15666
16027
  this.options.localStreamController.addTrack(newTrack);
15667
- logger$18.debug(`[TransceiverController] Track replacement fallback succeeded for ${kind}. New track: ${newTrack.id}`);
16028
+ logger$18.debug(`[TransceiverController] Track replacement fallback succeeded for ${kind} (deviceId fallback level: ${fallbackLevel}). New track: ${newTrack.id}`);
15668
16029
  }
15669
16030
  getMediaDirections() {
15670
16031
  if (this.peerConnection.connectionState === "connected") return this.peerConnection.getTransceivers().reduce((acc, transceiver) => {
@@ -15734,48 +16095,44 @@ var RTCPeerConnectionController = class extends Destroyable {
15734
16095
  this.negotiationNeeded$.next();
15735
16096
  };
15736
16097
  this.updateSelectedInputDevice = async (kind, deviceInfo) => {
16098
+ const { localStream } = this;
16099
+ if (!localStream) {
16100
+ logger$17.warn("[RTCPeerConnectionController] No local stream available to update input device.");
16101
+ return;
16102
+ }
16103
+ const currentTrack = localStream.getTracks().find((track) => track.kind === kind);
16104
+ if (!currentTrack) {
16105
+ logger$17.debug(`[RTCPeerConnectionController] No ${kind} track to switch.`);
16106
+ return;
16107
+ }
16108
+ if (!deviceInfo) {
16109
+ logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected: none`);
16110
+ this.stopTrackSender(kind);
16111
+ return;
16112
+ }
16113
+ const constraints = {
16114
+ ...currentTrack.getConstraints(),
16115
+ ...this.deviceController.deviceInfoToConstraints(deviceInfo)
16116
+ };
15737
16117
  try {
15738
- const { localStream } = this;
15739
- if (!localStream) {
15740
- logger$17.warn("[RTCPeerConnectionController] No local stream available to update input device.");
15741
- return;
15742
- }
15743
- logger$17.debug(`[RTCPeerConnectionController] Updating selected ${kind} input device:`, localStream.getTracks());
15744
- const track = localStream.getTracks().find((track$1) => track$1.kind === kind);
15745
- if (track) {
15746
- this.transceiverController?.stopTrackSender(kind);
15747
- this.localStreamController.removeTrack(track.id);
15748
- logger$17.debug(`[RTCPeerConnectionController] Stopped existing ${kind} track: ${track.id}`, localStream.getTracks());
15749
- if (!deviceInfo) {
15750
- logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected: none`);
15751
- return;
15752
- }
15753
- const streamTrack = (await this.getUserMedia({ [kind]: {
15754
- ...track.getConstraints(),
15755
- ...this.deviceController.deviceInfoToConstraints(deviceInfo)
15756
- } })).getTracks().find((t) => t.kind === kind);
15757
- if (streamTrack) {
15758
- logger$17.debug(`[RTCPeerConnectionController] Adding new ${kind} track: ${streamTrack.id}`);
15759
- this.localStreamController.addTrack(streamTrack);
15760
- await this.transceiverController?.replaceSenderTrack(kind, streamTrack);
15761
- logger$17.debug(`[RTCPeerConnectionController] Added new ${kind} track: ${streamTrack.id}`, this.localStream?.getTracks());
15762
- }
15763
- }
15764
- logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected:`, deviceInfo?.label);
16118
+ const newTrack = await this.acquireInputTrack(kind, constraints, deviceInfo, currentTrack);
16119
+ await this.attachInputTrack(kind, newTrack, currentTrack);
16120
+ logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected:`, deviceInfo.label, newTrack.id);
15765
16121
  } catch (error) {
15766
16122
  logger$17.error(`[RTCPeerConnectionController] Failed to select ${kind} input device:`, error);
15767
16123
  this._errors$.next(new MediaTrackError("updateSelectedInputDevice", kind, error));
15768
- throw error;
15769
16124
  }
15770
16125
  };
15771
16126
  this._isNegotiating$ = this.createBehaviorSubject(false);
15772
16127
  this._memberId = null;
16128
+ this._nodeId = null;
15773
16129
  this._iceConnectionState$ = this.createReplaySubject(1);
15774
16130
  this._connectionState$ = this.createReplaySubject(1);
15775
16131
  this._signalingState$ = this.createReplaySubject(1);
15776
16132
  this._iceGatheringState$ = this.createReplaySubject(1);
15777
16133
  this._errors$ = this.createReplaySubject(1);
15778
16134
  this._iceCandidates$ = this.createReplaySubject(1);
16135
+ this._localMediaSettled$ = this.createReplaySubject(1);
15779
16136
  this._initialized$ = this.createReplaySubject(1);
15780
16137
  this._remoteDescription$ = this.createReplaySubject(1);
15781
16138
  this._remoteStream$ = this.createBehaviorSubject(null);
@@ -15808,6 +16165,7 @@ var RTCPeerConnectionController = class extends Destroyable {
15808
16165
  inputVideoStream: this.options.inputVideoStream,
15809
16166
  inputAudioDeviceConstraints: this.inputAudioDeviceConstraints,
15810
16167
  inputVideoDeviceConstraints: this.inputVideoDeviceConstraints,
16168
+ screenShareAudio: this.options.screenShareAudio,
15811
16169
  getUserMedia: async (constraints) => this.getUserMedia(constraints),
15812
16170
  getDisplayMedia: async (options$1) => this.getDisplayMedia(options$1)
15813
16171
  });
@@ -15834,6 +16192,13 @@ var RTCPeerConnectionController = class extends Destroyable {
15834
16192
  get memberId() {
15835
16193
  return this._memberId;
15836
16194
  }
16195
+ /** The node this leg's invite landed on — auxiliary legs are placed independently. */
16196
+ setNodeId(nodeId) {
16197
+ this._nodeId = nodeId;
16198
+ }
16199
+ get nodeId() {
16200
+ return this._nodeId;
16201
+ }
15837
16202
  stopTrackSender(kind, options = { updateTransceiverDirection: false }) {
15838
16203
  const audioCovered = kind === "audio" || kind === "both";
15839
16204
  if (audioCovered && this._localAudioPipeline) this.stopRawAudioInputForPipeline();
@@ -15843,10 +16208,7 @@ var RTCPeerConnectionController = class extends Destroyable {
15843
16208
  }
15844
16209
  stopRawAudioInputForPipeline() {
15845
16210
  const rawTracks = this.localStreamController.localAudioTracks;
15846
- for (const track of rawTracks) if (track.readyState === "live") {
15847
- track.stop();
15848
- this.localStreamController.removeTrack(track.id);
15849
- }
16211
+ for (const track of rawTracks) if (track.readyState === "live") this.localStreamController.removeTrack(track.id);
15850
16212
  this._localAudioPipeline?.setInputTrack(null);
15851
16213
  }
15852
16214
  get isNegotiating$() {
@@ -15879,6 +16241,10 @@ var RTCPeerConnectionController = class extends Destroyable {
15879
16241
  get remoteDescription$() {
15880
16242
  return this.cachedObservable("remoteDescription$", () => this._remoteDescription$.asObservable().pipe((0, import_cjs$16.takeUntil)(this.destroyed$)));
15881
16243
  }
16244
+ /** Emits once local media is settled — acquired, or knowingly receive-only. */
16245
+ get localMediaSettled$() {
16246
+ return this.cachedObservable("localMediaSettled$", () => this._localMediaSettled$.asObservable().pipe((0, import_cjs$16.takeUntil)(this.destroyed$)));
16247
+ }
15882
16248
  get localStream$() {
15883
16249
  return this.cachedObservable("localStream$", () => this.localStreamController.localStream$.pipe((0, import_cjs$16.takeUntil)(this.destroyed$)));
15884
16250
  }
@@ -15906,6 +16272,9 @@ var RTCPeerConnectionController = class extends Destroyable {
15906
16272
  get propose() {
15907
16273
  return this.options.propose ?? "main";
15908
16274
  }
16275
+ get connectionState() {
16276
+ return this.peerConnection?.connectionState;
16277
+ }
15909
16278
  get isAdditionalDevice() {
15910
16279
  return this.propose === "additional-device";
15911
16280
  }
@@ -16034,7 +16403,7 @@ var RTCPeerConnectionController = class extends Destroyable {
16034
16403
  this._isNegotiating$.next(true);
16035
16404
  await this._setRemoteDescription(this.sdpInit);
16036
16405
  } else {
16037
- await this.setupTrackHandling();
16406
+ if (!await this.setupTrackHandling()) return;
16038
16407
  this._initialized$.next(true);
16039
16408
  }
16040
16409
  } catch (error) {
@@ -16158,7 +16527,10 @@ var RTCPeerConnectionController = class extends Destroyable {
16158
16527
  inputVideoDeviceConstraints: this.inputVideoDeviceConstraints
16159
16528
  });
16160
16529
  }
16161
- await this.setupLocalTracks();
16530
+ if (!await this.setupLocalTracks()) {
16531
+ logger$17.debug("[RTCPeerConnectionController] Inbound answer abandoned; the connection went away.");
16532
+ return;
16533
+ }
16162
16534
  const { answerOptions } = this;
16163
16535
  logger$17.debug("[RTCPeerConnectionController] Creating inbound answer with options:", answerOptions);
16164
16536
  await this.createAnswer(answerOptions);
@@ -16296,35 +16668,52 @@ var RTCPeerConnectionController = class extends Destroyable {
16296
16668
  }
16297
16669
  /**
16298
16670
  * Setup track handling for remote tracks.
16671
+ *
16672
+ * @returns `false` when the connection went away while local media was being
16673
+ * acquired — see {@link setupLocalTracks}.
16299
16674
  */
16300
16675
  async setupTrackHandling() {
16301
16676
  if (!this.peerConnection) throw new DependencyError("RTCPeerConnection is not initialized");
16302
- await this.setupLocalTracks();
16677
+ if (!await this.setupLocalTracks()) return false;
16303
16678
  await this.setupRemoteTracks();
16679
+ return true;
16304
16680
  }
16681
+ /**
16682
+ * @returns `false` when the connection was torn down while getUserMedia was
16683
+ * in flight. The acquisition is not cancellable, so the caller must stop
16684
+ * rather than go on to touch a peer connection that is closed or gone.
16685
+ */
16305
16686
  async setupLocalTracks() {
16306
16687
  logger$17.debug("[RTCPeerConnectionController] Setting up local tracks/transceivers.");
16307
16688
  if (this.hasNoLocalMediaToSend()) {
16308
16689
  if (!this.receiveAudio && !this.receiveVideo) throw new InvalidParams("Call requests no media: enable audio/video or receiveAudio/receiveVideo");
16309
16690
  logger$17.debug("[RTCPeerConnectionController] No local media requested; negotiating receive-only.");
16310
16691
  this.setupReceiveOnlyTransceivers();
16311
- return;
16692
+ this._localMediaSettled$.next();
16693
+ return true;
16312
16694
  }
16313
16695
  let localStream;
16314
16696
  try {
16315
16697
  localStream = this.localStream ?? await this.localStreamController.buildLocalStream();
16316
16698
  } catch (error) {
16317
16699
  this.handleLocalMediaFailure(error);
16318
- return;
16700
+ this._localMediaSettled$.next();
16701
+ return true;
16319
16702
  }
16703
+ if (!this.peerConnection || this.peerConnection.signalingState === "closed") {
16704
+ logger$17.debug("[RTCPeerConnectionController] Local media arrived after teardown; releasing it.");
16705
+ localStream.getTracks().forEach((track) => track.stop());
16706
+ return false;
16707
+ }
16708
+ this._localMediaSettled$.next();
16320
16709
  if (this.transceiverController?.useAddStream ?? false) {
16321
16710
  logger$17.warn("[RTCPeerConnectionController] Using deprecated addStream API to add local stream.");
16322
- this.peerConnection?.addStream(localStream);
16711
+ this.peerConnection.addStream(localStream);
16323
16712
  if (!this.isNegotiating) {
16324
16713
  logger$17.debug("[RTCPeerConnectionController] Forcing negotiationneeded after local tracks setup.");
16325
16714
  this.negotiationNeeded$.next();
16326
16715
  }
16327
- return;
16716
+ return true;
16328
16717
  }
16329
16718
  for (const kind of ["audio", "video"]) {
16330
16719
  const tracks = (kind === "audio" ? localStream.getAudioTracks() : localStream.getVideoTracks()).map((track, index) => ({
@@ -16338,10 +16727,11 @@ var RTCPeerConnectionController = class extends Destroyable {
16338
16727
  await this.transceiverController?.setupTransceiverSender(track, localStream, transceivers[index]);
16339
16728
  } else {
16340
16729
  logger$17.debug(`[RTCPeerConnectionController] Using addTrack for local ${kind} track:`, track.id);
16341
- this.peerConnection?.addTrack(track, localStream);
16730
+ this.peerConnection.addTrack(track, localStream);
16342
16731
  }
16343
16732
  }
16344
16733
  }
16734
+ return true;
16345
16735
  }
16346
16736
  /** True for a main connection with no local media to send. */
16347
16737
  hasNoLocalMediaToSend() {
@@ -16430,6 +16820,61 @@ var RTCPeerConnectionController = class extends Destroyable {
16430
16820
  this._localAudioPipeline.setInputTrack(newTrack);
16431
16821
  }
16432
16822
  /**
16823
+ * Capture the newly selected device, leaving the current capture running.
16824
+ *
16825
+ * A rejection must leave the current track sending, so nothing is released
16826
+ * until the replacement is in hand. The one exception is hardware that admits
16827
+ * a single opener — a phone's front and back cameras, typically — which
16828
+ * rejects the second capture until the first is closed.
16829
+ */
16830
+ async acquireInputTrack(kind, constraints, deviceInfo, currentTrack) {
16831
+ try {
16832
+ return await this.captureTrack(kind, constraints, deviceInfo.deviceId);
16833
+ } catch (error) {
16834
+ if (!isMediaDeviceInUse(error)) throw error;
16835
+ logger$17.warn(`[RTCPeerConnectionController] ${kind} device is held exclusively; releasing the current capture to retry:`, error);
16836
+ const previousDeviceId = currentTrack.getSettings().deviceId;
16837
+ this.stopTrackSender(kind);
16838
+ try {
16839
+ return await this.captureTrack(kind, constraints, deviceInfo.deviceId);
16840
+ } catch (retryError) {
16841
+ await this.restorePreviousInputTrack(kind, constraints, previousDeviceId, currentTrack);
16842
+ throw retryError;
16843
+ }
16844
+ }
16845
+ }
16846
+ async captureTrack(kind, constraints, deviceId) {
16847
+ const { stream, fallbackLevel } = await getUserMediaWithFallback({ getUserMedia: async (c) => this.getUserMedia(c) }, { [kind]: constraints }, kind, deviceId);
16848
+ const track = stream.getTracks().find((t) => t.kind === kind);
16849
+ if (!track) {
16850
+ stream.getTracks().forEach((t) => t.stop());
16851
+ throw new MediaTrackError("updateSelectedInputDevice", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
16852
+ }
16853
+ if (fallbackLevel !== "exact") logger$17.warn(`[RTCPeerConnectionController] ${kind} device acquired at fallback level '${fallbackLevel}'; the capture may not be the requested device.`);
16854
+ return track;
16855
+ }
16856
+ /** Best-effort return to the device that was released for an exclusive retry. */
16857
+ async restorePreviousInputTrack(kind, constraints, previousDeviceId, releasedTrack) {
16858
+ try {
16859
+ const restored = await this.captureTrack(kind, constraints, previousDeviceId);
16860
+ await this.attachInputTrack(kind, restored, releasedTrack);
16861
+ } catch (error) {
16862
+ logger$17.error(`[RTCPeerConnectionController] Failed to restore the previous ${kind} device:`, error);
16863
+ }
16864
+ }
16865
+ async attachInputTrack(kind, newTrack, oldTrack) {
16866
+ const pipelineOwnsAudio = kind === "audio" && this._localAudioPipeline;
16867
+ if (!pipelineOwnsAudio) try {
16868
+ await this.transceiverController?.replaceSenderTrack(kind, newTrack);
16869
+ } catch (error) {
16870
+ newTrack.stop();
16871
+ throw error;
16872
+ }
16873
+ this.localStreamController.removeTrack(oldTrack.id);
16874
+ this.localStreamController.addTrack(newTrack);
16875
+ if (pipelineOwnsAudio) this._localAudioPipeline?.setInputTrack(newTrack);
16876
+ }
16877
+ /**
16433
16878
  * Return the lazily-created {@link LocalAudioPipeline}, constructing it on
16434
16879
  * first access. On creation the current audio sender's track is routed
16435
16880
  * through the pipeline (input → gain → analyser → destination) and the
@@ -16462,6 +16907,7 @@ var RTCPeerConnectionController = class extends Destroyable {
16462
16907
  const sender = (this.transceiverController?.audioTransceivers.at(0))?.sender ?? this.peerConnection.getSenders().find((s) => s.track?.kind === "audio");
16463
16908
  if (!sender || !raw) return;
16464
16909
  try {
16910
+ this.localStreamController.setTrackOrigin(this._localAudioPipeline.outputTrack, "processed");
16465
16911
  await sender.replaceTrack(this._localAudioPipeline.outputTrack);
16466
16912
  } catch (error) {
16467
16913
  logger$17.warn("[RTCPeerConnectionController] Failed to route audio sender through pipeline:", error);
@@ -16522,42 +16968,73 @@ var RTCPeerConnectionController = class extends Destroyable {
16522
16968
  for (const existingTrack of existingTracks) this.removeLocalTrack(existingTrack.id);
16523
16969
  this.addLocalTrack(track);
16524
16970
  }
16525
- async updateSendersConstraints(kind, constraints) {
16526
- await this.transceiverController?.updateSendersConstraints(kind, constraints);
16527
- }
16528
16971
  /**
16529
- * Replace the current audio track with a new one using the given constraints.
16530
- * Used for server-pushed audio constraint changes where applyConstraints
16531
- * fails on iOS Safari. Stops the current track, acquires a new one via
16532
- * getUserMedia, and replaces the sender track.
16972
+ * @returns whether the constraints reached the media the leg is sending.
16973
+ *
16974
+ * With the pipeline engaged the audio sender carries the processed
16975
+ * destination track, so the sender scan would find nothing it may touch and
16976
+ * every audio constraint API would silently no-op. The constraints belong to
16977
+ * the pipeline's device source, which is the capture that sender ultimately
16978
+ * carries.
16533
16979
  */
16534
- async replaceAudioTrackWithConstraints(constraints) {
16535
- const senders = this.peerConnection?.getSenders().filter((s) => s.track?.kind === "audio" && s.track.readyState === "live");
16536
- if (!senders || senders.length === 0) {
16537
- logger$17.warn("[RTCPeerConnectionController] No live audio sender to replace");
16538
- return;
16539
- }
16540
- for (const sender of senders) {
16541
- const oldTrack = sender.track;
16542
- if (!oldTrack) continue;
16543
- const { deviceId } = oldTrack.getSettings();
16544
- const mergedConstraints = {
16545
- ...oldTrack.getConstraints(),
16546
- ...constraints,
16547
- ...deviceId ? { deviceId: { exact: deviceId } } : {}
16548
- };
16549
- const trackId = oldTrack.id;
16550
- oldTrack.stop();
16551
- this.localStreamController.removeTrack(trackId);
16552
- const newTrack = (await this.getUserMedia({ audio: mergedConstraints })).getAudioTracks()[0];
16553
- await sender.replaceTrack(newTrack);
16554
- this.localStreamController.addTrack(newTrack);
16555
- logger$17.debug(`[RTCPeerConnectionController] Audio track replaced for server-pushed params. New track: ${newTrack.id}`);
16980
+ async updateSendersConstraints(kind, constraints) {
16981
+ if (kind === "audio" && this._localAudioPipeline) {
16982
+ if (!constraints) {
16983
+ this.stopTrackSender("audio");
16984
+ return false;
16985
+ }
16986
+ return this.applyPipelineSourceConstraints(constraints);
16556
16987
  }
16988
+ return await this.transceiverController?.updateSendersConstraints(kind, constraints) ?? false;
16557
16989
  }
16558
16990
  /**
16559
- * Clean up resources and close the peer connection.
16560
- * Completes all observables to prevent memory leaks.
16991
+ * Mirror of the sender path for a piped audio leg: same merge, same fallback
16992
+ * ladder, same device-capture invariant — but the swap target is the pipeline
16993
+ * input, so the sender keeps emitting the pipeline's output track and its
16994
+ * identity survives the change.
16995
+ */
16996
+ async applyPipelineSourceConstraints(constraints) {
16997
+ const pipeline = this._localAudioPipeline;
16998
+ const source = this.localStreamController.localAudioTracks.at(0);
16999
+ if (!pipeline || !source) {
17000
+ logger$17.debug("[RTCPeerConnectionController] No pipeline input to constrain.");
17001
+ return false;
17002
+ }
17003
+ if (!this.localStreamController.isDeviceCapture(source)) {
17004
+ logger$17.debug(`[RTCPeerConnectionController] Skipping audio constraints for a non-device pipeline input (origin: ${this.localStreamController.getTrackOrigin(source) ?? "unrecorded"}).`);
17005
+ return false;
17006
+ }
17007
+ const merged = {
17008
+ ...source.getConstraints(),
17009
+ ...constraints
17010
+ };
17011
+ try {
17012
+ await source.applyConstraints(merged);
17013
+ logger$17.debug("[RTCPeerConnectionController] Pipeline input constraints updated:", merged);
17014
+ return true;
17015
+ } catch (error) {
17016
+ logger$17.warn("[RTCPeerConnectionController] applyConstraints failed on the pipeline input, re-acquiring:", error);
17017
+ }
17018
+ try {
17019
+ const { stream } = await getUserMediaWithFallback({ getUserMedia: async (c) => this.getUserMedia(c) }, { audio: merged }, "audio", source.getSettings().deviceId);
17020
+ const newTrack = stream.getAudioTracks().at(0);
17021
+ if (!newTrack) {
17022
+ stream.getTracks().forEach((track) => track.stop());
17023
+ throw new Error("getUserMedia returned no audio track");
17024
+ }
17025
+ this.localStreamController.removeTrack(source.id);
17026
+ this.localStreamController.addTrack(newTrack);
17027
+ pipeline.setInputTrack(newTrack);
17028
+ return true;
17029
+ } catch (error) {
17030
+ logger$17.warn("[RTCPeerConnectionController] Failed to re-acquire the pipeline input for constraints:", error);
17031
+ this._errors$.next(new MediaTrackError("updateSendersConstraints", "audio", error));
17032
+ return false;
17033
+ }
17034
+ }
17035
+ /**
17036
+ * Clean up resources and close the peer connection.
17037
+ * Completes all observables to prevent memory leaks.
16561
17038
  */
16562
17039
  destroy() {
16563
17040
  logger$17.debug(`[RTCPeerConnectionController] Destroying RTCPeerConnectionController. ${this.propose}`);
@@ -16672,6 +17149,48 @@ const logger$16 = getLogger();
16672
17149
  function resolveInviteNodeId(args) {
16673
17150
  return args.isInvite && !args.reattach && !args.explicitNodeId ? "" : args.currentNodeId ?? "";
16674
17151
  }
17152
+ /**
17153
+ * Surface the real outcome of a `webrtc.verto` reply.
17154
+ *
17155
+ * A webrtc.verto response nests several envelopes, each keyed by a verto-style
17156
+ * string `code` ("200" ok, "400"/etc. fail) rather than a JSON-RPC `error`. An outer
17157
+ * layer reports only whether the frame was delivered; an inner layer carries the op's
17158
+ * own outcome:
17159
+ *
17160
+ * response.result = { code:"200", result:{…} } ← delivery acknowledgement
17161
+ * .result = { jsonrpc, id, result:{…} } ← the reply payload
17162
+ * .result = { code:"400", message:"Bad request" } ← the actual op outcome
17163
+ *
17164
+ * A failure can appear at any layer (delivery refused, or the op itself rejected
17165
+ * deeper down), so walk every nested `.result` object and return the FIRST non-2xx
17166
+ * `code` with its message. Returns null when every `code` seen is 2xx or absent —
17167
+ * i.e. the op succeeded. This is the only way to detect that e.g. a mute/kick was
17168
+ * rejected, since the outer delivery `code` is "200" (delivered) even then.
17169
+ *
17170
+ * Pure function — exported for unit testing.
17171
+ */
17172
+ function findNestedVertoFailure(response) {
17173
+ let node = response;
17174
+ while (node !== null && typeof node === "object") {
17175
+ const obj = node;
17176
+ const err = obj.error;
17177
+ if (err !== null && typeof err === "object") {
17178
+ const e = err;
17179
+ const errCode = typeof e.code === "string" || typeof e.code === "number" ? String(e.code) : void 0;
17180
+ if (errCode !== void 0 && !/^2\d\d$/.test(errCode)) return {
17181
+ code: errCode,
17182
+ message: typeof e.message === "string" ? e.message : void 0
17183
+ };
17184
+ }
17185
+ const code = typeof obj.code === "string" || typeof obj.code === "number" ? String(obj.code) : void 0;
17186
+ if (code !== void 0 && !/^2\d\d$/.test(code)) return {
17187
+ code,
17188
+ message: typeof obj.message === "string" ? obj.message : void 0
17189
+ };
17190
+ node = obj.result !== null && typeof obj.result === "object" ? obj.result : null;
17191
+ }
17192
+ return null;
17193
+ }
16675
17194
  var VertoManager = class extends Destroyable {
16676
17195
  constructor(callSession) {
16677
17196
  super();
@@ -16694,7 +17213,7 @@ var WebRTCVertoManager = class extends VertoManager {
16694
17213
  this._signalingStatus$ = this.createReplaySubject(1);
16695
17214
  this._screenShareStatus$ = this.createBehaviorSubject("none");
16696
17215
  this._rtcPeerConnectionsMap = /* @__PURE__ */ new Map();
16697
- this._screenShareTimeoutMs = 5e4;
17216
+ this._legErrors$ = this.createSubject();
16698
17217
  this._nodeId$ = this.createBehaviorSubject(options.nodeId ?? null);
16699
17218
  this.onError = options.onError;
16700
17219
  this.onModifyFailed = options.onModifyFailed;
@@ -16742,6 +17261,10 @@ var WebRTCVertoManager = class extends VertoManager {
16742
17261
  get selfId$() {
16743
17262
  return this._selfId$.asObservable();
16744
17263
  }
17264
+ /** Separates the media phase of call creation from the signalling phase. */
17265
+ get localMediaSettled$() {
17266
+ return this.mainPeerConnection.localMediaSettled$;
17267
+ }
16745
17268
  get localStream() {
16746
17269
  return this._rtcPeerConnectionsMap.get(this.webRtcCallSession.id)?.localStream ?? null;
16747
17270
  }
@@ -16798,35 +17321,95 @@ var WebRTCVertoManager = class extends VertoManager {
16798
17321
  const { mediaParams, callID } = event;
16799
17322
  const rtcPeerConnController = this._rtcPeerConnectionsMap.get(callID);
16800
17323
  const { audio, video } = mediaParams;
16801
- (async () => {
16802
- try {
16803
- if (audio && rtcPeerConnController) await rtcPeerConnController.replaceAudioTrackWithConstraints(audio);
16804
- if (video) await rtcPeerConnController?.updateSendersConstraints("video", video);
16805
- this.webRtcCallSession.emitMediaParamsUpdated({
16806
- audio,
16807
- video,
16808
- timestamp: Date.now()
16809
- });
16810
- } catch (error) {
16811
- logger$16.warn("[WebRTCManager] Error applying server-pushed media params:", error);
16812
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
16813
- }
16814
- })();
17324
+ if (!rtcPeerConnController) {
17325
+ logger$16.warn(`[WebRTCManager] Ignoring server-pushed media params for unknown leg ${callID}`);
17326
+ return;
17327
+ }
17328
+ this.applyServerMediaParams(rtcPeerConnController, audio, video);
16815
17329
  });
16816
17330
  this.subscribeTo(this.vertoPing$, (vertoPing) => {
16817
- this.attachManager.attach(this.buildAttachableCall());
17331
+ this.attachManager.refresh(this.buildAttachableCall());
16818
17332
  this.sendVertoPong(vertoPing);
16819
17333
  });
16820
17334
  }
16821
17335
  /**
17336
+ * An auxiliary-leg failure must never destroy the call; main-leg and
17337
+ * call-level errors keep `CallFactory.isFatalError`'s classification.
17338
+ *
17339
+ * Every site holding a peer connection reports through here, so the invariant
17340
+ * is structural rather than per-call-site — which is how cloud-product#20523
17341
+ * happened, with only one of fourteen sites passing `{ fatal: false }`.
17342
+ *
17343
+ * `override` composes rather than replaces: a caller may force non-fatal for a
17344
+ * reason of its own (a `verto.info` frame is best-effort whichever leg carries
17345
+ * it), and an auxiliary leg stays non-fatal regardless.
17346
+ */
17347
+ reportLegError(error, rtcPeerConnController, override) {
17348
+ const leg = rtcPeerConnController?.propose;
17349
+ const legId = rtcPeerConnController?.id;
17350
+ const auxiliary = Boolean(rtcPeerConnController) && !rtcPeerConnController?.isMainDevice;
17351
+ this.onError?.(error, {
17352
+ ...override?.fatal === false || auxiliary ? { fatal: false } : {},
17353
+ ...leg ? { leg } : {},
17354
+ ...legId ? { legId } : {}
17355
+ });
17356
+ if (legId) this._legErrors$.next({
17357
+ legId,
17358
+ error
17359
+ });
17360
+ }
17361
+ /**
17362
+ * Errors reported for one leg, as a stream that fails with them.
17363
+ *
17364
+ * Signaling failures are reported, never thrown — so nothing that waits on a
17365
+ * leg's progress would otherwise learn of a rejected invite. Merging this in
17366
+ * lets the wait end with the reason the server gave.
17367
+ */
17368
+ legError$(legId) {
17369
+ return this._legErrors$.pipe((0, import_cjs$15.filter)((report) => report.legId === legId), (0, import_cjs$15.map)((report) => {
17370
+ throw report.error;
17371
+ }));
17372
+ }
17373
+ /**
17374
+ * Audio and video are applied independently so a failure in one cannot
17375
+ * suppress the other, and `mediaParamsUpdated` is emitted whatever happens —
17376
+ * an application should not be starved of the params by a constraint failure.
17377
+ */
17378
+ async applyServerMediaParams(rtcPeerConnController, audio, video) {
17379
+ const failures = [];
17380
+ let applied = true;
17381
+ if (audio) try {
17382
+ applied = await rtcPeerConnController.updateSendersConstraints("audio", audio) && applied;
17383
+ } catch (error) {
17384
+ applied = false;
17385
+ failures.push(toError(error));
17386
+ }
17387
+ if (video) try {
17388
+ applied = await rtcPeerConnController.updateSendersConstraints("video", video) && applied;
17389
+ } catch (error) {
17390
+ applied = false;
17391
+ failures.push(toError(error));
17392
+ }
17393
+ this.webRtcCallSession.emitMediaParamsUpdated({
17394
+ audio,
17395
+ video,
17396
+ timestamp: Date.now(),
17397
+ applied
17398
+ });
17399
+ for (const failure of failures) {
17400
+ logger$16.warn("[WebRTCManager] Error applying server-pushed media params:", failure);
17401
+ this.reportLegError(failure, rtcPeerConnController, { fatal: false });
17402
+ }
17403
+ }
17404
+ /**
16822
17405
  * Set node_id/selfId only when the current value is null.
16823
17406
  *
16824
17407
  * During reattach, `call.joined` and `verto.answer` events can deliver
16825
17408
  * these identifiers before the `verto.invite` RPC response (`CALL CREATED`)
16826
17409
  * arrives. These methods let early events populate them eagerly so that
16827
17410
  * downstream RPC calls (e.g. `call.layout.list`) don't fail with empty
16828
- * identifiers. `processInviteResponse()` remains the authoritative source
16829
- * and always overwrites unconditionally.
17411
+ * identifiers. `processInviteResponse()` remains the authoritative source and
17412
+ * overwrites unconditionally — for selfId, on the main leg only.
16830
17413
  */
16831
17414
  setNodeIdIfNull(nodeId) {
16832
17415
  if (!this._nodeId$.value && nodeId) {
@@ -16849,24 +17432,33 @@ var WebRTCVertoManager = class extends VertoManager {
16849
17432
  this.onError?.(new VertoPongError(error));
16850
17433
  }
16851
17434
  }
17435
+ /**
17436
+ * @returns whether the constraints reached the media the call is sending.
17437
+ * `false` is an outcome, not a failure: the leg may have no live sender of
17438
+ * the kind, or carry media the SDK did not capture and may not replace.
17439
+ * A failure behind it still reaches the call's `errors$`, so a caller that
17440
+ * ignores this value learns of it there.
17441
+ */
16852
17442
  async updateMediaConstraints(options = {}) {
16853
17443
  const { audio, video } = options;
17444
+ let applied = true;
16854
17445
  try {
16855
- if (audio) await this.mainPeerConnection.updateSendersConstraints("audio", audio);
16856
- if (video) await this.mainPeerConnection.updateSendersConstraints("video", video);
17446
+ if (audio) applied = await this.mainPeerConnection.updateSendersConstraints("audio", audio) && applied;
17447
+ if (video) applied = await this.mainPeerConnection.updateSendersConstraints("video", video) && applied;
16857
17448
  } catch (error) {
16858
17449
  logger$16.warn("[WebRTCManager] Error updating media constraints:", error);
16859
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
17450
+ this.reportLegError(toError(error), this.mainPeerConnection);
16860
17451
  throw error;
16861
17452
  }
17453
+ return applied;
16862
17454
  }
16863
17455
  get selfId() {
16864
17456
  return this._selfId$.value;
16865
17457
  }
16866
17458
  /** Build an AttachableCall from the current call state. */
16867
- buildAttachableCall(idOverride) {
17459
+ buildAttachableCall(idOverride, nodeIdOverride) {
16868
17460
  return {
16869
- nodeId: this.nodeId ?? void 0,
17461
+ nodeId: nodeIdOverride ?? this.nodeId ?? void 0,
16870
17462
  id: idOverride ?? this.webRtcCallSession.id,
16871
17463
  to: this.webRtcCallSession.to,
16872
17464
  mediaDirections: this.webRtcCallSession.mediaDirections
@@ -16996,24 +17588,49 @@ var WebRTCVertoManager = class extends VertoManager {
16996
17588
  get vertoPing$() {
16997
17589
  return this.cachedObservable("vertoPing$", () => this.webRtcCallSession.webrtcMessages$.pipe(filterAs(isVertoPingInnerParams, "params"), (0, import_cjs$15.takeUntil)(this.destroyed$)));
16998
17590
  }
17591
+ /**
17592
+ * Send a member-control op in-dialog via verto.info.
17593
+ *
17594
+ * The control payload rides in `params.command` — a sibling of `dialogParams`,
17595
+ * at the same level as `dtmf` in {@link sendDigits} — and the inner verto.info
17596
+ * is matched to this call's channel by `dialogParams.callID`, the in-dialog
17597
+ * convention for member-scoped frames. Because it is delivered on the dialog
17598
+ * itself, control lands on the call's own channel with no {node_id,call_id,member_id}
17599
+ * "self" tuple to get wrong. The outer webrtc.verto envelope (added by executeVerto)
17600
+ * still carries the own-leg callID + node_id for session routing.
17601
+ *
17602
+ * Keep `command` OUT of `dialogParams`: it is read at the params level, and
17603
+ * filterVertoParams rewrites/filters dialogParams keys but passes params-level
17604
+ * keys through verbatim.
17605
+ */
17606
+ async sendCallControl(method, params) {
17607
+ const response = await this.executeVerto(VertoInfo({
17608
+ dialogParams: { callID: this.webRtcCallSession.id },
17609
+ command: {
17610
+ method,
17611
+ params
17612
+ }
17613
+ }));
17614
+ const failure = findNestedVertoFailure(response);
17615
+ if (failure) throw new JSONRPCError(Number.parseInt(failure.code, 10) || 0, `Call control "${method}" failed (code ${failure.code})${failure.message ? `: ${failure.message}` : ""}`, void 0);
17616
+ return response;
17617
+ }
16999
17618
  async executeVerto(message, optionals = {}) {
17000
- const webrtcVertoMessage = WebrtcVerto({
17619
+ const params = {
17001
17620
  callID: optionals.callID ?? this.webRtcCallSession.id,
17002
17621
  node_id: optionals.node_id ?? this._nodeId$.value ?? "",
17003
17622
  message,
17004
17623
  subscribe: optionals.subscribe
17005
- });
17624
+ };
17625
+ const webrtcVertoMessage = WebrtcVerto(params);
17006
17626
  const response = await this.webRtcCallSession.execute(webrtcVertoMessage);
17007
- if (response.error) {
17008
- const error = new JSONRPCError(response.error.code, response.error.message, response.error.data);
17009
- this.onError?.(error);
17010
- return response;
17011
- }
17627
+ const nonFatal = message.method === "verto.info" ? { fatal: false } : void 0;
17012
17628
  const innerResult = getValueFrom(response, "result.result");
17013
- if (innerResult?.error) {
17014
- const error = new JSONRPCError(innerResult.error.code, innerResult.error.message, innerResult.error.data);
17015
- this.onError?.(error);
17016
- return response;
17629
+ const failure = response.error ?? innerResult?.error;
17630
+ if (failure) {
17631
+ const error = new JSONRPCError(failure.code, failure.message, failure.data);
17632
+ if (message.method === "verto.invite" || message.method === "verto.answer") throw error;
17633
+ this.reportLegError(error, this._rtcPeerConnectionsMap.get(params.callID), nonFatal);
17017
17634
  }
17018
17635
  return response;
17019
17636
  }
@@ -17032,8 +17649,9 @@ var WebRTCVertoManager = class extends VertoManager {
17032
17649
  default:
17033
17650
  }
17034
17651
  } catch (error) {
17652
+ if (vertoMethod === "verto.answer") throw error;
17035
17653
  logger$16.error(`[WebRTCManager] Error sending Verto ${vertoMethod}:`, error);
17036
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
17654
+ this.reportLegError(toError(error), rtcPeerConnController);
17037
17655
  if (vertoMethod === "verto.modify") this.onModifyFailed?.();
17038
17656
  }
17039
17657
  }
@@ -17049,7 +17667,7 @@ var WebRTCVertoManager = class extends VertoManager {
17049
17667
  } catch (error) {
17050
17668
  logger$16.warn("[WebRTCManager] Error processing modify response:", error);
17051
17669
  const modifyError = error instanceof Error ? error : new Error(String(error), { cause: error });
17052
- this.onError?.(modifyError);
17670
+ this.reportLegError(modifyError, rtcPeerConnController);
17053
17671
  }
17054
17672
  }
17055
17673
  }
@@ -17062,37 +17680,35 @@ var WebRTCVertoManager = class extends VertoManager {
17062
17680
  status,
17063
17681
  signalingError
17064
17682
  });
17065
- this.onError?.(signalingError);
17683
+ this.reportLegError(signalingError, null, { fatal: false });
17066
17684
  return;
17067
17685
  }
17068
17686
  if (rtcPeerConnController.isMainDevice) this._signalingStatus$.next(status);
17069
17687
  }
17070
17688
  processInviteResponse(response, rtcPeerConnController) {
17071
- if (!response.error && getValueFrom(response, "result.result.result.message") === "CALL CREATED") {
17689
+ if (getValueFrom(response, "result.result.result.message") === "CALL CREATED") {
17072
17690
  this.emitMainSignalingStatus(rtcPeerConnController.id, "trying");
17073
- this._nodeId$.next(getValueFrom(response, "result.node_id") ?? null);
17691
+ const nodeId = getValueFrom(response, "result.node_id") ?? null;
17074
17692
  const memberId = getValueFrom(response, "result.result.result.memberID") ?? null;
17075
- const callId = getValueFrom(response, "result.result.result.callID") ?? null;
17693
+ const callId = getValueFrom(response, "result.result.result.callID");
17076
17694
  logger$16.debug("[WebRTCManager] Verto invite response:", {
17077
17695
  callId,
17078
17696
  memberId,
17079
17697
  response
17080
17698
  });
17081
- this._selfId$.next(memberId);
17082
17699
  rtcPeerConnController.setMemberId(memberId);
17083
- if (callId) {
17084
- this.webRtcCallSession.addCallId(callId);
17085
- this.attachManager.attach(this.buildAttachableCall(callId));
17086
- } else logger$16.warn("[WebRTCManager] Cannot attach call, missing callId:", {
17087
- nodeId: this.nodeId,
17088
- callId
17089
- });
17700
+ rtcPeerConnController.setNodeId(nodeId);
17701
+ if (rtcPeerConnController.isMainDevice) {
17702
+ this._selfId$.next(memberId);
17703
+ this._nodeId$.next(nodeId);
17704
+ this.attachManager.attach(this.buildAttachableCall(callId, nodeId ?? void 0));
17705
+ }
17706
+ if (callId) this.webRtcCallSession.addCallId(callId);
17090
17707
  logger$16.info("[WebRTCManager] Verto invite successful");
17091
17708
  logger$16.debug(`[WebRTCManager] nodeid: ${this._nodeId$.value}, selfId: ${this._selfId$.value}`);
17092
17709
  } else {
17093
17710
  logger$16.error("[WebRTCManager] Verto invite failed:", response);
17094
- const inviteError = response.error ? new JSONRPCError(response.error.code, response.error.message, response.error.data) : /* @__PURE__ */ new Error("Verto invite failed: unexpected response");
17095
- this.onError?.(inviteError);
17711
+ this.reportLegError(/* @__PURE__ */ new Error("Verto invite failed: unexpected response"), rtcPeerConnController);
17096
17712
  }
17097
17713
  }
17098
17714
  get RTCPeerConnectionConfig() {
@@ -17131,7 +17747,7 @@ var WebRTCVertoManager = class extends VertoManager {
17131
17747
  this._rtcPeerConnectionsMap.set(rtcPeerConnController.id, rtcPeerConnController);
17132
17748
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
17133
17749
  this.subscribeTo(rtcPeerConnController.errors$, (error) => {
17134
- this.onError?.(error);
17750
+ this.reportLegError(error, rtcPeerConnController);
17135
17751
  });
17136
17752
  if (options.initOffer) this.handleInboundAnswer(rtcPeerConnController);
17137
17753
  }
@@ -17160,7 +17776,7 @@ var WebRTCVertoManager = class extends VertoManager {
17160
17776
  await rtcPeerConnController.acceptInbound(answerOptions);
17161
17777
  } catch (error) {
17162
17778
  logger$16.error("[WebRTCManager] Error creating inbound answer:", error);
17163
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
17779
+ this.reportLegError(toError(error), rtcPeerConnController);
17164
17780
  }
17165
17781
  }
17166
17782
  }
@@ -17232,7 +17848,7 @@ var WebRTCVertoManager = class extends VertoManager {
17232
17848
  isInvite: isVertoInviteMessage(vertoMessage),
17233
17849
  reattach: this.webRtcCallSession.options.reattach === true,
17234
17850
  explicitNodeId: this.webRtcCallSession.options.nodeId,
17235
- currentNodeId: this._nodeId$.value
17851
+ currentNodeId: rtcPeerConnController.nodeId ?? this._nodeId$.value
17236
17852
  }),
17237
17853
  subscribe
17238
17854
  };
@@ -17264,7 +17880,7 @@ var WebRTCVertoManager = class extends VertoManager {
17264
17880
  await this.attachManager.attach(this.buildAttachableCall());
17265
17881
  } catch (error) {
17266
17882
  logger$16.error("[WebRTCManager] Error sending Verto answer:", error);
17267
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
17883
+ this.reportLegError(toError(error), rtcPeerConnectionController);
17268
17884
  await rtcPeerConnectionController.updateAnswerStatus({ status: "failed" });
17269
17885
  }
17270
17886
  }
@@ -17336,15 +17952,19 @@ var WebRTCVertoManager = class extends VertoManager {
17336
17952
  await this.mainPeerConnection.restoreTrackSender(deviceKind);
17337
17953
  } else {
17338
17954
  const error = new InvalidParams("No valid device to be added");
17339
- this.onError?.(error);
17955
+ this.reportLegError(error, this.mainPeerConnection);
17340
17956
  throw error;
17341
17957
  }
17342
17958
  }
17343
- async addScreenMedia(options = { audio: false }) {
17344
- await this.initAdditionalPeerConnection("screenshare", options);
17959
+ async addScreenMedia(options = {}) {
17960
+ await this.initAdditionalPeerConnection("screenshare", {
17961
+ audio: false,
17962
+ screenShareAudio: options.audio ?? false
17963
+ });
17345
17964
  }
17346
17965
  async initAdditionalPeerConnection(propose, options) {
17347
17966
  const isScreenShare = propose === "screenshare";
17967
+ if (isScreenShare && this._screenShareId && this._rtcPeerConnectionsMap.has(this._screenShareId)) throw new ScreenShareAlreadyActiveError(this._screenShareId);
17348
17968
  let firstPeerConnectionError;
17349
17969
  let rtcPeerConnController = null;
17350
17970
  try {
@@ -17361,21 +17981,36 @@ var WebRTCVertoManager = class extends VertoManager {
17361
17981
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
17362
17982
  this.subscribeTo(rtcPeerConnController.errors$, (error) => {
17363
17983
  firstPeerConnectionError ??= error;
17364
- this.onError?.(error, { fatal: false });
17984
+ this.reportLegError(error, rtcPeerConnController);
17365
17985
  });
17366
- 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$)));
17986
+ const pc = rtcPeerConnController;
17987
+ 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$))));
17367
17988
  if (isScreenShare) this._screenShareStatus$.next("started");
17368
17989
  logger$16.info(`[WebRTCManager] Additional peer connection connected (${propose}).`);
17369
17990
  return rtcPeerConnController.id;
17370
17991
  } catch (error) {
17371
- logger$16.warn("[WebRTCManager] Error initializing additional peer connection:", error);
17372
- if (rtcPeerConnController) rtcPeerConnController.destroy();
17373
- if (isScreenShare) this._screenShareStatus$.next("none");
17992
+ const cancelled = error instanceof AuxiliaryLegCancelledError;
17993
+ const aborted = error instanceof import_cjs$15.EmptyError && !firstPeerConnectionError;
17994
+ if (!cancelled && !aborted) logger$16.warn("[WebRTCManager] Error initializing additional peer connection:", error);
17995
+ if (rtcPeerConnController && this._rtcPeerConnectionsMap.has(rtcPeerConnController.id)) {
17996
+ rtcPeerConnController.destroy();
17997
+ this._rtcPeerConnectionsMap.delete(rtcPeerConnController.id);
17998
+ this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
17999
+ }
18000
+ if (isScreenShare) {
18001
+ this._screenShareId = void 0;
18002
+ this._screenShareStatus$.next("none");
18003
+ }
18004
+ if (cancelled) {
18005
+ logger$16.debug("[WebRTCManager] Additional peer connection removed before connecting.");
18006
+ throw error;
18007
+ }
17374
18008
  if (firstPeerConnectionError) throw firstPeerConnectionError instanceof MediaAccessError && firstPeerConnectionError.originalError instanceof Error ? firstPeerConnectionError.originalError : firstPeerConnectionError;
17375
18009
  if (error instanceof import_cjs$15.EmptyError) {
17376
18010
  logger$16.debug("[WebRTCManager] Additional peer connection aborted before connecting.");
17377
18011
  return;
17378
18012
  }
18013
+ if (error instanceof import_cjs$15.TimeoutError) throw new AuxiliaryLegTimeoutError(propose, error);
17379
18014
  throw error instanceof Error ? error : new Error(String(error), { cause: error });
17380
18015
  }
17381
18016
  }
@@ -17392,7 +18027,10 @@ var WebRTCVertoManager = class extends VertoManager {
17392
18027
  if (removeTrack) return this.mainPeerConnection.stopTrackSender(removeTrack, { updateTransceiverDirection: true });
17393
18028
  }
17394
18029
  async removeScreenMedia() {
17395
- if (!["starting", "started"].includes(this._screenShareStatus$.value)) logger$16.warn("[WebRTCManager] No active screen share to stop.");
18030
+ if (!["starting", "started"].includes(this._screenShareStatus$.value)) {
18031
+ logger$16.warn("[WebRTCManager] No active screen share to stop.");
18032
+ return;
18033
+ }
17396
18034
  if (!this._screenShareId) {
17397
18035
  logger$16.debug("[WebRTCManager] No screen share peer connection found.");
17398
18036
  return;
@@ -17407,6 +18045,10 @@ var WebRTCVertoManager = class extends VertoManager {
17407
18045
  try {
17408
18046
  if (rtcPeerConnController) await this.executeVertoBye(rtcPeerConnController);
17409
18047
  } finally {
18048
+ if (rtcPeerConnController && rtcPeerConnController.connectionState !== "connected") this._legErrors$.next({
18049
+ legId: id,
18050
+ error: new AuxiliaryLegCancelledError(rtcPeerConnController.propose)
18051
+ });
17410
18052
  rtcPeerConnController?.destroy();
17411
18053
  this._rtcPeerConnectionsMap.delete(id);
17412
18054
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
@@ -17421,7 +18063,10 @@ var WebRTCVertoManager = class extends VertoManager {
17421
18063
  await this.executeVerto(VertoBye({
17422
18064
  ...causeParams,
17423
18065
  dialogParams: this.dialogParams(rtcPeerConnController)
17424
- }));
18066
+ }), {
18067
+ callID: rtcPeerConnController.id,
18068
+ node_id: rtcPeerConnController.nodeId ?? void 0
18069
+ });
17425
18070
  } catch (error) {
17426
18071
  logger$16.warn("[WebRTCManager] Call might already be disconnected, error sending Verto bye:", error);
17427
18072
  throw error;
@@ -18300,17 +18945,38 @@ function mosToQualityLevel(mos) {
18300
18945
  return "critical";
18301
18946
  }
18302
18947
 
18948
+ //#endregion
18949
+ //#region src/utils/unwrapVertoReply.ts
18950
+ /**
18951
+ * Unwrap a method's own reply from a `webrtc.verto` envelope.
18952
+ *
18953
+ * A control verb sent in-dialog comes back nested two levels deep:
18954
+ *
18955
+ * ```
18956
+ * { result: { node_id, code, result: { jsonrpc, id, result: <method payload> } } }
18957
+ * ```
18958
+ *
18959
+ * whereas the routed transport resolves the method payload directly under
18960
+ * `.result`. Readers want the latter shape, and the difference is silent when it
18961
+ * is wrong — `response.result.layouts` simply evaluates to `undefined` against the
18962
+ * envelope, so the data arrives, nothing throws, and the caller sees an empty
18963
+ * value. That exact failure produced an empty layout dropdown with a successful
18964
+ * request behind it.
18965
+ *
18966
+ * Accepting both shapes here keeps every reader indifferent to which transport
18967
+ * produced the response. Anything that is not a nested envelope (a plain ack, or
18968
+ * an already-unwrapped reply) passes through untouched.
18969
+ */
18970
+ function unwrapVertoReply(response) {
18971
+ const inner = getValueFrom(response, "result.result");
18972
+ return inner && typeof inner === "object" && "result" in inner ? inner : response;
18973
+ }
18974
+
18303
18975
  //#endregion
18304
18976
  //#region src/core/entities/Call.ts
18305
18977
  var import_cjs$11 = require_cjs();
18306
18978
  const logger$12 = getLogger();
18307
18979
  /**
18308
- * Verto method for setting member layout positions. Its gateway DTO requires a
18309
- * `targets` array whose entries are `{ target, position }` (NOT bare targets),
18310
- * so {@link WebRTCCall.buildMethodParams} special-cases it. See issue #19400.
18311
- */
18312
- const POSITION_SET_METHOD = "call.member.position.set";
18313
- /**
18314
18980
  * Ratio between the critical and warning RTT spike multipliers.
18315
18981
  * Warning threshold = baseline * warningMultiplier (default 3x)
18316
18982
  * Critical threshold = baseline * warningMultiplier * RTT_CRITICAL_TO_WARNING_RATIO
@@ -18405,8 +19071,11 @@ var WebRTCCall = class extends Destroyable {
18405
19071
  emitError(callError) {
18406
19072
  if (this._status$.value === "destroyed" || this._status$.value === "failed") return;
18407
19073
  this._errors$.next(callError);
18408
- if (callError.fatal) {
19074
+ if (callError.fatal && this._status$.value !== "disconnecting") {
18409
19075
  this._status$.next("failed");
19076
+ this.vertoManager.bye().catch((error) => {
19077
+ logger$12.debug("[Call] fatal-teardown bye failed (signaling likely already dead):", error);
19078
+ });
18410
19079
  this.destroy();
18411
19080
  }
18412
19081
  }
@@ -18461,7 +19130,7 @@ var WebRTCCall = class extends Destroyable {
18461
19130
  /** Toggles the call lock state, preventing or allowing new participants from joining. */
18462
19131
  async toggleLock() {
18463
19132
  const method = this.locked ? "call.unlock" : "call.lock";
18464
- await this.executeMethod(this.selfId ?? "", method, {});
19133
+ await this.executeMethod(this.callSelf, method, {});
18465
19134
  }
18466
19135
  /**
18467
19136
  * Toggles the hold state of the call (pauses/resumes local media transmission).
@@ -18510,14 +19179,25 @@ var WebRTCCall = class extends Destroyable {
18510
19179
  *
18511
19180
  * Constructs call context (node_id, call_id, member_id) and sends the RPC request.
18512
19181
  *
18513
- * @param target - Target member ID string, or a {@link MemberTarget} object.
19182
+ * @param target - Target {@link MemberTarget} triple, or the local member's
19183
+ * ID string for self-operations (any other string is rejected — a bare
19184
+ * member id cannot carry the remote member's own call context).
18514
19185
  * @param method - Verto method name (e.g. `'call.mute'`, `'call.member.remove'`).
18515
19186
  * @param args - Parameters for the RPC method.
18516
19187
  * @returns The RPC response.
19188
+ * @throws {CallNotReadyError} If the call has no self member context yet.
19189
+ * @throws {InvalidParams} If a string target is not the local member's ID.
18517
19190
  * @throws {JSONRPCError} If the RPC call returns an error.
18518
19191
  */
18519
19192
  async executeMethod(target, method, args) {
18520
- const params = this.buildMethodParams(target, args, method);
19193
+ const self = this.callSelf;
19194
+ 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}`);
19195
+ if (this.clientSession.callControl === "in-dialog") return this.executeMethodInDialog(target, method, args);
19196
+ const params = {
19197
+ ...args,
19198
+ self,
19199
+ target: typeof target === "string" ? self : target
19200
+ };
18521
19201
  const request = buildRPCRequest({
18522
19202
  method,
18523
19203
  params
@@ -18531,29 +19211,98 @@ var WebRTCCall = class extends Destroyable {
18531
19211
  throw error;
18532
19212
  }
18533
19213
  }
18534
- buildMethodParams(target, args, method) {
18535
- const self = {
18536
- node_id: this.nodeId ?? "",
18537
- call_id: this.id,
18538
- member_id: this.vertoManager.selfId ?? ""
18539
- };
18540
- if (method === POSITION_SET_METHOD) return {
18541
- ...args,
18542
- self
18543
- };
18544
- if (typeof target === "object") return {
18545
- ...args,
18546
- self,
18547
- targets: [target]
18548
- };
18549
- return {
18550
- ...args,
18551
- self,
18552
- target: {
18553
- node_id: this.nodeId ?? "",
19214
+ /**
19215
+ * `executeMethod` for a call opened with `callControl: 'in-dialog'`.
19216
+ *
19217
+ * Translates the routed transport's calling convention into the in-dialog one. No
19218
+ * `self` tuple is sent, but a `target` is — the same {call_id, member_id} the routed
19219
+ * transport puts in `target` (minus node_id), for self-ops and cross-member ops alike.
19220
+ *
19221
+ * Target shapes are per-verb and irregular, so they are centralised here rather
19222
+ * than left to callers: most verbs take a singular `target`, `call.member.remove`
19223
+ * takes a plural `targets` array, and `call.member.position.set` takes a flat
19224
+ * `targets` of `{call_id, position}` — the one verb keyed on call_id rather than
19225
+ * member_id, so the member triple `Participant.setPosition` built is unwrapped.
19226
+ */
19227
+ async executeMethodInDialog(target, method, args) {
19228
+ const control = { ...args };
19229
+ if (method === "call.member.position.set") control.targets = (args.targets ?? []).map((entry) => ({
19230
+ call_id: entry.target?.call_id ?? entry.call_id,
19231
+ position: entry.position
19232
+ }));
19233
+ else {
19234
+ const member = typeof target === "object" ? {
19235
+ call_id: target.call_id,
19236
+ member_id: target.member_id
19237
+ } : {
18554
19238
  call_id: this.id,
18555
19239
  member_id: target
18556
- }
19240
+ };
19241
+ if (method === "call.member.remove") control.targets = [member];
19242
+ else control.target = member;
19243
+ }
19244
+ return this.sendCommand(method, control);
19245
+ }
19246
+ /**
19247
+ * Sends a `call.*` control verb **in-dialog** via `verto.info`, as an alternative
19248
+ * to the routed {@link executeMethod} transport.
19249
+ *
19250
+ * Why both exist: `executeMethod` addresses the member with an explicit
19251
+ * `{node_id, call_id, member_id}` tuple, which does not resolve for every conference,
19252
+ * so the op can fail. An in-dialog frame carries the verb on the member's own
19253
+ * signaling channel instead, so control works without the client needing to know how
19254
+ * the conference is hosted.
19255
+ *
19256
+ * The trade-off is reach: the in-dialog transport is only accepted for calls that
19257
+ * join a conference over SWML (e.g. an SWML `join_conference`); use the routed
19258
+ * default otherwise.
19259
+ *
19260
+ * `params` are sent verbatim — nothing is built for you, which includes the target.
19261
+ * **A self-directed op still needs one**, or it is refused; name yourself explicitly:
19262
+ *
19263
+ * ```ts
19264
+ * const { call_id, member_id } = call.self.target;
19265
+ * await call.sendCommand('call.mute', { channels: ['audio'], target: { call_id, member_id } });
19266
+ * ```
19267
+ *
19268
+ * Never include `node_id` — only the two ids. The shapes are per-verb: most take a
19269
+ * singular `target`, `call.member.remove` takes a plural `targets` array, and
19270
+ * `call.member.position.set` takes a flat `targets: [{call_id, position}]` (the one
19271
+ * verb keyed on `call_id` rather than `member_id`). Verbs that act on the call as a
19272
+ * whole, or that the SDK does not wrap at all, take no target.
19273
+ *
19274
+ * For the typed alternative that handles all of this, create the client with
19275
+ * `callControl: 'in-dialog'` and use the ordinary `Call`/`Participant` methods.
19276
+ *
19277
+ * @internal Not part of the supported surface while the in-dialog transport is still
19278
+ * rolling out. `WebRTCCall` is exported from the package entry, so without this tag
19279
+ * TypeDoc publishes the method — and the example above — as public API.
19280
+ *
19281
+ * @param method - A `call.*` method name (e.g. `'call.mute'`).
19282
+ * @param params - Method parameters, sent verbatim.
19283
+ * @returns The method's own reply, unwrapped from the `verto.info` envelope.
19284
+ * @throws {JSONRPCError} If the control op fails.
19285
+ */
19286
+ async sendCommand(method, params = {}) {
19287
+ return unwrapVertoReply(await this.vertoManager.sendCallControl(method, params));
19288
+ }
19289
+ /**
19290
+ * The local leg's member triple — sent as `self` in every member RPC
19291
+ * envelope, and as the `target` of call-scoped self-operations (e.g. lock,
19292
+ * layout).
19293
+ *
19294
+ * @throws {CallNotReadyError} Before `call.joined` delivers the self member
19295
+ * context (`selfId`/`nodeId`) — an RPC without it cannot be routed, so fail
19296
+ * fast instead of sending a doomed request.
19297
+ */
19298
+ get callSelf() {
19299
+ const node_id = this.nodeId;
19300
+ const member_id = this.vertoManager.selfId;
19301
+ if (!node_id || !member_id) throw new CallNotReadyError(this.id);
19302
+ return {
19303
+ node_id,
19304
+ call_id: this.id,
19305
+ member_id
18557
19306
  };
18558
19307
  }
18559
19308
  /** Observable of the current call status (e.g. `'ringing'`, `'connected'`). */
@@ -18709,13 +19458,13 @@ var WebRTCCall = class extends Destroyable {
18709
19458
  get bandwidthConstrained$() {
18710
19459
  return this.deferEmission(this._bandwidthConstrained$.asObservable());
18711
19460
  }
18712
- /** Observable that emits when server-pushed media params are applied. */
19461
+ /** Observable that emits when the server pushes media params. */
18713
19462
  get mediaParamsUpdated$() {
18714
19463
  return this.deferEmission(this._mediaParamsUpdated$.asObservable());
18715
19464
  }
18716
19465
  /**
18717
19466
  * @internal Emit a media params update event.
18718
- * Called by the VertoManager when server-pushed media params are applied.
19467
+ * Called by the VertoManager when the server pushes media params.
18719
19468
  */
18720
19469
  emitMediaParamsUpdated(event) {
18721
19470
  this._mediaParamsUpdated$.next(event);
@@ -18923,6 +19672,10 @@ var WebRTCCall = class extends Destroyable {
18923
19672
  get selfId$() {
18924
19673
  return this.vertoManager.selfId$;
18925
19674
  }
19675
+ /** @internal Lets call creation bound the media and signalling phases apart. */
19676
+ get localMediaSettled$() {
19677
+ return this.vertoManager.localMediaSettled$;
19678
+ }
18926
19679
  /** Local participant's member ID, or `null` if not joined. */
18927
19680
  get selfId() {
18928
19681
  return this.vertoManager.selfId;
@@ -19116,12 +19869,17 @@ var WebRTCCall = class extends Destroyable {
19116
19869
  *
19117
19870
  * **These operations are NOT atomic.** The layout is applied first, then each
19118
19871
  * member position sequentially, so members may briefly flash into their
19119
- * default slots before being moved to the requested positions.
19872
+ * default slots before being moved to the requested positions. Targeted
19873
+ * members are validated upfront, though: when any of them has no
19874
+ * {@link Participant.target | member call context} yet, the whole call
19875
+ * rejects before any request is sent and the layout is left unchanged.
19120
19876
  *
19121
19877
  * @param layout - Layout name (must be one of {@link layouts}).
19122
19878
  * @param positions - Optional map of member IDs to {@link VideoPosition} values.
19123
19879
  * When omitted or empty, only the layout is changed.
19124
19880
  * @throws {InvalidParams} If the layout is not in the available {@link layouts}.
19881
+ * @throws {ParticipantNotReadyError} If a targeted member's call context has
19882
+ * not been received yet — thrown before any request is sent.
19125
19883
  *
19126
19884
  * @example
19127
19885
  * ```ts
@@ -19132,18 +19890,19 @@ var WebRTCCall = class extends Destroyable {
19132
19890
  */
19133
19891
  async setLayout(layout, positions) {
19134
19892
  if (!this.layouts.includes(layout)) throw new InvalidParams(`Layout ${layout} is not available in the current call layouts: ${this.layouts.join(", ")}`);
19135
- const selfId = await (0, import_cjs$11.firstValueFrom)(this.selfId$.pipe((0, import_cjs$11.filter)((id) => id !== null)));
19136
- await this.executeMethod(selfId, "call.layout.set", { layout });
19137
- const positionEntries = Object.entries(positions ?? {});
19138
- if (positionEntries.length === 0) return;
19139
- for (const [memberId, position] of positionEntries) {
19893
+ const targets = [];
19894
+ for (const [memberId, position] of Object.entries(positions ?? {})) {
19140
19895
  const participant = this.participants.find((p) => p.id === memberId);
19141
19896
  if (!participant) {
19142
19897
  logger$12.warn(`[Call] setLayout: member ${memberId} not found in participants; skipping position ${position}`);
19143
19898
  continue;
19144
19899
  }
19145
- await participant.setPosition(position);
19900
+ participant.target;
19901
+ targets.push([participant, position]);
19146
19902
  }
19903
+ const selfId = await (0, import_cjs$11.firstValueFrom)(this.selfId$.pipe((0, import_cjs$11.filter)((id) => id !== null)));
19904
+ await this.executeMethod(selfId, "call.layout.set", { layout });
19905
+ for (const [participant, position] of targets) await participant.setPosition(position);
19147
19906
  }
19148
19907
  /**
19149
19908
  * Transfers the call to another destination.
@@ -19245,17 +20004,28 @@ var WebRTCCall = class extends Destroyable {
19245
20004
  * (notably iOS Safari) fall back to re-acquiring the track with the new
19246
20005
  * constraint set and plumbing the replacement through the local audio
19247
20006
  * pipeline if one is active.
20007
+ *
20008
+ * @returns whether the constraint reached the microphone. `false` is an
20009
+ * outcome rather than an error — a leg sending media the SDK did not capture
20010
+ * is left alone — so a UI that reflects the toggle must read it. Any failure
20011
+ * behind a `false` is also reported on {@link errors$}.
19248
20012
  */
19249
20013
  async setEchoCancellation(enabled) {
19250
- await this.vertoManager.updateMediaConstraints({ audio: { echoCancellation: enabled } });
20014
+ return this.vertoManager.updateMediaConstraints({ audio: { echoCancellation: enabled } });
19251
20015
  }
19252
- /** Toggle browser noise suppression on the local mic at runtime. */
20016
+ /**
20017
+ * Toggle browser noise suppression on the local mic at runtime.
20018
+ * @returns whether the constraint reached the microphone.
20019
+ */
19253
20020
  async setNoiseSuppression(enabled) {
19254
- await this.vertoManager.updateMediaConstraints({ audio: { noiseSuppression: enabled } });
20021
+ return this.vertoManager.updateMediaConstraints({ audio: { noiseSuppression: enabled } });
19255
20022
  }
19256
- /** Toggle browser automatic gain control on the local mic at runtime. */
20023
+ /**
20024
+ * Toggle browser automatic gain control on the local mic at runtime.
20025
+ * @returns whether the constraint reached the microphone.
20026
+ */
19257
20027
  async setAutoGainControl(enabled) {
19258
- await this.vertoManager.updateMediaConstraints({ audio: { autoGainControl: enabled } });
20028
+ return this.vertoManager.updateMediaConstraints({ audio: { autoGainControl: enabled } });
19259
20029
  }
19260
20030
  /**
19261
20031
  * Observable of the aggregate remote audio level, 0..1 RMS. The server
@@ -19312,6 +20082,9 @@ var WebRTCCall = class extends Destroyable {
19312
20082
  /**
19313
20083
  * Infers the semantic error category from a raw Error thrown by VertoManager
19314
20084
  * or an RTCPeerConnection layer.
20085
+ *
20086
+ * Pure function — exported for unit testing.
20087
+ * @internal
19315
20088
  */
19316
20089
  function inferCallErrorKind(error) {
19317
20090
  if (error instanceof RPCTimeoutError) return "timeout";
@@ -19330,7 +20103,14 @@ const RECOVERABLE_RPC_CODES = new Set([
19330
20103
  RPC_ERROR_AUTHENTICATION_FAILED,
19331
20104
  RPC_ERROR_INVALID_PARAMS
19332
20105
  ]);
19333
- /** Determines whether an error should be fatal (destroy the call). */
20106
+ /**
20107
+ * A *fallback*: callers knowing which leg failed pass an explicit `fatal` and
20108
+ * never reach here, so the default-fatal branch only sees call- and main-leg
20109
+ * errors. Auxiliary legs go through `WebRTCVertoManager.reportLegError`.
20110
+ *
20111
+ * Pure function — exported for unit testing.
20112
+ * @internal
20113
+ */
19334
20114
  function isFatalError(error) {
19335
20115
  if (error instanceof VertoPongError) return false;
19336
20116
  if (error instanceof MediaTrackError) return false;
@@ -19365,7 +20145,9 @@ var CallFactory = class {
19365
20145
  kind: inferCallErrorKind(error),
19366
20146
  fatal: options$1?.fatal ?? isFatalError(error),
19367
20147
  error,
19368
- callId: callInstance.id
20148
+ callId: callInstance.id,
20149
+ ...options$1?.leg ? { leg: options$1.leg } : {},
20150
+ ...options$1?.legId ? { legId: options$1.legId } : {}
19369
20151
  };
19370
20152
  callInstance.emitError(callError);
19371
20153
  },
@@ -19837,6 +20619,30 @@ const logger$9 = getLogger();
19837
20619
  function shouldAbortDial(callError) {
19838
20620
  return callError.fatal || !(callError.error instanceof MediaAccessError);
19839
20621
  }
20622
+ /**
20623
+ * Wait for a dialed call to be ready, or for the failure that stops it.
20624
+ *
20625
+ * Local media acquisition is deliberately unbounded: a permission prompt or a
20626
+ * device picker is human time, and `getUserMedia` cannot be cancelled anyway.
20627
+ * The clock starts only once acquisition settles, so a slow human never spends
20628
+ * the server's budget.
20629
+ *
20630
+ * `merge` rather than `race`, because the two legs settle asymmetrically. A
20631
+ * fatal acquisition failure reports the error and then destroys the call in the
20632
+ * same synchronous step; `errors$` defers delivery by a microtask while
20633
+ * `localMediaSettled$` completes immediately. Under `race` that bare completion
20634
+ * ended the wait first and `dial()` rejected with an RxJS `EmptyError`, burying
20635
+ * the `NotAllowedError` applications are told to inspect. Under `merge` the
20636
+ * completed leg is simply spent, and the queued error — enqueued before the
20637
+ * completion, so delivered before it — arrives to reject the wait. A dial
20638
+ * abandoned with no error at all still ends both legs, and the resulting
20639
+ * `EmptyError` remains the benign-cancel signal.
20640
+ *
20641
+ * Exported for unit testing.
20642
+ */
20643
+ async function awaitDialReady(session, signalingTimeoutMs) {
20644
+ 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)))));
20645
+ }
19840
20646
  const getAddressSearchURI = (options) => {
19841
20647
  const to = options.to?.split("?")[0];
19842
20648
  const from$9 = options.from?.startsWith("subscriber://") ? options.from.replace("subscriber://", "") : options.from;
@@ -19853,7 +20659,6 @@ var ClientSessionManager = class extends Destroyable {
19853
20659
  this.authorizationStateKey = authorizationStateKey;
19854
20660
  this.attachManager = attachManager;
19855
20661
  this.dpopManager = dpopManager;
19856
- this.callCreateTimeout = 6e3;
19857
20662
  this.agent = `signalwire-js/4.0.0`;
19858
20663
  this.eventAcks = true;
19859
20664
  this.authorizationState$ = this.createReplaySubject(1);
@@ -19862,6 +20667,7 @@ var ClientSessionManager = class extends Destroyable {
19862
20667
  minor: 0,
19863
20668
  revision: 0
19864
20669
  };
20670
+ this.callControl = "routed";
19865
20671
  this._authorization$ = this.createBehaviorSubject(void 0);
19866
20672
  this._errors$ = this.createReplaySubject(1);
19867
20673
  this._authState$ = this.createBehaviorSubject({ kind: "unauthenticated" });
@@ -20062,21 +20868,18 @@ var ClientSessionManager = class extends Destroyable {
20062
20868
  }
20063
20869
  async handleAuthenticationError(error) {
20064
20870
  logger$9.error("Authentication error:", error);
20065
- const isRecoverableAuthError = error instanceof JSONRPCError && (error.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === RPC_ERROR_INVALID_PARAMS || error.code === RPC_ERROR_AUTHENTICATION_FAILED);
20871
+ 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);
20066
20872
  const hasStoredState = await (0, import_cjs$7.firstValueFrom)(this.authorizationState$.pipe((0, import_cjs$7.take)(1))) !== void 0;
20067
- if (isRecoverableAuthError && hasStoredState) {
20873
+ if (isRecoverableAuthError$1 && hasStoredState) {
20068
20874
  logger$9.debug("[Session] Recoverable auth error — cleaning up stored state and reconnecting fresh");
20069
- try {
20070
- await this.cleanupStoredConnectionParams();
20071
- } catch (cleanupError) {
20072
- logger$9.error("Failed to cleanup stored connection params:", cleanupError);
20073
- } finally {
20074
- this.transport.reconnect();
20075
- }
20875
+ await this.discardResumeStateAndReconnect();
20076
20876
  } else this._errors$.next(error);
20077
20877
  }
20078
20878
  /**
20079
- * Clear the resume state (authorization_state + protocol) only.
20879
+ * Clear the resume state (authorization_state + protocol) and ask the
20880
+ * transport to reconnect. The `connected` event re-triggers
20881
+ * `authenticate()`, which now has no stored state and so performs a fresh
20882
+ * connect.
20080
20883
  *
20081
20884
  * This is the stale-auth-state recovery helper used by handleAuthError:
20082
20885
  * the server rejected a reconnect, so the resume state is discarded and a
@@ -20084,9 +20887,24 @@ var ClientSessionManager = class extends Destroyable {
20084
20887
  * session lives on through the reconnect and reattachCalls() needs the
20085
20888
  * stored call references afterwards. Do NOT add detachAll() here.
20086
20889
  *
20890
+ * Connect-time recovery only. A *request* refused on an already
20891
+ * authenticated session is never healed here: dropping the resume state
20892
+ * destroys the association between the socket and the previous session,
20893
+ * which is what reattach depends on. That path mints a fresh credential and
20894
+ * reauthenticates instead (see `SignalWire.recoverAndRetry`).
20895
+ *
20087
20896
  * For public teardown (disconnect/destroy), use {@link teardownSessionState}
20088
20897
  * instead, which clears the attach records as well.
20089
20898
  */
20899
+ async discardResumeStateAndReconnect() {
20900
+ try {
20901
+ await this.cleanupStoredConnectionParams();
20902
+ } catch (cleanupError) {
20903
+ logger$9.error("Failed to cleanup stored connection params:", cleanupError);
20904
+ } finally {
20905
+ this.transport.reconnect();
20906
+ }
20907
+ }
20090
20908
  async cleanupStoredConnectionParams() {
20091
20909
  await this.transport.setProtocol(void 0);
20092
20910
  await this.updateAuthorizationStateInStorage(void 0);
@@ -20160,11 +20978,15 @@ var ClientSessionManager = class extends Destroyable {
20160
20978
  const isReconnect = hasReconnectState && storedToken;
20161
20979
  let dpopToken;
20162
20980
  if (isReconnect) logger$9.debug("[Session] Reconnecting with stored jwt_token + authorization_state");
20163
- else if (this.onBeforeReconnect && this.clientBound) {
20164
- logger$9.debug("[Session] Refreshing credentials before fresh connect");
20165
- await this.onBeforeReconnect();
20981
+ else {
20982
+ const credential = this.getCredential();
20983
+ const credentialExpired = credential.expiry_at !== void 0 && credential.expiry_at <= Date.now() + CREDENTIAL_EXPIRY_SKEW_MS;
20984
+ if (this.onBeforeReconnect && (this.clientBound || credentialExpired)) {
20985
+ logger$9.debug("[Session] Refreshing credentials before fresh connect");
20986
+ await this.onBeforeReconnect();
20987
+ }
20166
20988
  }
20167
- if ((!isReconnect || this.clientBound) && this.dpopManager?.initialized) try {
20989
+ if (this.dpopManager?.initialized) try {
20168
20990
  dpopToken = await this.dpopManager.createRpcProof({ method: "signalwire.connect" });
20169
20991
  } catch (error) {
20170
20992
  if (this.clientBound) throw error;
@@ -20197,6 +21019,7 @@ var ClientSessionManager = class extends Destroyable {
20197
21019
  });
20198
21020
  if (response.protocol) await this.transport.setProtocol(response.protocol);
20199
21021
  this._authorization$.next(response.authorization);
21022
+ if (response.authorization.cnf?.jkt) this._wasClientBound = true;
20200
21023
  this._iceServers$.next(response.ice_servers ?? []);
20201
21024
  this._authState$.next({ kind: "authenticated" });
20202
21025
  logger$9.debug("[Session] Authentication completed successfully");
@@ -20263,7 +21086,7 @@ var ClientSessionManager = class extends Destroyable {
20263
21086
  to: destinationURI,
20264
21087
  ...options
20265
21088
  });
20266
- 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)))));
21089
+ await awaitDialReady(callSession, DEFAULT_CALL_SIGNALING_TIMEOUT_MS);
20267
21090
  this._calls$.next({
20268
21091
  [`${callSession.id}`]: callSession,
20269
21092
  ...this._calls$.value
@@ -20316,12 +21139,23 @@ var ClientSessionWrapper = class {
20316
21139
  get authenticated() {
20317
21140
  return this.clientSessionManager.authenticated;
20318
21141
  }
21142
+ /**
21143
+ * Whether the session is using a Client Bound SAT (DPoP). Sticky — set
21144
+ * when the binding is established or restored from a resumed session's
21145
+ * server authorization.
21146
+ */
21147
+ get clientBound() {
21148
+ return this.clientSessionManager.clientBound;
21149
+ }
20319
21150
  get signalingEvent$() {
20320
21151
  return this.clientSessionManager.signalingEvent$;
20321
21152
  }
20322
21153
  get iceServers() {
20323
21154
  return this.clientSessionManager.iceServers;
20324
21155
  }
21156
+ get callControl() {
21157
+ return this.clientSessionManager.callControl;
21158
+ }
20325
21159
  async execute(request, options) {
20326
21160
  return this.clientSessionManager.execute(request, options);
20327
21161
  }
@@ -20518,7 +21352,7 @@ var DeviceTokenManager = class extends Destroyable {
20518
21352
  await session.reauthenticate(tokenData.token, rpcProof, { clientBound: true });
20519
21353
  updateCredential({ token: tokenData.token });
20520
21354
  logger$7.info("[DeviceToken] Client Bound SAT activated successfully");
20521
- this._currentToken$.next(tokenData);
21355
+ this.emitCurrentToken(tokenData);
20522
21356
  return { activated: true };
20523
21357
  } catch (error) {
20524
21358
  logger$7.error("[DeviceToken] Failed to activate Client Bound SAT:", error);
@@ -20530,6 +21364,21 @@ var DeviceTokenManager = class extends Destroyable {
20530
21364
  }
20531
21365
  }
20532
21366
  /**
21367
+ * Emit a freshly received token to the reactive pipeline, stamping an
21368
+ * absolute `expires_at` when the response carried only `expires_in`.
21369
+ * Resolving the expiry at RECEIVE time (not at read time) is what lets
21370
+ * {@link refreshNowIfDue} detect due-ness on resume: a bare `expires_in`
21371
+ * re-resolved later would always compute a full TTL from "now" and never
21372
+ * cross the refresh buffer.
21373
+ */
21374
+ emitCurrentToken(token) {
21375
+ const stamped = token.expires_at ? token : {
21376
+ ...token,
21377
+ expires_at: resolveExpiresAt(token)
21378
+ };
21379
+ this._currentToken$.next(stamped);
21380
+ }
21381
+ /**
20533
21382
  * Returns true when the cached token has enough headroom before expiry to
20534
21383
  * be safely reused on reactivation. The headroom matches the refresh
20535
21384
  * buffer, so a token within the refresh window is treated as stale (the
@@ -20547,7 +21396,7 @@ var DeviceTokenManager = class extends Destroyable {
20547
21396
  method: "POST",
20548
21397
  uri: DEVICE_TOKEN_ENDPOINT
20549
21398
  });
20550
- const response = await this.http.request({
21399
+ const response = await this.http().request({
20551
21400
  url: DEVICE_TOKEN_ENDPOINT,
20552
21401
  ...POST_PARAMS,
20553
21402
  body: JSON.stringify({
@@ -20574,7 +21423,7 @@ var DeviceTokenManager = class extends Destroyable {
20574
21423
  uri: DEVICE_REFRESH_ENDPOINT,
20575
21424
  accessToken: currentToken
20576
21425
  });
20577
- const response = await this.http.request({
21426
+ const response = await this.http().request({
20578
21427
  url: DEVICE_REFRESH_ENDPOINT,
20579
21428
  ...POST_PARAMS,
20580
21429
  body: JSON.stringify({
@@ -20622,7 +21471,7 @@ var DeviceTokenManager = class extends Destroyable {
20622
21471
  const currentToken = this.getCredential().token;
20623
21472
  if (!currentToken) throw new TokenRefreshError("No current token available for refresh");
20624
21473
  const newTokenData = await this.retryRefresh(session, currentToken, updateCredential);
20625
- this._currentToken$.next(newTokenData);
21474
+ this.emitCurrentToken(newTokenData);
20626
21475
  } catch (error) {
20627
21476
  logger$7.error("[DeviceToken] Automatic Client Bound SAT refresh failed:", error);
20628
21477
  this.errorHandler(error instanceof TokenRefreshError ? error : new TokenRefreshError("Automatic token refresh failed", error));
@@ -20649,6 +21498,22 @@ var DeviceTokenManager = class extends Destroyable {
20649
21498
  throw lastError instanceof Error ? lastError : new TokenRefreshError("All refresh retries exhausted", lastError);
20650
21499
  }
20651
21500
  /**
21501
+ * Force an immediate refresh when the cached Client Bound SAT is already
21502
+ * past its refresh window. Called on resume from suspension where
21503
+ * background-tab throttling can delay the reactive timer past the buffer.
21504
+ * A no-op when no token is cached or it still has headroom; the normal
21505
+ * {@link executeRefresh} guards (paused / in-progress / unauthenticated)
21506
+ * still apply.
21507
+ */
21508
+ refreshNowIfDue() {
21509
+ const token = this._currentToken$.value;
21510
+ if (!token) return;
21511
+ if (resolveExpiresAt(token) * 1e3 - Date.now() <= DEVICE_TOKEN_REFRESH_BUFFER_MS) {
21512
+ logger$7.debug("[DeviceToken] Resume: cached SAT past refresh window; refreshing now");
21513
+ this.executeRefresh();
21514
+ }
21515
+ }
21516
+ /**
20652
21517
  * Stops the reactive refresh pipeline from firing. Use when the underlying
20653
21518
  * session is being torn down (e.g., during {@link SignalWire.disconnect})
20654
21519
  * so a scheduled refresh cannot fire against a destroyed session.
@@ -20699,6 +21564,7 @@ var CredentialRefreshCoordinator = class extends Destroyable {
20699
21564
  this.deps = deps;
20700
21565
  this._activating = false;
20701
21566
  this._activationGeneration = 0;
21567
+ this._developerRefreshInProgress = false;
20702
21568
  if (dpopManager?.initialized) this._deviceTokenManager = (deps.deviceTokenManagerFactory ?? defaultDeviceTokenManagerFactory)(dpopManager, deps.http, (error) => deps.notifier.onError(error), () => deps.store.read());
20703
21569
  }
20704
21570
  /** True when the Client Bound SAT path is available (DPoP initialized). */
@@ -20717,28 +21583,120 @@ var CredentialRefreshCoordinator = class extends Destroyable {
20717
21583
  * invokes `deps.onRefreshExhausted` so the orchestrator can disconnect.
20718
21584
  */
20719
21585
  scheduleDeveloperRefresh(provider, expiresAt, attempt = 0) {
21586
+ this._activeProvider = provider;
20720
21587
  if (this._developerTimerId !== void 0) clearTimeout(this._developerTimerId);
20721
21588
  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);
20722
- this._developerTimerId = setTimeout(async () => {
21589
+ this._developerTimerId = setTimeout(() => {
21590
+ this._developerTimerId = void 0;
21591
+ this.executeDeveloperRefresh(provider, expiresAt, attempt);
21592
+ }, refreshInterval);
21593
+ }
21594
+ /**
21595
+ * Runs the developer-provided refresh once: mints a new credential, stores
21596
+ * and persists it, reauthenticates the live session (via the notifier), and
21597
+ * reschedules against the new expiry. On failure retries with backoff up to
21598
+ * {@link CREDENTIAL_REFRESH_MAX_RETRIES}, then signals exhaustion.
21599
+ *
21600
+ * Shared by the scheduled timer tick and {@link forceRefreshIfDue}. The
21601
+ * `_developerRefreshInProgress` guard prevents the two from overlapping.
21602
+ */
21603
+ async executeDeveloperRefresh(provider, expiresAt, attempt) {
21604
+ if (this._developerRefreshInProgress) {
21605
+ logger$6.debug("[Coordinator] Developer refresh already in progress; skipping");
21606
+ return;
21607
+ }
21608
+ this._developerRefreshInProgress = true;
21609
+ try {
21610
+ const newCredentials = await this.refreshCredential(provider);
21611
+ this.deps.store.write(newCredentials);
21612
+ this.deps.store.persist(newCredentials);
20723
21613
  try {
20724
- if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
20725
- const newCredentials = await provider.refresh();
20726
- this.deps.store.write(newCredentials);
20727
- this.deps.store.persist(newCredentials);
20728
- logger$6.info("[Coordinator] Credentials refreshed successfully.");
20729
- if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
20730
- } catch (error) {
20731
- const nextAttempt = attempt + 1;
20732
- logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
20733
- this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
20734
- if (nextAttempt < CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
20735
- else {
20736
- logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
20737
- this.deps.notifier.onError(new TokenRefreshError("Credential refresh failed after max retries"));
20738
- this.deps.notifier.onRefreshExhausted();
20739
- }
21614
+ await this.deps.notifier.onCredentialRefreshed(newCredentials);
21615
+ } catch (reauthError) {
21616
+ logger$6.warn("[Coordinator] onCredentialRefreshed rejected (non-fatal):", reauthError);
20740
21617
  }
20741
- }, refreshInterval);
21618
+ logger$6.info("[Coordinator] Credentials refreshed successfully.");
21619
+ if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
21620
+ } catch (error) {
21621
+ const nextAttempt = attempt + 1;
21622
+ logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
21623
+ this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
21624
+ if (nextAttempt < CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
21625
+ else {
21626
+ logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
21627
+ this.deps.notifier.onError(new TokenRefreshError("Credential refresh failed after max retries"));
21628
+ this.deps.notifier.onRefreshExhausted();
21629
+ }
21630
+ } finally {
21631
+ this._developerRefreshInProgress = false;
21632
+ }
21633
+ }
21634
+ /**
21635
+ * Force an immediate refresh when the current credential is already past its
21636
+ * scheduled refresh window. Called on resume from suspension, where
21637
+ * background-tab timer throttling can delay the armed refresh well past
21638
+ * expiry, leaving the live session stale.
21639
+ *
21640
+ * Routes to whichever mechanism is armed: the developer timer if armed,
21641
+ * otherwise the Client Bound SAT pipeline. A no-op when nothing is due.
21642
+ */
21643
+ forceRefreshIfDue() {
21644
+ if (this._developerTimerId !== void 0 && this._activeProvider) {
21645
+ const expiry = this.deps.store.read().expiry_at;
21646
+ if (expiry !== void 0 && Date.now() >= expiry - CREDENTIAL_REFRESH_BUFFER_MS) {
21647
+ logger$6.debug("[Coordinator] Resume: credential past refresh window; forcing refresh");
21648
+ clearTimeout(this._developerTimerId);
21649
+ this._developerTimerId = void 0;
21650
+ this.executeDeveloperRefresh(this._activeProvider, expiry, 0);
21651
+ }
21652
+ return;
21653
+ }
21654
+ this._deviceTokenManager?.refreshNowIfDue();
21655
+ }
21656
+ /**
21657
+ * Sync the credential's expiry from the server-provided authorization (the
21658
+ * `signalwire.connect` result). SATs are opaque JWE, so
21659
+ * `fabric_subscriber.expires_at` is the authoritative expiry of the token
21660
+ * the session actually connected with — the provider-reported `expiry_at`
21661
+ * is only a hint (and may be wrong or absent). Corrects the stored
21662
+ * credential and re-arms the developer refresh timer against the real
21663
+ * deadline when the provider supports `refresh()`.
21664
+ */
21665
+ syncExpiryFromAuthorization(authorization, provider) {
21666
+ const expiresAtSec = authorization?.fabric_subscriber?.expires_at;
21667
+ if (!expiresAtSec) return;
21668
+ const expiryAt = expiresAtSec * 1e3;
21669
+ const credential = this.deps.store.read();
21670
+ if (credential.expiry_at === expiryAt) return;
21671
+ logger$6.debug(`[Coordinator] Correcting credential expiry from server authorization: ${new Date(expiryAt).toISOString()}`);
21672
+ const updated = {
21673
+ ...credential,
21674
+ expiry_at: expiryAt
21675
+ };
21676
+ this.deps.store.write(updated);
21677
+ this.deps.store.persist(updated);
21678
+ if (provider?.refresh) this.scheduleDeveloperRefresh(provider, expiryAt);
21679
+ }
21680
+ /**
21681
+ * Invoke `provider.refresh()` deduped against any concurrent developer
21682
+ * refresh. Concurrent callers — the scheduled tick, a resume-forced refresh,
21683
+ * and the orchestrator's -32003 recovery / reconnect re-mint — share one
21684
+ * in-flight promise, so a provider backed by one-time-use rotating refresh
21685
+ * tokens is never invoked twice in parallel.
21686
+ *
21687
+ * The caller owns applying the returned credential (store write, session
21688
+ * reauth, rescheduling); this method only serializes the network call.
21689
+ */
21690
+ async refreshCredential(provider) {
21691
+ if (this._refreshInFlight) return this._refreshInFlight;
21692
+ if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
21693
+ const run = provider.refresh();
21694
+ this._refreshInFlight = run;
21695
+ const clear = () => {
21696
+ if (this._refreshInFlight === run) this._refreshInFlight = void 0;
21697
+ };
21698
+ run.then(clear, clear);
21699
+ return run;
20742
21700
  }
20743
21701
  /**
20744
21702
  * Cancels any scheduled developer-provided refresh. Idempotent.
@@ -21522,6 +22480,13 @@ var TransportManager = class extends Destroyable {
21522
22480
  //#region src/clients/SignalWire.ts
21523
22481
  var import_cjs$1 = require_cjs();
21524
22482
  const logger$1 = getLogger();
22483
+ /**
22484
+ * Storage key for the client-bound marker. The SAT and authorization_state are
22485
+ * both opaque to the SDK, so on a page reload the preflight recovery — which
22486
+ * runs before any session exists — has no other way to know the session was
22487
+ * client-bound. See {@link SignalWire.persistClientBoundMarker}.
22488
+ */
22489
+ const CLIENT_BOUND_STORAGE_KEY = "sw:client_bound";
21525
22490
  const buildOptionsFromDestination = (destination) => {
21526
22491
  if (typeof destination === "string") {
21527
22492
  const queryStartIndex = destination.indexOf("?");
@@ -21565,6 +22530,7 @@ var SignalWire = class extends Destroyable {
21565
22530
  this.preferences = new ClientPreferences();
21566
22531
  this._user$ = this.createBehaviorSubject(void 0);
21567
22532
  this._directory$ = this.createBehaviorSubject(void 0);
22533
+ this._credentialRecovered = false;
21568
22534
  this._isConnected$ = this.createBehaviorSubject(false);
21569
22535
  this._isRegistered$ = this.createBehaviorSubject(false);
21570
22536
  this._errors$ = this.createReplaySubject(1);
@@ -21603,6 +22569,16 @@ var SignalWire = class extends Destroyable {
21603
22569
  });
21604
22570
  }
21605
22571
  /**
22572
+ * Build the refresh path's own HTTP controller, against whatever host is current.
22573
+ *
22574
+ * Called on first use rather than up front, so `apiHost` already reflects the
22575
+ * token's `ch` claim. Same credential source as the container's controller — only
22576
+ * the instance, and therefore its observable streams, is separate.
22577
+ */
22578
+ createRefreshHttpController() {
22579
+ return new HTTPRequestController(this._deps.apiHost, () => this._deps.credential);
22580
+ }
22581
+ /**
21606
22582
  * Initializes DPoP if not already set up. Returns the fingerprint on success.
21607
22583
  */
21608
22584
  async initDPoP() {
@@ -21629,11 +22605,19 @@ var SignalWire = class extends Destroyable {
21629
22605
  async resolveCredentials() {
21630
22606
  const fingerprint = await this.initDPoP();
21631
22607
  this._refreshCoordinator = new CredentialRefreshCoordinator(this._dpopManager, {
21632
- http: this._deps.http,
22608
+ http: () => {
22609
+ if (!this._refreshHttp || this._refreshHttpHost !== this._deps.apiHost) {
22610
+ this._refreshHttp?.destroy();
22611
+ this._refreshHttp = this.createRefreshHttpController();
22612
+ this._refreshHttpHost = this._deps.apiHost;
22613
+ }
22614
+ return this._refreshHttp;
22615
+ },
21633
22616
  notifier: {
21634
22617
  onError: (error) => this._errors$.next(error),
21635
22618
  onWarning: (warning) => this._warnings$.next(warning),
21636
- onRefreshExhausted: () => void this.disconnect()
22619
+ onRefreshExhausted: () => void this.disconnect(),
22620
+ onCredentialRefreshed: async (credential) => this.reauthenticateLiveSession(credential)
21637
22621
  },
21638
22622
  store: {
21639
22623
  read: () => this._deps.credential,
@@ -21645,6 +22629,7 @@ var SignalWire = class extends Destroyable {
21645
22629
  ...this._deps.credential,
21646
22630
  ...partial
21647
22631
  };
22632
+ this.persistCredential(this._deps.credential);
21648
22633
  },
21649
22634
  persist: (credential) => this.persistCredential(credential)
21650
22635
  }
@@ -21690,20 +22675,181 @@ var SignalWire = class extends Destroyable {
21690
22675
  }
21691
22676
  this._deps.credential = _credentials;
21692
22677
  this.persistCredential(_credentials);
21693
- if (this.isConnected && this._clientSession.authenticated && _credentials.token) try {
21694
- await this._clientSession.reauthenticate(_credentials.token);
22678
+ await this.reauthenticateLiveSession(_credentials);
22679
+ }
22680
+ /**
22681
+ * Reauthenticate the currently-open session with a freshly obtained
22682
+ * credential so the new token takes effect on the live socket immediately —
22683
+ * not just on the next reconnect. No-op when the session is not
22684
+ * connected/authenticated or the credential carries no token (e.g. an
22685
+ * authorization-state-only refresh). Non-fatal: reauth failures surface on
22686
+ * `errors$` without aborting the refresh that triggered this.
22687
+ */
22688
+ async reauthenticateLiveSession(credential) {
22689
+ if (!this.isConnected || !this._clientSession.authenticated || !credential.token) return;
22690
+ try {
22691
+ await this._clientSession.reauthenticate(credential.token);
21695
22692
  logger$1.info("[SignalWire] Session refreshed with new credentials.");
21696
22693
  } catch (error) {
21697
22694
  logger$1.error("[SignalWire] Failed to refresh session with new credentials:", error);
21698
22695
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
21699
22696
  }
21700
22697
  }
22698
+ /**
22699
+ * Recover a session the server is refusing: mint a fresh credential,
22700
+ * reauthenticate the live session with it, and retry the operation.
22701
+ *
22702
+ * The connection is deliberately kept. A reload authenticates the new socket
22703
+ * against the persisted `authorization_state`, and that handshake is what
22704
+ * associates the socket with the previous session — the association reattach
22705
+ * depends on. `signalwire.reauthenticate` swaps the credential *on that same
22706
+ * session*, so recovery never touches the resume state. Discarding it would
22707
+ * heal the credential by destroying the very thing the caller is trying to
22708
+ * get back to.
22709
+ *
22710
+ * The operation is still the verdict, never the RPC. Reauthenticating with
22711
+ * the in-memory token is accepted by a resume even while requests stay
22712
+ * refused, because the persisted `authorization_state` short-circuits token
22713
+ * validation — and `signalwire.reauthenticate` with a *freshly minted* token
22714
+ * has also been observed accepted while `subscriber.online` keeps being
22715
+ * refused (staging run 33826974634). Both look like success and are not.
22716
+ *
22717
+ * @returns the operation's value, or the reason recovery could not deliver
22718
+ * one. `error` is undefined when there was no way to mint at all.
22719
+ */
22720
+ async recoverAndRetry(operation) {
22721
+ if (!await this.remintAndReauthenticate()) return { ok: false };
22722
+ try {
22723
+ const value = await operation();
22724
+ this._credentialRecovered = true;
22725
+ return {
22726
+ ok: true,
22727
+ value
22728
+ };
22729
+ } catch (error) {
22730
+ logger$1.warn("[SignalWire] Reauthentication was accepted but the operation is still refused:", error);
22731
+ return {
22732
+ ok: false,
22733
+ error
22734
+ };
22735
+ }
22736
+ }
22737
+ /**
22738
+ * Re-mint a credential and adopt it only if the live session accepts it.
22739
+ *
22740
+ * The mechanism follows the binding: a client-bound session re-mints a bound
22741
+ * base SAT through `authenticate()` with the DPoP fingerprint, because the
22742
+ * developer refresh handler would hand back an unbound token and silently
22743
+ * degrade the session. An unbound session uses the refresh handler. Rotation
22744
+ * cost is not a reason to skip this — the only reason is having no mechanism.
22745
+ *
22746
+ * @returns whether the session is now running on a freshly accepted credential.
22747
+ */
22748
+ async remintAndReauthenticate() {
22749
+ const provider = this._credentialProvider;
22750
+ if (!provider) return false;
22751
+ const { clientBound } = this._clientSession;
22752
+ if (!clientBound && !provider.refresh) {
22753
+ logger$1.debug("[SignalWire] [SW-NO-REFRESH-HANDLER] Unbound session with no refresh handler; cannot re-mint.");
22754
+ return false;
22755
+ }
22756
+ try {
22757
+ const newCredentials = clientBound ? await provider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0) : await this.remintCredential(provider);
22758
+ if (!newCredentials.token) {
22759
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the current one.");
22760
+ return false;
22761
+ }
22762
+ await this._clientSession.reauthenticate(newCredentials.token);
22763
+ this._deps.credential = newCredentials;
22764
+ this.persistCredential(newCredentials);
22765
+ if (newCredentials.expiry_at && provider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(provider, newCredentials.expiry_at);
22766
+ return true;
22767
+ } catch (error) {
22768
+ logger$1.warn("[SignalWire] Re-mint recovery failed:", error);
22769
+ return false;
22770
+ }
22771
+ }
22772
+ /**
22773
+ * Re-mint a credential via `provider.refresh()`, routed through the
22774
+ * coordinator's shared in-flight guard so concurrent re-mint paths (a
22775
+ * scheduled/resume refresh, -32003 recovery, and reconnect) never fire a
22776
+ * second `provider.refresh()` in parallel — which rotating one-time-use
22777
+ * refresh tokens reject. Falls back to a direct call only if the coordinator
22778
+ * has not been constructed yet.
22779
+ */
22780
+ async remintCredential(provider) {
22781
+ if (this._refreshCoordinator) return this._refreshCoordinator.refreshCredential(provider);
22782
+ if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
22783
+ return provider.refresh();
22784
+ }
22785
+ /**
22786
+ * Re-mint credentials before a fresh (re)connect (`onBeforeReconnect` hook).
22787
+ * The session invokes this only when it is client-bound OR the in-memory
22788
+ * token is expired. The re-mint mechanism depends on the binding:
22789
+ * - Client-bound: `authenticate()` with the DPoP fingerprint to obtain a
22790
+ * fresh base SAT the upcoming reconnect can re-bind (the
22791
+ * DeviceTokenManager re-activates afterwards).
22792
+ * - Unbound: the developer's non-interactive `refresh()` handler.
22793
+ * `authenticate()` is deliberately NOT used here — it may be interactive
22794
+ * (a login prompt) and must not fire on a background reconnect.
22795
+ *
22796
+ * Rejects on failure so the session aborts the reconnect rather than
22797
+ * replaying a stale token.
22798
+ */
22799
+ async refreshCredentialForReconnect() {
22800
+ if (!this._credentialProvider) return;
22801
+ try {
22802
+ let newCredentials;
22803
+ if (this._clientSession?.clientBound ?? await this.wasClientBound()) {
22804
+ logger$1.debug("[SignalWire] Re-minting client-bound base SAT before reconnect");
22805
+ newCredentials = await this._credentialProvider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0);
22806
+ } else if (this._credentialProvider.refresh) {
22807
+ logger$1.debug("[SignalWire] Refreshing unbound credential before reconnect");
22808
+ newCredentials = await this.remintCredential(this._credentialProvider);
22809
+ } else {
22810
+ logger$1.warn("[SignalWire] [SW-NO-REFRESH-HANDLER] Token expired on reconnect but no refresh handler; reconnecting with the existing token.");
22811
+ return;
22812
+ }
22813
+ if (!newCredentials.token) {
22814
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the existing credential for reconnect.");
22815
+ return;
22816
+ }
22817
+ this._deps.credential = newCredentials;
22818
+ this.persistCredential(newCredentials);
22819
+ if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
22820
+ logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
22821
+ } catch (error) {
22822
+ logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
22823
+ this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22824
+ throw error;
22825
+ }
22826
+ }
21701
22827
  /** Persist credential to localStorage when persistSession is enabled. */
21702
22828
  persistCredential(credential) {
21703
22829
  if (!credential.token) return;
21704
22830
  this._deps.storage.setItem("sw:cached_credential", credential);
21705
22831
  if (this._deps.persistSession) this._deps.storage.setItem("sw:cached_credential", credential, "local");
21706
22832
  }
22833
+ /**
22834
+ * Persist whether the session is client-bound, mirroring the credential's
22835
+ * storage scopes so it survives a reload. The preflight recovery reads it
22836
+ * before any session exists to decide whether to re-bind via `authenticate()`
22837
+ * or refresh an unbound token; the marker tracks the latest binding, so an
22838
+ * unbound reconnect clears a stale marker from an earlier client-bound login.
22839
+ */
22840
+ persistClientBoundMarker(bound) {
22841
+ const scopes = this._deps.persistSession ? ["session", "local"] : ["session"];
22842
+ for (const scope of scopes) if (bound) this._deps.storage.setItem(CLIENT_BOUND_STORAGE_KEY, true, scope);
22843
+ else this._deps.storage.removeItem(CLIENT_BOUND_STORAGE_KEY, scope);
22844
+ }
22845
+ /** Read the persisted client-bound marker (see {@link persistClientBoundMarker}). */
22846
+ async wasClientBound() {
22847
+ const scopes = this._deps.persistSession ? ["local", "session"] : ["session"];
22848
+ for (const scope of scopes) try {
22849
+ if (await this._deps.storage.getItem(CLIENT_BOUND_STORAGE_KEY, scope)) return true;
22850
+ } catch {}
22851
+ return false;
22852
+ }
21707
22853
  async init() {
21708
22854
  this._user$.next(new User(this._deps.http));
21709
22855
  if (!this._options.skipConnection) await this.connect();
@@ -21727,6 +22873,42 @@ var SignalWire = class extends Destroyable {
21727
22873
  } catch (error) {
21728
22874
  logger$1.error("[SignalWire] Failed to reattach calls:", error);
21729
22875
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22876
+ } finally {
22877
+ this._credentialRecovered = false;
22878
+ }
22879
+ }
22880
+ /**
22881
+ * Fetch the authenticated user profile, recovering a stale credential.
22882
+ *
22883
+ * On a reload the persisted credential can be expired. Unlike the WS resume —
22884
+ * which the server accepts against the persisted `authorization_state` even
22885
+ * with an expired token — this REST preflight has no such short-circuit and is
22886
+ * refused (401). There is no session yet to reauthenticate, so recovery
22887
+ * re-mints the credential through the provider ({@link refreshCredentialForReconnect})
22888
+ * and retries with a FRESH {@link User}: Fetchable memoizes its result
22889
+ * (shareReplay), so reusing the instance would replay the 401 instead of
22890
+ * re-fetching with the new token. Without the user id the transport/session —
22891
+ * and the reattach a reload is trying to preserve — cannot even be addressed.
22892
+ */
22893
+ async fetchUserOrRecover() {
22894
+ const fetchUser = async (user$1) => {
22895
+ if (!await (0, import_cjs$1.firstValueFrom)(user$1.fetched$)) throw new UnexpectedError("Failed to fetch user information - fetched$ emitted false");
22896
+ this._deps.user = user$1;
22897
+ };
22898
+ const user = this._user$.value;
22899
+ if (!user) throw new UnexpectedError("User not initialized before connect");
22900
+ try {
22901
+ await fetchUser(user);
22902
+ } catch (firstError) {
22903
+ 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.`);
22904
+ try {
22905
+ await this.refreshCredentialForReconnect();
22906
+ const refetched = new User(this._deps.http);
22907
+ await fetchUser(refetched);
22908
+ this._user$.next(refetched);
22909
+ } catch (retryError) {
22910
+ throw new UnexpectedError("Error fetching user information", { cause: retryError });
22911
+ }
21730
22912
  }
21731
22913
  }
21732
22914
  /**
@@ -21768,40 +22950,23 @@ var SignalWire = class extends Destroyable {
21768
22950
  */
21769
22951
  async connect() {
21770
22952
  await this.teardownTransportAndSession();
21771
- try {
21772
- const user = this._user$.value;
21773
- if (!user) throw new UnexpectedError("User not initialized before connect");
21774
- if (!await (0, import_cjs$1.firstValueFrom)(user.fetched$)) throw new UnexpectedError("Failed to fetch user information - fetched$ emitted false");
21775
- this._deps.user = user;
21776
- } catch (error) {
21777
- 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.`);
21778
- throw new UnexpectedError("Error fetching user information", { cause: error });
21779
- }
22953
+ await this.fetchUserOrRecover();
21780
22954
  const errorHandler = (error) => {
21781
22955
  this._errors$.next(error);
21782
22956
  };
21783
22957
  this._transport = new TransportManager(this._deps.storage, this._deps.protocolKey, this._deps.WebSocket, PreferencesContainer.instance.relayHost ?? this._deps.relayHost, errorHandler);
21784
- this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey);
22958
+ this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey, () => this._credentialRecovered);
21785
22959
  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$);
21786
22960
  this._publicSession = new ClientSessionWrapper(this._clientSession);
21787
- this._clientSession.onBeforeReconnect = async () => {
21788
- if (!this._credentialProvider) return;
21789
- try {
21790
- const fingerprint = this._dpopManager?.initialized ? this._dpopManager.fingerprint : void 0;
21791
- logger$1.debug("[SignalWire] Credential expired, refreshing before reconnect");
21792
- const newCredentials = await this._credentialProvider.authenticate(fingerprint ? { fingerprint } : void 0);
21793
- this._deps.credential = newCredentials;
21794
- if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
21795
- logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
21796
- } catch (error) {
21797
- logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
21798
- this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
21799
- throw error;
21800
- }
21801
- };
22961
+ this._clientSession.callControl = this._options.callControl ?? "routed";
22962
+ this._clientSession.onBeforeReconnect = async () => this.refreshCredentialForReconnect();
21802
22963
  this.subscribeTo(this._clientSession.errors$, (error) => {
21803
22964
  this._errors$.next(error);
21804
22965
  });
22966
+ this.subscribeTo(this._clientSession.authorization$, (authorization) => {
22967
+ this._refreshCoordinator?.syncExpiryFromAuthorization(authorization, this._credentialProvider);
22968
+ this.persistClientBoundMarker(Boolean(authorization?.cnf?.jkt));
22969
+ });
21805
22970
  await this._clientSession.connect();
21806
22971
  await this._refreshCoordinator?.activate(this._deps.user, this._clientSession);
21807
22972
  this.subscribeTo(this._clientSession.authenticated$.pipe((0, import_cjs$1.skip)(1), (0, import_cjs$1.filter)(Boolean)), async () => {
@@ -21964,6 +23129,13 @@ var SignalWire = class extends Destroyable {
21964
23129
  }
21965
23130
  try {
21966
23131
  this._visibilityController = new VisibilityController();
23132
+ this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, import_cjs$1.filter)((event) => event.to === "visible")), () => {
23133
+ try {
23134
+ this._refreshCoordinator?.forceRefreshIfDue();
23135
+ } catch (error) {
23136
+ logger$1.warn("[SignalWire] Resume credential revalidation failed (non-fatal):", error);
23137
+ }
23138
+ });
21967
23139
  this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, import_cjs$1.filter)((event) => event.to === "visible" && PreferencesContainer.instance.refreshDevicesOnVisible)), () => {
21968
23140
  logger$1.debug("[SignalWire] Page visible, re-enumerating devices");
21969
23141
  try {
@@ -21975,7 +23147,7 @@ var SignalWire = class extends Destroyable {
21975
23147
  logger$1.warn("[SignalWire] Failed to initialize VisibilityController:", error);
21976
23148
  }
21977
23149
  try {
21978
- this._diagnosticsCollector = new DiagnosticsCollector({ sdkVersion: "4.0.0-rc.2" });
23150
+ this._diagnosticsCollector = new DiagnosticsCollector({ sdkVersion: "4.0.0-rc.3" });
21979
23151
  } catch (error) {
21980
23152
  logger$1.warn("[SignalWire] Failed to initialize DiagnosticsCollector:", error);
21981
23153
  }
@@ -22046,21 +23218,22 @@ var SignalWire = class extends Destroyable {
22046
23218
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22047
23219
  throw error;
22048
23220
  }
22049
- logger$1.debug("[SignalWire] Failed to register user, trying reauthentication...");
22050
- try {
22051
- await this._clientSession.reauthenticate(this._deps.credential.token);
22052
- logger$1.debug("[SignalWire] Reauthentication successful, retrying register()");
23221
+ logger$1.debug("[SignalWire] Failed to register user, attempting credential recovery...");
23222
+ const outcome = await this.recoverAndRetry(async () => {
22053
23223
  await this._transport.execute(RPCExecute({
22054
23224
  method: "subscriber.online",
22055
23225
  params: {}
22056
23226
  }));
23227
+ });
23228
+ if (outcome.ok) {
23229
+ logger$1.debug("[SignalWire] Recovery restored registration");
22057
23230
  this._isRegistered$.next(true);
22058
- } catch (reauthError) {
22059
- logger$1.error("[SignalWire] Reauthentication failed during register():", reauthError);
22060
- 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 }) });
22061
- this._errors$.next(registerError);
22062
- throw registerError;
23231
+ return;
22063
23232
  }
23233
+ const failureCause = outcome.error ?? error;
23234
+ 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 }) });
23235
+ this._errors$.next(registerError);
23236
+ throw registerError;
22064
23237
  }
22065
23238
  }
22066
23239
  /**
@@ -22091,6 +23264,11 @@ var SignalWire = class extends Destroyable {
22091
23264
  * Returns a {@link Call} in `'ringing'` state. Subscribe to {@link Call.status$}
22092
23265
  * to track progression through `'connected'` → `'disconnected'`.
22093
23266
  *
23267
+ * Local media acquisition is deliberately unbounded: an unanswered permission
23268
+ * prompt leaves this promise pending indefinitely, so apply your own bound if
23269
+ * your UI needs one. The 12 s signaling budget starts only once acquisition
23270
+ * settles.
23271
+ *
22094
23272
  * @param destination - Address URI string (e.g. `'/public/my-room'`) or {@link Address} instance.
22095
23273
  * @param options - Media and dial options (audio/video, device constraints). Overrides defaults.
22096
23274
  * @returns The created {@link Call} instance.
@@ -22113,7 +23291,18 @@ var SignalWire = class extends Destroyable {
22113
23291
  };
22114
23292
  await this.waitAuthentication();
22115
23293
  logger$1.debug("[SignalWire] Dialing with options:", computed_options);
22116
- return this._clientSession.createOutboundCall(destination, computed_options);
23294
+ try {
23295
+ return await this._clientSession.createOutboundCall(destination, computed_options);
23296
+ } catch (error) {
23297
+ if (!isRecoverableAuthError(error)) throw error;
23298
+ logger$1.debug("[SignalWire] Dial hit a recoverable auth error; recovering the session and retrying");
23299
+ const outcome = await this.recoverAndRetry(async () => {
23300
+ await this.waitAuthentication();
23301
+ return this._clientSession.createOutboundCall(destination, computed_options);
23302
+ });
23303
+ if (outcome.ok) return outcome.value;
23304
+ throw outcome.error ?? error;
23305
+ }
22117
23306
  }
22118
23307
  /**
22119
23308
  * Runs a multi-phase connectivity test against the given destination.
@@ -22394,10 +23583,13 @@ var SignalWire = class extends Destroyable {
22394
23583
  destroy() {
22395
23584
  this._refreshCoordinator?.destroy();
22396
23585
  this._refreshCoordinator = void 0;
23586
+ this._refreshHttp?.destroy();
23587
+ this._refreshHttp = void 0;
22397
23588
  this._dpopManager?.destroy();
22398
- this._clientSession.teardownSessionState();
22399
- this._transport.destroy();
22400
- this._clientSession.destroy();
23589
+ const session = this._clientSession;
23590
+ session?.teardownSessionState();
23591
+ this._transport?.destroy();
23592
+ session?.destroy();
22401
23593
  try {
22402
23594
  this._networkMonitor?.destroy();
22403
23595
  } catch {}
@@ -22510,7 +23702,7 @@ var StaticCredentialProvider = class {
22510
23702
  /**
22511
23703
  * Library version from package.json, injected at build time.
22512
23704
  */
22513
- const version = "4.0.0-rc.2";
23705
+ const version = "4.0.0-rc.3";
22514
23706
  /**
22515
23707
  * Flag indicating the library has been loaded and is ready to use.
22516
23708
  * For UMD builds: `window.SignalWire.ready`
@@ -22532,7 +23724,7 @@ const ready = true;
22532
23724
  */
22533
23725
  const emitReadyEvent = () => {
22534
23726
  if (typeof window !== "undefined") {
22535
- const event = new CustomEvent("signalwire:js:ready", { detail: { version: "4.0.0-rc.2" } });
23727
+ const event = new CustomEvent("signalwire:js:ready", { detail: { version: "4.0.0-rc.3" } });
22536
23728
  window.dispatchEvent(event);
22537
23729
  }
22538
23730
  };
@@ -22557,7 +23749,10 @@ if (typeof process === "undefined") globalThis.process = { env: { NODE_ENV: "pro
22557
23749
 
22558
23750
  //#endregion
22559
23751
  exports.Address = Address;
23752
+ exports.AuxiliaryLegCancelledError = AuxiliaryLegCancelledError;
23753
+ exports.AuxiliaryLegTimeoutError = AuxiliaryLegTimeoutError;
22560
23754
  exports.CallCreateError = CallCreateError;
23755
+ exports.CallNotReadyError = CallNotReadyError;
22561
23756
  exports.ClientPreferences = ClientPreferences;
22562
23757
  exports.CollectionFetchError = CollectionFetchError;
22563
23758
  exports.DPoPInitError = DPoPInitError;
@@ -22569,8 +23764,10 @@ exports.MediaTrackError = MediaTrackError;
22569
23764
  exports.MessageParseError = MessageParseError;
22570
23765
  exports.OverconstrainedFallbackError = OverconstrainedFallbackError;
22571
23766
  exports.Participant = Participant;
23767
+ exports.ParticipantNotReadyError = ParticipantNotReadyError;
22572
23768
  exports.PreflightError = PreflightError;
22573
23769
  exports.RecoveryError = RecoveryError;
23770
+ exports.ScreenShareAlreadyActiveError = ScreenShareAlreadyActiveError;
22574
23771
  exports.SelfCapabilities = SelfCapabilities;
22575
23772
  exports.SelfParticipant = SelfParticipant;
22576
23773
  exports.SignalWire = SignalWire;