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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.mjs CHANGED
@@ -4186,7 +4186,7 @@ var require_merge$1 = /* @__PURE__ */ __commonJSMin(((exports) => {
4186
4186
  var empty_1$7 = require_empty();
4187
4187
  var args_1$8 = require_args();
4188
4188
  var from_1$4 = require_from();
4189
- function merge$6() {
4189
+ function merge$7() {
4190
4190
  var args = [];
4191
4191
  for (var _i = 0; _i < arguments.length; _i++) args[_i] = arguments[_i];
4192
4192
  var scheduler = args_1$8.popScheduler(args);
@@ -4194,7 +4194,7 @@ var require_merge$1 = /* @__PURE__ */ __commonJSMin(((exports) => {
4194
4194
  var sources = args;
4195
4195
  return !sources.length ? empty_1$7.EMPTY : sources.length === 1 ? innerFrom_1$31.innerFrom(sources[0]) : mergeAll_1$3.mergeAll(concurrent)(from_1$4.from(sources, scheduler));
4196
4196
  }
4197
- exports.merge = merge$6;
4197
+ exports.merge = merge$7;
4198
4198
  }));
4199
4199
 
4200
4200
  //#endregion
@@ -6076,7 +6076,7 @@ var require_merge = /* @__PURE__ */ __commonJSMin(((exports) => {
6076
6076
  var mergeAll_1$2 = require_mergeAll();
6077
6077
  var args_1$3 = require_args();
6078
6078
  var from_1$1 = require_from();
6079
- function merge$5() {
6079
+ function merge$6() {
6080
6080
  var args = [];
6081
6081
  for (var _i = 0; _i < arguments.length; _i++) args[_i] = arguments[_i];
6082
6082
  var scheduler = args_1$3.popScheduler(args);
@@ -6085,7 +6085,7 @@ var require_merge = /* @__PURE__ */ __commonJSMin(((exports) => {
6085
6085
  mergeAll_1$2.mergeAll(concurrent)(from_1$1.from(__spreadArray$8([source], __read$8(args)), scheduler)).subscribe(subscriber);
6086
6086
  });
6087
6087
  }
6088
- exports.merge = merge$5;
6088
+ exports.merge = merge$6;
6089
6089
  }));
6090
6090
 
6091
6091
  //#endregion
@@ -6823,12 +6823,12 @@ var require_skip = /* @__PURE__ */ __commonJSMin(((exports) => {
6823
6823
  Object.defineProperty(exports, "__esModule", { value: true });
6824
6824
  exports.skip = void 0;
6825
6825
  var filter_1$3 = require_filter();
6826
- function skip$3(count$1) {
6826
+ function skip$5(count$1) {
6827
6827
  return filter_1$3.filter(function(_, index) {
6828
6828
  return count$1 <= index;
6829
6829
  });
6830
6830
  }
6831
- exports.skip = skip$3;
6831
+ exports.skip = skip$5;
6832
6832
  }));
6833
6833
 
6834
6834
  //#endregion
@@ -6932,7 +6932,7 @@ var require_switchMap = /* @__PURE__ */ __commonJSMin(((exports) => {
6932
6932
  var innerFrom_1$6 = require_innerFrom();
6933
6933
  var lift_1$13 = require_lift();
6934
6934
  var OperatorSubscriber_1$11 = require_OperatorSubscriber();
6935
- function switchMap$7(project, resultSelector) {
6935
+ function switchMap$8(project, resultSelector) {
6936
6936
  return lift_1$13.operate(function(source, subscriber) {
6937
6937
  var innerSubscriber = null;
6938
6938
  var index = 0;
@@ -6956,7 +6956,7 @@ var require_switchMap = /* @__PURE__ */ __commonJSMin(((exports) => {
6956
6956
  }));
6957
6957
  });
6958
6958
  }
6959
- exports.switchMap = switchMap$7;
6959
+ exports.switchMap = switchMap$8;
6960
6960
  }));
6961
6961
 
6962
6962
  //#endregion
@@ -9022,6 +9022,37 @@ const DEFAULT_ICE_CANDIDATE_TIMEOUT_MS = 600;
9022
9022
  const DEFAULT_ICE_GATHERING_TIMEOUT_MS = 6e3;
9023
9023
  const DEFAULT_RECONNECT_CALLS_TIMEOUT_MS = 300 * 1e3;
9024
9024
  const DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
9025
+ /**
9026
+ * The server sends `signalwire.ping` every 10s. A socket that stays silent
9027
+ * longer than this is half-open: the server has dropped the session but the
9028
+ * browser never fired `close`.
9029
+ */
9030
+ const SERVER_PING_TIMEOUT_MS = 15e3;
9031
+ /**
9032
+ * How long a `signalwire.ping` the client sends after the server's pings
9033
+ * stopped may take to be answered. The server defers its own pings while its
9034
+ * threadpool is congested, so silence alone does not mean the socket is dead.
9035
+ */
9036
+ const SERVER_PING_PROBE_TIMEOUT_MS = 1e4;
9037
+ /**
9038
+ * How long call setup may wait for the network, measured from local media
9039
+ * settling until the member id arrives.
9040
+ *
9041
+ * Local media acquisition is deliberately NOT inside this: a permission prompt
9042
+ * or a device picker is human time, and a human may take as long as they like
9043
+ * without spending the server's budget. The clock starts when acquisition ends.
9044
+ *
9045
+ * Must exceed `iceGatheringTimeout + the RPC timeout`, which both run inside it.
9046
+ */
9047
+ const DEFAULT_CALL_SIGNALING_TIMEOUT_MS = 12e3;
9048
+ /**
9049
+ * How long an auxiliary leg (screen share, additional device) may take to
9050
+ * connect once its media is in hand.
9051
+ *
9052
+ * One bound for both: the picker is human time and sits outside it. What remains
9053
+ * — offer, ICE, invite, answer, DTLS — is the same work for either leg kind.
9054
+ */
9055
+ const DEFAULT_AUX_LEG_CONNECT_TIMEOUT_MS = 15e3;
9025
9056
  const DEFAULT_RECONNECT_DELAY_MIN_MS = 100;
9026
9057
  const DEFAULT_RECONNECT_DELAY_MAX_MS = 3e3;
9027
9058
  const DEFAULT_DEVICE_DEBOUNCE_TIME_MS = 1500;
@@ -9049,6 +9080,14 @@ const CREDENTIAL_REFRESH_MAX_DELAY_MS = 3e4;
9049
9080
  /** Buffer in milliseconds before token expiry to trigger refresh. */
9050
9081
  const CREDENTIAL_REFRESH_BUFFER_MS = 5e3;
9051
9082
  /**
9083
+ * Clock-skew allowance (ms) for treating an in-memory credential as expired
9084
+ * when deciding whether a fresh (re)connect must re-mint the token before
9085
+ * authenticating. A token within this window of its `expiry_at` is treated as
9086
+ * stale so the reconnect re-mints via the credential provider instead of
9087
+ * replaying a dead token (which the server rejects with -32003).
9088
+ */
9089
+ const CREDENTIAL_EXPIRY_SKEW_MS = 3e4;
9090
+ /**
9052
9091
  * Maximum time the coordinator will wait for `DeviceTokenManager.activate()`
9053
9092
  * to resolve before treating the activation as failed and falling back to
9054
9093
  * the developer-provided refresh path. Prevents a wedged HTTP layer from
@@ -9067,6 +9106,8 @@ const MEDIA_ACCESS_DENIAL_NAMES = [
9067
9106
  "SecurityError",
9068
9107
  "PermissionDeniedError"
9069
9108
  ];
9109
+ /** Error names browsers use when the capture hardware is already held exclusively. */
9110
+ const MEDIA_DEVICE_IN_USE_NAMES = ["NotReadableError", "TrackStartError"];
9070
9111
  /** Default polling interval for RTCPeerConnection.getStats() in milliseconds. */
9071
9112
  const DEFAULT_STATS_POLLING_INTERVAL_MS = 1e3;
9072
9113
  /** Number of initial samples used to build a baseline for spike detection. */
@@ -9281,6 +9322,20 @@ var CallCreateError = class extends Error {
9281
9322
  this.name = "CallCreateError";
9282
9323
  }
9283
9324
  };
9325
+ var CallNotReadyError = class extends Error {
9326
+ constructor(callId, options) {
9327
+ super(`Call "${callId}" has no self member context yet: selfId/nodeId have not been received from the server`, options);
9328
+ this.callId = callId;
9329
+ this.name = "CallNotReadyError";
9330
+ }
9331
+ };
9332
+ var ParticipantNotReadyError = class extends Error {
9333
+ constructor(memberId, options) {
9334
+ super(`Participant "${memberId}" has no call context yet: its member state (call_id/node_id) has not been received from the server`, options);
9335
+ this.memberId = memberId;
9336
+ this.name = "ParticipantNotReadyError";
9337
+ }
9338
+ };
9284
9339
  var JSONRPCError = class extends Error {
9285
9340
  constructor(code, message, data, options, requestId) {
9286
9341
  super(message, options);
@@ -9352,6 +9407,32 @@ var CollectionFetchError = class extends Error {
9352
9407
  this.name = "CollectionFetchError";
9353
9408
  }
9354
9409
  };
9410
+ /**
9411
+ * An auxiliary leg did not connect within its budget. Typed rather than a bare
9412
+ * RxJS `TimeoutError` so the leg and cause survive.
9413
+ */
9414
+ var AuxiliaryLegTimeoutError = class extends Error {
9415
+ constructor(leg, originalError) {
9416
+ super(`Timed out waiting for the ${leg} connection to be established`, { cause: originalError });
9417
+ this.leg = leg;
9418
+ this.originalError = originalError;
9419
+ this.name = "AuxiliaryLegTimeoutError";
9420
+ }
9421
+ };
9422
+ /**
9423
+ * An auxiliary leg was removed before it finished connecting.
9424
+ *
9425
+ * Typed rather than a bare resolve so a caller awaiting the start can tell a
9426
+ * cancel apart from a share that actually came up — the public methods return
9427
+ * `void`, so the promise is the only signal they have.
9428
+ */
9429
+ var AuxiliaryLegCancelledError = class extends Error {
9430
+ constructor(leg) {
9431
+ super(`The ${leg} leg was removed before it finished connecting`);
9432
+ this.leg = leg;
9433
+ this.name = "AuxiliaryLegCancelledError";
9434
+ }
9435
+ };
9355
9436
  var MediaTrackError = class extends Error {
9356
9437
  constructor(operation, kind, originalError) {
9357
9438
  super(`Media track ${operation} failed for ${kind}`, { cause: originalError instanceof Error ? originalError : void 0 });
@@ -9365,6 +9446,10 @@ var MediaTrackError = class extends Error {
9365
9446
  function isMediaAccessDenial(originalError) {
9366
9447
  return originalError instanceof Error && MEDIA_ACCESS_DENIAL_NAMES.includes(originalError.name);
9367
9448
  }
9449
+ /** True when a `getUserMedia` rejection means the hardware is already held exclusively. */
9450
+ function isMediaDeviceInUse(originalError) {
9451
+ return originalError instanceof Error && MEDIA_DEVICE_IN_USE_NAMES.includes(originalError.name);
9452
+ }
9368
9453
  /**
9369
9454
  * Failure to acquire local media (camera, microphone, or screen capture)
9370
9455
  * via `getUserMedia`/`getDisplayMedia`.
@@ -9388,6 +9473,22 @@ var MediaAccessError = class extends Error {
9388
9473
  return isMediaAccessDenial(this.originalError);
9389
9474
  }
9390
9475
  };
9476
+ /**
9477
+ * Thrown by `startScreenShare()` when the call is already sharing a screen.
9478
+ *
9479
+ * A call carries at most one screen share. Accepting a second one would
9480
+ * overwrite the only reference the SDK holds to the first, leaving it
9481
+ * capturing and sending with no way to stop it — so the second request is
9482
+ * rejected and the live share is left untouched. Call `stopScreenShare()`
9483
+ * first to replace it.
9484
+ */
9485
+ var ScreenShareAlreadyActiveError = class extends Error {
9486
+ constructor(screenShareId, options) {
9487
+ super(`A screen share is already active on this call (${screenShareId}). Call stopScreenShare() before starting another one.`, options);
9488
+ this.screenShareId = screenShareId;
9489
+ this.name = "ScreenShareAlreadyActiveError";
9490
+ }
9491
+ };
9391
9492
  var DPoPInitError = class extends Error {
9392
9493
  constructor(originalError, message = "Failed to initialize DPoP key pair") {
9393
9494
  super(message, { cause: originalError instanceof Error ? originalError : void 0 });
@@ -9628,9 +9729,9 @@ var require_loglevel = /* @__PURE__ */ __commonJSMin(((exports, module) => {
9628
9729
  defaultLogger$1 = new Logger();
9629
9730
  defaultLogger$1.getLogger = function getLogger$1(name) {
9630
9731
  if (typeof name !== "symbol" && typeof name !== "string" || name === "") throw new TypeError("You must supply a name when creating a logger.");
9631
- var logger$33 = _loggersByName[name];
9632
- if (!logger$33) logger$33 = _loggersByName[name] = new Logger(name, defaultLogger$1.methodFactory);
9633
- return logger$33;
9732
+ var logger$34 = _loggersByName[name];
9733
+ if (!logger$34) logger$34 = _loggersByName[name] = new Logger(name, defaultLogger$1.methodFactory);
9734
+ return logger$34;
9634
9735
  };
9635
9736
  var _log = typeof window !== undefinedType ? window.log : void 0;
9636
9737
  defaultLogger$1.noConflict = function() {
@@ -9666,8 +9767,8 @@ const defaultLoggerLevel = defaultLogger.levels.WARN;
9666
9767
  defaultLogger.setLevel(defaultLoggerLevel);
9667
9768
  let userLogger = null;
9668
9769
  /** Replace the built-in logger with a custom implementation. Pass `null` to restore defaults. */
9669
- const setLogger = (logger$33) => {
9670
- userLogger = logger$33;
9770
+ const setLogger = (logger$34) => {
9771
+ userLogger = logger$34;
9671
9772
  };
9672
9773
  let debugOptions = {};
9673
9774
  /** Configure debug options (e.g., `{ logWsTraffic: true }`). */
@@ -9711,8 +9812,8 @@ const wsTraffic = (options) => {
9711
9812
  loggerInstance.debug(`${options.type.toUpperCase()}: \n`, msg, "\n");
9712
9813
  };
9713
9814
  const getLogger = () => {
9714
- const logger$33 = getLoggerInstance();
9715
- return new Proxy(logger$33, { get(_target, prop, _receiver) {
9815
+ const logger$34 = getLoggerInstance();
9816
+ return new Proxy(logger$34, { get(_target, prop, _receiver) {
9716
9817
  if (prop === "wsTraffic") return wsTraffic;
9717
9818
  const instance = getLoggerInstance();
9718
9819
  const value = Reflect.get(instance, prop);
@@ -9764,7 +9865,7 @@ const asyncRetry = async ({ asyncCallable, maxRetries: retries = DEFAULT_MAX_RET
9764
9865
 
9765
9866
  //#endregion
9766
9867
  //#region src/controllers/HTTPRequestController.ts
9767
- const logger$32 = getLogger();
9868
+ const logger$33 = getLogger();
9768
9869
  const GET_PARAMS = {
9769
9870
  method: "GET",
9770
9871
  headers: { Accept: "application/json" }
@@ -9828,7 +9929,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9828
9929
  this._responses$.next(response);
9829
9930
  return response;
9830
9931
  } catch (error) {
9831
- logger$32.error("[HTTPRequestController] Request error:", error);
9932
+ logger$33.error("[HTTPRequestController] Request error:", error);
9832
9933
  this._status$.next("error");
9833
9934
  const err = error instanceof Error ? error : new Error("HTTP request failed", { cause: error });
9834
9935
  this._errors$.next(err);
@@ -9855,7 +9956,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9855
9956
  const url = this.buildURL(request.url);
9856
9957
  const headers = this.buildHeaders(request.headers);
9857
9958
  const timeout$5 = request.timeout ?? this.requestTimeout;
9858
- logger$32.debug("[HTTPRequestController] Executing request:", {
9959
+ logger$33.debug("[HTTPRequestController] Executing request:", {
9859
9960
  method: request.method,
9860
9961
  url,
9861
9962
  headers: Object.keys(headers).reduce((acc, key) => {
@@ -9875,7 +9976,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9875
9976
  });
9876
9977
  clearTimeout(timeoutId);
9877
9978
  const httpResponse = await this.convertResponse(response);
9878
- logger$32.debug("[HTTPRequestController] Response received:", {
9979
+ logger$33.debug("[HTTPRequestController] Response received:", {
9879
9980
  status: response.status,
9880
9981
  statusText: response.statusText,
9881
9982
  headers: [...response.headers.entries()],
@@ -9885,7 +9986,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9885
9986
  } catch (error) {
9886
9987
  clearTimeout(timeoutId);
9887
9988
  if (error instanceof Error && error.name === "AbortError") throw new RequestTimeoutError(`Request timeout after ${timeout$5}ms`, { cause: error });
9888
- logger$32.error("[HTTPRequestController] Request failed:", error);
9989
+ logger$33.error("[HTTPRequestController] Request failed:", error);
9889
9990
  throw error;
9890
9991
  }
9891
9992
  }
@@ -9899,8 +10000,8 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
9899
10000
  const credential = this.getCredential();
9900
10001
  if (credential.token) {
9901
10002
  headers.Authorization = `Bearer ${credential.token}`;
9902
- logger$32.debug("[HTTPRequestController] Using Bearer token auth, token length:", credential.token.length);
9903
- } else logger$32.warn("[HTTPRequestController] No credentials available for authentication");
10003
+ logger$33.debug("[HTTPRequestController] Using Bearer token auth, token length:", credential.token.length);
10004
+ } else logger$33.warn("[HTTPRequestController] No credentials available for authentication");
9904
10005
  return headers;
9905
10006
  }
9906
10007
  /**
@@ -10048,7 +10149,7 @@ function fromMsToSec(milliseconds) {
10048
10149
 
10049
10150
  //#endregion
10050
10151
  //#region src/containers/PreferencesContainer.ts
10051
- const logger$31 = getLogger();
10152
+ const logger$32 = getLogger();
10052
10153
  var PreferencesContainer = class PreferencesContainer {
10053
10154
  static get instance() {
10054
10155
  this._instance ??= new PreferencesContainer();
@@ -10710,7 +10811,7 @@ var ClientPreferences = class {
10710
10811
  if (!this._storage) return;
10711
10812
  const data = collectStoredPreferences();
10712
10813
  this._storage.setItem(PREFERENCES_STORAGE_KEY, data, "local").catch((error) => {
10713
- logger$31.error(`[ClientPreferences] Failed to save preferences: ${String(error)}`);
10814
+ logger$32.error(`[ClientPreferences] Failed to save preferences: ${String(error)}`);
10714
10815
  });
10715
10816
  }
10716
10817
  /** Loads preferences from storage and applies them to the container. */
@@ -10719,7 +10820,7 @@ var ClientPreferences = class {
10719
10820
  this._storage.getItem(PREFERENCES_STORAGE_KEY, "local").then((stored) => {
10720
10821
  if (stored) applyStoredPreferences(stored);
10721
10822
  }).catch((error) => {
10722
- logger$31.error(`[ClientPreferences] Failed to load preferences: ${String(error)}`);
10823
+ logger$32.error(`[ClientPreferences] Failed to load preferences: ${String(error)}`);
10723
10824
  });
10724
10825
  }
10725
10826
  };
@@ -10741,7 +10842,7 @@ function toError(value) {
10741
10842
  //#endregion
10742
10843
  //#region src/controllers/NavigatorDeviceController.ts
10743
10844
  var import_cjs$29 = require_cjs();
10744
- const logger$30 = getLogger();
10845
+ const logger$31 = getLogger();
10745
10846
  /** Maps a device kind to its storage key. */
10746
10847
  const DEVICE_STORAGE_KEYS = {
10747
10848
  audioinput: DEVICE_STORAGE_KEY_AUDIO_INPUT,
@@ -10763,7 +10864,7 @@ var NavigatorDeviceController = class extends Destroyable {
10763
10864
  super();
10764
10865
  this.webRTCApiProvider = webRTCApiProvider;
10765
10866
  this.deviceChangeHandler = () => {
10766
- logger$30.debug("[DeviceController] Device change detected");
10867
+ logger$31.debug("[DeviceController] Device change detected");
10767
10868
  this.enumerateDevices();
10768
10869
  };
10769
10870
  this._devicesState$ = this.createBehaviorSubject(initialDevicesState);
@@ -10828,13 +10929,13 @@ var NavigatorDeviceController = class extends Destroyable {
10828
10929
  return this.cachedObservable("videoInputDevices$", () => this._devicesState$.pipe((0, import_cjs$29.map)((state) => state.videoinput), (0, import_cjs$29.distinctUntilChanged)(), (0, import_cjs$29.takeUntil)(this.destroyed$)));
10829
10930
  }
10830
10931
  get selectedAudioInputDevice$() {
10831
- return this.cachedObservable("selectedAudioInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, import_cjs$29.map)((state) => state.audioinput), (0, import_cjs$29.distinctUntilChanged)(), (0, import_cjs$29.takeUntil)(this.destroyed$), (0, import_cjs$29.tap)((info) => logger$30.debug("[DeviceController] Selected audio input device changed:", info))));
10932
+ return this.cachedObservable("selectedAudioInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, import_cjs$29.map)((state) => state.audioinput), (0, import_cjs$29.distinctUntilChanged)(), (0, import_cjs$29.takeUntil)(this.destroyed$), (0, import_cjs$29.tap)((info) => logger$31.debug("[DeviceController] Selected audio input device changed:", info))));
10832
10933
  }
10833
10934
  get selectedAudioOutputDevice$() {
10834
- return this.cachedObservable("selectedAudioOutputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, import_cjs$29.map)((state) => state.audiooutput), (0, import_cjs$29.distinctUntilChanged)(), (0, import_cjs$29.takeUntil)(this.destroyed$), (0, import_cjs$29.tap)((info) => logger$30.debug("[DeviceController] Selected audio output device changed:", info))));
10935
+ return this.cachedObservable("selectedAudioOutputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, import_cjs$29.map)((state) => state.audiooutput), (0, import_cjs$29.distinctUntilChanged)(), (0, import_cjs$29.takeUntil)(this.destroyed$), (0, import_cjs$29.tap)((info) => logger$31.debug("[DeviceController] Selected audio output device changed:", info))));
10835
10936
  }
10836
10937
  get selectedVideoInputDevice$() {
10837
- return this.cachedObservable("selectedVideoInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, import_cjs$29.map)((state) => state.videoinput), (0, import_cjs$29.distinctUntilChanged)(), (0, import_cjs$29.takeUntil)(this.destroyed$), (0, import_cjs$29.tap)((info) => logger$30.debug("[DeviceController] Selected video input device changed:", info))));
10938
+ return this.cachedObservable("selectedVideoInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, import_cjs$29.map)((state) => state.videoinput), (0, import_cjs$29.distinctUntilChanged)(), (0, import_cjs$29.takeUntil)(this.destroyed$), (0, import_cjs$29.tap)((info) => logger$31.debug("[DeviceController] Selected video input device changed:", info))));
10838
10939
  }
10839
10940
  get selectedAudioInputDevice() {
10840
10941
  if (this._audioInputDisabled$.value) return null;
@@ -10909,7 +11010,7 @@ var NavigatorDeviceController = class extends Destroyable {
10909
11010
  if (device) this.persistDeviceSelection("audioinput", device);
10910
11011
  }
10911
11012
  selectVideoInputDevice(device) {
10912
- logger$30.debug("[DeviceController] Setting selected video input device:", device);
11013
+ logger$31.debug("[DeviceController] Setting selected video input device:", device);
10913
11014
  if (this._videoInputDisabled$.value && device) this._videoInputDisabled$.next(false);
10914
11015
  const previous = this._selectedDevicesState$.value.videoinput;
10915
11016
  if (previous && previous.deviceId !== device?.deviceId) this._deviceHistory.push("videoinput", previous);
@@ -10966,7 +11067,7 @@ var NavigatorDeviceController = class extends Destroyable {
10966
11067
  }
10967
11068
  const fromHistory = this._deviceHistory.findInHistory(kind, devices);
10968
11069
  if (fromHistory) {
10969
- logger$30.debug(`[DeviceController] Device disappeared, falling back to history: ${fromHistory.label}`);
11070
+ logger$31.debug(`[DeviceController] Device disappeared, falling back to history: ${fromHistory.label}`);
10970
11071
  this.emitDeviceRecovered(kind, selected, fromHistory, "device_disconnected");
10971
11072
  return fromHistory;
10972
11073
  }
@@ -11019,7 +11120,7 @@ var NavigatorDeviceController = class extends Destroyable {
11019
11120
  try {
11020
11121
  await this._storageManager.setItem(DEVICE_STORAGE_KEYS[kind], stored, "local");
11021
11122
  } catch (error) {
11022
- logger$30.error(`[DeviceController] Failed to persist device selection for ${kind}:`, error);
11123
+ logger$31.error(`[DeviceController] Failed to persist device selection for ${kind}:`, error);
11023
11124
  }
11024
11125
  }
11025
11126
  async loadPersistedDevices() {
@@ -11035,7 +11136,7 @@ var NavigatorDeviceController = class extends Destroyable {
11035
11136
  [kind]: stored
11036
11137
  };
11037
11138
  } catch (error) {
11038
- logger$30.error(`[DeviceController] Failed to load persisted device for ${kind}:`, error);
11139
+ logger$31.error(`[DeviceController] Failed to load persisted device for ${kind}:`, error);
11039
11140
  }
11040
11141
  }
11041
11142
  /** Clears device history, persisted selections, and re-enumerates devices. */
@@ -11053,7 +11154,7 @@ var NavigatorDeviceController = class extends Destroyable {
11053
11154
  this.disableDeviceMonitoring();
11054
11155
  this.webRTCApiProvider.mediaDevices.addEventListener("devicechange", this.deviceChangeHandler);
11055
11156
  if (PreferencesContainer.instance.devicePollingInterval > 0) this._devicesPoolingSubscription = (0, import_cjs$29.interval)(PreferencesContainer.instance.devicePollingInterval).subscribe(() => {
11056
- logger$30.debug("[DeviceController] Polling devices due to interval");
11157
+ logger$31.debug("[DeviceController] Polling devices due to interval");
11057
11158
  this.enumerateDevices();
11058
11159
  });
11059
11160
  this.enumerateDevices();
@@ -11079,13 +11180,13 @@ var NavigatorDeviceController = class extends Destroyable {
11079
11180
  videoinput: []
11080
11181
  });
11081
11182
  this._devicesState$.next(devicesByKind);
11082
- logger$30.debug("[DeviceController] Devices enumerated:", {
11183
+ logger$31.debug("[DeviceController] Devices enumerated:", {
11083
11184
  audioInputs: devicesByKind.audioinput.length,
11084
11185
  audioOutputs: devicesByKind.audiooutput.length,
11085
11186
  videoInputs: devicesByKind.videoinput.length
11086
11187
  });
11087
11188
  } catch (error) {
11088
- logger$30.error("[DeviceController] Failed to enumerate devices:", error);
11189
+ logger$31.error("[DeviceController] Failed to enumerate devices:", error);
11089
11190
  this._errors$.next(toError(error));
11090
11191
  }
11091
11192
  }
@@ -11101,7 +11202,7 @@ var NavigatorDeviceController = class extends Destroyable {
11101
11202
  stream.getTracks().forEach((t) => t.stop());
11102
11203
  return capabilities;
11103
11204
  } catch (error) {
11104
- logger$30.error("[DeviceController] Failed to get device capabilities:", error);
11205
+ logger$31.error("[DeviceController] Failed to get device capabilities:", error);
11105
11206
  this._errors$.next(toError(error));
11106
11207
  throw error;
11107
11208
  }
@@ -11352,7 +11453,7 @@ var DependencyContainer = class {
11352
11453
 
11353
11454
  //#endregion
11354
11455
  //#region src/controllers/CryptoController.ts
11355
- const logger$29 = getLogger();
11456
+ const logger$30 = getLogger();
11356
11457
  const DPOP_DB_NAME = "sw-dpop";
11357
11458
  const DPOP_DB_VERSION = 1;
11358
11459
  const DPOP_STORE_NAME = "keys";
@@ -11411,7 +11512,7 @@ async function loadKeyPairFromDB() {
11411
11512
  tx.oncomplete = () => db.close();
11412
11513
  });
11413
11514
  } catch (error) {
11414
- logger$29.warn("[DPoP] Failed to load key pair from IndexedDB:", error);
11515
+ logger$30.warn("[DPoP] Failed to load key pair from IndexedDB:", error);
11415
11516
  return null;
11416
11517
  }
11417
11518
  }
@@ -11431,7 +11532,7 @@ async function saveKeyPairToDB(keyPair) {
11431
11532
  };
11432
11533
  });
11433
11534
  } catch (error) {
11434
- logger$29.warn("[DPoP] Failed to save key pair to IndexedDB:", error);
11535
+ logger$30.warn("[DPoP] Failed to save key pair to IndexedDB:", error);
11435
11536
  }
11436
11537
  }
11437
11538
  async function deleteKeyPairFromDB() {
@@ -11450,7 +11551,7 @@ async function deleteKeyPairFromDB() {
11450
11551
  };
11451
11552
  });
11452
11553
  } catch (error) {
11453
- logger$29.warn("[DPoP] Failed to delete key pair from IndexedDB:", error);
11554
+ logger$30.warn("[DPoP] Failed to delete key pair from IndexedDB:", error);
11454
11555
  }
11455
11556
  }
11456
11557
  /**
@@ -11510,13 +11611,13 @@ var CryptoController = class {
11510
11611
  this._publicJwk = await crypto.subtle.exportKey("jwk", stored.publicKey);
11511
11612
  this._fingerprint = await computeJwkThumbprint(this._publicJwk);
11512
11613
  this._initialized = true;
11513
- logger$29.debug("[DPoP] Key pair restored from IndexedDB, fingerprint:", this._fingerprint);
11614
+ logger$30.debug("[DPoP] Key pair restored from IndexedDB, fingerprint:", this._fingerprint);
11514
11615
  return this._fingerprint;
11515
11616
  } catch (error) {
11516
- logger$29.warn("[DPoP] Stored key pair unusable, generating new one:", error);
11617
+ logger$30.warn("[DPoP] Stored key pair unusable, generating new one:", error);
11517
11618
  await deleteKeyPairFromDB();
11518
11619
  }
11519
- logger$29.debug("[DPoP] Generating RSA key pair");
11620
+ logger$30.debug("[DPoP] Generating RSA key pair");
11520
11621
  this._keyPair = await crypto.subtle.generateKey({
11521
11622
  name: "RSASSA-PKCS1-v1_5",
11522
11623
  modulusLength: 2048,
@@ -11531,7 +11632,7 @@ var CryptoController = class {
11531
11632
  this._fingerprint = await computeJwkThumbprint(this._publicJwk);
11532
11633
  this._initialized = true;
11533
11634
  await saveKeyPairToDB(this._keyPair);
11534
- logger$29.debug("[DPoP] Key pair generated and persisted, fingerprint:", this._fingerprint);
11635
+ logger$30.debug("[DPoP] Key pair generated and persisted, fingerprint:", this._fingerprint);
11535
11636
  return this._fingerprint;
11536
11637
  }
11537
11638
  /**
@@ -11597,7 +11698,7 @@ var CryptoController = class {
11597
11698
  this._fingerprint = null;
11598
11699
  this._initialized = false;
11599
11700
  deleteKeyPairFromDB();
11600
- logger$29.debug("[DPoP] Controller destroyed");
11701
+ logger$30.debug("[DPoP] Controller destroyed");
11601
11702
  }
11602
11703
  get publicJwk() {
11603
11704
  if (!this._publicJwk) throw new DPoPInitError("CryptoController not initialized. Call init() first.");
@@ -11621,7 +11722,7 @@ var CryptoController = class {
11621
11722
  //#endregion
11622
11723
  //#region src/controllers/NetworkMonitor.ts
11623
11724
  var import_cjs$28 = require_cjs();
11624
- const logger$28 = getLogger();
11725
+ const logger$29 = getLogger();
11625
11726
  /**
11626
11727
  * Safely check whether we are running in a browser environment
11627
11728
  * with `window` and the relevant event targets.
@@ -11678,7 +11779,7 @@ var NetworkMonitor = class extends Destroyable {
11678
11779
  }
11679
11780
  attachListeners() {
11680
11781
  if (!hasBrowserNetworkEvents()) {
11681
- logger$28.debug("NetworkMonitor: no browser environment detected, skipping event listeners");
11782
+ logger$29.debug("NetworkMonitor: no browser environment detected, skipping event listeners");
11682
11783
  return;
11683
11784
  }
11684
11785
  window.addEventListener("online", this._onOnline);
@@ -11686,7 +11787,7 @@ var NetworkMonitor = class extends Destroyable {
11686
11787
  const connection = getNetworkConnection();
11687
11788
  if (connection) connection.addEventListener("change", this._onConnectionChange);
11688
11789
  this._listenersAttached = true;
11689
- logger$28.debug("NetworkMonitor: event listeners attached");
11790
+ logger$29.debug("NetworkMonitor: event listeners attached");
11690
11791
  }
11691
11792
  removeListeners() {
11692
11793
  if (!this._listenersAttached) return;
@@ -11697,10 +11798,10 @@ var NetworkMonitor = class extends Destroyable {
11697
11798
  if (connection) connection.removeEventListener("change", this._onConnectionChange);
11698
11799
  }
11699
11800
  this._listenersAttached = false;
11700
- logger$28.debug("NetworkMonitor: event listeners removed");
11801
+ logger$29.debug("NetworkMonitor: event listeners removed");
11701
11802
  }
11702
11803
  handleOnline() {
11703
- logger$28.info("NetworkMonitor: browser went online");
11804
+ logger$29.info("NetworkMonitor: browser went online");
11704
11805
  this._isOnline$.next(true);
11705
11806
  this._networkChange$.next({
11706
11807
  type: "online",
@@ -11709,7 +11810,7 @@ var NetworkMonitor = class extends Destroyable {
11709
11810
  });
11710
11811
  }
11711
11812
  handleOffline() {
11712
- logger$28.info("NetworkMonitor: browser went offline");
11813
+ logger$29.info("NetworkMonitor: browser went offline");
11713
11814
  this._isOnline$.next(false);
11714
11815
  this._networkChange$.next({
11715
11816
  type: "offline",
@@ -11718,7 +11819,7 @@ var NetworkMonitor = class extends Destroyable {
11718
11819
  }
11719
11820
  handleConnectionChange() {
11720
11821
  const networkType = getNetworkType();
11721
- logger$28.info(`NetworkMonitor: connection changed — effectiveType=${networkType ?? "unknown"}`);
11822
+ logger$29.info(`NetworkMonitor: connection changed — effectiveType=${networkType ?? "unknown"}`);
11722
11823
  this._networkChange$.next({
11723
11824
  type: "connection_change",
11724
11825
  timestamp: Date.now(),
@@ -11834,7 +11935,7 @@ function getNavigatorMediaDevices() {
11834
11935
  //#endregion
11835
11936
  //#region src/controllers/PreflightRunner.ts
11836
11937
  var import_cjs$27 = require_cjs();
11837
- const logger$27 = getLogger();
11938
+ const logger$28 = getLogger();
11838
11939
  const DEFAULT_MEDIA_TEST_DURATION_S = 10;
11839
11940
  const ICE_GATHERING_TIMEOUT_MS = 1e4;
11840
11941
  const SIGNALING_RTT_TIMEOUT_MS = 5e3;
@@ -11883,7 +11984,7 @@ var PreflightRunner = class extends Destroyable {
11883
11984
  if (!this._options.skipMediaTest) try {
11884
11985
  bandwidth = await this.testMediaBandwidth(destination);
11885
11986
  } catch (error) {
11886
- logger$27.warn("[PreflightRunner] Media bandwidth test failed:", error);
11987
+ logger$28.warn("[PreflightRunner] Media bandwidth test failed:", error);
11887
11988
  warnings.push("Media bandwidth test failed");
11888
11989
  }
11889
11990
  return {
@@ -11895,7 +11996,7 @@ var PreflightRunner = class extends Destroyable {
11895
11996
  warnings
11896
11997
  };
11897
11998
  } catch (error) {
11898
- logger$27.error("[PreflightRunner] Preflight test failed:", error);
11999
+ logger$28.error("[PreflightRunner] Preflight test failed:", error);
11899
12000
  throw new PreflightError("preflight", error instanceof Error ? error : new Error(String(error)));
11900
12001
  } finally {
11901
12002
  this.destroy();
@@ -11926,7 +12027,7 @@ var PreflightRunner = class extends Destroyable {
11926
12027
  if (track.kind === "video" && track.readyState === "live") videoWorking = true;
11927
12028
  }
11928
12029
  } catch (error) {
11929
- logger$27.warn("[PreflightRunner] Device test failed:", error);
12030
+ logger$28.warn("[PreflightRunner] Device test failed:", error);
11930
12031
  } finally {
11931
12032
  if (audioStream) audioStream.getTracks().forEach((t) => t.stop());
11932
12033
  }
@@ -11984,7 +12085,7 @@ var PreflightRunner = class extends Destroyable {
11984
12085
  rttMs
11985
12086
  };
11986
12087
  } catch (error) {
11987
- logger$27.warn("[PreflightRunner] ICE connectivity test failed:", error);
12088
+ logger$28.warn("[PreflightRunner] ICE connectivity test failed:", error);
11988
12089
  return {
11989
12090
  type: "failed",
11990
12091
  turnReachable: false,
@@ -12032,7 +12133,7 @@ var PreflightRunner = class extends Destroyable {
12032
12133
  //#endregion
12033
12134
  //#region src/controllers/VisibilityController.ts
12034
12135
  var import_cjs$26 = require_cjs();
12035
- const logger$26 = getLogger();
12136
+ const logger$27 = getLogger();
12036
12137
  /**
12037
12138
  * Checks whether the document visibility API is available.
12038
12139
  */
@@ -12069,8 +12170,8 @@ var VisibilityController = class extends Destroyable {
12069
12170
  this._boundHandler = this._handleVisibilityChange.bind(this);
12070
12171
  if (this._hasVisibilityApi) {
12071
12172
  document.addEventListener("visibilitychange", this._boundHandler);
12072
- logger$26.debug("VisibilityController: listening for visibilitychange events");
12073
- } else logger$26.debug("VisibilityController: document visibility API not available, defaulting to visible");
12173
+ logger$27.debug("VisibilityController: listening for visibilitychange events");
12174
+ } else logger$27.debug("VisibilityController: document visibility API not available, defaulting to visible");
12074
12175
  }
12075
12176
  /**
12076
12177
  * Observable of the current visibility state.
@@ -12095,7 +12196,7 @@ var VisibilityController = class extends Destroyable {
12095
12196
  destroy() {
12096
12197
  if (this._hasVisibilityApi) {
12097
12198
  document.removeEventListener("visibilitychange", this._boundHandler);
12098
- logger$26.debug("VisibilityController: removed visibilitychange listener");
12199
+ logger$27.debug("VisibilityController: removed visibilitychange listener");
12099
12200
  }
12100
12201
  super.destroy();
12101
12202
  }
@@ -12113,7 +12214,7 @@ var VisibilityController = class extends Destroyable {
12113
12214
  timestamp: Date.now()
12114
12215
  };
12115
12216
  this._visibilityChange$.next(changeEvent);
12116
- logger$26.debug("VisibilityController: visibility changed", {
12217
+ logger$27.debug("VisibilityController: visibility changed", {
12117
12218
  from: previousState,
12118
12219
  to: newState
12119
12220
  });
@@ -12264,6 +12365,12 @@ const RPCReauthenticate = (params) => {
12264
12365
 
12265
12366
  //#endregion
12266
12367
  //#region src/core/RPCMessages/RPCPing.ts
12368
+ const RPCPing = () => {
12369
+ return buildRPCRequest({
12370
+ method: "signalwire.ping",
12371
+ params: { timestamp: Date.now() / 1e3 }
12372
+ });
12373
+ };
12267
12374
  const RPCPingResponse = (id, timestamp$1) => {
12268
12375
  return makeRPCResponse({
12269
12376
  id,
@@ -12352,15 +12459,57 @@ const RPCEventAckResponse = (id) => makeRPCResponse({
12352
12459
  result: {}
12353
12460
  });
12354
12461
 
12462
+ //#endregion
12463
+ //#region src/utils/authRecovery.ts
12464
+ /**
12465
+ * Walk an error's `error`/`cause` chain looking for a {@link JSONRPCError}.
12466
+ * Errors thrown by call creation are wrapped (e.g. `CallCreateError`), so the
12467
+ * underlying signaling error is nested. Bounded by a visited set to guard
12468
+ * against cyclic causes.
12469
+ */
12470
+ function findJSONRPCError(error) {
12471
+ const seen = /* @__PURE__ */ new Set();
12472
+ let current = error;
12473
+ while (current instanceof Error && !seen.has(current)) {
12474
+ seen.add(current);
12475
+ if (current instanceof JSONRPCError) return current;
12476
+ current = current.error ?? current.cause;
12477
+ }
12478
+ }
12479
+ /**
12480
+ * Whether an error is a session-recoverable authentication failure
12481
+ * (`-32002` authentication failed or `-32003` requester validation failed)
12482
+ * that a credential re-mint + retry can heal.
12483
+ */
12484
+ function isRecoverableAuthError(error) {
12485
+ const rpcError = findJSONRPCError(error);
12486
+ return rpcError !== void 0 && (rpcError.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || rpcError.code === RPC_ERROR_AUTHENTICATION_FAILED);
12487
+ }
12488
+ /**
12489
+ * Whether an error is specifically a requester-validation rejection
12490
+ * (`-32003`) — the server refusing the session's credential.
12491
+ *
12492
+ * Narrower than {@link isRecoverableAuthError} on purpose. `-32002` is
12493
+ * overloaded server-side: a rejected reattach arrives as `-32002` with
12494
+ * `cause: INVALID_MSG_UNSPECIFIED` and message `CALL ERROR`, which is a
12495
+ * call-level rejection and says nothing about the credential. Use this where
12496
+ * the decision must not be fooled by that, such as deciding whether retrying
12497
+ * an operation could possibly succeed.
12498
+ */
12499
+ function isRequesterValidationError(error) {
12500
+ return findJSONRPCError(error)?.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED;
12501
+ }
12502
+
12355
12503
  //#endregion
12356
12504
  //#region src/managers/AttachManager.ts
12357
- const logger$25 = getLogger();
12505
+ const logger$26 = getLogger();
12358
12506
  var AttachManager = class {
12359
- constructor(storage, deviceController, reconnectCallsTimeout, attachKey) {
12507
+ constructor(storage, deviceController, reconnectCallsTimeout, attachKey, credentialRecovered) {
12360
12508
  this.storage = storage;
12361
12509
  this.deviceController = deviceController;
12362
12510
  this.reconnectCallsTimeout = reconnectCallsTimeout;
12363
12511
  this.attachKey = attachKey;
12512
+ this.credentialRecovered = credentialRecovered;
12364
12513
  this.writeQueue = Promise.resolve();
12365
12514
  }
12366
12515
  async detachAll() {
@@ -12375,7 +12524,7 @@ var AttachManager = class {
12375
12524
  try {
12376
12525
  return await this.storage.getItem(this.attachKey) ?? {};
12377
12526
  } catch (error) {
12378
- logger$25.warn("[AttachManager] Failed to retrieve attached calls from storage", error);
12527
+ logger$26.warn("[AttachManager] Failed to retrieve attached calls from storage", error);
12379
12528
  return {};
12380
12529
  }
12381
12530
  }
@@ -12383,7 +12532,7 @@ var AttachManager = class {
12383
12532
  try {
12384
12533
  await this.storage.setItem(this.attachKey, attached);
12385
12534
  } catch (error) {
12386
- logger$25.warn("[AttachManager] Failed to write attached calls to storage", error);
12535
+ logger$26.warn("[AttachManager] Failed to write attached calls to storage", error);
12387
12536
  }
12388
12537
  }
12389
12538
  /**
@@ -12402,11 +12551,39 @@ var AttachManager = class {
12402
12551
  }
12403
12552
  async attach(call) {
12404
12553
  if (!call.to) {
12405
- logger$25.warn("[AttachManager] Skip attach for calls with no destination");
12554
+ logger$26.warn("[AttachManager] Skip attach for calls with no destination");
12406
12555
  return;
12407
12556
  }
12557
+ const attachment = this.buildAttachment(call, call.to);
12558
+ await this.mutate((attached) => ({
12559
+ ...attached,
12560
+ [call.id]: attachment
12561
+ }));
12562
+ }
12563
+ /**
12564
+ * Keep an already-stored call's reference alive and current — the periodic
12565
+ * refresh the `verto.ping` keepalive drives.
12566
+ *
12567
+ * Only ever updates: a call with no record is one nothing wants reattached,
12568
+ * and re-creating it here would undo a `detach`. That matters because a ping
12569
+ * can land in the window between `bye()` detaching and the call being torn
12570
+ * down, and a record revived there survives the hangup — so the next page
12571
+ * load dials a call nobody is on. The existence check and the write share
12572
+ * one {@link mutate} turn, so a concurrent detach cannot slip between them.
12573
+ */
12574
+ async refresh(call) {
12575
+ if (!call.to) return;
12408
12576
  const destination = call.to;
12409
- const attachment = {
12577
+ await this.mutate((attached) => {
12578
+ if (!Object.hasOwn(attached, call.id)) return attached;
12579
+ return {
12580
+ ...attached,
12581
+ [call.id]: this.buildAttachment(call, destination)
12582
+ };
12583
+ });
12584
+ }
12585
+ buildAttachment(call, destination) {
12586
+ return {
12410
12587
  nodeId: call.nodeId,
12411
12588
  destination,
12412
12589
  mediaDirections: call.mediaDirections,
@@ -12414,10 +12591,6 @@ var AttachManager = class {
12414
12591
  videoInputDevice: call.mediaDirections.video !== "inactive" ? this.deviceController.selectedVideoInputDevice : null,
12415
12592
  attachedAt: Date.now()
12416
12593
  };
12417
- await this.mutate((attached) => ({
12418
- ...attached,
12419
- [call.id]: attachment
12420
- }));
12421
12594
  }
12422
12595
  async detach(call) {
12423
12596
  await this.mutate((attached) => {
@@ -12440,8 +12613,14 @@ var AttachManager = class {
12440
12613
  * rejecting. Once that fix is deployed, this will work for both
12441
12614
  * page reloads and WebSocket reconnects.
12442
12615
  *
12443
- * Failed reattach attempts are handled gracefully — the stale call
12444
- * reference is cleaned up from storage.
12616
+ * A failed reattach does NOT generally cost the stored reference. It is
12617
+ * discarded only when the server denied the reattach on a session whose
12618
+ * credential it had already accepted — a verified reauthentication followed
12619
+ * by a refusal is the server saying the call is gone, and that is the one
12620
+ * refusal worth acting on. Until then the credential may be what is being
12621
+ * refused, and the record is the only way a later reload can try again;
12622
+ * keeping it costs nothing, since `detachExpired` reaps it once it is older
12623
+ * than `reconnectCallsTimeout`.
12445
12624
  */
12446
12625
  async reattachCalls() {
12447
12626
  const attached = await this.readAttached();
@@ -12450,25 +12629,31 @@ var AttachManager = class {
12450
12629
  const { destination } = attachment;
12451
12630
  const options = this.buildCallOptions(attachment);
12452
12631
  let succeeded = false;
12632
+ let refusedOnCredentials = false;
12453
12633
  for (let attempt = 1; attempt <= 3; attempt++) try {
12454
12634
  await this.session.createOutboundCall(destination, {
12455
12635
  callId,
12456
12636
  ...options
12457
12637
  });
12458
- logger$25.info(`[AttachManager] Reattached call ${callId} (attempt ${attempt})`);
12638
+ logger$26.info(`[AttachManager] Reattached call ${callId} (attempt ${attempt})`);
12459
12639
  succeeded = true;
12460
12640
  break;
12461
12641
  } catch (error) {
12462
- logger$25.warn(`[AttachManager] Reattach attempt ${attempt}/3 failed for call ${callId}:`, error);
12642
+ logger$26.warn(`[AttachManager] Reattach attempt ${attempt}/3 failed for call ${callId}:`, error);
12643
+ if (isRequesterValidationError(error)) {
12644
+ refusedOnCredentials = true;
12645
+ logger$26.warn(`[AttachManager] Reattach of ${callId} was refused on credentials; not retrying.`);
12646
+ break;
12647
+ }
12463
12648
  if (attempt < 3) await new Promise((r) => setTimeout(r, (attempt + 1) * 1e3));
12464
12649
  }
12465
- if (!succeeded) {
12466
- logger$25.warn(`[AttachManager] Reattach failed after 3 attempts for call ${callId}, removing reference`);
12650
+ if (!succeeded) if (this.credentialRecovered() && !refusedOnCredentials) {
12651
+ logger$26.warn(`[AttachManager] Reattach of ${callId} was denied after a verified reauthentication, removing reference`);
12467
12652
  await this.detach({
12468
12653
  id: callId,
12469
12654
  mediaDirections: attachment.mediaDirections
12470
12655
  });
12471
- }
12656
+ } else logger$26.warn(`[AttachManager] Reattach failed for call ${callId}; keeping the reference (credential refused or never proven good)`);
12472
12657
  }
12473
12658
  }
12474
12659
  /**
@@ -13634,7 +13819,7 @@ function toggleHandraiseMethod(is) {
13634
13819
 
13635
13820
  //#endregion
13636
13821
  //#region src/core/entities/Participant.ts
13637
- const logger$24 = getLogger();
13822
+ const logger$25 = getLogger();
13638
13823
  const initialState = {};
13639
13824
  /**
13640
13825
  * Represents a participant in a call.
@@ -13644,9 +13829,9 @@ const initialState = {};
13644
13829
  * the local participant with additional device control.
13645
13830
  */
13646
13831
  var Participant = class extends Destroyable {
13647
- constructor(id, executeMethod, deviceController) {
13832
+ constructor(id, callExecuteMethod, deviceController) {
13648
13833
  super();
13649
- this.executeMethod = executeMethod;
13834
+ this.callExecuteMethod = callExecuteMethod;
13650
13835
  this.deviceController = deviceController;
13651
13836
  this._state$ = this.createBehaviorSubject(initialState);
13652
13837
  this.id = id;
@@ -13868,22 +14053,55 @@ var Participant = class extends Destroyable {
13868
14053
  get value() {
13869
14054
  return this._state$.value;
13870
14055
  }
14056
+ /**
14057
+ * Target triple for member RPCs, built from the participant's own state.
14058
+ * The backend locates the member's session by the target `call_id`/`node_id`,
14059
+ * so this must always be the participant's own call context — never the
14060
+ * local call's id (issue #19400).
14061
+ *
14062
+ * Reading it doubles as a readiness probe: it throws until the first full
14063
+ * member event (`member.joined`/`member.updated` or the `call.joined`
14064
+ * roster) arrives, and never regresses afterwards.
14065
+ *
14066
+ * @throws {ParticipantNotReadyError} If the member state has not been
14067
+ * received yet (e.g. a participant first seen via `member.talking`) — an
14068
+ * empty call context can never address the member, so fail fast instead of
14069
+ * sending a doomed RPC.
14070
+ */
14071
+ get target() {
14072
+ const { call_id, node_id } = this._state$.value;
14073
+ if (!call_id || !node_id) throw new ParticipantNotReadyError(this.id);
14074
+ return {
14075
+ member_id: this.id,
14076
+ call_id,
14077
+ node_id
14078
+ };
14079
+ }
14080
+ /**
14081
+ * Executes a member RPC against this participant, injecting its own
14082
+ * {@link target} as the target.
14083
+ *
14084
+ * @throws {ParticipantNotReadyError} Via {@link target}, when the
14085
+ * member state has not been received yet.
14086
+ */
14087
+ async executeMethod(method, args) {
14088
+ return this.callExecuteMethod(this.target, method, args);
14089
+ }
13871
14090
  /** Toggles the deafened state (mutes/unmutes incoming audio). */
13872
14091
  async toggleDeaf() {
13873
- const method = toggleDeafMethod(this.deaf);
13874
- await this.executeMethod(this.id, method, {});
14092
+ await this.executeMethod(toggleDeafMethod(this.deaf), {});
13875
14093
  }
13876
14094
  /** Toggles the hand-raised state. */
13877
14095
  async toggleHandraise() {
13878
- await this.executeMethod(this.id, toggleHandraiseMethod(this.handraised), {});
14096
+ await this.executeMethod(toggleHandraiseMethod(this.handraised), {});
13879
14097
  }
13880
14098
  /** Mutes the participant's audio. */
13881
14099
  async mute() {
13882
- await this.executeMethod(this.id, "call.mute", { channels: ["audio"] });
14100
+ await this.executeMethod("call.mute", { channels: ["audio"] });
13883
14101
  }
13884
14102
  /** Unmutes the participant's audio. */
13885
14103
  async unmute() {
13886
- await this.executeMethod(this.id, "call.unmute", { channels: ["audio"] });
14104
+ await this.executeMethod("call.unmute", { channels: ["audio"] });
13887
14105
  }
13888
14106
  /** Toggles the participant's audio mute state. */
13889
14107
  async toggleMute() {
@@ -13891,11 +14109,11 @@ var Participant = class extends Destroyable {
13891
14109
  }
13892
14110
  /** Mutes the participant's video. */
13893
14111
  async muteVideo() {
13894
- await this.executeMethod(this.id, "call.mute", { channels: ["video"] });
14112
+ await this.executeMethod("call.mute", { channels: ["video"] });
13895
14113
  }
13896
14114
  /** Unmutes the participant's video. */
13897
14115
  async unmuteVideo() {
13898
- await this.executeMethod(this.id, "call.unmute", { channels: ["video"] });
14116
+ await this.executeMethod("call.unmute", { channels: ["video"] });
13899
14117
  }
13900
14118
  /** Toggles the participant's video mute state. */
13901
14119
  async toggleMuteVideo() {
@@ -13903,7 +14121,7 @@ var Participant = class extends Destroyable {
13903
14121
  }
13904
14122
  /** Toggles echo cancellation on the audio input. */
13905
14123
  async toggleEchoCancellation() {
13906
- await this.executeMethod(this.id, "call.audioflags.set", {
14124
+ await this.executeMethod("call.audioflags.set", {
13907
14125
  echo_cancellation: !this.echoCancellation,
13908
14126
  auto_gain: this.autoGain,
13909
14127
  noise_suppression: this.noiseSuppression
@@ -13911,7 +14129,7 @@ var Participant = class extends Destroyable {
13911
14129
  }
13912
14130
  /** Toggles automatic gain control on the audio input. */
13913
14131
  async toggleAudioInputAutoGain() {
13914
- await this.executeMethod(this.id, "call.audioflags.set", {
14132
+ await this.executeMethod("call.audioflags.set", {
13915
14133
  echo_cancellation: this.echoCancellation,
13916
14134
  auto_gain: !this.autoGain,
13917
14135
  noise_suppression: this.noiseSuppression
@@ -13919,7 +14137,7 @@ var Participant = class extends Destroyable {
13919
14137
  }
13920
14138
  /** Toggles noise suppression on the audio input. */
13921
14139
  async toggleNoiseSuppression() {
13922
- await this.executeMethod(this.id, "call.audioflags.set", {
14140
+ await this.executeMethod("call.audioflags.set", {
13923
14141
  echo_cancellation: this.echoCancellation,
13924
14142
  auto_gain: this.autoGain,
13925
14143
  noise_suppression: !this.noiseSuppression
@@ -13927,7 +14145,7 @@ var Participant = class extends Destroyable {
13927
14145
  }
13928
14146
  /** Toggles low-bitrate mode for this participant's media. */
13929
14147
  async toggleLowbitrate() {
13930
- await this.executeMethod(this.id, "call.lowbitrate.set", { lowbitrate: !this.lowbitrate });
14148
+ await this.executeMethod("call.lowbitrate.set", { lowbitrate: !this.lowbitrate });
13931
14149
  }
13932
14150
  /**
13933
14151
  * Adjusts the **conference-only** microphone energy gate / sensitivity level
@@ -13943,7 +14161,7 @@ var Participant = class extends Destroyable {
13943
14161
  * (integer, larger values are more sensitive).
13944
14162
  */
13945
14163
  async setAudioInputSensitivity(value) {
13946
- await this.executeMethod(this.id, "call.microphone.sensitivity.set", { sensitivity: value });
14164
+ await this.executeMethod("call.microphone.sensitivity.set", { sensitivity: value });
13947
14165
  }
13948
14166
  /**
13949
14167
  * Sets the **server-side** microphone volume on this participant's bridged
@@ -13956,7 +14174,7 @@ var Participant = class extends Destroyable {
13956
14174
  * @param value - Volume level (0-100).
13957
14175
  */
13958
14176
  async setAudioInputVolume(value) {
13959
- await this.executeMethod(this.id, "call.microphone.volume.set", { volume: value });
14177
+ await this.executeMethod("call.microphone.volume.set", { volume: value });
13960
14178
  }
13961
14179
  /**
13962
14180
  * Sets the **server-side** speaker volume on this participant's bridged call
@@ -13970,45 +14188,31 @@ var Participant = class extends Destroyable {
13970
14188
  * @param value - Volume level (0-100).
13971
14189
  */
13972
14190
  async setAudioOutputVolume(value) {
13973
- await this.executeMethod(this.id, "call.speaker.volume.set", { volume: value });
14191
+ await this.executeMethod("call.speaker.volume.set", { volume: value });
13974
14192
  }
13975
14193
  /**
13976
14194
  * Sets the participant's position in the video layout.
13977
14195
  *
13978
- * Requires the `member.position` capability. The gateway keys positions by the
13979
- * **target member's own** `call_id`/`node_id` (see issue #19400 and the legacy
13980
- * `setPositions` implementation), so this sends the participant's own call
13981
- * context — matching {@link Participant.remove}. A resolved promise does not
13982
- * guarantee a visible change: the backend silently returns `200` (no-op) for
13983
- * non-conference targets.
14196
+ * Requires the `member.position` capability. The gateway requires a
14197
+ * `targets` array of `{ target, position }` entries (issue #19400). A
14198
+ * resolved promise does not guarantee a visible change: the backend silently
14199
+ * returns `200` (no-op) for non-conference targets.
13984
14200
  *
13985
14201
  * @param value - The {@link VideoPosition} to assign (e.g. `'auto'`, `'reserved-0'`).
13986
14202
  */
13987
14203
  async setPosition(value) {
13988
- const state = this._state$.value;
13989
- const target = {
13990
- member_id: this.id,
13991
- call_id: state.call_id ?? "",
13992
- node_id: state.node_id ?? ""
13993
- };
13994
- await this.executeMethod(target, "call.member.position.set", { targets: [{
13995
- target,
14204
+ await this.executeMethod("call.member.position.set", { targets: [{
14205
+ target: this.target,
13996
14206
  position: value
13997
14207
  }] });
13998
14208
  }
13999
14209
  /** Removes this participant from the call. */
14000
14210
  async remove() {
14001
- const state = this._state$.value;
14002
- const target = {
14003
- member_id: this.id,
14004
- call_id: state.call_id ?? "",
14005
- node_id: state.node_id ?? ""
14006
- };
14007
- await this.executeMethod(target, "call.member.remove", {});
14211
+ await this.executeMethod("call.member.remove", { targets: [this.target] });
14008
14212
  }
14009
14213
  /** Ends the call for this participant. */
14010
14214
  async end() {
14011
- await this.executeMethod(this.id, "call.end", {});
14215
+ await this.executeMethod("call.end", {});
14012
14216
  }
14013
14217
  /**
14014
14218
  * Replaces custom metadata for this participant.
@@ -14028,7 +14232,7 @@ var Participant = class extends Destroyable {
14028
14232
  }
14029
14233
  /** Destroys the participant, releasing all subscriptions and references. */
14030
14234
  destroy() {
14031
- this.executeMethod = void 0;
14235
+ this.callExecuteMethod = void 0;
14032
14236
  super.destroy();
14033
14237
  }
14034
14238
  };
@@ -14040,8 +14244,8 @@ var Participant = class extends Destroyable {
14040
14244
  */
14041
14245
  var SelfParticipant = class extends Participant {
14042
14246
  /** @internal */
14043
- constructor(id, executeMethod, vertoManager, deviceController) {
14044
- super(id, executeMethod, deviceController);
14247
+ constructor(id, callExecuteMethod, vertoManager, deviceController) {
14248
+ super(id, callExecuteMethod, deviceController);
14045
14249
  this.vertoManager = vertoManager;
14046
14250
  this._studioAudio$ = this.createBehaviorSubject(false);
14047
14251
  this.capabilities = new SelfCapabilities();
@@ -14065,7 +14269,7 @@ var SelfParticipant = class extends Participant {
14065
14269
  async enableStudioAudio() {
14066
14270
  if (this._studioAudio$.value) return;
14067
14271
  this._studioAudio$.next(true);
14068
- await this.executeMethod(this.id, "call.audioflags.set", {
14272
+ await this.executeMethod("call.audioflags.set", {
14069
14273
  echo_cancellation: false,
14070
14274
  auto_gain: false,
14071
14275
  noise_suppression: false
@@ -14078,7 +14282,7 @@ var SelfParticipant = class extends Participant {
14078
14282
  async disableStudioAudio() {
14079
14283
  if (!this._studioAudio$.value) return;
14080
14284
  this._studioAudio$.next(false);
14081
- await this.executeMethod(this.id, "call.audioflags.set", {
14285
+ await this.executeMethod("call.audioflags.set", {
14082
14286
  echo_cancellation: true,
14083
14287
  auto_gain: true,
14084
14288
  noise_suppression: true
@@ -14087,17 +14291,27 @@ var SelfParticipant = class extends Participant {
14087
14291
  /**
14088
14292
  * Starts sharing the local screen.
14089
14293
  *
14294
+ * A call carries at most one screen share. Read `screenShareStatus` before
14295
+ * calling and treat `'starting'`/`'stopping'` as busy.
14296
+ *
14090
14297
  * The call is unaffected when acquisition fails.
14091
14298
  *
14299
+ * @param options - Pass `{ audio: true }` to also request the shared
14300
+ * surface's audio. Defaults to video only.
14301
+ * @throws {ScreenShareAlreadyActiveError} When this call is already
14302
+ * sharing a screen. Call {@link stopScreenShare} before starting another.
14303
+ * @throws {AuxiliaryLegCancelledError} When {@link stopScreenShare} removes
14304
+ * the share before its leg finishes connecting.
14092
14305
  * @throws The raw `getDisplayMedia` error. A dismissed picker or a
14093
14306
  * permission denial rejects with a `NotAllowedError` `DOMException` —
14094
14307
  * inspect `error.name` to tell benign cancels apart from real failures.
14095
14308
  */
14096
- async startScreenShare() {
14309
+ async startScreenShare(options) {
14097
14310
  try {
14098
- await this.vertoManager.addScreenMedia();
14311
+ await this.vertoManager.addScreenMedia(options);
14099
14312
  } catch (error) {
14100
- logger$24.error("[Participant.startScreenShare] Screen share error:", error);
14313
+ if (error instanceof AuxiliaryLegCancelledError) logger$25.debug("[Participant.startScreenShare] Screen share cancelled before connecting.");
14314
+ else logger$25.error("[Participant.startScreenShare] Screen share error:", error);
14101
14315
  throw error;
14102
14316
  }
14103
14317
  }
@@ -14118,14 +14332,18 @@ var SelfParticipant = class extends Participant {
14118
14332
  *
14119
14333
  * The call is unaffected when acquisition fails.
14120
14334
  *
14335
+ * @throws {AuxiliaryLegCancelledError} When {@link removeAdditionalDevice}
14336
+ * removes the device before its leg finishes connecting.
14121
14337
  * @throws The raw `getUserMedia` error (e.g. `NotAllowedError` on
14122
- * permission denial) — inspect `error.name` to decide how to react.
14338
+ * permission denial) — inspect `error.name` to decide how to react — or
14339
+ * `AuxiliaryLegTimeoutError` if the leg does not connect in time.
14123
14340
  */
14124
14341
  async addAdditionalDevice(options) {
14125
14342
  try {
14126
14343
  await this.vertoManager.addInputDevice(options);
14127
14344
  } catch (error) {
14128
- logger$24.error("[Participant.addAdditionalDevice] Additional device error:", error);
14345
+ if (error instanceof AuxiliaryLegCancelledError) logger$25.debug("[Participant.addAdditionalDevice] Device removed before connecting.");
14346
+ else logger$25.error("[Participant.addAdditionalDevice] Additional device error:", error);
14129
14347
  throw error;
14130
14348
  }
14131
14349
  }
@@ -14160,22 +14378,31 @@ var SelfParticipant = class extends Participant {
14160
14378
  this.deviceController.selectAudioInputDevice(device);
14161
14379
  if (options.savePreference) PreferencesContainer.instance.preferredAudioInput = device;
14162
14380
  }
14163
- /** Updates the audio input track constraints for the active call. */
14381
+ /**
14382
+ * Updates the audio input track constraints for the active call.
14383
+ * @returns whether the constraints reached the media the call is sending.
14384
+ */
14164
14385
  async setAudioInputDeviceConstraints(constraints) {
14165
- await this.vertoManager.updateMediaConstraints({ audio: constraints });
14386
+ return this.vertoManager.updateMediaConstraints({ audio: constraints });
14166
14387
  }
14167
- /** Updates both audio and video input track constraints for the active call. */
14388
+ /**
14389
+ * Updates both audio and video input track constraints for the active call.
14390
+ * @returns whether both kinds took the constraints.
14391
+ */
14168
14392
  async setInputDevicesConstraints(constraints) {
14169
- await this.vertoManager.updateMediaConstraints(constraints);
14393
+ return this.vertoManager.updateMediaConstraints(constraints);
14170
14394
  }
14171
14395
  /** Selects the video input device for future calls. Optionally saves as a preference. */
14172
14396
  selectVideoInputDevice(device, options = {}) {
14173
14397
  this.deviceController.selectVideoInputDevice(device);
14174
14398
  if (options.savePreference) PreferencesContainer.instance.preferredVideoInput = device;
14175
14399
  }
14176
- /** Updates the video input track constraints for the active call. */
14400
+ /**
14401
+ * Updates the video input track constraints for the active call.
14402
+ * @returns whether the constraints reached the media the call is sending.
14403
+ */
14177
14404
  async setVideoInputDeviceConstraints(constraints) {
14178
- await this.vertoManager.updateMediaConstraints({ video: constraints });
14405
+ return this.vertoManager.updateMediaConstraints({ video: constraints });
14179
14406
  }
14180
14407
  /** Selects the audio output device. Optionally saves as a preference. */
14181
14408
  selectAudioOutputDevice(device, options = {}) {
@@ -14188,7 +14415,7 @@ var SelfParticipant = class extends Participant {
14188
14415
  */
14189
14416
  exitStudioModeIfActive() {
14190
14417
  if (this._studioAudio$.value) {
14191
- logger$24.debug("[SelfParticipant] Exiting studio audio mode due to individual flag toggle");
14418
+ logger$25.debug("[SelfParticipant] Exiting studio audio mode due to individual flag toggle");
14192
14419
  this._studioAudio$.next(false);
14193
14420
  }
14194
14421
  }
@@ -14212,7 +14439,7 @@ var SelfParticipant = class extends Participant {
14212
14439
  try {
14213
14440
  await super.mute();
14214
14441
  } catch (error) {
14215
- logger$24.warn("[Participant.toggleAudioInput] Server Error while muting audio input, proceeding with local toggle anyway", error);
14442
+ logger$25.warn("[Participant.toggleAudioInput] Server Error while muting audio input, proceeding with local toggle anyway", error);
14216
14443
  } finally {
14217
14444
  this.vertoManager.muteMainAudioInputDevice();
14218
14445
  }
@@ -14222,7 +14449,7 @@ var SelfParticipant = class extends Participant {
14222
14449
  try {
14223
14450
  await super.unmute();
14224
14451
  } catch (error) {
14225
- logger$24.warn("[Participant.toggleAudioInput] Server Error while unmuting audio input, proceeding with local toggle anyway", error);
14452
+ logger$25.warn("[Participant.toggleAudioInput] Server Error while unmuting audio input, proceeding with local toggle anyway", error);
14226
14453
  } finally {
14227
14454
  await this.vertoManager.unmuteMainAudioInputDevice();
14228
14455
  }
@@ -14232,7 +14459,7 @@ var SelfParticipant = class extends Participant {
14232
14459
  try {
14233
14460
  await super.muteVideo();
14234
14461
  } catch (error) {
14235
- logger$24.warn("[Participant.toggleVideoInput] Server Error while muting video input, proceeding with local toggle anyway", error);
14462
+ logger$25.warn("[Participant.toggleVideoInput] Server Error while muting video input, proceeding with local toggle anyway", error);
14236
14463
  } finally {
14237
14464
  this.vertoManager.muteMainVideoInputDevice();
14238
14465
  }
@@ -14242,7 +14469,7 @@ var SelfParticipant = class extends Participant {
14242
14469
  try {
14243
14470
  await super.unmuteVideo();
14244
14471
  } catch (error) {
14245
- logger$24.warn("[Participant.toggleVideoInput] Server Error while unmuting video input, proceeding with local toggle anyway", error);
14472
+ logger$25.warn("[Participant.toggleVideoInput] Server Error while unmuting video input, proceeding with local toggle anyway", error);
14246
14473
  } finally {
14247
14474
  await this.vertoManager.unmuteMainVideoInputDevice();
14248
14475
  }
@@ -14447,7 +14674,7 @@ function filterAs(predicate, resultPath) {
14447
14674
  //#endregion
14448
14675
  //#region src/operators/throwOnRPCError.ts
14449
14676
  var import_cjs$21 = require_cjs();
14450
- const logger$23 = getLogger();
14677
+ const logger$24 = getLogger();
14451
14678
  /**
14452
14679
  * RxJS operator that throws a {@link JSONRPCError} when the RPC response contains an error.
14453
14680
  * Passes successful responses through unchanged.
@@ -14455,14 +14682,14 @@ const logger$23 = getLogger();
14455
14682
  function throwOnRPCError() {
14456
14683
  return (0, import_cjs$21.map)((response) => {
14457
14684
  if (response.error) {
14458
- logger$23.error("[throwOnRPCError] RPC error response:", {
14685
+ logger$24.error("[throwOnRPCError] RPC error response:", {
14459
14686
  code: response.error.code,
14460
14687
  message: response.error.message,
14461
14688
  data: response.error.data
14462
14689
  });
14463
14690
  throw new JSONRPCError(response.error.code, response.error.message, response.error.data);
14464
14691
  }
14465
- logger$23.debug("[throwOnRPCError] RPC successful response:", response);
14692
+ logger$24.debug("[throwOnRPCError] RPC successful response:", response);
14466
14693
  return response;
14467
14694
  });
14468
14695
  }
@@ -14470,7 +14697,7 @@ function throwOnRPCError() {
14470
14697
  //#endregion
14471
14698
  //#region src/managers/CallEventsManager.ts
14472
14699
  var import_cjs$20 = require_cjs();
14473
- const logger$22 = getLogger();
14700
+ const logger$23 = getLogger();
14474
14701
  const initialSessionState = {};
14475
14702
  /** @internal */
14476
14703
  var CallEventsManager = class extends Destroyable {
@@ -14574,7 +14801,7 @@ var CallEventsManager = class extends Destroyable {
14574
14801
  }
14575
14802
  initSubscriptions() {
14576
14803
  this.subscribeTo(this.callJoinedEvent$, (callJoinedEvent) => {
14577
- logger$22.debug("[CallEventsManager] Handling call.joined event for call/session IDs:", {
14804
+ logger$23.debug("[CallEventsManager] Handling call.joined event for call/session IDs:", {
14578
14805
  callId: callJoinedEvent.call_id,
14579
14806
  roomSessionId: callJoinedEvent.room_session_id
14580
14807
  });
@@ -14601,19 +14828,19 @@ var CallEventsManager = class extends Destroyable {
14601
14828
  if (this._self$.value?.capabilities.setLayout) this.updateLayouts();
14602
14829
  });
14603
14830
  this.subscribeTo(this.memberUpdates$, (member) => {
14604
- logger$22.debug("[CallEventsManager] Handling member update event for member ID:", member);
14831
+ logger$23.debug("[CallEventsManager] Handling member update event for member ID:", member);
14605
14832
  this.upsertParticipant(member);
14606
14833
  });
14607
14834
  this.subscribeTo(this.webRtcCallSession.memberLeft$, (memberLeftEvent) => {
14608
- logger$22.debug("[CallEventsManager] Handling member.left event for member ID:", memberLeftEvent.member.member_id);
14835
+ logger$23.debug("[CallEventsManager] Handling member.left event for member ID:", memberLeftEvent.member.member_id);
14609
14836
  const participants = { ...this._participants$.value };
14610
14837
  if (memberLeftEvent.member.member_id in participants) {
14611
14838
  delete participants[memberLeftEvent.member.member_id];
14612
14839
  this._participants$.next(participants);
14613
- } else logger$22.warn(`[CallEventsManager] Received member.left event for unknown member ID: ${memberLeftEvent.member.member_id}`);
14840
+ } else logger$23.warn(`[CallEventsManager] Received member.left event for unknown member ID: ${memberLeftEvent.member.member_id}`);
14614
14841
  });
14615
14842
  this.subscribeTo(this.webRtcCallSession.callUpdated$, (callUpdatedEvent) => {
14616
- logger$22.debug("[CallEventsManager] Handling call.updated event:", callUpdatedEvent);
14843
+ logger$23.debug("[CallEventsManager] Handling call.updated event:", callUpdatedEvent);
14617
14844
  const roomSession = callUpdatedEvent.room_session;
14618
14845
  this._sessionState$.next({
14619
14846
  ...this._sessionState$.value,
@@ -14628,7 +14855,7 @@ var CallEventsManager = class extends Destroyable {
14628
14855
  });
14629
14856
  });
14630
14857
  this.subscribeTo(this.layoutChangedEvent$, (layoutChangedEvent) => {
14631
- logger$22.debug("[CallEventsManager] Handling layout.changed event:", layoutChangedEvent);
14858
+ logger$23.debug("[CallEventsManager] Handling layout.changed event:", layoutChangedEvent);
14632
14859
  this._sessionState$.next({
14633
14860
  ...this._sessionState$.value,
14634
14861
  layout_name: layoutChangedEvent.id,
@@ -14638,10 +14865,10 @@ var CallEventsManager = class extends Destroyable {
14638
14865
  });
14639
14866
  }
14640
14867
  updateParticipantPositions(layoutChangedEvent) {
14641
- if (Object.keys(this._participants$.value).length > 0 && !layoutChangedEvent.layers.some((layer) => !!layer.member_id)) logger$22.warn("[CallEventsManager] No layers with member_id found in layout.changed event. Nothing to update.");
14868
+ if (Object.keys(this._participants$.value).length > 0 && !layoutChangedEvent.layers.some((layer) => !!layer.member_id)) logger$23.warn("[CallEventsManager] No layers with member_id found in layout.changed event. Nothing to update.");
14642
14869
  layoutChangedEvent.layers.filter((layer) => !!layer.member_id).filter((layer) => {
14643
14870
  if (!(layer.member_id in this._participants$.value)) {
14644
- logger$22.warn(`[CallEventsManager] Skipping layout layer for unknown member_id: ${layer.member_id}`);
14871
+ logger$23.warn(`[CallEventsManager] Skipping layout layer for unknown member_id: ${layer.member_id}`);
14645
14872
  return false;
14646
14873
  }
14647
14874
  return true;
@@ -14659,12 +14886,17 @@ var CallEventsManager = class extends Destroyable {
14659
14886
  updateLayouts() {
14660
14887
  if (!this.selfId) return;
14661
14888
  this.webRtcCallSession.executeMethod(this.selfId, "call.layout.list", {}).then((response) => {
14889
+ const layouts = response.result?.layouts;
14890
+ if (!layouts) {
14891
+ logger$23.warn("[CallEventsManager] Layout list response carried no layouts; keeping current layouts");
14892
+ return;
14893
+ }
14662
14894
  this._sessionState$.next({
14663
14895
  ...this._sessionState$.value,
14664
- layouts: response.result.layouts
14896
+ layouts
14665
14897
  });
14666
14898
  }).catch((error) => {
14667
- logger$22.error("[CallEventsManager] Error fetching layouts:", error);
14899
+ logger$23.error("[CallEventsManager] Error fetching layouts:", error);
14668
14900
  });
14669
14901
  }
14670
14902
  updateParticipants(members) {
@@ -14680,7 +14912,7 @@ var CallEventsManager = class extends Destroyable {
14680
14912
  }
14681
14913
  const participant = this._participants$.value[member.member_id];
14682
14914
  const oldValue = participant.value;
14683
- logger$22.debug("[CallEventsManager] Updating participant:", member.member_id, {
14915
+ logger$23.debug("[CallEventsManager] Updating participant:", member.member_id, {
14684
14916
  oldValue,
14685
14917
  newValue: member
14686
14918
  });
@@ -14693,17 +14925,17 @@ var CallEventsManager = class extends Destroyable {
14693
14925
  }
14694
14926
  get callJoinedEvent$() {
14695
14927
  return this.cachedObservable("callJoinedEvent$", () => this.webRtcCallSession.callEvent$.pipe((0, import_cjs$20.filter)(isCallJoinedPayload), (0, import_cjs$20.tap)((event) => {
14696
- logger$22.debug("[CallEventsManager] Call joined event:", event);
14928
+ logger$23.debug("[CallEventsManager] Call joined event:", event);
14697
14929
  })));
14698
14930
  }
14699
14931
  get layoutChangedEvent$() {
14700
14932
  return this.cachedObservable("layoutChangedEvent$", () => this.webRtcCallSession.callEvent$.pipe(filterAs(isLayoutChangedPayload, "layout"), (0, import_cjs$20.tap)((event) => {
14701
- logger$22.debug("[CallEventsManager] Layout changed event:", event);
14933
+ logger$23.debug("[CallEventsManager] Layout changed event:", event);
14702
14934
  })));
14703
14935
  }
14704
14936
  get memberUpdates$() {
14705
14937
  return this.cachedObservable("memberUpdates$", () => (0, import_cjs$20.merge)(this.webRtcCallSession.memberJoined$, this.webRtcCallSession.memberUpdated$, this.webRtcCallSession.memberTalking$).pipe((0, import_cjs$20.map)((event) => event.member), (0, import_cjs$20.tap)((event) => {
14706
- logger$22.debug("[CallEventsManager] Member update event:", event);
14938
+ logger$23.debug("[CallEventsManager] Member update event:", event);
14707
14939
  })));
14708
14940
  }
14709
14941
  destroy() {
@@ -14722,6 +14954,111 @@ var CallEventsManager = class extends Destroyable {
14722
14954
  }
14723
14955
  };
14724
14956
 
14957
+ //#endregion
14958
+ //#region src/controllers/ConstraintFallbackHelper.ts
14959
+ /**
14960
+ * ConstraintFallbackHelper - Provides getUserMedia with automatic constraint
14961
+ * fallback on OverconstrainedError.
14962
+ *
14963
+ * When a specific device ID is requested, the helper tries progressively
14964
+ * looser constraints:
14965
+ * 1. `{ deviceId: { exact: deviceId } }` -- exact match
14966
+ * 2. `{ deviceId: deviceId }` -- preferred (browser may pick another)
14967
+ * 3. `{}` -- no constraint, browser default
14968
+ *
14969
+ * This prevents stale device IDs from blocking call setup.
14970
+ *
14971
+ * @see Section 5.8 and Section 11 of the Implementation Guide
14972
+ */
14973
+ const logger$22 = getLogger();
14974
+ /**
14975
+ * Attempts getUserMedia with progressively looser constraints.
14976
+ *
14977
+ * The function tries three levels of constraint specificity for the given
14978
+ * device kind. Each level is only attempted if the previous one fails with
14979
+ * an OverconstrainedError. Non-OverconstrainedError failures (e.g.,
14980
+ * NotAllowedError) are thrown immediately without fallback.
14981
+ *
14982
+ * @param mediaDevices - Anything exposing `getUserMedia` (a full
14983
+ * `WebRTCMediaDevices`, or a shim wrapping one)
14984
+ * @param constraints - The full MediaStreamConstraints to use as a base
14985
+ * @param kind - Which track kind to apply fallback to ('audio' | 'video')
14986
+ * @param deviceId - The device ID to try (if undefined, calls getUserMedia as-is)
14987
+ * @returns The stream and the fallback level that succeeded
14988
+ * @throws When all fallback levels fail, or when a non-OverconstrainedError occurs
14989
+ */
14990
+ async function getUserMediaWithFallback(mediaDevices, constraints, kind, deviceId) {
14991
+ if (!deviceId) return {
14992
+ stream: await mediaDevices.getUserMedia(constraints),
14993
+ fallbackLevel: "default"
14994
+ };
14995
+ const baseConstraints = typeof constraints[kind] === "object" ? constraints[kind] : {};
14996
+ try {
14997
+ const exactConstraints = {
14998
+ ...constraints,
14999
+ [kind]: {
15000
+ ...baseConstraints,
15001
+ deviceId: { exact: deviceId }
15002
+ }
15003
+ };
15004
+ return {
15005
+ stream: await mediaDevices.getUserMedia(exactConstraints),
15006
+ fallbackLevel: "exact"
15007
+ };
15008
+ } catch (error) {
15009
+ if (!isOverconstrainedError(error)) throw error;
15010
+ logger$22.debug(`[ConstraintFallbackHelper] Exact constraint failed for ${kind}, trying preferred`, { deviceId });
15011
+ }
15012
+ try {
15013
+ const preferredConstraints = {
15014
+ ...constraints,
15015
+ [kind]: {
15016
+ ...baseConstraints,
15017
+ deviceId
15018
+ }
15019
+ };
15020
+ return {
15021
+ stream: await mediaDevices.getUserMedia(preferredConstraints),
15022
+ fallbackLevel: "preferred"
15023
+ };
15024
+ } catch (error) {
15025
+ if (!isOverconstrainedError(error)) throw error;
15026
+ logger$22.debug(`[ConstraintFallbackHelper] Preferred constraint failed for ${kind}, trying default`, { deviceId });
15027
+ }
15028
+ try {
15029
+ const defaultConstraints = {
15030
+ ...constraints,
15031
+ [kind]: { ...baseConstraints }
15032
+ };
15033
+ if (typeof defaultConstraints[kind] === "object") {
15034
+ const { deviceId: _removed, ...rest } = defaultConstraints[kind];
15035
+ defaultConstraints[kind] = rest;
15036
+ }
15037
+ const stream = await mediaDevices.getUserMedia(defaultConstraints);
15038
+ logger$22.warn(`[ConstraintFallbackHelper] Fell back to browser default for ${kind}`, { requestedDeviceId: deviceId });
15039
+ return {
15040
+ stream,
15041
+ fallbackLevel: "default"
15042
+ };
15043
+ } catch (error) {
15044
+ logger$22.error(`[ConstraintFallbackHelper] All fallback levels exhausted for ${kind}`, {
15045
+ deviceId,
15046
+ error
15047
+ });
15048
+ throw error;
15049
+ }
15050
+ }
15051
+ /**
15052
+ * Checks whether an error is an OverconstrainedError.
15053
+ *
15054
+ * Browsers may throw either a native OverconstrainedError or a DOMException
15055
+ * with a specific name.
15056
+ */
15057
+ function isOverconstrainedError(error) {
15058
+ if (error instanceof Error) return error.name === "OverconstrainedError" || error.name === "ConstraintNotSatisfiedError";
15059
+ return false;
15060
+ }
15061
+
14725
15062
  //#endregion
14726
15063
  //#region src/helpers/SDPHelper.ts
14727
15064
  /**
@@ -15280,6 +15617,7 @@ var LocalStreamController = class extends Destroyable {
15280
15617
  this._localAudioTracks$ = this.createBehaviorSubject([]);
15281
15618
  this._localVideoTracks$ = this.createBehaviorSubject([]);
15282
15619
  this._mediaTrackEnded$ = this.createSubject();
15620
+ this._trackOrigins = /* @__PURE__ */ new WeakMap();
15283
15621
  }
15284
15622
  get localStream$() {
15285
15623
  return this._localStream$.asObservable().pipe((0, import_cjs$17.takeUntil)(this.destroyed$));
@@ -15302,6 +15640,22 @@ var LocalStreamController = class extends Destroyable {
15302
15640
  get localVideoTracks() {
15303
15641
  return this._localVideoTracks$.value;
15304
15642
  }
15643
+ tagTracks(tracks, origin) {
15644
+ for (const track of tracks) this._trackOrigins.set(track, origin);
15645
+ }
15646
+ setTrackOrigin(track, origin) {
15647
+ this._trackOrigins.set(track, origin);
15648
+ }
15649
+ getTrackOrigin(track) {
15650
+ return this._trackOrigins.get(track);
15651
+ }
15652
+ /**
15653
+ * Fail-safe: an unrecorded track reads as not-a-device-capture, so a missed
15654
+ * tagging site leaves media alone rather than destroying it.
15655
+ */
15656
+ isDeviceCapture(track) {
15657
+ return this._trackOrigins.get(track) === "device";
15658
+ }
15305
15659
  /**
15306
15660
  * Build the local media stream based on the provided options.
15307
15661
  */
@@ -15311,13 +15665,16 @@ var LocalStreamController = class extends Destroyable {
15311
15665
  if (this.options.inputAudioStream ?? this.options.inputVideoStream) {
15312
15666
  const tracks = [...this.options.inputAudioStream?.getTracks() ?? [], ...this.options.inputVideoStream?.getTracks() ?? []];
15313
15667
  stream = new MediaStream(tracks);
15668
+ this.tagTracks(tracks, "application");
15314
15669
  } else if (this.options.propose === "screenshare") {
15315
- logger$19.debug("[LocalStreamController] Requesting display media for screen sharing with audio:", Boolean(this.options.inputAudioDeviceConstraints));
15670
+ const audio = this.options.screenShareAudio ?? false;
15671
+ logger$19.debug("[LocalStreamController] Requesting display media for screen sharing with audio:", audio);
15316
15672
  stream = await this.options.getDisplayMedia({
15317
15673
  video: true,
15318
- audio: Boolean(this.options.inputAudioDeviceConstraints)
15674
+ audio
15319
15675
  });
15320
15676
  logger$19.debug("[LocalStreamController] Screen share media obtained:", stream);
15677
+ this.tagTracks(stream.getTracks(), "display");
15321
15678
  } else {
15322
15679
  const constraints = {
15323
15680
  audio: this.options.inputAudioDeviceConstraints,
@@ -15326,6 +15683,7 @@ var LocalStreamController = class extends Destroyable {
15326
15683
  logger$19.debug("[LocalStreamController] Requesting user media with constraints:", constraints);
15327
15684
  stream = await this.options.getUserMedia(constraints);
15328
15685
  logger$19.debug("[LocalStreamController] User media obtained:", stream);
15686
+ this.tagTracks(stream.getTracks(), "device");
15329
15687
  }
15330
15688
  this._localStream$.next(stream);
15331
15689
  this._localAudioTracks$.next(stream.getAudioTracks());
@@ -15335,10 +15693,13 @@ var LocalStreamController = class extends Destroyable {
15335
15693
  /**
15336
15694
  * Add a local media track to the local stream.
15337
15695
  * @param track - The MediaStreamTrack to add
15696
+ * @param origin - Defaults to `'device'`; every internal caller passes a
15697
+ * fresh `getUserMedia` capture.
15338
15698
  * @returns The MediaStream (either existing or newly created)
15339
15699
  */
15340
- addTrack(track) {
15700
+ addTrack(track, origin = "device") {
15341
15701
  const localStream = this._localStream$.value ?? new MediaStream();
15702
+ this._trackOrigins.set(track, origin);
15342
15703
  track.addEventListener("ended", this.mediaTrackEndedHandler);
15343
15704
  localStream.addTrack(track);
15344
15705
  this._localStream$.next(localStream);
@@ -15608,15 +15969,27 @@ var TransceiverController = class extends Destroyable {
15608
15969
  for (let i = 0; i < Number(msStreamsNumber); i++) this.peerConnection.addTransceiver("video", { direction: "recvonly" });
15609
15970
  }
15610
15971
  }
15972
+ /**
15973
+ * @returns whether every live sender of the kind took the constraints. A
15974
+ * skipped non-device sender, an exhausted fallback, and having no live sender
15975
+ * at all all report `false` — `mediaParamsUpdated.applied` is built from this,
15976
+ * and an application told `true` cannot tell a working push from a no-op.
15977
+ */
15611
15978
  async updateSendersConstraints(kind, constraints) {
15612
15979
  if (!constraints) {
15613
15980
  this.stopTrackSender(kind);
15614
- return Promise.resolve();
15981
+ return false;
15615
15982
  }
15616
15983
  const senders = this.peerConnection.getSenders().filter((sender) => sender.track?.kind === kind && sender.track.readyState === "live");
15984
+ let applied = senders.length > 0;
15617
15985
  for (const sender of senders) {
15618
15986
  const { track } = sender;
15619
15987
  if (track) {
15988
+ if (!this.options.localStreamController.isDeviceCapture(track)) {
15989
+ logger$18.debug(`[TransceiverController] Skipping ${kind} constraints for a non-device track (origin: ${this.options.localStreamController.getTrackOrigin(track) ?? "unrecorded"}), track ${track.id}`);
15990
+ applied = false;
15991
+ continue;
15992
+ }
15620
15993
  const constraintsToApply = {
15621
15994
  ...track.getConstraints(),
15622
15995
  ...constraints
@@ -15632,33 +16005,39 @@ var TransceiverController = class extends Destroyable {
15632
16005
  } catch (fallbackError) {
15633
16006
  logger$18.warn(`[TransceiverController] Track replacement fallback also failed for ${kind} track:`, fallbackError);
15634
16007
  this.options.onError?.(new MediaTrackError("updateSendersConstraints", kind, fallbackError));
16008
+ applied = false;
15635
16009
  }
15636
16010
  }
15637
16011
  }
15638
16012
  }
16013
+ return applied;
15639
16014
  }
15640
16015
  /**
15641
- * Fallback when applyConstraints fails: stop the current track, acquire a new
15642
- * one via getUserMedia with the merged constraints (preserving the current
15643
- * deviceId), replace the sender track, and update the localStream.
16016
+ * Fallback when applyConstraints fails, which on iOS Safari it silently does.
15644
16017
  *
15645
- * This is critical for iOS Safari where applyConstraints on audio tracks
15646
- * silently fails or throws.
16018
+ * Order matters: acquiring before stopping means a failed acquisition leaves
16019
+ * the existing media playing. The deviceId goes through the fallback ladder
16020
+ * rather than pinned `{ exact }`, so a stale id degrades instead of failing.
15647
16021
  */
15648
16022
  async replaceTrackFallback(sender, oldTrack, kind, mergedConstraints) {
15649
16023
  const { deviceId } = oldTrack.getSettings();
15650
- const constraintsWithDevice = {
15651
- ...mergedConstraints,
15652
- ...deviceId ? { deviceId: { exact: deviceId } } : {}
15653
- };
15654
- const trackId = oldTrack.id;
16024
+ const { stream, fallbackLevel } = await getUserMediaWithFallback({ getUserMedia: this.options.getUserMedia }, { [kind]: mergedConstraints }, kind, deviceId);
16025
+ const newTrack = stream.getTracks().find((t) => t.kind === kind);
16026
+ if (!newTrack) {
16027
+ stream.getTracks().forEach((t) => t.stop());
16028
+ throw new MediaTrackError("replaceTrackFallback", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
16029
+ }
16030
+ try {
16031
+ await sender.replaceTrack(newTrack);
16032
+ } catch (error) {
16033
+ stream.getTracks().forEach((t) => t.stop());
16034
+ throw error;
16035
+ }
16036
+ const oldTrackId = oldTrack.id;
16037
+ this.options.localStreamController.removeTrack(oldTrackId);
15655
16038
  oldTrack.stop();
15656
- this.options.localStreamController.removeTrack(trackId);
15657
- const newTrack = (await this.options.getUserMedia({ [kind]: constraintsWithDevice })).getTracks().find((t) => t.kind === kind);
15658
- if (!newTrack) throw new MediaTrackError("replaceTrackFallback", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
15659
- await sender.replaceTrack(newTrack);
15660
16039
  this.options.localStreamController.addTrack(newTrack);
15661
- logger$18.debug(`[TransceiverController] Track replacement fallback succeeded for ${kind}. New track: ${newTrack.id}`);
16040
+ logger$18.debug(`[TransceiverController] Track replacement fallback succeeded for ${kind} (deviceId fallback level: ${fallbackLevel}). New track: ${newTrack.id}`);
15662
16041
  }
15663
16042
  getMediaDirections() {
15664
16043
  if (this.peerConnection.connectionState === "connected") return this.peerConnection.getTransceivers().reduce((acc, transceiver) => {
@@ -15728,48 +16107,44 @@ var RTCPeerConnectionController = class extends Destroyable {
15728
16107
  this.negotiationNeeded$.next();
15729
16108
  };
15730
16109
  this.updateSelectedInputDevice = async (kind, deviceInfo) => {
16110
+ const { localStream } = this;
16111
+ if (!localStream) {
16112
+ logger$17.warn("[RTCPeerConnectionController] No local stream available to update input device.");
16113
+ return;
16114
+ }
16115
+ const currentTrack = localStream.getTracks().find((track) => track.kind === kind);
16116
+ if (!currentTrack) {
16117
+ logger$17.debug(`[RTCPeerConnectionController] No ${kind} track to switch.`);
16118
+ return;
16119
+ }
16120
+ if (!deviceInfo) {
16121
+ logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected: none`);
16122
+ this.stopTrackSender(kind);
16123
+ return;
16124
+ }
16125
+ const constraints = {
16126
+ ...currentTrack.getConstraints(),
16127
+ ...this.deviceController.deviceInfoToConstraints(deviceInfo)
16128
+ };
15731
16129
  try {
15732
- const { localStream } = this;
15733
- if (!localStream) {
15734
- logger$17.warn("[RTCPeerConnectionController] No local stream available to update input device.");
15735
- return;
15736
- }
15737
- logger$17.debug(`[RTCPeerConnectionController] Updating selected ${kind} input device:`, localStream.getTracks());
15738
- const track = localStream.getTracks().find((track$1) => track$1.kind === kind);
15739
- if (track) {
15740
- this.transceiverController?.stopTrackSender(kind);
15741
- this.localStreamController.removeTrack(track.id);
15742
- logger$17.debug(`[RTCPeerConnectionController] Stopped existing ${kind} track: ${track.id}`, localStream.getTracks());
15743
- if (!deviceInfo) {
15744
- logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected: none`);
15745
- return;
15746
- }
15747
- const streamTrack = (await this.getUserMedia({ [kind]: {
15748
- ...track.getConstraints(),
15749
- ...this.deviceController.deviceInfoToConstraints(deviceInfo)
15750
- } })).getTracks().find((t) => t.kind === kind);
15751
- if (streamTrack) {
15752
- logger$17.debug(`[RTCPeerConnectionController] Adding new ${kind} track: ${streamTrack.id}`);
15753
- this.localStreamController.addTrack(streamTrack);
15754
- await this.transceiverController?.replaceSenderTrack(kind, streamTrack);
15755
- logger$17.debug(`[RTCPeerConnectionController] Added new ${kind} track: ${streamTrack.id}`, this.localStream?.getTracks());
15756
- }
15757
- }
15758
- logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected:`, deviceInfo?.label);
16130
+ const newTrack = await this.acquireInputTrack(kind, constraints, deviceInfo, currentTrack);
16131
+ await this.attachInputTrack(kind, newTrack, currentTrack);
16132
+ logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected:`, deviceInfo.label, newTrack.id);
15759
16133
  } catch (error) {
15760
16134
  logger$17.error(`[RTCPeerConnectionController] Failed to select ${kind} input device:`, error);
15761
16135
  this._errors$.next(new MediaTrackError("updateSelectedInputDevice", kind, error));
15762
- throw error;
15763
16136
  }
15764
16137
  };
15765
16138
  this._isNegotiating$ = this.createBehaviorSubject(false);
15766
16139
  this._memberId = null;
16140
+ this._nodeId = null;
15767
16141
  this._iceConnectionState$ = this.createReplaySubject(1);
15768
16142
  this._connectionState$ = this.createReplaySubject(1);
15769
16143
  this._signalingState$ = this.createReplaySubject(1);
15770
16144
  this._iceGatheringState$ = this.createReplaySubject(1);
15771
16145
  this._errors$ = this.createReplaySubject(1);
15772
16146
  this._iceCandidates$ = this.createReplaySubject(1);
16147
+ this._localMediaSettled$ = this.createReplaySubject(1);
15773
16148
  this._initialized$ = this.createReplaySubject(1);
15774
16149
  this._remoteDescription$ = this.createReplaySubject(1);
15775
16150
  this._remoteStream$ = this.createBehaviorSubject(null);
@@ -15802,6 +16177,7 @@ var RTCPeerConnectionController = class extends Destroyable {
15802
16177
  inputVideoStream: this.options.inputVideoStream,
15803
16178
  inputAudioDeviceConstraints: this.inputAudioDeviceConstraints,
15804
16179
  inputVideoDeviceConstraints: this.inputVideoDeviceConstraints,
16180
+ screenShareAudio: this.options.screenShareAudio,
15805
16181
  getUserMedia: async (constraints) => this.getUserMedia(constraints),
15806
16182
  getDisplayMedia: async (options$1) => this.getDisplayMedia(options$1)
15807
16183
  });
@@ -15828,6 +16204,13 @@ var RTCPeerConnectionController = class extends Destroyable {
15828
16204
  get memberId() {
15829
16205
  return this._memberId;
15830
16206
  }
16207
+ /** The node this leg's invite landed on — auxiliary legs are placed independently. */
16208
+ setNodeId(nodeId) {
16209
+ this._nodeId = nodeId;
16210
+ }
16211
+ get nodeId() {
16212
+ return this._nodeId;
16213
+ }
15831
16214
  stopTrackSender(kind, options = { updateTransceiverDirection: false }) {
15832
16215
  const audioCovered = kind === "audio" || kind === "both";
15833
16216
  if (audioCovered && this._localAudioPipeline) this.stopRawAudioInputForPipeline();
@@ -15837,10 +16220,7 @@ var RTCPeerConnectionController = class extends Destroyable {
15837
16220
  }
15838
16221
  stopRawAudioInputForPipeline() {
15839
16222
  const rawTracks = this.localStreamController.localAudioTracks;
15840
- for (const track of rawTracks) if (track.readyState === "live") {
15841
- track.stop();
15842
- this.localStreamController.removeTrack(track.id);
15843
- }
16223
+ for (const track of rawTracks) if (track.readyState === "live") this.localStreamController.removeTrack(track.id);
15844
16224
  this._localAudioPipeline?.setInputTrack(null);
15845
16225
  }
15846
16226
  get isNegotiating$() {
@@ -15873,6 +16253,10 @@ var RTCPeerConnectionController = class extends Destroyable {
15873
16253
  get remoteDescription$() {
15874
16254
  return this.cachedObservable("remoteDescription$", () => this._remoteDescription$.asObservable().pipe((0, import_cjs$16.takeUntil)(this.destroyed$)));
15875
16255
  }
16256
+ /** Emits once local media is settled — acquired, or knowingly receive-only. */
16257
+ get localMediaSettled$() {
16258
+ return this.cachedObservable("localMediaSettled$", () => this._localMediaSettled$.asObservable().pipe((0, import_cjs$16.takeUntil)(this.destroyed$)));
16259
+ }
15876
16260
  get localStream$() {
15877
16261
  return this.cachedObservable("localStream$", () => this.localStreamController.localStream$.pipe((0, import_cjs$16.takeUntil)(this.destroyed$)));
15878
16262
  }
@@ -15900,6 +16284,9 @@ var RTCPeerConnectionController = class extends Destroyable {
15900
16284
  get propose() {
15901
16285
  return this.options.propose ?? "main";
15902
16286
  }
16287
+ get connectionState() {
16288
+ return this.peerConnection?.connectionState;
16289
+ }
15903
16290
  get isAdditionalDevice() {
15904
16291
  return this.propose === "additional-device";
15905
16292
  }
@@ -16028,7 +16415,7 @@ var RTCPeerConnectionController = class extends Destroyable {
16028
16415
  this._isNegotiating$.next(true);
16029
16416
  await this._setRemoteDescription(this.sdpInit);
16030
16417
  } else {
16031
- await this.setupTrackHandling();
16418
+ if (!await this.setupTrackHandling()) return;
16032
16419
  this._initialized$.next(true);
16033
16420
  }
16034
16421
  } catch (error) {
@@ -16152,7 +16539,10 @@ var RTCPeerConnectionController = class extends Destroyable {
16152
16539
  inputVideoDeviceConstraints: this.inputVideoDeviceConstraints
16153
16540
  });
16154
16541
  }
16155
- await this.setupLocalTracks();
16542
+ if (!await this.setupLocalTracks()) {
16543
+ logger$17.debug("[RTCPeerConnectionController] Inbound answer abandoned; the connection went away.");
16544
+ return;
16545
+ }
16156
16546
  const { answerOptions } = this;
16157
16547
  logger$17.debug("[RTCPeerConnectionController] Creating inbound answer with options:", answerOptions);
16158
16548
  await this.createAnswer(answerOptions);
@@ -16290,35 +16680,52 @@ var RTCPeerConnectionController = class extends Destroyable {
16290
16680
  }
16291
16681
  /**
16292
16682
  * Setup track handling for remote tracks.
16683
+ *
16684
+ * @returns `false` when the connection went away while local media was being
16685
+ * acquired — see {@link setupLocalTracks}.
16293
16686
  */
16294
16687
  async setupTrackHandling() {
16295
16688
  if (!this.peerConnection) throw new DependencyError("RTCPeerConnection is not initialized");
16296
- await this.setupLocalTracks();
16689
+ if (!await this.setupLocalTracks()) return false;
16297
16690
  await this.setupRemoteTracks();
16691
+ return true;
16298
16692
  }
16693
+ /**
16694
+ * @returns `false` when the connection was torn down while getUserMedia was
16695
+ * in flight. The acquisition is not cancellable, so the caller must stop
16696
+ * rather than go on to touch a peer connection that is closed or gone.
16697
+ */
16299
16698
  async setupLocalTracks() {
16300
16699
  logger$17.debug("[RTCPeerConnectionController] Setting up local tracks/transceivers.");
16301
16700
  if (this.hasNoLocalMediaToSend()) {
16302
16701
  if (!this.receiveAudio && !this.receiveVideo) throw new InvalidParams("Call requests no media: enable audio/video or receiveAudio/receiveVideo");
16303
16702
  logger$17.debug("[RTCPeerConnectionController] No local media requested; negotiating receive-only.");
16304
16703
  this.setupReceiveOnlyTransceivers();
16305
- return;
16704
+ this._localMediaSettled$.next();
16705
+ return true;
16306
16706
  }
16307
16707
  let localStream;
16308
16708
  try {
16309
16709
  localStream = this.localStream ?? await this.localStreamController.buildLocalStream();
16310
16710
  } catch (error) {
16311
16711
  this.handleLocalMediaFailure(error);
16312
- return;
16712
+ this._localMediaSettled$.next();
16713
+ return true;
16714
+ }
16715
+ if (!this.peerConnection || this.peerConnection.signalingState === "closed") {
16716
+ logger$17.debug("[RTCPeerConnectionController] Local media arrived after teardown; releasing it.");
16717
+ localStream.getTracks().forEach((track) => track.stop());
16718
+ return false;
16313
16719
  }
16720
+ this._localMediaSettled$.next();
16314
16721
  if (this.transceiverController?.useAddStream ?? false) {
16315
16722
  logger$17.warn("[RTCPeerConnectionController] Using deprecated addStream API to add local stream.");
16316
- this.peerConnection?.addStream(localStream);
16723
+ this.peerConnection.addStream(localStream);
16317
16724
  if (!this.isNegotiating) {
16318
16725
  logger$17.debug("[RTCPeerConnectionController] Forcing negotiationneeded after local tracks setup.");
16319
16726
  this.negotiationNeeded$.next();
16320
16727
  }
16321
- return;
16728
+ return true;
16322
16729
  }
16323
16730
  for (const kind of ["audio", "video"]) {
16324
16731
  const tracks = (kind === "audio" ? localStream.getAudioTracks() : localStream.getVideoTracks()).map((track, index) => ({
@@ -16332,10 +16739,11 @@ var RTCPeerConnectionController = class extends Destroyable {
16332
16739
  await this.transceiverController?.setupTransceiverSender(track, localStream, transceivers[index]);
16333
16740
  } else {
16334
16741
  logger$17.debug(`[RTCPeerConnectionController] Using addTrack for local ${kind} track:`, track.id);
16335
- this.peerConnection?.addTrack(track, localStream);
16742
+ this.peerConnection.addTrack(track, localStream);
16336
16743
  }
16337
16744
  }
16338
16745
  }
16746
+ return true;
16339
16747
  }
16340
16748
  /** True for a main connection with no local media to send. */
16341
16749
  hasNoLocalMediaToSend() {
@@ -16424,6 +16832,61 @@ var RTCPeerConnectionController = class extends Destroyable {
16424
16832
  this._localAudioPipeline.setInputTrack(newTrack);
16425
16833
  }
16426
16834
  /**
16835
+ * Capture the newly selected device, leaving the current capture running.
16836
+ *
16837
+ * A rejection must leave the current track sending, so nothing is released
16838
+ * until the replacement is in hand. The one exception is hardware that admits
16839
+ * a single opener — a phone's front and back cameras, typically — which
16840
+ * rejects the second capture until the first is closed.
16841
+ */
16842
+ async acquireInputTrack(kind, constraints, deviceInfo, currentTrack) {
16843
+ try {
16844
+ return await this.captureTrack(kind, constraints, deviceInfo.deviceId);
16845
+ } catch (error) {
16846
+ if (!isMediaDeviceInUse(error)) throw error;
16847
+ logger$17.warn(`[RTCPeerConnectionController] ${kind} device is held exclusively; releasing the current capture to retry:`, error);
16848
+ const previousDeviceId = currentTrack.getSettings().deviceId;
16849
+ this.stopTrackSender(kind);
16850
+ try {
16851
+ return await this.captureTrack(kind, constraints, deviceInfo.deviceId);
16852
+ } catch (retryError) {
16853
+ await this.restorePreviousInputTrack(kind, constraints, previousDeviceId, currentTrack);
16854
+ throw retryError;
16855
+ }
16856
+ }
16857
+ }
16858
+ async captureTrack(kind, constraints, deviceId) {
16859
+ const { stream, fallbackLevel } = await getUserMediaWithFallback({ getUserMedia: async (c) => this.getUserMedia(c) }, { [kind]: constraints }, kind, deviceId);
16860
+ const track = stream.getTracks().find((t) => t.kind === kind);
16861
+ if (!track) {
16862
+ stream.getTracks().forEach((t) => t.stop());
16863
+ throw new MediaTrackError("updateSelectedInputDevice", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
16864
+ }
16865
+ if (fallbackLevel !== "exact") logger$17.warn(`[RTCPeerConnectionController] ${kind} device acquired at fallback level '${fallbackLevel}'; the capture may not be the requested device.`);
16866
+ return track;
16867
+ }
16868
+ /** Best-effort return to the device that was released for an exclusive retry. */
16869
+ async restorePreviousInputTrack(kind, constraints, previousDeviceId, releasedTrack) {
16870
+ try {
16871
+ const restored = await this.captureTrack(kind, constraints, previousDeviceId);
16872
+ await this.attachInputTrack(kind, restored, releasedTrack);
16873
+ } catch (error) {
16874
+ logger$17.error(`[RTCPeerConnectionController] Failed to restore the previous ${kind} device:`, error);
16875
+ }
16876
+ }
16877
+ async attachInputTrack(kind, newTrack, oldTrack) {
16878
+ const pipelineOwnsAudio = kind === "audio" && this._localAudioPipeline;
16879
+ if (!pipelineOwnsAudio) try {
16880
+ await this.transceiverController?.replaceSenderTrack(kind, newTrack);
16881
+ } catch (error) {
16882
+ newTrack.stop();
16883
+ throw error;
16884
+ }
16885
+ this.localStreamController.removeTrack(oldTrack.id);
16886
+ this.localStreamController.addTrack(newTrack);
16887
+ if (pipelineOwnsAudio) this._localAudioPipeline?.setInputTrack(newTrack);
16888
+ }
16889
+ /**
16427
16890
  * Return the lazily-created {@link LocalAudioPipeline}, constructing it on
16428
16891
  * first access. On creation the current audio sender's track is routed
16429
16892
  * through the pipeline (input → gain → analyser → destination) and the
@@ -16456,6 +16919,7 @@ var RTCPeerConnectionController = class extends Destroyable {
16456
16919
  const sender = (this.transceiverController?.audioTransceivers.at(0))?.sender ?? this.peerConnection.getSenders().find((s) => s.track?.kind === "audio");
16457
16920
  if (!sender || !raw) return;
16458
16921
  try {
16922
+ this.localStreamController.setTrackOrigin(this._localAudioPipeline.outputTrack, "processed");
16459
16923
  await sender.replaceTrack(this._localAudioPipeline.outputTrack);
16460
16924
  } catch (error) {
16461
16925
  logger$17.warn("[RTCPeerConnectionController] Failed to route audio sender through pipeline:", error);
@@ -16516,37 +16980,68 @@ var RTCPeerConnectionController = class extends Destroyable {
16516
16980
  for (const existingTrack of existingTracks) this.removeLocalTrack(existingTrack.id);
16517
16981
  this.addLocalTrack(track);
16518
16982
  }
16983
+ /**
16984
+ * @returns whether the constraints reached the media the leg is sending.
16985
+ *
16986
+ * With the pipeline engaged the audio sender carries the processed
16987
+ * destination track, so the sender scan would find nothing it may touch and
16988
+ * every audio constraint API would silently no-op. The constraints belong to
16989
+ * the pipeline's device source, which is the capture that sender ultimately
16990
+ * carries.
16991
+ */
16519
16992
  async updateSendersConstraints(kind, constraints) {
16520
- await this.transceiverController?.updateSendersConstraints(kind, constraints);
16993
+ if (kind === "audio" && this._localAudioPipeline) {
16994
+ if (!constraints) {
16995
+ this.stopTrackSender("audio");
16996
+ return false;
16997
+ }
16998
+ return this.applyPipelineSourceConstraints(constraints);
16999
+ }
17000
+ return await this.transceiverController?.updateSendersConstraints(kind, constraints) ?? false;
16521
17001
  }
16522
17002
  /**
16523
- * Replace the current audio track with a new one using the given constraints.
16524
- * Used for server-pushed audio constraint changes where applyConstraints
16525
- * fails on iOS Safari. Stops the current track, acquires a new one via
16526
- * getUserMedia, and replaces the sender track.
17003
+ * Mirror of the sender path for a piped audio leg: same merge, same fallback
17004
+ * ladder, same device-capture invariant — but the swap target is the pipeline
17005
+ * input, so the sender keeps emitting the pipeline's output track and its
17006
+ * identity survives the change.
16527
17007
  */
16528
- async replaceAudioTrackWithConstraints(constraints) {
16529
- const senders = this.peerConnection?.getSenders().filter((s) => s.track?.kind === "audio" && s.track.readyState === "live");
16530
- if (!senders || senders.length === 0) {
16531
- logger$17.warn("[RTCPeerConnectionController] No live audio sender to replace");
16532
- return;
17008
+ async applyPipelineSourceConstraints(constraints) {
17009
+ const pipeline = this._localAudioPipeline;
17010
+ const source = this.localStreamController.localAudioTracks.at(0);
17011
+ if (!pipeline || !source) {
17012
+ logger$17.debug("[RTCPeerConnectionController] No pipeline input to constrain.");
17013
+ return false;
16533
17014
  }
16534
- for (const sender of senders) {
16535
- const oldTrack = sender.track;
16536
- if (!oldTrack) continue;
16537
- const { deviceId } = oldTrack.getSettings();
16538
- const mergedConstraints = {
16539
- ...oldTrack.getConstraints(),
16540
- ...constraints,
16541
- ...deviceId ? { deviceId: { exact: deviceId } } : {}
16542
- };
16543
- const trackId = oldTrack.id;
16544
- oldTrack.stop();
16545
- this.localStreamController.removeTrack(trackId);
16546
- const newTrack = (await this.getUserMedia({ audio: mergedConstraints })).getAudioTracks()[0];
16547
- await sender.replaceTrack(newTrack);
17015
+ if (!this.localStreamController.isDeviceCapture(source)) {
17016
+ logger$17.debug(`[RTCPeerConnectionController] Skipping audio constraints for a non-device pipeline input (origin: ${this.localStreamController.getTrackOrigin(source) ?? "unrecorded"}).`);
17017
+ return false;
17018
+ }
17019
+ const merged = {
17020
+ ...source.getConstraints(),
17021
+ ...constraints
17022
+ };
17023
+ try {
17024
+ await source.applyConstraints(merged);
17025
+ logger$17.debug("[RTCPeerConnectionController] Pipeline input constraints updated:", merged);
17026
+ return true;
17027
+ } catch (error) {
17028
+ logger$17.warn("[RTCPeerConnectionController] applyConstraints failed on the pipeline input, re-acquiring:", error);
17029
+ }
17030
+ try {
17031
+ const { stream } = await getUserMediaWithFallback({ getUserMedia: async (c) => this.getUserMedia(c) }, { audio: merged }, "audio", source.getSettings().deviceId);
17032
+ const newTrack = stream.getAudioTracks().at(0);
17033
+ if (!newTrack) {
17034
+ stream.getTracks().forEach((track) => track.stop());
17035
+ throw new Error("getUserMedia returned no audio track");
17036
+ }
17037
+ this.localStreamController.removeTrack(source.id);
16548
17038
  this.localStreamController.addTrack(newTrack);
16549
- logger$17.debug(`[RTCPeerConnectionController] Audio track replaced for server-pushed params. New track: ${newTrack.id}`);
17039
+ pipeline.setInputTrack(newTrack);
17040
+ return true;
17041
+ } catch (error) {
17042
+ logger$17.warn("[RTCPeerConnectionController] Failed to re-acquire the pipeline input for constraints:", error);
17043
+ this._errors$.next(new MediaTrackError("updateSendersConstraints", "audio", error));
17044
+ return false;
16550
17045
  }
16551
17046
  }
16552
17047
  /**
@@ -16666,6 +17161,48 @@ const logger$16 = getLogger();
16666
17161
  function resolveInviteNodeId(args) {
16667
17162
  return args.isInvite && !args.reattach && !args.explicitNodeId ? "" : args.currentNodeId ?? "";
16668
17163
  }
17164
+ /**
17165
+ * Surface the real outcome of a `webrtc.verto` reply.
17166
+ *
17167
+ * A webrtc.verto response nests several envelopes, each keyed by a verto-style
17168
+ * string `code` ("200" ok, "400"/etc. fail) rather than a JSON-RPC `error`. An outer
17169
+ * layer reports only whether the frame was delivered; an inner layer carries the op's
17170
+ * own outcome:
17171
+ *
17172
+ * response.result = { code:"200", result:{…} } ← delivery acknowledgement
17173
+ * .result = { jsonrpc, id, result:{…} } ← the reply payload
17174
+ * .result = { code:"400", message:"Bad request" } ← the actual op outcome
17175
+ *
17176
+ * A failure can appear at any layer (delivery refused, or the op itself rejected
17177
+ * deeper down), so walk every nested `.result` object and return the FIRST non-2xx
17178
+ * `code` with its message. Returns null when every `code` seen is 2xx or absent —
17179
+ * i.e. the op succeeded. This is the only way to detect that e.g. a mute/kick was
17180
+ * rejected, since the outer delivery `code` is "200" (delivered) even then.
17181
+ *
17182
+ * Pure function — exported for unit testing.
17183
+ */
17184
+ function findNestedVertoFailure(response) {
17185
+ let node = response;
17186
+ while (node !== null && typeof node === "object") {
17187
+ const obj = node;
17188
+ const err = obj.error;
17189
+ if (err !== null && typeof err === "object") {
17190
+ const e = err;
17191
+ const errCode = typeof e.code === "string" || typeof e.code === "number" ? String(e.code) : void 0;
17192
+ if (errCode !== void 0 && !/^2\d\d$/.test(errCode)) return {
17193
+ code: errCode,
17194
+ message: typeof e.message === "string" ? e.message : void 0
17195
+ };
17196
+ }
17197
+ const code = typeof obj.code === "string" || typeof obj.code === "number" ? String(obj.code) : void 0;
17198
+ if (code !== void 0 && !/^2\d\d$/.test(code)) return {
17199
+ code,
17200
+ message: typeof obj.message === "string" ? obj.message : void 0
17201
+ };
17202
+ node = obj.result !== null && typeof obj.result === "object" ? obj.result : null;
17203
+ }
17204
+ return null;
17205
+ }
16669
17206
  var VertoManager = class extends Destroyable {
16670
17207
  constructor(callSession) {
16671
17208
  super();
@@ -16688,7 +17225,8 @@ var WebRTCVertoManager = class extends VertoManager {
16688
17225
  this._signalingStatus$ = this.createReplaySubject(1);
16689
17226
  this._screenShareStatus$ = this.createBehaviorSubject("none");
16690
17227
  this._rtcPeerConnectionsMap = /* @__PURE__ */ new Map();
16691
- this._screenShareTimeoutMs = 5e4;
17228
+ this._lastVertoPings = /* @__PURE__ */ new Map();
17229
+ this._legErrors$ = this.createSubject();
16692
17230
  this._nodeId$ = this.createBehaviorSubject(options.nodeId ?? null);
16693
17231
  this.onError = options.onError;
16694
17232
  this.onModifyFailed = options.onModifyFailed;
@@ -16736,6 +17274,10 @@ var WebRTCVertoManager = class extends VertoManager {
16736
17274
  get selfId$() {
16737
17275
  return this._selfId$.asObservable();
16738
17276
  }
17277
+ /** Separates the media phase of call creation from the signalling phase. */
17278
+ get localMediaSettled$() {
17279
+ return this.mainPeerConnection.localMediaSettled$;
17280
+ }
16739
17281
  get localStream() {
16740
17282
  return this._rtcPeerConnectionsMap.get(this.webRtcCallSession.id)?.localStream ?? null;
16741
17283
  }
@@ -16792,25 +17334,89 @@ var WebRTCVertoManager = class extends VertoManager {
16792
17334
  const { mediaParams, callID } = event;
16793
17335
  const rtcPeerConnController = this._rtcPeerConnectionsMap.get(callID);
16794
17336
  const { audio, video } = mediaParams;
16795
- (async () => {
16796
- try {
16797
- if (audio && rtcPeerConnController) await rtcPeerConnController.replaceAudioTrackWithConstraints(audio);
16798
- if (video) await rtcPeerConnController?.updateSendersConstraints("video", video);
16799
- this.webRtcCallSession.emitMediaParamsUpdated({
16800
- audio,
16801
- video,
16802
- timestamp: Date.now()
16803
- });
16804
- } catch (error) {
16805
- logger$16.warn("[WebRTCManager] Error applying server-pushed media params:", error);
16806
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
16807
- }
16808
- })();
17337
+ if (!rtcPeerConnController) {
17338
+ logger$16.warn(`[WebRTCManager] Ignoring server-pushed media params for unknown leg ${callID}`);
17339
+ return;
17340
+ }
17341
+ this.applyServerMediaParams(rtcPeerConnController, audio, video);
16809
17342
  });
16810
17343
  this.subscribeTo(this.vertoPing$, (vertoPing) => {
16811
- this.attachManager.attach(this.buildAttachableCall());
17344
+ this._lastVertoPings.set(vertoPing.callID, vertoPing);
17345
+ this.attachManager.refresh(this.buildAttachableCall());
16812
17346
  this.sendVertoPong(vertoPing);
16813
17347
  });
17348
+ this.subscribeTo(this.webRtcCallSession.clientSession.authenticated$.pipe((0, import_cjs$15.skip)(1), (0, import_cjs$15.filter)(Boolean)), () => {
17349
+ this._lastVertoPings.forEach((vertoPing) => void this.sendVertoPong(vertoPing));
17350
+ });
17351
+ }
17352
+ /**
17353
+ * An auxiliary-leg failure must never destroy the call; main-leg and
17354
+ * call-level errors keep `CallFactory.isFatalError`'s classification.
17355
+ *
17356
+ * Every site holding a peer connection reports through here, so the invariant
17357
+ * is structural rather than per-call-site — which is how cloud-product#20523
17358
+ * happened, with only one of fourteen sites passing `{ fatal: false }`.
17359
+ *
17360
+ * `override` composes rather than replaces: a caller may force non-fatal for a
17361
+ * reason of its own (a `verto.info` frame is best-effort whichever leg carries
17362
+ * it), and an auxiliary leg stays non-fatal regardless.
17363
+ */
17364
+ reportLegError(error, rtcPeerConnController, override) {
17365
+ const leg = rtcPeerConnController?.propose;
17366
+ const legId = rtcPeerConnController?.id;
17367
+ const auxiliary = Boolean(rtcPeerConnController) && !rtcPeerConnController?.isMainDevice;
17368
+ this.onError?.(error, {
17369
+ ...override?.fatal === false || auxiliary ? { fatal: false } : {},
17370
+ ...leg ? { leg } : {},
17371
+ ...legId ? { legId } : {}
17372
+ });
17373
+ if (legId) this._legErrors$.next({
17374
+ legId,
17375
+ error
17376
+ });
17377
+ }
17378
+ /**
17379
+ * Errors reported for one leg, as a stream that fails with them.
17380
+ *
17381
+ * Signaling failures are reported, never thrown — so nothing that waits on a
17382
+ * leg's progress would otherwise learn of a rejected invite. Merging this in
17383
+ * lets the wait end with the reason the server gave.
17384
+ */
17385
+ legError$(legId) {
17386
+ return this._legErrors$.pipe((0, import_cjs$15.filter)((report) => report.legId === legId), (0, import_cjs$15.map)((report) => {
17387
+ throw report.error;
17388
+ }));
17389
+ }
17390
+ /**
17391
+ * Audio and video are applied independently so a failure in one cannot
17392
+ * suppress the other, and `mediaParamsUpdated` is emitted whatever happens —
17393
+ * an application should not be starved of the params by a constraint failure.
17394
+ */
17395
+ async applyServerMediaParams(rtcPeerConnController, audio, video) {
17396
+ const failures = [];
17397
+ let applied = true;
17398
+ if (audio) try {
17399
+ applied = await rtcPeerConnController.updateSendersConstraints("audio", audio) && applied;
17400
+ } catch (error) {
17401
+ applied = false;
17402
+ failures.push(toError(error));
17403
+ }
17404
+ if (video) try {
17405
+ applied = await rtcPeerConnController.updateSendersConstraints("video", video) && applied;
17406
+ } catch (error) {
17407
+ applied = false;
17408
+ failures.push(toError(error));
17409
+ }
17410
+ this.webRtcCallSession.emitMediaParamsUpdated({
17411
+ audio,
17412
+ video,
17413
+ timestamp: Date.now(),
17414
+ applied
17415
+ });
17416
+ for (const failure of failures) {
17417
+ logger$16.warn("[WebRTCManager] Error applying server-pushed media params:", failure);
17418
+ this.reportLegError(failure, rtcPeerConnController, { fatal: false });
17419
+ }
16814
17420
  }
16815
17421
  /**
16816
17422
  * Set node_id/selfId only when the current value is null.
@@ -16819,8 +17425,8 @@ var WebRTCVertoManager = class extends VertoManager {
16819
17425
  * these identifiers before the `verto.invite` RPC response (`CALL CREATED`)
16820
17426
  * arrives. These methods let early events populate them eagerly so that
16821
17427
  * downstream RPC calls (e.g. `call.layout.list`) don't fail with empty
16822
- * identifiers. `processInviteResponse()` remains the authoritative source
16823
- * and always overwrites unconditionally.
17428
+ * identifiers. `processInviteResponse()` remains the authoritative source and
17429
+ * overwrites unconditionally — for selfId, on the main leg only.
16824
17430
  */
16825
17431
  setNodeIdIfNull(nodeId) {
16826
17432
  if (!this._nodeId$.value && nodeId) {
@@ -16843,24 +17449,33 @@ var WebRTCVertoManager = class extends VertoManager {
16843
17449
  this.onError?.(new VertoPongError(error));
16844
17450
  }
16845
17451
  }
17452
+ /**
17453
+ * @returns whether the constraints reached the media the call is sending.
17454
+ * `false` is an outcome, not a failure: the leg may have no live sender of
17455
+ * the kind, or carry media the SDK did not capture and may not replace.
17456
+ * A failure behind it still reaches the call's `errors$`, so a caller that
17457
+ * ignores this value learns of it there.
17458
+ */
16846
17459
  async updateMediaConstraints(options = {}) {
16847
17460
  const { audio, video } = options;
17461
+ let applied = true;
16848
17462
  try {
16849
- if (audio) await this.mainPeerConnection.updateSendersConstraints("audio", audio);
16850
- if (video) await this.mainPeerConnection.updateSendersConstraints("video", video);
17463
+ if (audio) applied = await this.mainPeerConnection.updateSendersConstraints("audio", audio) && applied;
17464
+ if (video) applied = await this.mainPeerConnection.updateSendersConstraints("video", video) && applied;
16851
17465
  } catch (error) {
16852
17466
  logger$16.warn("[WebRTCManager] Error updating media constraints:", error);
16853
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
17467
+ this.reportLegError(toError(error), this.mainPeerConnection);
16854
17468
  throw error;
16855
17469
  }
17470
+ return applied;
16856
17471
  }
16857
17472
  get selfId() {
16858
17473
  return this._selfId$.value;
16859
17474
  }
16860
17475
  /** Build an AttachableCall from the current call state. */
16861
- buildAttachableCall(idOverride) {
17476
+ buildAttachableCall(idOverride, nodeIdOverride) {
16862
17477
  return {
16863
- nodeId: this.nodeId ?? void 0,
17478
+ nodeId: nodeIdOverride ?? this.nodeId ?? void 0,
16864
17479
  id: idOverride ?? this.webRtcCallSession.id,
16865
17480
  to: this.webRtcCallSession.to,
16866
17481
  mediaDirections: this.webRtcCallSession.mediaDirections
@@ -16990,24 +17605,49 @@ var WebRTCVertoManager = class extends VertoManager {
16990
17605
  get vertoPing$() {
16991
17606
  return this.cachedObservable("vertoPing$", () => this.webRtcCallSession.webrtcMessages$.pipe(filterAs(isVertoPingInnerParams, "params"), (0, import_cjs$15.takeUntil)(this.destroyed$)));
16992
17607
  }
17608
+ /**
17609
+ * Send a member-control op in-dialog via verto.info.
17610
+ *
17611
+ * The control payload rides in `params.command` — a sibling of `dialogParams`,
17612
+ * at the same level as `dtmf` in {@link sendDigits} — and the inner verto.info
17613
+ * is matched to this call's channel by `dialogParams.callID`, the in-dialog
17614
+ * convention for member-scoped frames. Because it is delivered on the dialog
17615
+ * itself, control lands on the call's own channel with no {node_id,call_id,member_id}
17616
+ * "self" tuple to get wrong. The outer webrtc.verto envelope (added by executeVerto)
17617
+ * still carries the own-leg callID + node_id for session routing.
17618
+ *
17619
+ * Keep `command` OUT of `dialogParams`: it is read at the params level, and
17620
+ * filterVertoParams rewrites/filters dialogParams keys but passes params-level
17621
+ * keys through verbatim.
17622
+ */
17623
+ async sendCallControl(method, params) {
17624
+ const response = await this.executeVerto(VertoInfo({
17625
+ dialogParams: { callID: this.webRtcCallSession.id },
17626
+ command: {
17627
+ method,
17628
+ params
17629
+ }
17630
+ }));
17631
+ const failure = findNestedVertoFailure(response);
17632
+ if (failure) throw new JSONRPCError(Number.parseInt(failure.code, 10) || 0, `Call control "${method}" failed (code ${failure.code})${failure.message ? `: ${failure.message}` : ""}`, void 0);
17633
+ return response;
17634
+ }
16993
17635
  async executeVerto(message, optionals = {}) {
16994
- const webrtcVertoMessage = WebrtcVerto({
17636
+ const params = {
16995
17637
  callID: optionals.callID ?? this.webRtcCallSession.id,
16996
17638
  node_id: optionals.node_id ?? this._nodeId$.value ?? "",
16997
17639
  message,
16998
17640
  subscribe: optionals.subscribe
16999
- });
17641
+ };
17642
+ const webrtcVertoMessage = WebrtcVerto(params);
17000
17643
  const response = await this.webRtcCallSession.execute(webrtcVertoMessage);
17001
- if (response.error) {
17002
- const error = new JSONRPCError(response.error.code, response.error.message, response.error.data);
17003
- this.onError?.(error);
17004
- return response;
17005
- }
17644
+ const nonFatal = message.method === "verto.info" ? { fatal: false } : void 0;
17006
17645
  const innerResult = getValueFrom(response, "result.result");
17007
- if (innerResult?.error) {
17008
- const error = new JSONRPCError(innerResult.error.code, innerResult.error.message, innerResult.error.data);
17009
- this.onError?.(error);
17010
- return response;
17646
+ const failure = response.error ?? innerResult?.error;
17647
+ if (failure) {
17648
+ const error = new JSONRPCError(failure.code, failure.message, failure.data);
17649
+ if (message.method === "verto.invite" || message.method === "verto.answer") throw error;
17650
+ this.reportLegError(error, this._rtcPeerConnectionsMap.get(params.callID), nonFatal);
17011
17651
  }
17012
17652
  return response;
17013
17653
  }
@@ -17026,8 +17666,9 @@ var WebRTCVertoManager = class extends VertoManager {
17026
17666
  default:
17027
17667
  }
17028
17668
  } catch (error) {
17669
+ if (vertoMethod === "verto.answer") throw error;
17029
17670
  logger$16.error(`[WebRTCManager] Error sending Verto ${vertoMethod}:`, error);
17030
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
17671
+ this.reportLegError(toError(error), rtcPeerConnController);
17031
17672
  if (vertoMethod === "verto.modify") this.onModifyFailed?.();
17032
17673
  }
17033
17674
  }
@@ -17043,7 +17684,7 @@ var WebRTCVertoManager = class extends VertoManager {
17043
17684
  } catch (error) {
17044
17685
  logger$16.warn("[WebRTCManager] Error processing modify response:", error);
17045
17686
  const modifyError = error instanceof Error ? error : new Error(String(error), { cause: error });
17046
- this.onError?.(modifyError);
17687
+ this.reportLegError(modifyError, rtcPeerConnController);
17047
17688
  }
17048
17689
  }
17049
17690
  }
@@ -17056,37 +17697,35 @@ var WebRTCVertoManager = class extends VertoManager {
17056
17697
  status,
17057
17698
  signalingError
17058
17699
  });
17059
- this.onError?.(signalingError);
17700
+ this.reportLegError(signalingError, null, { fatal: false });
17060
17701
  return;
17061
17702
  }
17062
17703
  if (rtcPeerConnController.isMainDevice) this._signalingStatus$.next(status);
17063
17704
  }
17064
17705
  processInviteResponse(response, rtcPeerConnController) {
17065
- if (!response.error && getValueFrom(response, "result.result.result.message") === "CALL CREATED") {
17706
+ if (getValueFrom(response, "result.result.result.message") === "CALL CREATED") {
17066
17707
  this.emitMainSignalingStatus(rtcPeerConnController.id, "trying");
17067
- this._nodeId$.next(getValueFrom(response, "result.node_id") ?? null);
17708
+ const nodeId = getValueFrom(response, "result.node_id") ?? null;
17068
17709
  const memberId = getValueFrom(response, "result.result.result.memberID") ?? null;
17069
- const callId = getValueFrom(response, "result.result.result.callID") ?? null;
17710
+ const callId = getValueFrom(response, "result.result.result.callID");
17070
17711
  logger$16.debug("[WebRTCManager] Verto invite response:", {
17071
17712
  callId,
17072
17713
  memberId,
17073
17714
  response
17074
17715
  });
17075
- this._selfId$.next(memberId);
17076
17716
  rtcPeerConnController.setMemberId(memberId);
17077
- if (callId) {
17078
- this.webRtcCallSession.addCallId(callId);
17079
- this.attachManager.attach(this.buildAttachableCall(callId));
17080
- } else logger$16.warn("[WebRTCManager] Cannot attach call, missing callId:", {
17081
- nodeId: this.nodeId,
17082
- callId
17083
- });
17717
+ rtcPeerConnController.setNodeId(nodeId);
17718
+ if (rtcPeerConnController.isMainDevice) {
17719
+ this._selfId$.next(memberId);
17720
+ this._nodeId$.next(nodeId);
17721
+ this.attachManager.attach(this.buildAttachableCall(callId, nodeId ?? void 0));
17722
+ }
17723
+ if (callId) this.webRtcCallSession.addCallId(callId);
17084
17724
  logger$16.info("[WebRTCManager] Verto invite successful");
17085
17725
  logger$16.debug(`[WebRTCManager] nodeid: ${this._nodeId$.value}, selfId: ${this._selfId$.value}`);
17086
17726
  } else {
17087
17727
  logger$16.error("[WebRTCManager] Verto invite failed:", response);
17088
- const inviteError = response.error ? new JSONRPCError(response.error.code, response.error.message, response.error.data) : /* @__PURE__ */ new Error("Verto invite failed: unexpected response");
17089
- this.onError?.(inviteError);
17728
+ this.reportLegError(/* @__PURE__ */ new Error("Verto invite failed: unexpected response"), rtcPeerConnController);
17090
17729
  }
17091
17730
  }
17092
17731
  get RTCPeerConnectionConfig() {
@@ -17125,7 +17764,7 @@ var WebRTCVertoManager = class extends VertoManager {
17125
17764
  this._rtcPeerConnectionsMap.set(rtcPeerConnController.id, rtcPeerConnController);
17126
17765
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
17127
17766
  this.subscribeTo(rtcPeerConnController.errors$, (error) => {
17128
- this.onError?.(error);
17767
+ this.reportLegError(error, rtcPeerConnController);
17129
17768
  });
17130
17769
  if (options.initOffer) this.handleInboundAnswer(rtcPeerConnController);
17131
17770
  }
@@ -17154,7 +17793,7 @@ var WebRTCVertoManager = class extends VertoManager {
17154
17793
  await rtcPeerConnController.acceptInbound(answerOptions);
17155
17794
  } catch (error) {
17156
17795
  logger$16.error("[WebRTCManager] Error creating inbound answer:", error);
17157
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
17796
+ this.reportLegError(toError(error), rtcPeerConnController);
17158
17797
  }
17159
17798
  }
17160
17799
  }
@@ -17226,7 +17865,7 @@ var WebRTCVertoManager = class extends VertoManager {
17226
17865
  isInvite: isVertoInviteMessage(vertoMessage),
17227
17866
  reattach: this.webRtcCallSession.options.reattach === true,
17228
17867
  explicitNodeId: this.webRtcCallSession.options.nodeId,
17229
- currentNodeId: this._nodeId$.value
17868
+ currentNodeId: rtcPeerConnController.nodeId ?? this._nodeId$.value
17230
17869
  }),
17231
17870
  subscribe
17232
17871
  };
@@ -17258,7 +17897,7 @@ var WebRTCVertoManager = class extends VertoManager {
17258
17897
  await this.attachManager.attach(this.buildAttachableCall());
17259
17898
  } catch (error) {
17260
17899
  logger$16.error("[WebRTCManager] Error sending Verto answer:", error);
17261
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
17900
+ this.reportLegError(toError(error), rtcPeerConnectionController);
17262
17901
  await rtcPeerConnectionController.updateAnswerStatus({ status: "failed" });
17263
17902
  }
17264
17903
  }
@@ -17330,15 +17969,19 @@ var WebRTCVertoManager = class extends VertoManager {
17330
17969
  await this.mainPeerConnection.restoreTrackSender(deviceKind);
17331
17970
  } else {
17332
17971
  const error = new InvalidParams("No valid device to be added");
17333
- this.onError?.(error);
17972
+ this.reportLegError(error, this.mainPeerConnection);
17334
17973
  throw error;
17335
17974
  }
17336
17975
  }
17337
- async addScreenMedia(options = { audio: false }) {
17338
- await this.initAdditionalPeerConnection("screenshare", options);
17976
+ async addScreenMedia(options = {}) {
17977
+ await this.initAdditionalPeerConnection("screenshare", {
17978
+ audio: false,
17979
+ screenShareAudio: options.audio ?? false
17980
+ });
17339
17981
  }
17340
17982
  async initAdditionalPeerConnection(propose, options) {
17341
17983
  const isScreenShare = propose === "screenshare";
17984
+ if (isScreenShare && this._screenShareId && this._rtcPeerConnectionsMap.has(this._screenShareId)) throw new ScreenShareAlreadyActiveError(this._screenShareId);
17342
17985
  let firstPeerConnectionError;
17343
17986
  let rtcPeerConnController = null;
17344
17987
  try {
@@ -17355,21 +17998,36 @@ var WebRTCVertoManager = class extends VertoManager {
17355
17998
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
17356
17999
  this.subscribeTo(rtcPeerConnController.errors$, (error) => {
17357
18000
  firstPeerConnectionError ??= error;
17358
- this.onError?.(error, { fatal: false });
18001
+ this.reportLegError(error, rtcPeerConnController);
17359
18002
  });
17360
- await (0, import_cjs$15.firstValueFrom)(rtcPeerConnController.connectionState$.pipe((0, import_cjs$15.filter)((state) => state === "connected"), (0, import_cjs$15.take)(1), (0, import_cjs$15.timeout)(this._screenShareTimeoutMs), (0, import_cjs$15.takeUntil)(this.destroyed$)));
18003
+ const pc = rtcPeerConnController;
18004
+ await (0, import_cjs$15.firstValueFrom)((0, import_cjs$15.merge)(pc.localMediaSettled$.pipe((0, import_cjs$15.take)(1), (0, import_cjs$15.switchMap)(() => pc.connectionState$.pipe((0, import_cjs$15.filter)((state) => state === "connected"), (0, import_cjs$15.take)(1), (0, import_cjs$15.timeout)(DEFAULT_AUX_LEG_CONNECT_TIMEOUT_MS)))), this.legError$(pc.id)).pipe((0, import_cjs$15.takeUntil)((0, import_cjs$15.merge)(this.destroyed$, pc.destroyed$))));
17361
18005
  if (isScreenShare) this._screenShareStatus$.next("started");
17362
18006
  logger$16.info(`[WebRTCManager] Additional peer connection connected (${propose}).`);
17363
18007
  return rtcPeerConnController.id;
17364
18008
  } catch (error) {
17365
- logger$16.warn("[WebRTCManager] Error initializing additional peer connection:", error);
17366
- if (rtcPeerConnController) rtcPeerConnController.destroy();
17367
- if (isScreenShare) this._screenShareStatus$.next("none");
18009
+ const cancelled = error instanceof AuxiliaryLegCancelledError;
18010
+ const aborted = error instanceof import_cjs$15.EmptyError && !firstPeerConnectionError;
18011
+ if (!cancelled && !aborted) logger$16.warn("[WebRTCManager] Error initializing additional peer connection:", error);
18012
+ if (rtcPeerConnController && this._rtcPeerConnectionsMap.has(rtcPeerConnController.id)) {
18013
+ rtcPeerConnController.destroy();
18014
+ this._rtcPeerConnectionsMap.delete(rtcPeerConnController.id);
18015
+ this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
18016
+ }
18017
+ if (isScreenShare) {
18018
+ this._screenShareId = void 0;
18019
+ this._screenShareStatus$.next("none");
18020
+ }
18021
+ if (cancelled) {
18022
+ logger$16.debug("[WebRTCManager] Additional peer connection removed before connecting.");
18023
+ throw error;
18024
+ }
17368
18025
  if (firstPeerConnectionError) throw firstPeerConnectionError instanceof MediaAccessError && firstPeerConnectionError.originalError instanceof Error ? firstPeerConnectionError.originalError : firstPeerConnectionError;
17369
18026
  if (error instanceof import_cjs$15.EmptyError) {
17370
18027
  logger$16.debug("[WebRTCManager] Additional peer connection aborted before connecting.");
17371
18028
  return;
17372
18029
  }
18030
+ if (error instanceof import_cjs$15.TimeoutError) throw new AuxiliaryLegTimeoutError(propose, error);
17373
18031
  throw error instanceof Error ? error : new Error(String(error), { cause: error });
17374
18032
  }
17375
18033
  }
@@ -17386,7 +18044,10 @@ var WebRTCVertoManager = class extends VertoManager {
17386
18044
  if (removeTrack) return this.mainPeerConnection.stopTrackSender(removeTrack, { updateTransceiverDirection: true });
17387
18045
  }
17388
18046
  async removeScreenMedia() {
17389
- if (!["starting", "started"].includes(this._screenShareStatus$.value)) logger$16.warn("[WebRTCManager] No active screen share to stop.");
18047
+ if (!["starting", "started"].includes(this._screenShareStatus$.value)) {
18048
+ logger$16.warn("[WebRTCManager] No active screen share to stop.");
18049
+ return;
18050
+ }
17390
18051
  if (!this._screenShareId) {
17391
18052
  logger$16.debug("[WebRTCManager] No screen share peer connection found.");
17392
18053
  return;
@@ -17401,6 +18062,10 @@ var WebRTCVertoManager = class extends VertoManager {
17401
18062
  try {
17402
18063
  if (rtcPeerConnController) await this.executeVertoBye(rtcPeerConnController);
17403
18064
  } finally {
18065
+ if (rtcPeerConnController && rtcPeerConnController.connectionState !== "connected") this._legErrors$.next({
18066
+ legId: id,
18067
+ error: new AuxiliaryLegCancelledError(rtcPeerConnController.propose)
18068
+ });
17404
18069
  rtcPeerConnController?.destroy();
17405
18070
  this._rtcPeerConnectionsMap.delete(id);
17406
18071
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
@@ -17415,7 +18080,10 @@ var WebRTCVertoManager = class extends VertoManager {
17415
18080
  await this.executeVerto(VertoBye({
17416
18081
  ...causeParams,
17417
18082
  dialogParams: this.dialogParams(rtcPeerConnController)
17418
- }));
18083
+ }), {
18084
+ callID: rtcPeerConnController.id,
18085
+ node_id: rtcPeerConnController.nodeId ?? void 0
18086
+ });
17419
18087
  } catch (error) {
17420
18088
  logger$16.warn("[WebRTCManager] Call might already be disconnected, error sending Verto bye:", error);
17421
18089
  throw error;
@@ -18294,17 +18962,38 @@ function mosToQualityLevel(mos) {
18294
18962
  return "critical";
18295
18963
  }
18296
18964
 
18965
+ //#endregion
18966
+ //#region src/utils/unwrapVertoReply.ts
18967
+ /**
18968
+ * Unwrap a method's own reply from a `webrtc.verto` envelope.
18969
+ *
18970
+ * A control verb sent in-dialog comes back nested two levels deep:
18971
+ *
18972
+ * ```
18973
+ * { result: { node_id, code, result: { jsonrpc, id, result: <method payload> } } }
18974
+ * ```
18975
+ *
18976
+ * whereas the routed transport resolves the method payload directly under
18977
+ * `.result`. Readers want the latter shape, and the difference is silent when it
18978
+ * is wrong — `response.result.layouts` simply evaluates to `undefined` against the
18979
+ * envelope, so the data arrives, nothing throws, and the caller sees an empty
18980
+ * value. That exact failure produced an empty layout dropdown with a successful
18981
+ * request behind it.
18982
+ *
18983
+ * Accepting both shapes here keeps every reader indifferent to which transport
18984
+ * produced the response. Anything that is not a nested envelope (a plain ack, or
18985
+ * an already-unwrapped reply) passes through untouched.
18986
+ */
18987
+ function unwrapVertoReply(response) {
18988
+ const inner = getValueFrom(response, "result.result");
18989
+ return inner && typeof inner === "object" && "result" in inner ? inner : response;
18990
+ }
18991
+
18297
18992
  //#endregion
18298
18993
  //#region src/core/entities/Call.ts
18299
18994
  var import_cjs$11 = require_cjs();
18300
18995
  const logger$12 = getLogger();
18301
18996
  /**
18302
- * Verto method for setting member layout positions. Its gateway DTO requires a
18303
- * `targets` array whose entries are `{ target, position }` (NOT bare targets),
18304
- * so {@link WebRTCCall.buildMethodParams} special-cases it. See issue #19400.
18305
- */
18306
- const POSITION_SET_METHOD = "call.member.position.set";
18307
- /**
18308
18997
  * Ratio between the critical and warning RTT spike multipliers.
18309
18998
  * Warning threshold = baseline * warningMultiplier (default 3x)
18310
18999
  * Critical threshold = baseline * warningMultiplier * RTT_CRITICAL_TO_WARNING_RATIO
@@ -18399,8 +19088,11 @@ var WebRTCCall = class extends Destroyable {
18399
19088
  emitError(callError) {
18400
19089
  if (this._status$.value === "destroyed" || this._status$.value === "failed") return;
18401
19090
  this._errors$.next(callError);
18402
- if (callError.fatal) {
19091
+ if (callError.fatal && this._status$.value !== "disconnecting") {
18403
19092
  this._status$.next("failed");
19093
+ this.vertoManager.bye().catch((error) => {
19094
+ logger$12.debug("[Call] fatal-teardown bye failed (signaling likely already dead):", error);
19095
+ });
18404
19096
  this.destroy();
18405
19097
  }
18406
19098
  }
@@ -18455,7 +19147,7 @@ var WebRTCCall = class extends Destroyable {
18455
19147
  /** Toggles the call lock state, preventing or allowing new participants from joining. */
18456
19148
  async toggleLock() {
18457
19149
  const method = this.locked ? "call.unlock" : "call.lock";
18458
- await this.executeMethod(this.selfId ?? "", method, {});
19150
+ await this.executeMethod(this.callSelf, method, {});
18459
19151
  }
18460
19152
  /**
18461
19153
  * Toggles the hold state of the call (pauses/resumes local media transmission).
@@ -18504,14 +19196,25 @@ var WebRTCCall = class extends Destroyable {
18504
19196
  *
18505
19197
  * Constructs call context (node_id, call_id, member_id) and sends the RPC request.
18506
19198
  *
18507
- * @param target - Target member ID string, or a {@link MemberTarget} object.
19199
+ * @param target - Target {@link MemberTarget} triple, or the local member's
19200
+ * ID string for self-operations (any other string is rejected — a bare
19201
+ * member id cannot carry the remote member's own call context).
18508
19202
  * @param method - Verto method name (e.g. `'call.mute'`, `'call.member.remove'`).
18509
19203
  * @param args - Parameters for the RPC method.
18510
19204
  * @returns The RPC response.
19205
+ * @throws {CallNotReadyError} If the call has no self member context yet.
19206
+ * @throws {InvalidParams} If a string target is not the local member's ID.
18511
19207
  * @throws {JSONRPCError} If the RPC call returns an error.
18512
19208
  */
18513
19209
  async executeMethod(target, method, args) {
18514
- const params = this.buildMethodParams(target, args, method);
19210
+ const self = this.callSelf;
19211
+ 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}`);
19212
+ if (this.clientSession.callControl === "in-dialog") return this.executeMethodInDialog(target, method, args);
19213
+ const params = {
19214
+ ...args,
19215
+ self,
19216
+ target: typeof target === "string" ? self : target
19217
+ };
18515
19218
  const request = buildRPCRequest({
18516
19219
  method,
18517
19220
  params
@@ -18525,29 +19228,98 @@ var WebRTCCall = class extends Destroyable {
18525
19228
  throw error;
18526
19229
  }
18527
19230
  }
18528
- buildMethodParams(target, args, method) {
18529
- const self = {
18530
- node_id: this.nodeId ?? "",
18531
- call_id: this.id,
18532
- member_id: this.vertoManager.selfId ?? ""
18533
- };
18534
- if (method === POSITION_SET_METHOD) return {
18535
- ...args,
18536
- self
18537
- };
18538
- if (typeof target === "object") return {
18539
- ...args,
18540
- self,
18541
- targets: [target]
18542
- };
18543
- return {
18544
- ...args,
18545
- self,
18546
- target: {
18547
- node_id: this.nodeId ?? "",
19231
+ /**
19232
+ * `executeMethod` for a call opened with `callControl: 'in-dialog'`.
19233
+ *
19234
+ * Translates the routed transport's calling convention into the in-dialog one. No
19235
+ * `self` tuple is sent, but a `target` is — the same {call_id, member_id} the routed
19236
+ * transport puts in `target` (minus node_id), for self-ops and cross-member ops alike.
19237
+ *
19238
+ * Target shapes are per-verb and irregular, so they are centralised here rather
19239
+ * than left to callers: most verbs take a singular `target`, `call.member.remove`
19240
+ * takes a plural `targets` array, and `call.member.position.set` takes a flat
19241
+ * `targets` of `{call_id, position}` — the one verb keyed on call_id rather than
19242
+ * member_id, so the member triple `Participant.setPosition` built is unwrapped.
19243
+ */
19244
+ async executeMethodInDialog(target, method, args) {
19245
+ const control = { ...args };
19246
+ if (method === "call.member.position.set") control.targets = (args.targets ?? []).map((entry) => ({
19247
+ call_id: entry.target?.call_id ?? entry.call_id,
19248
+ position: entry.position
19249
+ }));
19250
+ else {
19251
+ const member = typeof target === "object" ? {
19252
+ call_id: target.call_id,
19253
+ member_id: target.member_id
19254
+ } : {
18548
19255
  call_id: this.id,
18549
19256
  member_id: target
18550
- }
19257
+ };
19258
+ if (method === "call.member.remove") control.targets = [member];
19259
+ else control.target = member;
19260
+ }
19261
+ return this.sendCommand(method, control);
19262
+ }
19263
+ /**
19264
+ * Sends a `call.*` control verb **in-dialog** via `verto.info`, as an alternative
19265
+ * to the routed {@link executeMethod} transport.
19266
+ *
19267
+ * Why both exist: `executeMethod` addresses the member with an explicit
19268
+ * `{node_id, call_id, member_id}` tuple, which does not resolve for every conference,
19269
+ * so the op can fail. An in-dialog frame carries the verb on the member's own
19270
+ * signaling channel instead, so control works without the client needing to know how
19271
+ * the conference is hosted.
19272
+ *
19273
+ * The trade-off is reach: the in-dialog transport is only accepted for calls that
19274
+ * join a conference over SWML (e.g. an SWML `join_conference`); use the routed
19275
+ * default otherwise.
19276
+ *
19277
+ * `params` are sent verbatim — nothing is built for you, which includes the target.
19278
+ * **A self-directed op still needs one**, or it is refused; name yourself explicitly:
19279
+ *
19280
+ * ```ts
19281
+ * const { call_id, member_id } = call.self.target;
19282
+ * await call.sendCommand('call.mute', { channels: ['audio'], target: { call_id, member_id } });
19283
+ * ```
19284
+ *
19285
+ * Never include `node_id` — only the two ids. The shapes are per-verb: most take a
19286
+ * singular `target`, `call.member.remove` takes a plural `targets` array, and
19287
+ * `call.member.position.set` takes a flat `targets: [{call_id, position}]` (the one
19288
+ * verb keyed on `call_id` rather than `member_id`). Verbs that act on the call as a
19289
+ * whole, or that the SDK does not wrap at all, take no target.
19290
+ *
19291
+ * For the typed alternative that handles all of this, create the client with
19292
+ * `callControl: 'in-dialog'` and use the ordinary `Call`/`Participant` methods.
19293
+ *
19294
+ * @internal Not part of the supported surface while the in-dialog transport is still
19295
+ * rolling out. `WebRTCCall` is exported from the package entry, so without this tag
19296
+ * TypeDoc publishes the method — and the example above — as public API.
19297
+ *
19298
+ * @param method - A `call.*` method name (e.g. `'call.mute'`).
19299
+ * @param params - Method parameters, sent verbatim.
19300
+ * @returns The method's own reply, unwrapped from the `verto.info` envelope.
19301
+ * @throws {JSONRPCError} If the control op fails.
19302
+ */
19303
+ async sendCommand(method, params = {}) {
19304
+ return unwrapVertoReply(await this.vertoManager.sendCallControl(method, params));
19305
+ }
19306
+ /**
19307
+ * The local leg's member triple — sent as `self` in every member RPC
19308
+ * envelope, and as the `target` of call-scoped self-operations (e.g. lock,
19309
+ * layout).
19310
+ *
19311
+ * @throws {CallNotReadyError} Before `call.joined` delivers the self member
19312
+ * context (`selfId`/`nodeId`) — an RPC without it cannot be routed, so fail
19313
+ * fast instead of sending a doomed request.
19314
+ */
19315
+ get callSelf() {
19316
+ const node_id = this.nodeId;
19317
+ const member_id = this.vertoManager.selfId;
19318
+ if (!node_id || !member_id) throw new CallNotReadyError(this.id);
19319
+ return {
19320
+ node_id,
19321
+ call_id: this.id,
19322
+ member_id
18551
19323
  };
18552
19324
  }
18553
19325
  /** Observable of the current call status (e.g. `'ringing'`, `'connected'`). */
@@ -18703,13 +19475,13 @@ var WebRTCCall = class extends Destroyable {
18703
19475
  get bandwidthConstrained$() {
18704
19476
  return this.deferEmission(this._bandwidthConstrained$.asObservable());
18705
19477
  }
18706
- /** Observable that emits when server-pushed media params are applied. */
19478
+ /** Observable that emits when the server pushes media params. */
18707
19479
  get mediaParamsUpdated$() {
18708
19480
  return this.deferEmission(this._mediaParamsUpdated$.asObservable());
18709
19481
  }
18710
19482
  /**
18711
19483
  * @internal Emit a media params update event.
18712
- * Called by the VertoManager when server-pushed media params are applied.
19484
+ * Called by the VertoManager when the server pushes media params.
18713
19485
  */
18714
19486
  emitMediaParamsUpdated(event) {
18715
19487
  this._mediaParamsUpdated$.next(event);
@@ -18917,6 +19689,10 @@ var WebRTCCall = class extends Destroyable {
18917
19689
  get selfId$() {
18918
19690
  return this.vertoManager.selfId$;
18919
19691
  }
19692
+ /** @internal Lets call creation bound the media and signalling phases apart. */
19693
+ get localMediaSettled$() {
19694
+ return this.vertoManager.localMediaSettled$;
19695
+ }
18920
19696
  /** Local participant's member ID, or `null` if not joined. */
18921
19697
  get selfId() {
18922
19698
  return this.vertoManager.selfId;
@@ -19110,12 +19886,17 @@ var WebRTCCall = class extends Destroyable {
19110
19886
  *
19111
19887
  * **These operations are NOT atomic.** The layout is applied first, then each
19112
19888
  * member position sequentially, so members may briefly flash into their
19113
- * default slots before being moved to the requested positions.
19889
+ * default slots before being moved to the requested positions. Targeted
19890
+ * members are validated upfront, though: when any of them has no
19891
+ * {@link Participant.target | member call context} yet, the whole call
19892
+ * rejects before any request is sent and the layout is left unchanged.
19114
19893
  *
19115
19894
  * @param layout - Layout name (must be one of {@link layouts}).
19116
19895
  * @param positions - Optional map of member IDs to {@link VideoPosition} values.
19117
19896
  * When omitted or empty, only the layout is changed.
19118
19897
  * @throws {InvalidParams} If the layout is not in the available {@link layouts}.
19898
+ * @throws {ParticipantNotReadyError} If a targeted member's call context has
19899
+ * not been received yet — thrown before any request is sent.
19119
19900
  *
19120
19901
  * @example
19121
19902
  * ```ts
@@ -19126,18 +19907,19 @@ var WebRTCCall = class extends Destroyable {
19126
19907
  */
19127
19908
  async setLayout(layout, positions) {
19128
19909
  if (!this.layouts.includes(layout)) throw new InvalidParams(`Layout ${layout} is not available in the current call layouts: ${this.layouts.join(", ")}`);
19129
- const selfId = await (0, import_cjs$11.firstValueFrom)(this.selfId$.pipe((0, import_cjs$11.filter)((id) => id !== null)));
19130
- await this.executeMethod(selfId, "call.layout.set", { layout });
19131
- const positionEntries = Object.entries(positions ?? {});
19132
- if (positionEntries.length === 0) return;
19133
- for (const [memberId, position] of positionEntries) {
19910
+ const targets = [];
19911
+ for (const [memberId, position] of Object.entries(positions ?? {})) {
19134
19912
  const participant = this.participants.find((p) => p.id === memberId);
19135
19913
  if (!participant) {
19136
19914
  logger$12.warn(`[Call] setLayout: member ${memberId} not found in participants; skipping position ${position}`);
19137
19915
  continue;
19138
19916
  }
19139
- await participant.setPosition(position);
19917
+ participant.target;
19918
+ targets.push([participant, position]);
19140
19919
  }
19920
+ const selfId = await (0, import_cjs$11.firstValueFrom)(this.selfId$.pipe((0, import_cjs$11.filter)((id) => id !== null)));
19921
+ await this.executeMethod(selfId, "call.layout.set", { layout });
19922
+ for (const [participant, position] of targets) await participant.setPosition(position);
19141
19923
  }
19142
19924
  /**
19143
19925
  * Transfers the call to another destination.
@@ -19239,17 +20021,28 @@ var WebRTCCall = class extends Destroyable {
19239
20021
  * (notably iOS Safari) fall back to re-acquiring the track with the new
19240
20022
  * constraint set and plumbing the replacement through the local audio
19241
20023
  * pipeline if one is active.
20024
+ *
20025
+ * @returns whether the constraint reached the microphone. `false` is an
20026
+ * outcome rather than an error — a leg sending media the SDK did not capture
20027
+ * is left alone — so a UI that reflects the toggle must read it. Any failure
20028
+ * behind a `false` is also reported on {@link errors$}.
19242
20029
  */
19243
20030
  async setEchoCancellation(enabled) {
19244
- await this.vertoManager.updateMediaConstraints({ audio: { echoCancellation: enabled } });
20031
+ return this.vertoManager.updateMediaConstraints({ audio: { echoCancellation: enabled } });
19245
20032
  }
19246
- /** Toggle browser noise suppression on the local mic at runtime. */
20033
+ /**
20034
+ * Toggle browser noise suppression on the local mic at runtime.
20035
+ * @returns whether the constraint reached the microphone.
20036
+ */
19247
20037
  async setNoiseSuppression(enabled) {
19248
- await this.vertoManager.updateMediaConstraints({ audio: { noiseSuppression: enabled } });
20038
+ return this.vertoManager.updateMediaConstraints({ audio: { noiseSuppression: enabled } });
19249
20039
  }
19250
- /** Toggle browser automatic gain control on the local mic at runtime. */
20040
+ /**
20041
+ * Toggle browser automatic gain control on the local mic at runtime.
20042
+ * @returns whether the constraint reached the microphone.
20043
+ */
19251
20044
  async setAutoGainControl(enabled) {
19252
- await this.vertoManager.updateMediaConstraints({ audio: { autoGainControl: enabled } });
20045
+ return this.vertoManager.updateMediaConstraints({ audio: { autoGainControl: enabled } });
19253
20046
  }
19254
20047
  /**
19255
20048
  * Observable of the aggregate remote audio level, 0..1 RMS. The server
@@ -19306,6 +20099,9 @@ var WebRTCCall = class extends Destroyable {
19306
20099
  /**
19307
20100
  * Infers the semantic error category from a raw Error thrown by VertoManager
19308
20101
  * or an RTCPeerConnection layer.
20102
+ *
20103
+ * Pure function — exported for unit testing.
20104
+ * @internal
19309
20105
  */
19310
20106
  function inferCallErrorKind(error) {
19311
20107
  if (error instanceof RPCTimeoutError) return "timeout";
@@ -19324,7 +20120,14 @@ const RECOVERABLE_RPC_CODES = new Set([
19324
20120
  RPC_ERROR_AUTHENTICATION_FAILED,
19325
20121
  RPC_ERROR_INVALID_PARAMS
19326
20122
  ]);
19327
- /** Determines whether an error should be fatal (destroy the call). */
20123
+ /**
20124
+ * A *fallback*: callers knowing which leg failed pass an explicit `fatal` and
20125
+ * never reach here, so the default-fatal branch only sees call- and main-leg
20126
+ * errors. Auxiliary legs go through `WebRTCVertoManager.reportLegError`.
20127
+ *
20128
+ * Pure function — exported for unit testing.
20129
+ * @internal
20130
+ */
19328
20131
  function isFatalError(error) {
19329
20132
  if (error instanceof VertoPongError) return false;
19330
20133
  if (error instanceof MediaTrackError) return false;
@@ -19359,7 +20162,9 @@ var CallFactory = class {
19359
20162
  kind: inferCallErrorKind(error),
19360
20163
  fatal: options$1?.fatal ?? isFatalError(error),
19361
20164
  error,
19362
- callId: callInstance.id
20165
+ callId: callInstance.id,
20166
+ ...options$1?.leg ? { leg: options$1.leg } : {},
20167
+ ...options$1?.legId ? { legId: options$1.legId } : {}
19363
20168
  };
19364
20169
  callInstance.emitError(callError);
19365
20170
  },
@@ -19831,6 +20636,30 @@ const logger$9 = getLogger();
19831
20636
  function shouldAbortDial(callError) {
19832
20637
  return callError.fatal || !(callError.error instanceof MediaAccessError);
19833
20638
  }
20639
+ /**
20640
+ * Wait for a dialed call to be ready, or for the failure that stops it.
20641
+ *
20642
+ * Local media acquisition is deliberately unbounded: a permission prompt or a
20643
+ * device picker is human time, and `getUserMedia` cannot be cancelled anyway.
20644
+ * The clock starts only once acquisition settles, so a slow human never spends
20645
+ * the server's budget.
20646
+ *
20647
+ * `merge` rather than `race`, because the two legs settle asymmetrically. A
20648
+ * fatal acquisition failure reports the error and then destroys the call in the
20649
+ * same synchronous step; `errors$` defers delivery by a microtask while
20650
+ * `localMediaSettled$` completes immediately. Under `race` that bare completion
20651
+ * ended the wait first and `dial()` rejected with an RxJS `EmptyError`, burying
20652
+ * the `NotAllowedError` applications are told to inspect. Under `merge` the
20653
+ * completed leg is simply spent, and the queued error — enqueued before the
20654
+ * completion, so delivered before it — arrives to reject the wait. A dial
20655
+ * abandoned with no error at all still ends both legs, and the resulting
20656
+ * `EmptyError` remains the benign-cancel signal.
20657
+ *
20658
+ * Exported for unit testing.
20659
+ */
20660
+ async function awaitDialReady(session, signalingTimeoutMs) {
20661
+ 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)))));
20662
+ }
19834
20663
  const getAddressSearchURI = (options) => {
19835
20664
  const to = options.to?.split("?")[0];
19836
20665
  const from$9 = options.from?.startsWith("subscriber://") ? options.from.replace("subscriber://", "") : options.from;
@@ -19847,7 +20676,6 @@ var ClientSessionManager = class extends Destroyable {
19847
20676
  this.authorizationStateKey = authorizationStateKey;
19848
20677
  this.attachManager = attachManager;
19849
20678
  this.dpopManager = dpopManager;
19850
- this.callCreateTimeout = 6e3;
19851
20679
  this.agent = `signalwire-js/4.0.0`;
19852
20680
  this.eventAcks = true;
19853
20681
  this.authorizationState$ = this.createReplaySubject(1);
@@ -19856,6 +20684,7 @@ var ClientSessionManager = class extends Destroyable {
19856
20684
  minor: 0,
19857
20685
  revision: 0
19858
20686
  };
20687
+ this.callControl = "routed";
19859
20688
  this._authorization$ = this.createBehaviorSubject(void 0);
19860
20689
  this._errors$ = this.createReplaySubject(1);
19861
20690
  this._authState$ = this.createBehaviorSubject({ kind: "unauthenticated" });
@@ -20055,22 +20884,23 @@ var ClientSessionManager = class extends Destroyable {
20055
20884
  await (0, import_cjs$7.firstValueFrom)(this.authenticated$.pipe((0, import_cjs$7.takeUntil)(this.destroyed$), (0, import_cjs$7.filter)(Boolean), (0, import_cjs$7.take)(1), (0, import_cjs$7.timeout)({ first: 15e3 })));
20056
20885
  }
20057
20886
  async handleAuthenticationError(error) {
20887
+ if (error instanceof TransportConnectionError) {
20888
+ logger$9.debug("[Session] Socket closed during authentication, waiting for reconnect");
20889
+ return;
20890
+ }
20058
20891
  logger$9.error("Authentication error:", error);
20059
- const isRecoverableAuthError = error instanceof JSONRPCError && (error.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === RPC_ERROR_INVALID_PARAMS || error.code === RPC_ERROR_AUTHENTICATION_FAILED);
20892
+ const isRecoverableAuthError$1 = error instanceof JSONRPCError && (error.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === RPC_ERROR_INVALID_PARAMS || error.code === RPC_ERROR_AUTHENTICATION_FAILED);
20060
20893
  const hasStoredState = await (0, import_cjs$7.firstValueFrom)(this.authorizationState$.pipe((0, import_cjs$7.take)(1))) !== void 0;
20061
- if (isRecoverableAuthError && hasStoredState) {
20894
+ if (isRecoverableAuthError$1 && hasStoredState) {
20062
20895
  logger$9.debug("[Session] Recoverable auth error — cleaning up stored state and reconnecting fresh");
20063
- try {
20064
- await this.cleanupStoredConnectionParams();
20065
- } catch (cleanupError) {
20066
- logger$9.error("Failed to cleanup stored connection params:", cleanupError);
20067
- } finally {
20068
- this.transport.reconnect();
20069
- }
20896
+ await this.discardResumeStateAndReconnect();
20070
20897
  } else this._errors$.next(error);
20071
20898
  }
20072
20899
  /**
20073
- * Clear the resume state (authorization_state + protocol) only.
20900
+ * Clear the resume state (authorization_state + protocol) and ask the
20901
+ * transport to reconnect. The `connected` event re-triggers
20902
+ * `authenticate()`, which now has no stored state and so performs a fresh
20903
+ * connect.
20074
20904
  *
20075
20905
  * This is the stale-auth-state recovery helper used by handleAuthError:
20076
20906
  * the server rejected a reconnect, so the resume state is discarded and a
@@ -20078,9 +20908,24 @@ var ClientSessionManager = class extends Destroyable {
20078
20908
  * session lives on through the reconnect and reattachCalls() needs the
20079
20909
  * stored call references afterwards. Do NOT add detachAll() here.
20080
20910
  *
20911
+ * Connect-time recovery only. A *request* refused on an already
20912
+ * authenticated session is never healed here: dropping the resume state
20913
+ * destroys the association between the socket and the previous session,
20914
+ * which is what reattach depends on. That path mints a fresh credential and
20915
+ * reauthenticates instead (see `SignalWire.recoverAndRetry`).
20916
+ *
20081
20917
  * For public teardown (disconnect/destroy), use {@link teardownSessionState}
20082
20918
  * instead, which clears the attach records as well.
20083
20919
  */
20920
+ async discardResumeStateAndReconnect() {
20921
+ try {
20922
+ await this.cleanupStoredConnectionParams();
20923
+ } catch (cleanupError) {
20924
+ logger$9.error("Failed to cleanup stored connection params:", cleanupError);
20925
+ } finally {
20926
+ this.transport.reconnect();
20927
+ }
20928
+ }
20084
20929
  async cleanupStoredConnectionParams() {
20085
20930
  await this.transport.setProtocol(void 0);
20086
20931
  await this.updateAuthorizationStateInStorage(void 0);
@@ -20154,11 +20999,15 @@ var ClientSessionManager = class extends Destroyable {
20154
20999
  const isReconnect = hasReconnectState && storedToken;
20155
21000
  let dpopToken;
20156
21001
  if (isReconnect) logger$9.debug("[Session] Reconnecting with stored jwt_token + authorization_state");
20157
- else if (this.onBeforeReconnect && this.clientBound) {
20158
- logger$9.debug("[Session] Refreshing credentials before fresh connect");
20159
- await this.onBeforeReconnect();
21002
+ else {
21003
+ const credential = this.getCredential();
21004
+ const credentialExpired = credential.expiry_at !== void 0 && credential.expiry_at <= Date.now() + CREDENTIAL_EXPIRY_SKEW_MS;
21005
+ if (this.onBeforeReconnect && (this.clientBound || credentialExpired)) {
21006
+ logger$9.debug("[Session] Refreshing credentials before fresh connect");
21007
+ await this.onBeforeReconnect();
21008
+ }
20160
21009
  }
20161
- if ((!isReconnect || this.clientBound) && this.dpopManager?.initialized) try {
21010
+ if (this.dpopManager?.initialized) try {
20162
21011
  dpopToken = await this.dpopManager.createRpcProof({ method: "signalwire.connect" });
20163
21012
  } catch (error) {
20164
21013
  if (this.clientBound) throw error;
@@ -20178,7 +21027,9 @@ var ClientSessionManager = class extends Destroyable {
20178
21027
  protocol: persistedParams.protocol
20179
21028
  } : {}
20180
21029
  });
20181
- const response = await (0, import_cjs$7.lastValueFrom)((0, import_cjs$7.from)(this.transport.execute(rpcConnectRequest)).pipe(throwOnRPCError(), (0, import_cjs$7.map)((res) => res.result), (0, import_cjs$7.filter)(isRPCConnectResult), (0, import_cjs$7.tap)(() => {
21030
+ if (this.transport.connectionStatus !== "connected") throw new TransportConnectionError("WebSocket closed during authentication");
21031
+ const socketClosed$ = this.transport.connectionStatus$.pipe((0, import_cjs$7.skip)(1), (0, import_cjs$7.filter)((status) => status === "reconnecting" || status === "disconnected"), (0, import_cjs$7.take)(1), (0, import_cjs$7.switchMap)(() => (0, import_cjs$7.throwError)(() => new TransportConnectionError("WebSocket closed during authentication"))));
21032
+ const response = await (0, import_cjs$7.lastValueFrom)((0, import_cjs$7.race)((0, import_cjs$7.from)(this.transport.execute(rpcConnectRequest)), socketClosed$).pipe(throwOnRPCError(), (0, import_cjs$7.map)((res) => res.result), (0, import_cjs$7.filter)(isRPCConnectResult), (0, import_cjs$7.tap)(() => {
20182
21033
  logger$9.debug("[Session] Response passed filter, processing authentication result");
20183
21034
  }), (0, import_cjs$7.take)(1), (0, import_cjs$7.catchError)((err) => {
20184
21035
  logger$9.error("[Session] Authentication RPC failed:", err);
@@ -20191,7 +21042,9 @@ var ClientSessionManager = class extends Destroyable {
20191
21042
  });
20192
21043
  if (response.protocol) await this.transport.setProtocol(response.protocol);
20193
21044
  this._authorization$.next(response.authorization);
21045
+ if (response.authorization.cnf?.jkt) this._wasClientBound = true;
20194
21046
  this._iceServers$.next(response.ice_servers ?? []);
21047
+ this.transport.setAuthenticated();
20195
21048
  this._authState$.next({ kind: "authenticated" });
20196
21049
  logger$9.debug("[Session] Authentication completed successfully");
20197
21050
  }
@@ -20257,7 +21110,7 @@ var ClientSessionManager = class extends Destroyable {
20257
21110
  to: destinationURI,
20258
21111
  ...options
20259
21112
  });
20260
- await (0, import_cjs$7.firstValueFrom)((0, import_cjs$7.race)(callSession.selfId$.pipe((0, import_cjs$7.filter)((id) => Boolean(id)), (0, import_cjs$7.take)(1), (0, import_cjs$7.timeout)(this.callCreateTimeout)), callSession.errors$.pipe((0, import_cjs$7.filter)(shouldAbortDial), (0, import_cjs$7.take)(1), (0, import_cjs$7.switchMap)((callError) => (0, import_cjs$7.throwError)(() => callError.error)))));
21113
+ await awaitDialReady(callSession, DEFAULT_CALL_SIGNALING_TIMEOUT_MS);
20261
21114
  this._calls$.next({
20262
21115
  [`${callSession.id}`]: callSession,
20263
21116
  ...this._calls$.value
@@ -20310,12 +21163,23 @@ var ClientSessionWrapper = class {
20310
21163
  get authenticated() {
20311
21164
  return this.clientSessionManager.authenticated;
20312
21165
  }
21166
+ /**
21167
+ * Whether the session is using a Client Bound SAT (DPoP). Sticky — set
21168
+ * when the binding is established or restored from a resumed session's
21169
+ * server authorization.
21170
+ */
21171
+ get clientBound() {
21172
+ return this.clientSessionManager.clientBound;
21173
+ }
20313
21174
  get signalingEvent$() {
20314
21175
  return this.clientSessionManager.signalingEvent$;
20315
21176
  }
20316
21177
  get iceServers() {
20317
21178
  return this.clientSessionManager.iceServers;
20318
21179
  }
21180
+ get callControl() {
21181
+ return this.clientSessionManager.callControl;
21182
+ }
20319
21183
  async execute(request, options) {
20320
21184
  return this.clientSessionManager.execute(request, options);
20321
21185
  }
@@ -20512,7 +21376,7 @@ var DeviceTokenManager = class extends Destroyable {
20512
21376
  await session.reauthenticate(tokenData.token, rpcProof, { clientBound: true });
20513
21377
  updateCredential({ token: tokenData.token });
20514
21378
  logger$7.info("[DeviceToken] Client Bound SAT activated successfully");
20515
- this._currentToken$.next(tokenData);
21379
+ this.emitCurrentToken(tokenData);
20516
21380
  return { activated: true };
20517
21381
  } catch (error) {
20518
21382
  logger$7.error("[DeviceToken] Failed to activate Client Bound SAT:", error);
@@ -20524,6 +21388,21 @@ var DeviceTokenManager = class extends Destroyable {
20524
21388
  }
20525
21389
  }
20526
21390
  /**
21391
+ * Emit a freshly received token to the reactive pipeline, stamping an
21392
+ * absolute `expires_at` when the response carried only `expires_in`.
21393
+ * Resolving the expiry at RECEIVE time (not at read time) is what lets
21394
+ * {@link refreshNowIfDue} detect due-ness on resume: a bare `expires_in`
21395
+ * re-resolved later would always compute a full TTL from "now" and never
21396
+ * cross the refresh buffer.
21397
+ */
21398
+ emitCurrentToken(token) {
21399
+ const stamped = token.expires_at ? token : {
21400
+ ...token,
21401
+ expires_at: resolveExpiresAt(token)
21402
+ };
21403
+ this._currentToken$.next(stamped);
21404
+ }
21405
+ /**
20527
21406
  * Returns true when the cached token has enough headroom before expiry to
20528
21407
  * be safely reused on reactivation. The headroom matches the refresh
20529
21408
  * buffer, so a token within the refresh window is treated as stale (the
@@ -20541,7 +21420,7 @@ var DeviceTokenManager = class extends Destroyable {
20541
21420
  method: "POST",
20542
21421
  uri: DEVICE_TOKEN_ENDPOINT
20543
21422
  });
20544
- const response = await this.http.request({
21423
+ const response = await this.http().request({
20545
21424
  url: DEVICE_TOKEN_ENDPOINT,
20546
21425
  ...POST_PARAMS,
20547
21426
  body: JSON.stringify({
@@ -20568,7 +21447,7 @@ var DeviceTokenManager = class extends Destroyable {
20568
21447
  uri: DEVICE_REFRESH_ENDPOINT,
20569
21448
  accessToken: currentToken
20570
21449
  });
20571
- const response = await this.http.request({
21450
+ const response = await this.http().request({
20572
21451
  url: DEVICE_REFRESH_ENDPOINT,
20573
21452
  ...POST_PARAMS,
20574
21453
  body: JSON.stringify({
@@ -20616,7 +21495,7 @@ var DeviceTokenManager = class extends Destroyable {
20616
21495
  const currentToken = this.getCredential().token;
20617
21496
  if (!currentToken) throw new TokenRefreshError("No current token available for refresh");
20618
21497
  const newTokenData = await this.retryRefresh(session, currentToken, updateCredential);
20619
- this._currentToken$.next(newTokenData);
21498
+ this.emitCurrentToken(newTokenData);
20620
21499
  } catch (error) {
20621
21500
  logger$7.error("[DeviceToken] Automatic Client Bound SAT refresh failed:", error);
20622
21501
  this.errorHandler(error instanceof TokenRefreshError ? error : new TokenRefreshError("Automatic token refresh failed", error));
@@ -20643,6 +21522,22 @@ var DeviceTokenManager = class extends Destroyable {
20643
21522
  throw lastError instanceof Error ? lastError : new TokenRefreshError("All refresh retries exhausted", lastError);
20644
21523
  }
20645
21524
  /**
21525
+ * Force an immediate refresh when the cached Client Bound SAT is already
21526
+ * past its refresh window. Called on resume from suspension where
21527
+ * background-tab throttling can delay the reactive timer past the buffer.
21528
+ * A no-op when no token is cached or it still has headroom; the normal
21529
+ * {@link executeRefresh} guards (paused / in-progress / unauthenticated)
21530
+ * still apply.
21531
+ */
21532
+ refreshNowIfDue() {
21533
+ const token = this._currentToken$.value;
21534
+ if (!token) return;
21535
+ if (resolveExpiresAt(token) * 1e3 - Date.now() <= DEVICE_TOKEN_REFRESH_BUFFER_MS) {
21536
+ logger$7.debug("[DeviceToken] Resume: cached SAT past refresh window; refreshing now");
21537
+ this.executeRefresh();
21538
+ }
21539
+ }
21540
+ /**
20646
21541
  * Stops the reactive refresh pipeline from firing. Use when the underlying
20647
21542
  * session is being torn down (e.g., during {@link SignalWire.disconnect})
20648
21543
  * so a scheduled refresh cannot fire against a destroyed session.
@@ -20693,6 +21588,7 @@ var CredentialRefreshCoordinator = class extends Destroyable {
20693
21588
  this.deps = deps;
20694
21589
  this._activating = false;
20695
21590
  this._activationGeneration = 0;
21591
+ this._developerRefreshInProgress = false;
20696
21592
  if (dpopManager?.initialized) this._deviceTokenManager = (deps.deviceTokenManagerFactory ?? defaultDeviceTokenManagerFactory)(dpopManager, deps.http, (error) => deps.notifier.onError(error), () => deps.store.read());
20697
21593
  }
20698
21594
  /** True when the Client Bound SAT path is available (DPoP initialized). */
@@ -20711,28 +21607,120 @@ var CredentialRefreshCoordinator = class extends Destroyable {
20711
21607
  * invokes `deps.onRefreshExhausted` so the orchestrator can disconnect.
20712
21608
  */
20713
21609
  scheduleDeveloperRefresh(provider, expiresAt, attempt = 0) {
21610
+ this._activeProvider = provider;
20714
21611
  if (this._developerTimerId !== void 0) clearTimeout(this._developerTimerId);
20715
21612
  const refreshInterval = attempt === 0 ? Math.max(expiresAt - Date.now() - CREDENTIAL_REFRESH_BUFFER_MS, 1e3) : Math.min(CREDENTIAL_REFRESH_RETRY_BASE_MS * Math.pow(2, attempt) * (.5 + Math.random() * .5), CREDENTIAL_REFRESH_MAX_DELAY_MS);
20716
- this._developerTimerId = setTimeout(async () => {
21613
+ this._developerTimerId = setTimeout(() => {
21614
+ this._developerTimerId = void 0;
21615
+ this.executeDeveloperRefresh(provider, expiresAt, attempt);
21616
+ }, refreshInterval);
21617
+ }
21618
+ /**
21619
+ * Runs the developer-provided refresh once: mints a new credential, stores
21620
+ * and persists it, reauthenticates the live session (via the notifier), and
21621
+ * reschedules against the new expiry. On failure retries with backoff up to
21622
+ * {@link CREDENTIAL_REFRESH_MAX_RETRIES}, then signals exhaustion.
21623
+ *
21624
+ * Shared by the scheduled timer tick and {@link forceRefreshIfDue}. The
21625
+ * `_developerRefreshInProgress` guard prevents the two from overlapping.
21626
+ */
21627
+ async executeDeveloperRefresh(provider, expiresAt, attempt) {
21628
+ if (this._developerRefreshInProgress) {
21629
+ logger$6.debug("[Coordinator] Developer refresh already in progress; skipping");
21630
+ return;
21631
+ }
21632
+ this._developerRefreshInProgress = true;
21633
+ try {
21634
+ const newCredentials = await this.refreshCredential(provider);
21635
+ this.deps.store.write(newCredentials);
21636
+ this.deps.store.persist(newCredentials);
20717
21637
  try {
20718
- if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
20719
- const newCredentials = await provider.refresh();
20720
- this.deps.store.write(newCredentials);
20721
- this.deps.store.persist(newCredentials);
20722
- logger$6.info("[Coordinator] Credentials refreshed successfully.");
20723
- if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
20724
- } catch (error) {
20725
- const nextAttempt = attempt + 1;
20726
- logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
20727
- this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
20728
- if (nextAttempt < CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
20729
- else {
20730
- logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
20731
- this.deps.notifier.onError(new TokenRefreshError("Credential refresh failed after max retries"));
20732
- this.deps.notifier.onRefreshExhausted();
20733
- }
21638
+ await this.deps.notifier.onCredentialRefreshed(newCredentials);
21639
+ } catch (reauthError) {
21640
+ logger$6.warn("[Coordinator] onCredentialRefreshed rejected (non-fatal):", reauthError);
20734
21641
  }
20735
- }, refreshInterval);
21642
+ logger$6.info("[Coordinator] Credentials refreshed successfully.");
21643
+ if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
21644
+ } catch (error) {
21645
+ const nextAttempt = attempt + 1;
21646
+ logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
21647
+ this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
21648
+ if (nextAttempt < CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
21649
+ else {
21650
+ logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
21651
+ this.deps.notifier.onError(new TokenRefreshError("Credential refresh failed after max retries"));
21652
+ this.deps.notifier.onRefreshExhausted();
21653
+ }
21654
+ } finally {
21655
+ this._developerRefreshInProgress = false;
21656
+ }
21657
+ }
21658
+ /**
21659
+ * Force an immediate refresh when the current credential is already past its
21660
+ * scheduled refresh window. Called on resume from suspension, where
21661
+ * background-tab timer throttling can delay the armed refresh well past
21662
+ * expiry, leaving the live session stale.
21663
+ *
21664
+ * Routes to whichever mechanism is armed: the developer timer if armed,
21665
+ * otherwise the Client Bound SAT pipeline. A no-op when nothing is due.
21666
+ */
21667
+ forceRefreshIfDue() {
21668
+ if (this._developerTimerId !== void 0 && this._activeProvider) {
21669
+ const expiry = this.deps.store.read().expiry_at;
21670
+ if (expiry !== void 0 && Date.now() >= expiry - CREDENTIAL_REFRESH_BUFFER_MS) {
21671
+ logger$6.debug("[Coordinator] Resume: credential past refresh window; forcing refresh");
21672
+ clearTimeout(this._developerTimerId);
21673
+ this._developerTimerId = void 0;
21674
+ this.executeDeveloperRefresh(this._activeProvider, expiry, 0);
21675
+ }
21676
+ return;
21677
+ }
21678
+ this._deviceTokenManager?.refreshNowIfDue();
21679
+ }
21680
+ /**
21681
+ * Sync the credential's expiry from the server-provided authorization (the
21682
+ * `signalwire.connect` result). SATs are opaque JWE, so
21683
+ * `fabric_subscriber.expires_at` is the authoritative expiry of the token
21684
+ * the session actually connected with — the provider-reported `expiry_at`
21685
+ * is only a hint (and may be wrong or absent). Corrects the stored
21686
+ * credential and re-arms the developer refresh timer against the real
21687
+ * deadline when the provider supports `refresh()`.
21688
+ */
21689
+ syncExpiryFromAuthorization(authorization, provider) {
21690
+ const expiresAtSec = authorization?.fabric_subscriber?.expires_at;
21691
+ if (!expiresAtSec) return;
21692
+ const expiryAt = expiresAtSec * 1e3;
21693
+ const credential = this.deps.store.read();
21694
+ if (credential.expiry_at === expiryAt) return;
21695
+ logger$6.debug(`[Coordinator] Correcting credential expiry from server authorization: ${new Date(expiryAt).toISOString()}`);
21696
+ const updated = {
21697
+ ...credential,
21698
+ expiry_at: expiryAt
21699
+ };
21700
+ this.deps.store.write(updated);
21701
+ this.deps.store.persist(updated);
21702
+ if (provider?.refresh) this.scheduleDeveloperRefresh(provider, expiryAt);
21703
+ }
21704
+ /**
21705
+ * Invoke `provider.refresh()` deduped against any concurrent developer
21706
+ * refresh. Concurrent callers — the scheduled tick, a resume-forced refresh,
21707
+ * and the orchestrator's -32003 recovery / reconnect re-mint — share one
21708
+ * in-flight promise, so a provider backed by one-time-use rotating refresh
21709
+ * tokens is never invoked twice in parallel.
21710
+ *
21711
+ * The caller owns applying the returned credential (store write, session
21712
+ * reauth, rescheduling); this method only serializes the network call.
21713
+ */
21714
+ async refreshCredential(provider) {
21715
+ if (this._refreshInFlight) return this._refreshInFlight;
21716
+ if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
21717
+ const run = provider.refresh();
21718
+ this._refreshInFlight = run;
21719
+ const clear = () => {
21720
+ if (this._refreshInFlight === run) this._refreshInFlight = void 0;
21721
+ };
21722
+ run.then(clear, clear);
21723
+ return run;
20736
21724
  }
20737
21725
  /**
20738
21726
  * Cancels any scheduled developer-provided refresh. Idempotent.
@@ -21253,6 +22241,7 @@ var WebSocketController = class WebSocketController extends Destroyable {
21253
22241
  }
21254
22242
  handleClose(_event) {
21255
22243
  this.clearConnectionTimeout();
22244
+ this.messageQueue = [];
21256
22245
  if (this.shouldReconnect) {
21257
22246
  this._status$.next("reconnecting");
21258
22247
  this.scheduleReconnection();
@@ -21322,6 +22311,9 @@ var WebSocketController = class WebSocketController extends Destroyable {
21322
22311
  function isSignalwirePingRequest(value) {
21323
22312
  return isJSONRPCRequest(value) && value.method === "signalwire.ping";
21324
22313
  }
22314
+ function isSignalwireConnectRequest(value) {
22315
+ return isJSONRPCRequest(value) && value.method === "signalwire.connect";
22316
+ }
21325
22317
 
21326
22318
  //#endregion
21327
22319
  //#region src/managers/TransportManager.ts
@@ -21336,6 +22328,10 @@ var TransportManager = class extends Destroyable {
21336
22328
  this.protocol$ = this.createReplaySubject(1);
21337
22329
  this.isConnecting = false;
21338
22330
  this.isConnected = false;
22331
+ this.socketStatus = "disconnected";
22332
+ this.sessionAuthenticated = false;
22333
+ this.heldRequests = [];
22334
+ this.serverPingWatch = 0;
21339
22335
  this.ackEvent = () => {
21340
22336
  return (0, import_cjs$2.tap)((message) => {
21341
22337
  if (isSignalwireRequest(message)) try {
@@ -21351,6 +22347,7 @@ var TransportManager = class extends Destroyable {
21351
22347
  if (isSignalwirePingRequest(message)) {
21352
22348
  try {
21353
22349
  logger$2.debug("[Transport] Received ping, sending pong", { pingId: message.id });
22350
+ if (this.sessionAuthenticated) this.armServerPingWatchdog();
21354
22351
  this.send(RPCPingResponse(message.id));
21355
22352
  } catch (error) {
21356
22353
  logger$2.error("[Transport] Failed to send ping response:", error);
@@ -21388,6 +22385,14 @@ var TransportManager = class extends Destroyable {
21388
22385
  this.subscribeTo(this._webSocketConnections.errors$, (error) => {
21389
22386
  this.onError?.(error);
21390
22387
  });
22388
+ this.subscribeTo(this._webSocketConnections.status$, (status) => {
22389
+ this.socketStatus = status;
22390
+ if (status !== "connected") {
22391
+ this.sessionAuthenticated = false;
22392
+ this.clearServerPingWatchdog();
22393
+ }
22394
+ if (status === "disconnected") this.heldRequests = [];
22395
+ });
21391
22396
  this.initialized$ = (0, import_cjs$2.defer)(() => (0, import_cjs$2.from)(this._init())).pipe((0, import_cjs$2.shareReplay)(1), (0, import_cjs$2.takeUntil)(this.destroyed$));
21392
22397
  this._jsonRPCMessage$ = this._webSocketConnections.incomingMessages$.pipe((0, import_cjs$2.map)((event) => {
21393
22398
  try {
@@ -21416,6 +22421,9 @@ var TransportManager = class extends Destroyable {
21416
22421
  get connectionStatus$() {
21417
22422
  return this._webSocketConnections.status$;
21418
22423
  }
22424
+ get connectionStatus() {
22425
+ return this.socketStatus;
22426
+ }
21419
22427
  async connect() {
21420
22428
  if (this.isConnecting || this.isConnected) {
21421
22429
  logger$2.warn("[Transport] Already connecting or connected");
@@ -21460,16 +22468,45 @@ var TransportManager = class extends Destroyable {
21460
22468
  }
21461
22469
  async execute(request, options) {
21462
22470
  this.send(request);
21463
- return new PendingRPC(request, this._jsonRPCResponse$, options).promise;
22471
+ const pending = new PendingRPC(request, this._jsonRPCResponse$, options).promise;
22472
+ pending.catch(() => {
22473
+ this.heldRequests = this.heldRequests.filter((held) => held.id !== request.id);
22474
+ });
22475
+ return pending;
21464
22476
  }
21465
22477
  send(message) {
21466
22478
  const payload = JSON.stringify(message);
22479
+ if (isJSONRPCResponse(message)) {
22480
+ if (this.socketStatus !== "connected") {
22481
+ logger$2.debug("[Transport] Dropping response while disconnected", { id: message.id });
22482
+ return;
22483
+ }
22484
+ } else if (!this.sessionAuthenticated && !isSignalwireConnectRequest(message)) {
22485
+ this.heldRequests.push({
22486
+ id: message.id,
22487
+ payload
22488
+ });
22489
+ return;
22490
+ }
21467
22491
  this._outgoingMessages$.next(payload);
21468
22492
  }
22493
+ /**
22494
+ * Called by the session when signalwire.connect succeeds on the current
22495
+ * socket. Sends the requests held since the socket opened, in order.
22496
+ */
22497
+ setAuthenticated() {
22498
+ if (this.socketStatus !== "connected") return;
22499
+ this.sessionAuthenticated = true;
22500
+ this.armServerPingWatchdog();
22501
+ const held = this.heldRequests;
22502
+ this.heldRequests = [];
22503
+ held.forEach(({ payload }) => this._outgoingMessages$.next(payload));
22504
+ }
21469
22505
  disconnect() {
21470
22506
  logger$2.debug("[Transport] Disconnecting");
21471
22507
  this.isConnected = false;
21472
22508
  this.isConnecting = false;
22509
+ this.clearServerPingWatchdog();
21473
22510
  this._webSocketConnections.disconnect();
21474
22511
  }
21475
22512
  destroy() {
@@ -21478,6 +22515,28 @@ var TransportManager = class extends Destroyable {
21478
22515
  super.destroy();
21479
22516
  this._webSocketConnections.destroy();
21480
22517
  }
22518
+ armServerPingWatchdog() {
22519
+ this.clearServerPingWatchdog();
22520
+ const watch = this.serverPingWatch;
22521
+ this.serverPingTimer = setTimeout(() => void this.probeServer(watch), SERVER_PING_TIMEOUT_MS);
22522
+ }
22523
+ /** Bumps the watch so a probe still in flight no longer acts on its result. */
22524
+ clearServerPingWatchdog() {
22525
+ clearTimeout(this.serverPingTimer);
22526
+ this.serverPingTimer = void 0;
22527
+ this.serverPingWatch += 1;
22528
+ }
22529
+ async probeServer(watch) {
22530
+ logger$2.debug(`[Transport] No signalwire.ping for ${SERVER_PING_TIMEOUT_MS}ms, probing the server`);
22531
+ try {
22532
+ await this.execute(RPCPing(), { timeoutMs: SERVER_PING_PROBE_TIMEOUT_MS });
22533
+ if (watch === this.serverPingWatch) this.armServerPingWatchdog();
22534
+ } catch (error) {
22535
+ if (watch !== this.serverPingWatch) return;
22536
+ logger$2.warn("[Transport] Server did not answer the ping probe, replacing the socket", error);
22537
+ this.reconnect();
22538
+ }
22539
+ }
21481
22540
  async _loadProtocolFromStorage() {
21482
22541
  try {
21483
22542
  const storedProtocol = await this.storage.getItem(this.protocolKey);
@@ -21516,6 +22575,13 @@ var TransportManager = class extends Destroyable {
21516
22575
  //#region src/clients/SignalWire.ts
21517
22576
  var import_cjs$1 = require_cjs();
21518
22577
  const logger$1 = getLogger();
22578
+ /**
22579
+ * Storage key for the client-bound marker. The SAT and authorization_state are
22580
+ * both opaque to the SDK, so on a page reload the preflight recovery — which
22581
+ * runs before any session exists — has no other way to know the session was
22582
+ * client-bound. See {@link SignalWire.persistClientBoundMarker}.
22583
+ */
22584
+ const CLIENT_BOUND_STORAGE_KEY = "sw:client_bound";
21519
22585
  const buildOptionsFromDestination = (destination) => {
21520
22586
  if (typeof destination === "string") {
21521
22587
  const queryStartIndex = destination.indexOf("?");
@@ -21559,6 +22625,7 @@ var SignalWire = class extends Destroyable {
21559
22625
  this.preferences = new ClientPreferences();
21560
22626
  this._user$ = this.createBehaviorSubject(void 0);
21561
22627
  this._directory$ = this.createBehaviorSubject(void 0);
22628
+ this._credentialRecovered = false;
21562
22629
  this._isConnected$ = this.createBehaviorSubject(false);
21563
22630
  this._isRegistered$ = this.createBehaviorSubject(false);
21564
22631
  this._errors$ = this.createReplaySubject(1);
@@ -21597,6 +22664,16 @@ var SignalWire = class extends Destroyable {
21597
22664
  });
21598
22665
  }
21599
22666
  /**
22667
+ * Build the refresh path's own HTTP controller, against whatever host is current.
22668
+ *
22669
+ * Called on first use rather than up front, so `apiHost` already reflects the
22670
+ * token's `ch` claim. Same credential source as the container's controller — only
22671
+ * the instance, and therefore its observable streams, is separate.
22672
+ */
22673
+ createRefreshHttpController() {
22674
+ return new HTTPRequestController(this._deps.apiHost, () => this._deps.credential);
22675
+ }
22676
+ /**
21600
22677
  * Initializes DPoP if not already set up. Returns the fingerprint on success.
21601
22678
  */
21602
22679
  async initDPoP() {
@@ -21623,11 +22700,19 @@ var SignalWire = class extends Destroyable {
21623
22700
  async resolveCredentials() {
21624
22701
  const fingerprint = await this.initDPoP();
21625
22702
  this._refreshCoordinator = new CredentialRefreshCoordinator(this._dpopManager, {
21626
- http: this._deps.http,
22703
+ http: () => {
22704
+ if (!this._refreshHttp || this._refreshHttpHost !== this._deps.apiHost) {
22705
+ this._refreshHttp?.destroy();
22706
+ this._refreshHttp = this.createRefreshHttpController();
22707
+ this._refreshHttpHost = this._deps.apiHost;
22708
+ }
22709
+ return this._refreshHttp;
22710
+ },
21627
22711
  notifier: {
21628
22712
  onError: (error) => this._errors$.next(error),
21629
22713
  onWarning: (warning) => this._warnings$.next(warning),
21630
- onRefreshExhausted: () => void this.disconnect()
22714
+ onRefreshExhausted: () => void this.disconnect(),
22715
+ onCredentialRefreshed: async (credential) => this.reauthenticateLiveSession(credential)
21631
22716
  },
21632
22717
  store: {
21633
22718
  read: () => this._deps.credential,
@@ -21639,6 +22724,7 @@ var SignalWire = class extends Destroyable {
21639
22724
  ...this._deps.credential,
21640
22725
  ...partial
21641
22726
  };
22727
+ this.persistCredential(this._deps.credential);
21642
22728
  },
21643
22729
  persist: (credential) => this.persistCredential(credential)
21644
22730
  }
@@ -21684,20 +22770,181 @@ var SignalWire = class extends Destroyable {
21684
22770
  }
21685
22771
  this._deps.credential = _credentials;
21686
22772
  this.persistCredential(_credentials);
21687
- if (this.isConnected && this._clientSession.authenticated && _credentials.token) try {
21688
- await this._clientSession.reauthenticate(_credentials.token);
22773
+ await this.reauthenticateLiveSession(_credentials);
22774
+ }
22775
+ /**
22776
+ * Reauthenticate the currently-open session with a freshly obtained
22777
+ * credential so the new token takes effect on the live socket immediately —
22778
+ * not just on the next reconnect. No-op when the session is not
22779
+ * connected/authenticated or the credential carries no token (e.g. an
22780
+ * authorization-state-only refresh). Non-fatal: reauth failures surface on
22781
+ * `errors$` without aborting the refresh that triggered this.
22782
+ */
22783
+ async reauthenticateLiveSession(credential) {
22784
+ if (!this.isConnected || !this._clientSession.authenticated || !credential.token) return;
22785
+ try {
22786
+ await this._clientSession.reauthenticate(credential.token);
21689
22787
  logger$1.info("[SignalWire] Session refreshed with new credentials.");
21690
22788
  } catch (error) {
21691
22789
  logger$1.error("[SignalWire] Failed to refresh session with new credentials:", error);
21692
22790
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
21693
22791
  }
21694
22792
  }
22793
+ /**
22794
+ * Recover a session the server is refusing: mint a fresh credential,
22795
+ * reauthenticate the live session with it, and retry the operation.
22796
+ *
22797
+ * The connection is deliberately kept. A reload authenticates the new socket
22798
+ * against the persisted `authorization_state`, and that handshake is what
22799
+ * associates the socket with the previous session — the association reattach
22800
+ * depends on. `signalwire.reauthenticate` swaps the credential *on that same
22801
+ * session*, so recovery never touches the resume state. Discarding it would
22802
+ * heal the credential by destroying the very thing the caller is trying to
22803
+ * get back to.
22804
+ *
22805
+ * The operation is still the verdict, never the RPC. Reauthenticating with
22806
+ * the in-memory token is accepted by a resume even while requests stay
22807
+ * refused, because the persisted `authorization_state` short-circuits token
22808
+ * validation — and `signalwire.reauthenticate` with a *freshly minted* token
22809
+ * has also been observed accepted while `subscriber.online` keeps being
22810
+ * refused (staging run 33826974634). Both look like success and are not.
22811
+ *
22812
+ * @returns the operation's value, or the reason recovery could not deliver
22813
+ * one. `error` is undefined when there was no way to mint at all.
22814
+ */
22815
+ async recoverAndRetry(operation) {
22816
+ if (!await this.remintAndReauthenticate()) return { ok: false };
22817
+ try {
22818
+ const value = await operation();
22819
+ this._credentialRecovered = true;
22820
+ return {
22821
+ ok: true,
22822
+ value
22823
+ };
22824
+ } catch (error) {
22825
+ logger$1.warn("[SignalWire] Reauthentication was accepted but the operation is still refused:", error);
22826
+ return {
22827
+ ok: false,
22828
+ error
22829
+ };
22830
+ }
22831
+ }
22832
+ /**
22833
+ * Re-mint a credential and adopt it only if the live session accepts it.
22834
+ *
22835
+ * The mechanism follows the binding: a client-bound session re-mints a bound
22836
+ * base SAT through `authenticate()` with the DPoP fingerprint, because the
22837
+ * developer refresh handler would hand back an unbound token and silently
22838
+ * degrade the session. An unbound session uses the refresh handler. Rotation
22839
+ * cost is not a reason to skip this — the only reason is having no mechanism.
22840
+ *
22841
+ * @returns whether the session is now running on a freshly accepted credential.
22842
+ */
22843
+ async remintAndReauthenticate() {
22844
+ const provider = this._credentialProvider;
22845
+ if (!provider) return false;
22846
+ const { clientBound } = this._clientSession;
22847
+ if (!clientBound && !provider.refresh) {
22848
+ logger$1.debug("[SignalWire] [SW-NO-REFRESH-HANDLER] Unbound session with no refresh handler; cannot re-mint.");
22849
+ return false;
22850
+ }
22851
+ try {
22852
+ const newCredentials = clientBound ? await provider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0) : await this.remintCredential(provider);
22853
+ if (!newCredentials.token) {
22854
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the current one.");
22855
+ return false;
22856
+ }
22857
+ await this._clientSession.reauthenticate(newCredentials.token);
22858
+ this._deps.credential = newCredentials;
22859
+ this.persistCredential(newCredentials);
22860
+ if (newCredentials.expiry_at && provider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(provider, newCredentials.expiry_at);
22861
+ return true;
22862
+ } catch (error) {
22863
+ logger$1.warn("[SignalWire] Re-mint recovery failed:", error);
22864
+ return false;
22865
+ }
22866
+ }
22867
+ /**
22868
+ * Re-mint a credential via `provider.refresh()`, routed through the
22869
+ * coordinator's shared in-flight guard so concurrent re-mint paths (a
22870
+ * scheduled/resume refresh, -32003 recovery, and reconnect) never fire a
22871
+ * second `provider.refresh()` in parallel — which rotating one-time-use
22872
+ * refresh tokens reject. Falls back to a direct call only if the coordinator
22873
+ * has not been constructed yet.
22874
+ */
22875
+ async remintCredential(provider) {
22876
+ if (this._refreshCoordinator) return this._refreshCoordinator.refreshCredential(provider);
22877
+ if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
22878
+ return provider.refresh();
22879
+ }
22880
+ /**
22881
+ * Re-mint credentials before a fresh (re)connect (`onBeforeReconnect` hook).
22882
+ * The session invokes this only when it is client-bound OR the in-memory
22883
+ * token is expired. The re-mint mechanism depends on the binding:
22884
+ * - Client-bound: `authenticate()` with the DPoP fingerprint to obtain a
22885
+ * fresh base SAT the upcoming reconnect can re-bind (the
22886
+ * DeviceTokenManager re-activates afterwards).
22887
+ * - Unbound: the developer's non-interactive `refresh()` handler.
22888
+ * `authenticate()` is deliberately NOT used here — it may be interactive
22889
+ * (a login prompt) and must not fire on a background reconnect.
22890
+ *
22891
+ * Rejects on failure so the session aborts the reconnect rather than
22892
+ * replaying a stale token.
22893
+ */
22894
+ async refreshCredentialForReconnect() {
22895
+ if (!this._credentialProvider) return;
22896
+ try {
22897
+ let newCredentials;
22898
+ if (this._clientSession?.clientBound ?? await this.wasClientBound()) {
22899
+ logger$1.debug("[SignalWire] Re-minting client-bound base SAT before reconnect");
22900
+ newCredentials = await this._credentialProvider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0);
22901
+ } else if (this._credentialProvider.refresh) {
22902
+ logger$1.debug("[SignalWire] Refreshing unbound credential before reconnect");
22903
+ newCredentials = await this.remintCredential(this._credentialProvider);
22904
+ } else {
22905
+ logger$1.warn("[SignalWire] [SW-NO-REFRESH-HANDLER] Token expired on reconnect but no refresh handler; reconnecting with the existing token.");
22906
+ return;
22907
+ }
22908
+ if (!newCredentials.token) {
22909
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the existing credential for reconnect.");
22910
+ return;
22911
+ }
22912
+ this._deps.credential = newCredentials;
22913
+ this.persistCredential(newCredentials);
22914
+ if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
22915
+ logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
22916
+ } catch (error) {
22917
+ logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
22918
+ this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22919
+ throw error;
22920
+ }
22921
+ }
21695
22922
  /** Persist credential to localStorage when persistSession is enabled. */
21696
22923
  persistCredential(credential) {
21697
22924
  if (!credential.token) return;
21698
22925
  this._deps.storage.setItem("sw:cached_credential", credential);
21699
22926
  if (this._deps.persistSession) this._deps.storage.setItem("sw:cached_credential", credential, "local");
21700
22927
  }
22928
+ /**
22929
+ * Persist whether the session is client-bound, mirroring the credential's
22930
+ * storage scopes so it survives a reload. The preflight recovery reads it
22931
+ * before any session exists to decide whether to re-bind via `authenticate()`
22932
+ * or refresh an unbound token; the marker tracks the latest binding, so an
22933
+ * unbound reconnect clears a stale marker from an earlier client-bound login.
22934
+ */
22935
+ persistClientBoundMarker(bound) {
22936
+ const scopes = this._deps.persistSession ? ["session", "local"] : ["session"];
22937
+ for (const scope of scopes) if (bound) this._deps.storage.setItem(CLIENT_BOUND_STORAGE_KEY, true, scope);
22938
+ else this._deps.storage.removeItem(CLIENT_BOUND_STORAGE_KEY, scope);
22939
+ }
22940
+ /** Read the persisted client-bound marker (see {@link persistClientBoundMarker}). */
22941
+ async wasClientBound() {
22942
+ const scopes = this._deps.persistSession ? ["local", "session"] : ["session"];
22943
+ for (const scope of scopes) try {
22944
+ if (await this._deps.storage.getItem(CLIENT_BOUND_STORAGE_KEY, scope)) return true;
22945
+ } catch {}
22946
+ return false;
22947
+ }
21701
22948
  async init() {
21702
22949
  this._user$.next(new User(this._deps.http));
21703
22950
  if (!this._options.skipConnection) await this.connect();
@@ -21721,6 +22968,42 @@ var SignalWire = class extends Destroyable {
21721
22968
  } catch (error) {
21722
22969
  logger$1.error("[SignalWire] Failed to reattach calls:", error);
21723
22970
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22971
+ } finally {
22972
+ this._credentialRecovered = false;
22973
+ }
22974
+ }
22975
+ /**
22976
+ * Fetch the authenticated user profile, recovering a stale credential.
22977
+ *
22978
+ * On a reload the persisted credential can be expired. Unlike the WS resume —
22979
+ * which the server accepts against the persisted `authorization_state` even
22980
+ * with an expired token — this REST preflight has no such short-circuit and is
22981
+ * refused (401). There is no session yet to reauthenticate, so recovery
22982
+ * re-mints the credential through the provider ({@link refreshCredentialForReconnect})
22983
+ * and retries with a FRESH {@link User}: Fetchable memoizes its result
22984
+ * (shareReplay), so reusing the instance would replay the 401 instead of
22985
+ * re-fetching with the new token. Without the user id the transport/session —
22986
+ * and the reattach a reload is trying to preserve — cannot even be addressed.
22987
+ */
22988
+ async fetchUserOrRecover() {
22989
+ const fetchUser = async (user$1) => {
22990
+ if (!await (0, import_cjs$1.firstValueFrom)(user$1.fetched$)) throw new UnexpectedError("Failed to fetch user information - fetched$ emitted false");
22991
+ this._deps.user = user$1;
22992
+ };
22993
+ const user = this._user$.value;
22994
+ if (!user) throw new UnexpectedError("User not initialized before connect");
22995
+ try {
22996
+ await fetchUser(user);
22997
+ } catch (firstError) {
22998
+ 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.`);
22999
+ try {
23000
+ await this.refreshCredentialForReconnect();
23001
+ const refetched = new User(this._deps.http);
23002
+ await fetchUser(refetched);
23003
+ this._user$.next(refetched);
23004
+ } catch (retryError) {
23005
+ throw new UnexpectedError("Error fetching user information", { cause: retryError });
23006
+ }
21724
23007
  }
21725
23008
  }
21726
23009
  /**
@@ -21762,40 +23045,23 @@ var SignalWire = class extends Destroyable {
21762
23045
  */
21763
23046
  async connect() {
21764
23047
  await this.teardownTransportAndSession();
21765
- try {
21766
- const user = this._user$.value;
21767
- if (!user) throw new UnexpectedError("User not initialized before connect");
21768
- if (!await (0, import_cjs$1.firstValueFrom)(user.fetched$)) throw new UnexpectedError("Failed to fetch user information - fetched$ emitted false");
21769
- this._deps.user = user;
21770
- } catch (error) {
21771
- logger$1.error(`[SignalWire] Failed to fetch user information: ${error instanceof Error ? error.message : "Unknown error"}. This usually means the user token is invalid or expired.`);
21772
- throw new UnexpectedError("Error fetching user information", { cause: error });
21773
- }
23048
+ await this.fetchUserOrRecover();
21774
23049
  const errorHandler = (error) => {
21775
23050
  this._errors$.next(error);
21776
23051
  };
21777
23052
  this._transport = new TransportManager(this._deps.storage, this._deps.protocolKey, this._deps.WebSocket, PreferencesContainer.instance.relayHost ?? this._deps.relayHost, errorHandler);
21778
- this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey);
23053
+ this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey, () => this._credentialRecovered);
21779
23054
  this._clientSession = new ClientSessionManager(() => this._deps.credential, this._transport, this._deps.storage, this._deps.authorizationStateKey, this._deps.deviceController, this._attachManager, this._deps.webRTCApiProvider, this._dpopManager, this._networkMonitor?.networkChange$);
21780
23055
  this._publicSession = new ClientSessionWrapper(this._clientSession);
21781
- this._clientSession.onBeforeReconnect = async () => {
21782
- if (!this._credentialProvider) return;
21783
- try {
21784
- const fingerprint = this._dpopManager?.initialized ? this._dpopManager.fingerprint : void 0;
21785
- logger$1.debug("[SignalWire] Credential expired, refreshing before reconnect");
21786
- const newCredentials = await this._credentialProvider.authenticate(fingerprint ? { fingerprint } : void 0);
21787
- this._deps.credential = newCredentials;
21788
- if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
21789
- logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
21790
- } catch (error) {
21791
- logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
21792
- this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
21793
- throw error;
21794
- }
21795
- };
23056
+ this._clientSession.callControl = this._options.callControl ?? "routed";
23057
+ this._clientSession.onBeforeReconnect = async () => this.refreshCredentialForReconnect();
21796
23058
  this.subscribeTo(this._clientSession.errors$, (error) => {
21797
23059
  this._errors$.next(error);
21798
23060
  });
23061
+ this.subscribeTo(this._clientSession.authorization$, (authorization) => {
23062
+ this._refreshCoordinator?.syncExpiryFromAuthorization(authorization, this._credentialProvider);
23063
+ this.persistClientBoundMarker(Boolean(authorization?.cnf?.jkt));
23064
+ });
21799
23065
  await this._clientSession.connect();
21800
23066
  await this._refreshCoordinator?.activate(this._deps.user, this._clientSession);
21801
23067
  this.subscribeTo(this._clientSession.authenticated$.pipe((0, import_cjs$1.skip)(1), (0, import_cjs$1.filter)(Boolean)), async () => {
@@ -21958,6 +23224,13 @@ var SignalWire = class extends Destroyable {
21958
23224
  }
21959
23225
  try {
21960
23226
  this._visibilityController = new VisibilityController();
23227
+ this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, import_cjs$1.filter)((event) => event.to === "visible")), () => {
23228
+ try {
23229
+ this._refreshCoordinator?.forceRefreshIfDue();
23230
+ } catch (error) {
23231
+ logger$1.warn("[SignalWire] Resume credential revalidation failed (non-fatal):", error);
23232
+ }
23233
+ });
21961
23234
  this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, import_cjs$1.filter)((event) => event.to === "visible" && PreferencesContainer.instance.refreshDevicesOnVisible)), () => {
21962
23235
  logger$1.debug("[SignalWire] Page visible, re-enumerating devices");
21963
23236
  try {
@@ -21969,7 +23242,7 @@ var SignalWire = class extends Destroyable {
21969
23242
  logger$1.warn("[SignalWire] Failed to initialize VisibilityController:", error);
21970
23243
  }
21971
23244
  try {
21972
- this._diagnosticsCollector = new DiagnosticsCollector({ sdkVersion: "4.0.0-rc.2" });
23245
+ this._diagnosticsCollector = new DiagnosticsCollector({ sdkVersion: "4.0.0-rc.4" });
21973
23246
  } catch (error) {
21974
23247
  logger$1.warn("[SignalWire] Failed to initialize DiagnosticsCollector:", error);
21975
23248
  }
@@ -22040,21 +23313,22 @@ var SignalWire = class extends Destroyable {
22040
23313
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22041
23314
  throw error;
22042
23315
  }
22043
- logger$1.debug("[SignalWire] Failed to register user, trying reauthentication...");
22044
- try {
22045
- await this._clientSession.reauthenticate(this._deps.credential.token);
22046
- logger$1.debug("[SignalWire] Reauthentication successful, retrying register()");
23316
+ logger$1.debug("[SignalWire] Failed to register user, attempting credential recovery...");
23317
+ const outcome = await this.recoverAndRetry(async () => {
22047
23318
  await this._transport.execute(RPCExecute({
22048
23319
  method: "subscriber.online",
22049
23320
  params: {}
22050
23321
  }));
23322
+ });
23323
+ if (outcome.ok) {
23324
+ logger$1.debug("[SignalWire] Recovery restored registration");
22051
23325
  this._isRegistered$.next(true);
22052
- } catch (reauthError) {
22053
- logger$1.error("[SignalWire] Reauthentication failed during register():", reauthError);
22054
- const registerError = new InvalidCredentialsError("Failed to register user, and reauthentication attempt also failed. Please check your credentials.", { cause: reauthError instanceof Error ? reauthError : new Error(String(reauthError), { cause: reauthError }) });
22055
- this._errors$.next(registerError);
22056
- throw registerError;
23326
+ return;
22057
23327
  }
23328
+ const failureCause = outcome.error ?? error;
23329
+ 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 }) });
23330
+ this._errors$.next(registerError);
23331
+ throw registerError;
22058
23332
  }
22059
23333
  }
22060
23334
  /**
@@ -22085,6 +23359,11 @@ var SignalWire = class extends Destroyable {
22085
23359
  * Returns a {@link Call} in `'ringing'` state. Subscribe to {@link Call.status$}
22086
23360
  * to track progression through `'connected'` → `'disconnected'`.
22087
23361
  *
23362
+ * Local media acquisition is deliberately unbounded: an unanswered permission
23363
+ * prompt leaves this promise pending indefinitely, so apply your own bound if
23364
+ * your UI needs one. The 12 s signaling budget starts only once acquisition
23365
+ * settles.
23366
+ *
22088
23367
  * @param destination - Address URI string (e.g. `'/public/my-room'`) or {@link Address} instance.
22089
23368
  * @param options - Media and dial options (audio/video, device constraints). Overrides defaults.
22090
23369
  * @returns The created {@link Call} instance.
@@ -22107,7 +23386,18 @@ var SignalWire = class extends Destroyable {
22107
23386
  };
22108
23387
  await this.waitAuthentication();
22109
23388
  logger$1.debug("[SignalWire] Dialing with options:", computed_options);
22110
- return this._clientSession.createOutboundCall(destination, computed_options);
23389
+ try {
23390
+ return await this._clientSession.createOutboundCall(destination, computed_options);
23391
+ } catch (error) {
23392
+ if (!isRecoverableAuthError(error)) throw error;
23393
+ logger$1.debug("[SignalWire] Dial hit a recoverable auth error; recovering the session and retrying");
23394
+ const outcome = await this.recoverAndRetry(async () => {
23395
+ await this.waitAuthentication();
23396
+ return this._clientSession.createOutboundCall(destination, computed_options);
23397
+ });
23398
+ if (outcome.ok) return outcome.value;
23399
+ throw outcome.error ?? error;
23400
+ }
22111
23401
  }
22112
23402
  /**
22113
23403
  * Runs a multi-phase connectivity test against the given destination.
@@ -22388,10 +23678,13 @@ var SignalWire = class extends Destroyable {
22388
23678
  destroy() {
22389
23679
  this._refreshCoordinator?.destroy();
22390
23680
  this._refreshCoordinator = void 0;
23681
+ this._refreshHttp?.destroy();
23682
+ this._refreshHttp = void 0;
22391
23683
  this._dpopManager?.destroy();
22392
- this._clientSession.teardownSessionState();
22393
- this._transport.destroy();
22394
- this._clientSession.destroy();
23684
+ const session = this._clientSession;
23685
+ session?.teardownSessionState();
23686
+ this._transport?.destroy();
23687
+ session?.destroy();
22395
23688
  try {
22396
23689
  this._networkMonitor?.destroy();
22397
23690
  } catch {}
@@ -22504,7 +23797,7 @@ var StaticCredentialProvider = class {
22504
23797
  /**
22505
23798
  * Library version from package.json, injected at build time.
22506
23799
  */
22507
- const version = "4.0.0-rc.2";
23800
+ const version = "4.0.0-rc.4";
22508
23801
  /**
22509
23802
  * Flag indicating the library has been loaded and is ready to use.
22510
23803
  * For UMD builds: `window.SignalWire.ready`
@@ -22526,7 +23819,7 @@ const ready = true;
22526
23819
  */
22527
23820
  const emitReadyEvent = () => {
22528
23821
  if (typeof window !== "undefined") {
22529
- const event = new CustomEvent("signalwire:js:ready", { detail: { version: "4.0.0-rc.2" } });
23822
+ const event = new CustomEvent("signalwire:js:ready", { detail: { version: "4.0.0-rc.4" } });
22530
23823
  window.dispatchEvent(event);
22531
23824
  }
22532
23825
  };
@@ -22550,5 +23843,5 @@ emitReadyEvent();
22550
23843
  if (typeof process === "undefined") globalThis.process = { env: { NODE_ENV: "production" } };
22551
23844
 
22552
23845
  //#endregion
22553
- export { Address, CallCreateError, ClientPreferences, CollectionFetchError, DPoPInitError, DeviceTokenError, EmbedTokenCredentialProvider, InvalidCredentialsError, MediaAccessError, MediaTrackError, MessageParseError, OverconstrainedFallbackError, Participant, PreflightError, RecoveryError, SelfCapabilities, SelfParticipant, SignalWire, StaticCredentialProvider, TokenRefreshError, UnexpectedError, User, VertoPongError, WebRTCCall, embeddableCall, getLogger, isSelfParticipant, ready, setDebugOptions, setLogLevel, setLogger, version };
23846
+ export { Address, AuxiliaryLegCancelledError, AuxiliaryLegTimeoutError, CallCreateError, CallNotReadyError, ClientPreferences, CollectionFetchError, DPoPInitError, DeviceTokenError, EmbedTokenCredentialProvider, InvalidCredentialsError, MediaAccessError, MediaTrackError, MessageParseError, OverconstrainedFallbackError, Participant, ParticipantNotReadyError, PreflightError, RecoveryError, ScreenShareAlreadyActiveError, SelfCapabilities, SelfParticipant, SignalWire, StaticCredentialProvider, TokenRefreshError, UnexpectedError, User, VertoPongError, WebRTCCall, embeddableCall, getLogger, isSelfParticipant, ready, setDebugOptions, setLogLevel, setLogger, version };
22554
23847
  //# sourceMappingURL=browser.mjs.map