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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,4 +1,4 @@
1
- const require_operators = require('./operators-D6a2J1KA.cjs');
1
+ const require_operators = require('./operators-BJ6QW1VP.cjs');
2
2
  let jwt_decode = require("jwt-decode");
3
3
  let rxjs = require("rxjs");
4
4
  let uuid = require("uuid");
@@ -131,7 +131,7 @@ const asyncRetry = async ({ asyncCallable, maxRetries: retries = DEFAULT_MAX_RET
131
131
 
132
132
  //#endregion
133
133
  //#region src/controllers/HTTPRequestController.ts
134
- const logger$31 = require_operators.getLogger();
134
+ const logger$32 = require_operators.getLogger();
135
135
  const GET_PARAMS = {
136
136
  method: "GET",
137
137
  headers: { Accept: "application/json" }
@@ -195,7 +195,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
195
195
  this._responses$.next(response);
196
196
  return response;
197
197
  } catch (error) {
198
- logger$31.error("[HTTPRequestController] Request error:", error);
198
+ logger$32.error("[HTTPRequestController] Request error:", error);
199
199
  this._status$.next("error");
200
200
  const err = error instanceof Error ? error : new Error("HTTP request failed", { cause: error });
201
201
  this._errors$.next(err);
@@ -222,7 +222,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
222
222
  const url = this.buildURL(request.url);
223
223
  const headers = this.buildHeaders(request.headers);
224
224
  const timeout$4 = request.timeout ?? this.requestTimeout;
225
- logger$31.debug("[HTTPRequestController] Executing request:", {
225
+ logger$32.debug("[HTTPRequestController] Executing request:", {
226
226
  method: request.method,
227
227
  url,
228
228
  headers: Object.keys(headers).reduce((acc, key) => {
@@ -242,7 +242,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
242
242
  });
243
243
  clearTimeout(timeoutId);
244
244
  const httpResponse = await this.convertResponse(response);
245
- logger$31.debug("[HTTPRequestController] Response received:", {
245
+ logger$32.debug("[HTTPRequestController] Response received:", {
246
246
  status: response.status,
247
247
  statusText: response.statusText,
248
248
  headers: [...response.headers.entries()],
@@ -252,7 +252,7 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
252
252
  } catch (error) {
253
253
  clearTimeout(timeoutId);
254
254
  if (error instanceof Error && error.name === "AbortError") throw new require_operators.RequestTimeoutError(`Request timeout after ${timeout$4}ms`, { cause: error });
255
- logger$31.error("[HTTPRequestController] Request failed:", error);
255
+ logger$32.error("[HTTPRequestController] Request failed:", error);
256
256
  throw error;
257
257
  }
258
258
  }
@@ -266,8 +266,8 @@ var HTTPRequestController = class HTTPRequestController extends Destroyable {
266
266
  const credential = this.getCredential();
267
267
  if (credential.token) {
268
268
  headers.Authorization = `Bearer ${credential.token}`;
269
- logger$31.debug("[HTTPRequestController] Using Bearer token auth, token length:", credential.token.length);
270
- } else logger$31.warn("[HTTPRequestController] No credentials available for authentication");
269
+ logger$32.debug("[HTTPRequestController] Using Bearer token auth, token length:", credential.token.length);
270
+ } else logger$32.warn("[HTTPRequestController] No credentials available for authentication");
271
271
  return headers;
272
272
  }
273
273
  /**
@@ -404,137 +404,6 @@ var DeviceHistoryManager = class {
404
404
  }
405
405
  };
406
406
 
407
- //#endregion
408
- //#region src/core/constants.ts
409
- const INVITE_VERSION = 1e3;
410
- const DEFAULT_ICE_CANDIDATE_TIMEOUT_MS = 600;
411
- const DEFAULT_ICE_GATHERING_TIMEOUT_MS = 6e3;
412
- const DEFAULT_RECONNECT_CALLS_TIMEOUT_MS = 300 * 1e3;
413
- const DEFAULT_CONNECTION_TIMEOUT_MS = 1e4;
414
- const DEFAULT_RECONNECT_DELAY_MIN_MS = 100;
415
- const DEFAULT_RECONNECT_DELAY_MAX_MS = 3e3;
416
- const DEFAULT_DEVICE_DEBOUNCE_TIME_MS = 1500;
417
- const DEFAULT_DEVICE_POLLING_INTERVAL_MS = 0;
418
- const PREFERENCES_STORAGE_KEY = "sw:preferences";
419
- /** Scope value that enables automatic token refresh. */
420
- const SAT_REFRESH_SCOPE = "sat:refresh";
421
- /** API endpoints for device token operations. */
422
- const DEVICE_TOKEN_ENDPOINT = "/api/fabric/subscriber/devices/token";
423
- const DEVICE_REFRESH_ENDPOINT = "/api/fabric/subscriber/devices/refresh";
424
- /** Default device token TTL in seconds (15 minutes). */
425
- const DEVICE_TOKEN_DEFAULT_EXPIRE_IN = 900;
426
- /** Buffer time in milliseconds before expiry to trigger refresh. */
427
- const DEVICE_TOKEN_REFRESH_BUFFER_MS = 3e4;
428
- /** Maximum retry attempts for device token refresh on transient failure. */
429
- const DEVICE_TOKEN_REFRESH_MAX_RETRIES = 3;
430
- /** Base delay in milliseconds for exponential backoff on refresh retry. */
431
- const DEVICE_TOKEN_REFRESH_RETRY_BASE_MS = 1e3;
432
- /** Maximum retry attempts for developer credential refresh on transient failure. */
433
- const CREDENTIAL_REFRESH_MAX_RETRIES = 5;
434
- /** Base delay in milliseconds for exponential backoff on credential refresh retry. */
435
- const CREDENTIAL_REFRESH_RETRY_BASE_MS = 1e3;
436
- /** Maximum delay in milliseconds for credential refresh backoff. */
437
- const CREDENTIAL_REFRESH_MAX_DELAY_MS = 3e4;
438
- /** Buffer in milliseconds before token expiry to trigger refresh. */
439
- const CREDENTIAL_REFRESH_BUFFER_MS = 5e3;
440
- /**
441
- * Maximum time the coordinator will wait for `DeviceTokenManager.activate()`
442
- * to resolve before treating the activation as failed and falling back to
443
- * the developer-provided refresh path. Prevents a wedged HTTP layer from
444
- * leaving the session with no active refresh mechanism.
445
- */
446
- const CREDENTIAL_ACTIVATE_TIMEOUT_MS = 3e4;
447
- /** JSON-RPC error code for requester validation failure (corrupted auth state). */
448
- const RPC_ERROR_REQUESTER_VALIDATION_FAILED = -32003;
449
- /** JSON-RPC error code for invalid params (e.g., missing authentication block). */
450
- const RPC_ERROR_INVALID_PARAMS = -32602;
451
- /** JSON-RPC error code for authentication failure (invalid token, missing DPoP, etc.). */
452
- const RPC_ERROR_AUTHENTICATION_FAILED = -32002;
453
- /** Default polling interval for RTCPeerConnection.getStats() in milliseconds. */
454
- const DEFAULT_STATS_POLLING_INTERVAL_MS = 1e3;
455
- /** Number of initial samples used to build a baseline for spike detection. */
456
- const DEFAULT_STATS_BASELINE_SAMPLES = 10;
457
- /** Duration in ms with no inbound audio packets before emitting a critical issue. */
458
- const DEFAULT_STATS_NO_PACKET_THRESHOLD_MS = 2e3;
459
- /** Multiplier applied to baseline RTT to detect a warning-level RTT spike. */
460
- const DEFAULT_STATS_RTT_SPIKE_MULTIPLIER = 3;
461
- /** Packet loss fraction (0-1) above which a warning is emitted. */
462
- const DEFAULT_STATS_PACKET_LOSS_THRESHOLD = .05;
463
- /** Multiplier applied to baseline jitter to detect a jitter spike. */
464
- const DEFAULT_STATS_JITTER_SPIKE_MULTIPLIER = 4;
465
- /** Number of seconds of metrics history to retain. */
466
- const DEFAULT_STATS_HISTORY_SIZE = 30;
467
- /** Maximum keyframe requests allowed within a single burst window. */
468
- const DEFAULT_KEYFRAME_MAX_BURST$1 = 3;
469
- /** Duration of the keyframe burst window in milliseconds. */
470
- const DEFAULT_KEYFRAME_BURST_WINDOW_MS$1 = 3e3;
471
- /** Cooldown period in ms after burst limit is reached before allowing more keyframes. */
472
- const DEFAULT_KEYFRAME_COOLDOWN_MS$1 = 1e4;
473
- /** Minimum time between re-INVITE attempts in milliseconds. */
474
- const DEFAULT_REINVITE_DEBOUNCE_TIME_MS = 1e4;
475
- /** Maximum number of re-INVITE attempts per call. */
476
- const DEFAULT_REINVITE_MAX_ATTEMPTS = 3;
477
- /** Timeout for a single re-INVITE attempt in milliseconds. */
478
- const DEFAULT_REINVITE_TIMEOUT_MS = 5e3;
479
- /** Debounce window in ms to collapse multiple detection signals into one trigger. */
480
- const DEFAULT_RECOVERY_DEBOUNCE_TIME_MS = 2e3;
481
- /** Cooldown period in ms between recovery attempts. */
482
- const DEFAULT_RECOVERY_COOLDOWN_MS = 1e4;
483
- /** Grace period in ms before treating ICE 'disconnected' as a failure. */
484
- const DEFAULT_ICE_DISCONNECTED_GRACE_PERIOD_MS = 3e3;
485
- /** Timeout for a single ICE restart attempt in milliseconds. */
486
- const DEFAULT_ICE_RESTART_TIMEOUT_MS$1 = 5e3;
487
- /** Maximum recovery attempts before emitting 'max_attempts_reached'. */
488
- const DEFAULT_MAX_RECOVERY_ATTEMPTS = 3;
489
- /** Upper bound in ms for waiting on iceGatheringState === 'complete' after an ICE restart. */
490
- const ICE_GATHERING_COMPLETE_TIMEOUT_MS = 1e4;
491
- /** Upper bound in ms for waiting on RTCPeerConnection.connectionState === 'connected' after a recovery ICE restart. */
492
- const PEER_CONNECTION_RECOVERY_WAIT_MS = 5e3;
493
- /** Polling interval in ms while waiting for RTCPeerConnection.connectionState to transition. */
494
- const PEER_CONNECTION_RECOVERY_POLL_MS = 100;
495
- /** Polling interval for LocalAudioPipeline.level$ (ms). ~30fps is smooth for meters. */
496
- const AUDIO_LEVEL_POLL_INTERVAL_MS = 33;
497
- /** RMS level threshold (0..1) above which the local participant is considered speaking. */
498
- const VAD_THRESHOLD = .03;
499
- /** Hold window in ms below the threshold before speaking$ flips back to false. */
500
- const VAD_HOLD_MS = 250;
501
- /** Whether to persist device selections to storage by default. */
502
- const DEFAULT_PERSIST_DEVICE_SELECTION = true;
503
- /** Whether to auto-apply device changes to active calls by default. */
504
- const DEFAULT_SYNC_DEVICES_TO_ACTIVE_CALLS = true;
505
- /** Storage keys for persisted device selections. */
506
- const DEVICE_STORAGE_KEY_AUDIO_INPUT = "sw:device:audioinput";
507
- const DEVICE_STORAGE_KEY_AUDIO_OUTPUT = "sw:device:audiooutput";
508
- const DEVICE_STORAGE_KEY_VIDEO_INPUT = "sw:device:videoinput";
509
- /** Whether to auto-mute video when the tab becomes hidden. */
510
- const DEFAULT_AUTO_MUTE_VIDEO_ON_HIDDEN = false;
511
- /** Whether to re-enumerate devices when the page becomes visible. */
512
- const DEFAULT_REFRESH_DEVICES_ON_VISIBLE = true;
513
- /** Whether to check peer connection health when the page becomes visible. */
514
- const DEFAULT_CHECK_CONNECTION_ON_VISIBLE = true;
515
- /** Whether automatic video degradation on low bandwidth is enabled. */
516
- const DEFAULT_ENABLE_AUTO_DEGRADATION = true;
517
- /** Bitrate in kbps below which video is automatically disabled. */
518
- const DEFAULT_DEGRADATION_BITRATE_THRESHOLD_KBPS = 150;
519
- /** Bitrate in kbps above which video is automatically re-enabled (hysteresis). */
520
- const DEFAULT_DEGRADATION_RECOVERY_THRESHOLD_KBPS = 300;
521
- /** Whether relay-only escalation is enabled as a last-resort recovery tier. */
522
- const DEFAULT_ENABLE_RELAY_FALLBACK = true;
523
- /** Whether to listen for browser online/offline/connection events. */
524
- const DEFAULT_ENABLE_NETWORK_CHANGE_DETECTION = true;
525
- /** Whether to intercept server-sent media-timeout hangups and attempt recovery. */
526
- const DEFAULT_ENABLE_SERVER_HANGUP_INTERCEPTION = true;
527
- /** Default video track constraints applied when video is enabled without explicit constraints. */
528
- const DEFAULT_VIDEO_CONSTRAINTS = {
529
- width: { ideal: 1280 },
530
- height: { ideal: 720 },
531
- aspectRatio: 16 / 9
532
- };
533
- /** Whether stereo Opus is enabled by default. */
534
- const DEFAULT_STEREO_AUDIO = false;
535
- /** Max average bitrate for stereo Opus in bits per second. */
536
- const DEFAULT_STEREO_MAX_AVERAGE_BITRATE = 51e4;
537
-
538
407
  //#endregion
539
408
  //#region src/utils/time.ts
540
409
  function fromSecToMs(seconds) {
@@ -546,23 +415,23 @@ function fromMsToSec(milliseconds) {
546
415
 
547
416
  //#endregion
548
417
  //#region src/containers/PreferencesContainer.ts
549
- const logger$30 = require_operators.getLogger();
418
+ const logger$31 = require_operators.getLogger();
550
419
  var PreferencesContainer = class PreferencesContainer {
551
420
  static get instance() {
552
421
  this._instance ??= new PreferencesContainer();
553
422
  return this._instance;
554
423
  }
555
424
  constructor() {
556
- this.deviceDebounceTime = DEFAULT_DEVICE_DEBOUNCE_TIME_MS;
557
- this.devicePollingInterval = DEFAULT_DEVICE_POLLING_INTERVAL_MS;
558
- this.reconnectCallsTimeout = DEFAULT_RECONNECT_CALLS_TIMEOUT_MS;
559
- this.connectionTimeout = DEFAULT_CONNECTION_TIMEOUT_MS;
560
- this.reconnectDelayMin = DEFAULT_RECONNECT_DELAY_MIN_MS;
561
- this.reconnectDelayMax = DEFAULT_RECONNECT_DELAY_MAX_MS;
425
+ this.deviceDebounceTime = require_operators.DEFAULT_DEVICE_DEBOUNCE_TIME_MS;
426
+ this.devicePollingInterval = require_operators.DEFAULT_DEVICE_POLLING_INTERVAL_MS;
427
+ this.reconnectCallsTimeout = require_operators.DEFAULT_RECONNECT_CALLS_TIMEOUT_MS;
428
+ this.connectionTimeout = require_operators.DEFAULT_CONNECTION_TIMEOUT_MS;
429
+ this.reconnectDelayMin = require_operators.DEFAULT_RECONNECT_DELAY_MIN_MS;
430
+ this.reconnectDelayMax = require_operators.DEFAULT_RECONNECT_DELAY_MAX_MS;
562
431
  this.disableUdpIceServers = false;
563
432
  this.relayOnly = false;
564
- this.iceCandidateTimeout = DEFAULT_ICE_CANDIDATE_TIMEOUT_MS;
565
- this.iceGatheringTimeout = DEFAULT_ICE_GATHERING_TIMEOUT_MS;
433
+ this.iceCandidateTimeout = require_operators.DEFAULT_ICE_CANDIDATE_TIMEOUT_MS;
434
+ this.iceGatheringTimeout = require_operators.DEFAULT_ICE_GATHERING_TIMEOUT_MS;
566
435
  this.defaultSignalWireOptions = {
567
436
  skipConnection: false,
568
437
  skipRegister: false,
@@ -589,38 +458,38 @@ var PreferencesContainer = class PreferencesContainer {
589
458
  "call.joined"
590
459
  ];
591
460
  this.userVariables = {};
592
- this.statsPollingInterval = DEFAULT_STATS_POLLING_INTERVAL_MS;
593
- this.statsBaselineSamples = DEFAULT_STATS_BASELINE_SAMPLES;
594
- this.statsNoPacketThreshold = DEFAULT_STATS_NO_PACKET_THRESHOLD_MS;
595
- this.statsRttSpikeMultiplier = DEFAULT_STATS_RTT_SPIKE_MULTIPLIER;
596
- this.statsPacketLossThreshold = DEFAULT_STATS_PACKET_LOSS_THRESHOLD;
597
- this.statsJitterSpikeMultiplier = DEFAULT_STATS_JITTER_SPIKE_MULTIPLIER;
598
- this.statsHistorySize = DEFAULT_STATS_HISTORY_SIZE;
599
- this.keyframeMaxBurst = DEFAULT_KEYFRAME_MAX_BURST$1;
600
- this.keyframeBurstWindow = DEFAULT_KEYFRAME_BURST_WINDOW_MS$1;
601
- this.keyframeCooldown = DEFAULT_KEYFRAME_COOLDOWN_MS$1;
602
- this.reinviteDebounceTime = DEFAULT_REINVITE_DEBOUNCE_TIME_MS;
603
- this.reinviteMaxAttempts = DEFAULT_REINVITE_MAX_ATTEMPTS;
604
- this.reinviteTimeout = DEFAULT_REINVITE_TIMEOUT_MS;
605
- this.recoveryDebounceTime = DEFAULT_RECOVERY_DEBOUNCE_TIME_MS;
606
- this.recoveryCooldown = DEFAULT_RECOVERY_COOLDOWN_MS;
607
- this.iceDisconnectedGracePeriod = DEFAULT_ICE_DISCONNECTED_GRACE_PERIOD_MS;
608
- this.iceRestartTimeout = DEFAULT_ICE_RESTART_TIMEOUT_MS$1;
609
- this.maxRecoveryAttempts = DEFAULT_MAX_RECOVERY_ATTEMPTS;
610
- this.enableRelayFallback = DEFAULT_ENABLE_RELAY_FALLBACK;
611
- this.enableNetworkChangeDetection = DEFAULT_ENABLE_NETWORK_CHANGE_DETECTION;
612
- this.enableServerHangupInterception = DEFAULT_ENABLE_SERVER_HANGUP_INTERCEPTION;
613
- this.persistDeviceSelection = DEFAULT_PERSIST_DEVICE_SELECTION;
614
- this.syncDevicesToActiveCalls = DEFAULT_SYNC_DEVICES_TO_ACTIVE_CALLS;
615
- this.autoMuteVideoOnHidden = DEFAULT_AUTO_MUTE_VIDEO_ON_HIDDEN;
616
- this.refreshDevicesOnVisible = DEFAULT_REFRESH_DEVICES_ON_VISIBLE;
617
- this.checkConnectionOnVisible = DEFAULT_CHECK_CONNECTION_ON_VISIBLE;
461
+ this.statsPollingInterval = require_operators.DEFAULT_STATS_POLLING_INTERVAL_MS;
462
+ this.statsBaselineSamples = require_operators.DEFAULT_STATS_BASELINE_SAMPLES;
463
+ this.statsNoPacketThreshold = require_operators.DEFAULT_STATS_NO_PACKET_THRESHOLD_MS;
464
+ this.statsRttSpikeMultiplier = require_operators.DEFAULT_STATS_RTT_SPIKE_MULTIPLIER;
465
+ this.statsPacketLossThreshold = require_operators.DEFAULT_STATS_PACKET_LOSS_THRESHOLD;
466
+ this.statsJitterSpikeMultiplier = require_operators.DEFAULT_STATS_JITTER_SPIKE_MULTIPLIER;
467
+ this.statsHistorySize = require_operators.DEFAULT_STATS_HISTORY_SIZE;
468
+ this.keyframeMaxBurst = require_operators.DEFAULT_KEYFRAME_MAX_BURST;
469
+ this.keyframeBurstWindow = require_operators.DEFAULT_KEYFRAME_BURST_WINDOW_MS;
470
+ this.keyframeCooldown = require_operators.DEFAULT_KEYFRAME_COOLDOWN_MS;
471
+ this.reinviteDebounceTime = require_operators.DEFAULT_REINVITE_DEBOUNCE_TIME_MS;
472
+ this.reinviteMaxAttempts = require_operators.DEFAULT_REINVITE_MAX_ATTEMPTS;
473
+ this.reinviteTimeout = require_operators.DEFAULT_REINVITE_TIMEOUT_MS;
474
+ this.recoveryDebounceTime = require_operators.DEFAULT_RECOVERY_DEBOUNCE_TIME_MS;
475
+ this.recoveryCooldown = require_operators.DEFAULT_RECOVERY_COOLDOWN_MS;
476
+ this.iceDisconnectedGracePeriod = require_operators.DEFAULT_ICE_DISCONNECTED_GRACE_PERIOD_MS;
477
+ this.iceRestartTimeout = require_operators.DEFAULT_ICE_RESTART_TIMEOUT_MS;
478
+ this.maxRecoveryAttempts = require_operators.DEFAULT_MAX_RECOVERY_ATTEMPTS;
479
+ this.enableRelayFallback = require_operators.DEFAULT_ENABLE_RELAY_FALLBACK;
480
+ this.enableNetworkChangeDetection = require_operators.DEFAULT_ENABLE_NETWORK_CHANGE_DETECTION;
481
+ this.enableServerHangupInterception = require_operators.DEFAULT_ENABLE_SERVER_HANGUP_INTERCEPTION;
482
+ this.persistDeviceSelection = require_operators.DEFAULT_PERSIST_DEVICE_SELECTION;
483
+ this.syncDevicesToActiveCalls = require_operators.DEFAULT_SYNC_DEVICES_TO_ACTIVE_CALLS;
484
+ this.autoMuteVideoOnHidden = require_operators.DEFAULT_AUTO_MUTE_VIDEO_ON_HIDDEN;
485
+ this.refreshDevicesOnVisible = require_operators.DEFAULT_REFRESH_DEVICES_ON_VISIBLE;
486
+ this.checkConnectionOnVisible = require_operators.DEFAULT_CHECK_CONNECTION_ON_VISIBLE;
618
487
  this.defaultAudioConstraints = void 0;
619
488
  this.defaultVideoConstraints = void 0;
620
- this.stereoAudio = DEFAULT_STEREO_AUDIO;
621
- this.enableAutoDegradation = DEFAULT_ENABLE_AUTO_DEGRADATION;
622
- this.degradationBitrateThreshold = DEFAULT_DEGRADATION_BITRATE_THRESHOLD_KBPS;
623
- this.degradationRecoveryThreshold = DEFAULT_DEGRADATION_RECOVERY_THRESHOLD_KBPS;
489
+ this.stereoAudio = require_operators.DEFAULT_STEREO_AUDIO;
490
+ this.enableAutoDegradation = require_operators.DEFAULT_ENABLE_AUTO_DEGRADATION;
491
+ this.degradationBitrateThreshold = require_operators.DEFAULT_DEGRADATION_BITRATE_THRESHOLD_KBPS;
492
+ this.degradationRecoveryThreshold = require_operators.DEFAULT_DEGRADATION_RECOVERY_THRESHOLD_KBPS;
624
493
  this.preferredVideoCodecs = [];
625
494
  this.preferredAudioCodecs = [];
626
495
  }
@@ -1207,17 +1076,17 @@ var ClientPreferences = class {
1207
1076
  _saveToStorage() {
1208
1077
  if (!this._storage) return;
1209
1078
  const data = collectStoredPreferences();
1210
- this._storage.setItem(PREFERENCES_STORAGE_KEY, data, "local").catch((error) => {
1211
- logger$30.error(`[ClientPreferences] Failed to save preferences: ${String(error)}`);
1079
+ this._storage.setItem(require_operators.PREFERENCES_STORAGE_KEY, data, "local").catch((error) => {
1080
+ logger$31.error(`[ClientPreferences] Failed to save preferences: ${String(error)}`);
1212
1081
  });
1213
1082
  }
1214
1083
  /** Loads preferences from storage and applies them to the container. */
1215
1084
  _loadFromStorage() {
1216
1085
  if (!this._storage) return;
1217
- this._storage.getItem(PREFERENCES_STORAGE_KEY, "local").then((stored) => {
1086
+ this._storage.getItem(require_operators.PREFERENCES_STORAGE_KEY, "local").then((stored) => {
1218
1087
  if (stored) applyStoredPreferences(stored);
1219
1088
  }).catch((error) => {
1220
- logger$30.error(`[ClientPreferences] Failed to load preferences: ${String(error)}`);
1089
+ logger$31.error(`[ClientPreferences] Failed to load preferences: ${String(error)}`);
1221
1090
  });
1222
1091
  }
1223
1092
  };
@@ -1238,12 +1107,12 @@ function toError(value) {
1238
1107
 
1239
1108
  //#endregion
1240
1109
  //#region src/controllers/NavigatorDeviceController.ts
1241
- const logger$29 = require_operators.getLogger();
1110
+ const logger$30 = require_operators.getLogger();
1242
1111
  /** Maps a device kind to its storage key. */
1243
1112
  const DEVICE_STORAGE_KEYS = {
1244
- audioinput: DEVICE_STORAGE_KEY_AUDIO_INPUT,
1245
- audiooutput: DEVICE_STORAGE_KEY_AUDIO_OUTPUT,
1246
- videoinput: DEVICE_STORAGE_KEY_VIDEO_INPUT
1113
+ audioinput: require_operators.DEVICE_STORAGE_KEY_AUDIO_INPUT,
1114
+ audiooutput: require_operators.DEVICE_STORAGE_KEY_AUDIO_OUTPUT,
1115
+ videoinput: require_operators.DEVICE_STORAGE_KEY_VIDEO_INPUT
1247
1116
  };
1248
1117
  const initialDevicesState = {
1249
1118
  audioinput: [],
@@ -1260,7 +1129,7 @@ var NavigatorDeviceController = class extends Destroyable {
1260
1129
  super();
1261
1130
  this.webRTCApiProvider = webRTCApiProvider;
1262
1131
  this.deviceChangeHandler = () => {
1263
- logger$29.debug("[DeviceController] Device change detected");
1132
+ logger$30.debug("[DeviceController] Device change detected");
1264
1133
  this.enumerateDevices();
1265
1134
  };
1266
1135
  this._devicesState$ = this.createBehaviorSubject(initialDevicesState);
@@ -1325,13 +1194,13 @@ var NavigatorDeviceController = class extends Destroyable {
1325
1194
  return this.cachedObservable("videoInputDevices$", () => this._devicesState$.pipe((0, rxjs.map)((state) => state.videoinput), (0, rxjs.distinctUntilChanged)(), (0, rxjs.takeUntil)(this.destroyed$)));
1326
1195
  }
1327
1196
  get selectedAudioInputDevice$() {
1328
- return this.cachedObservable("selectedAudioInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, rxjs.map)((state) => state.audioinput), (0, rxjs.distinctUntilChanged)(), (0, rxjs.takeUntil)(this.destroyed$), (0, rxjs.tap)((info) => logger$29.debug("[DeviceController] Selected audio input device changed:", info))));
1197
+ return this.cachedObservable("selectedAudioInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, rxjs.map)((state) => state.audioinput), (0, rxjs.distinctUntilChanged)(), (0, rxjs.takeUntil)(this.destroyed$), (0, rxjs.tap)((info) => logger$30.debug("[DeviceController] Selected audio input device changed:", info))));
1329
1198
  }
1330
1199
  get selectedAudioOutputDevice$() {
1331
- return this.cachedObservable("selectedAudioOutputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, rxjs.map)((state) => state.audiooutput), (0, rxjs.distinctUntilChanged)(), (0, rxjs.takeUntil)(this.destroyed$), (0, rxjs.tap)((info) => logger$29.debug("[DeviceController] Selected audio output device changed:", info))));
1200
+ return this.cachedObservable("selectedAudioOutputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, rxjs.map)((state) => state.audiooutput), (0, rxjs.distinctUntilChanged)(), (0, rxjs.takeUntil)(this.destroyed$), (0, rxjs.tap)((info) => logger$30.debug("[DeviceController] Selected audio output device changed:", info))));
1332
1201
  }
1333
1202
  get selectedVideoInputDevice$() {
1334
- return this.cachedObservable("selectedVideoInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, rxjs.map)((state) => state.videoinput), (0, rxjs.distinctUntilChanged)(), (0, rxjs.takeUntil)(this.destroyed$), (0, rxjs.tap)((info) => logger$29.debug("[DeviceController] Selected video input device changed:", info))));
1203
+ return this.cachedObservable("selectedVideoInputDevice$", () => this._selectedDevicesState$.asObservable().pipe((0, rxjs.map)((state) => state.videoinput), (0, rxjs.distinctUntilChanged)(), (0, rxjs.takeUntil)(this.destroyed$), (0, rxjs.tap)((info) => logger$30.debug("[DeviceController] Selected video input device changed:", info))));
1335
1204
  }
1336
1205
  get selectedAudioInputDevice() {
1337
1206
  if (this._audioInputDisabled$.value) return null;
@@ -1406,7 +1275,7 @@ var NavigatorDeviceController = class extends Destroyable {
1406
1275
  if (device) this.persistDeviceSelection("audioinput", device);
1407
1276
  }
1408
1277
  selectVideoInputDevice(device) {
1409
- logger$29.debug("[DeviceController] Setting selected video input device:", device);
1278
+ logger$30.debug("[DeviceController] Setting selected video input device:", device);
1410
1279
  if (this._videoInputDisabled$.value && device) this._videoInputDisabled$.next(false);
1411
1280
  const previous = this._selectedDevicesState$.value.videoinput;
1412
1281
  if (previous && previous.deviceId !== device?.deviceId) this._deviceHistory.push("videoinput", previous);
@@ -1463,7 +1332,7 @@ var NavigatorDeviceController = class extends Destroyable {
1463
1332
  }
1464
1333
  const fromHistory = this._deviceHistory.findInHistory(kind, devices);
1465
1334
  if (fromHistory) {
1466
- logger$29.debug(`[DeviceController] Device disappeared, falling back to history: ${fromHistory.label}`);
1335
+ logger$30.debug(`[DeviceController] Device disappeared, falling back to history: ${fromHistory.label}`);
1467
1336
  this.emitDeviceRecovered(kind, selected, fromHistory, "device_disconnected");
1468
1337
  return fromHistory;
1469
1338
  }
@@ -1516,7 +1385,7 @@ var NavigatorDeviceController = class extends Destroyable {
1516
1385
  try {
1517
1386
  await this._storageManager.setItem(DEVICE_STORAGE_KEYS[kind], stored, "local");
1518
1387
  } catch (error) {
1519
- logger$29.error(`[DeviceController] Failed to persist device selection for ${kind}:`, error);
1388
+ logger$30.error(`[DeviceController] Failed to persist device selection for ${kind}:`, error);
1520
1389
  }
1521
1390
  }
1522
1391
  async loadPersistedDevices() {
@@ -1532,7 +1401,7 @@ var NavigatorDeviceController = class extends Destroyable {
1532
1401
  [kind]: stored
1533
1402
  };
1534
1403
  } catch (error) {
1535
- logger$29.error(`[DeviceController] Failed to load persisted device for ${kind}:`, error);
1404
+ logger$30.error(`[DeviceController] Failed to load persisted device for ${kind}:`, error);
1536
1405
  }
1537
1406
  }
1538
1407
  /** Clears device history, persisted selections, and re-enumerates devices. */
@@ -1550,7 +1419,7 @@ var NavigatorDeviceController = class extends Destroyable {
1550
1419
  this.disableDeviceMonitoring();
1551
1420
  this.webRTCApiProvider.mediaDevices.addEventListener("devicechange", this.deviceChangeHandler);
1552
1421
  if (PreferencesContainer.instance.devicePollingInterval > 0) this._devicesPoolingSubscription = (0, rxjs.interval)(PreferencesContainer.instance.devicePollingInterval).subscribe(() => {
1553
- logger$29.debug("[DeviceController] Polling devices due to interval");
1422
+ logger$30.debug("[DeviceController] Polling devices due to interval");
1554
1423
  this.enumerateDevices();
1555
1424
  });
1556
1425
  this.enumerateDevices();
@@ -1576,13 +1445,13 @@ var NavigatorDeviceController = class extends Destroyable {
1576
1445
  videoinput: []
1577
1446
  });
1578
1447
  this._devicesState$.next(devicesByKind);
1579
- logger$29.debug("[DeviceController] Devices enumerated:", {
1448
+ logger$30.debug("[DeviceController] Devices enumerated:", {
1580
1449
  audioInputs: devicesByKind.audioinput.length,
1581
1450
  audioOutputs: devicesByKind.audiooutput.length,
1582
1451
  videoInputs: devicesByKind.videoinput.length
1583
1452
  });
1584
1453
  } catch (error) {
1585
- logger$29.error("[DeviceController] Failed to enumerate devices:", error);
1454
+ logger$30.error("[DeviceController] Failed to enumerate devices:", error);
1586
1455
  this._errors$.next(toError(error));
1587
1456
  }
1588
1457
  }
@@ -1598,7 +1467,7 @@ var NavigatorDeviceController = class extends Destroyable {
1598
1467
  stream.getTracks().forEach((t) => t.stop());
1599
1468
  return capabilities;
1600
1469
  } catch (error) {
1601
- logger$29.error("[DeviceController] Failed to get device capabilities:", error);
1470
+ logger$30.error("[DeviceController] Failed to get device capabilities:", error);
1602
1471
  this._errors$.next(toError(error));
1603
1472
  throw error;
1604
1473
  }
@@ -1849,7 +1718,7 @@ var DependencyContainer = class {
1849
1718
 
1850
1719
  //#endregion
1851
1720
  //#region src/controllers/CryptoController.ts
1852
- const logger$28 = require_operators.getLogger();
1721
+ const logger$29 = require_operators.getLogger();
1853
1722
  const DPOP_DB_NAME = "sw-dpop";
1854
1723
  const DPOP_DB_VERSION = 1;
1855
1724
  const DPOP_STORE_NAME = "keys";
@@ -1908,7 +1777,7 @@ async function loadKeyPairFromDB() {
1908
1777
  tx.oncomplete = () => db.close();
1909
1778
  });
1910
1779
  } catch (error) {
1911
- logger$28.warn("[DPoP] Failed to load key pair from IndexedDB:", error);
1780
+ logger$29.warn("[DPoP] Failed to load key pair from IndexedDB:", error);
1912
1781
  return null;
1913
1782
  }
1914
1783
  }
@@ -1928,7 +1797,7 @@ async function saveKeyPairToDB(keyPair) {
1928
1797
  };
1929
1798
  });
1930
1799
  } catch (error) {
1931
- logger$28.warn("[DPoP] Failed to save key pair to IndexedDB:", error);
1800
+ logger$29.warn("[DPoP] Failed to save key pair to IndexedDB:", error);
1932
1801
  }
1933
1802
  }
1934
1803
  async function deleteKeyPairFromDB() {
@@ -1947,7 +1816,7 @@ async function deleteKeyPairFromDB() {
1947
1816
  };
1948
1817
  });
1949
1818
  } catch (error) {
1950
- logger$28.warn("[DPoP] Failed to delete key pair from IndexedDB:", error);
1819
+ logger$29.warn("[DPoP] Failed to delete key pair from IndexedDB:", error);
1951
1820
  }
1952
1821
  }
1953
1822
  /**
@@ -2007,13 +1876,13 @@ var CryptoController = class {
2007
1876
  this._publicJwk = await crypto.subtle.exportKey("jwk", stored.publicKey);
2008
1877
  this._fingerprint = await computeJwkThumbprint(this._publicJwk);
2009
1878
  this._initialized = true;
2010
- logger$28.debug("[DPoP] Key pair restored from IndexedDB, fingerprint:", this._fingerprint);
1879
+ logger$29.debug("[DPoP] Key pair restored from IndexedDB, fingerprint:", this._fingerprint);
2011
1880
  return this._fingerprint;
2012
1881
  } catch (error) {
2013
- logger$28.warn("[DPoP] Stored key pair unusable, generating new one:", error);
1882
+ logger$29.warn("[DPoP] Stored key pair unusable, generating new one:", error);
2014
1883
  await deleteKeyPairFromDB();
2015
1884
  }
2016
- logger$28.debug("[DPoP] Generating RSA key pair");
1885
+ logger$29.debug("[DPoP] Generating RSA key pair");
2017
1886
  this._keyPair = await crypto.subtle.generateKey({
2018
1887
  name: "RSASSA-PKCS1-v1_5",
2019
1888
  modulusLength: 2048,
@@ -2028,7 +1897,7 @@ var CryptoController = class {
2028
1897
  this._fingerprint = await computeJwkThumbprint(this._publicJwk);
2029
1898
  this._initialized = true;
2030
1899
  await saveKeyPairToDB(this._keyPair);
2031
- logger$28.debug("[DPoP] Key pair generated and persisted, fingerprint:", this._fingerprint);
1900
+ logger$29.debug("[DPoP] Key pair generated and persisted, fingerprint:", this._fingerprint);
2032
1901
  return this._fingerprint;
2033
1902
  }
2034
1903
  /**
@@ -2094,7 +1963,7 @@ var CryptoController = class {
2094
1963
  this._fingerprint = null;
2095
1964
  this._initialized = false;
2096
1965
  deleteKeyPairFromDB();
2097
- logger$28.debug("[DPoP] Controller destroyed");
1966
+ logger$29.debug("[DPoP] Controller destroyed");
2098
1967
  }
2099
1968
  get publicJwk() {
2100
1969
  if (!this._publicJwk) throw new require_operators.DPoPInitError("CryptoController not initialized. Call init() first.");
@@ -2117,7 +1986,7 @@ var CryptoController = class {
2117
1986
 
2118
1987
  //#endregion
2119
1988
  //#region src/controllers/NetworkMonitor.ts
2120
- const logger$27 = require_operators.getLogger();
1989
+ const logger$28 = require_operators.getLogger();
2121
1990
  /**
2122
1991
  * Safely check whether we are running in a browser environment
2123
1992
  * with `window` and the relevant event targets.
@@ -2174,7 +2043,7 @@ var NetworkMonitor = class extends Destroyable {
2174
2043
  }
2175
2044
  attachListeners() {
2176
2045
  if (!hasBrowserNetworkEvents()) {
2177
- logger$27.debug("NetworkMonitor: no browser environment detected, skipping event listeners");
2046
+ logger$28.debug("NetworkMonitor: no browser environment detected, skipping event listeners");
2178
2047
  return;
2179
2048
  }
2180
2049
  window.addEventListener("online", this._onOnline);
@@ -2182,7 +2051,7 @@ var NetworkMonitor = class extends Destroyable {
2182
2051
  const connection = getNetworkConnection();
2183
2052
  if (connection) connection.addEventListener("change", this._onConnectionChange);
2184
2053
  this._listenersAttached = true;
2185
- logger$27.debug("NetworkMonitor: event listeners attached");
2054
+ logger$28.debug("NetworkMonitor: event listeners attached");
2186
2055
  }
2187
2056
  removeListeners() {
2188
2057
  if (!this._listenersAttached) return;
@@ -2193,10 +2062,10 @@ var NetworkMonitor = class extends Destroyable {
2193
2062
  if (connection) connection.removeEventListener("change", this._onConnectionChange);
2194
2063
  }
2195
2064
  this._listenersAttached = false;
2196
- logger$27.debug("NetworkMonitor: event listeners removed");
2065
+ logger$28.debug("NetworkMonitor: event listeners removed");
2197
2066
  }
2198
2067
  handleOnline() {
2199
- logger$27.info("NetworkMonitor: browser went online");
2068
+ logger$28.info("NetworkMonitor: browser went online");
2200
2069
  this._isOnline$.next(true);
2201
2070
  this._networkChange$.next({
2202
2071
  type: "online",
@@ -2205,7 +2074,7 @@ var NetworkMonitor = class extends Destroyable {
2205
2074
  });
2206
2075
  }
2207
2076
  handleOffline() {
2208
- logger$27.info("NetworkMonitor: browser went offline");
2077
+ logger$28.info("NetworkMonitor: browser went offline");
2209
2078
  this._isOnline$.next(false);
2210
2079
  this._networkChange$.next({
2211
2080
  type: "offline",
@@ -2214,7 +2083,7 @@ var NetworkMonitor = class extends Destroyable {
2214
2083
  }
2215
2084
  handleConnectionChange() {
2216
2085
  const networkType = getNetworkType();
2217
- logger$27.info(`NetworkMonitor: connection changed — effectiveType=${networkType ?? "unknown"}`);
2086
+ logger$28.info(`NetworkMonitor: connection changed — effectiveType=${networkType ?? "unknown"}`);
2218
2087
  this._networkChange$.next({
2219
2088
  type: "connection_change",
2220
2089
  timestamp: Date.now(),
@@ -2329,7 +2198,7 @@ function getNavigatorMediaDevices() {
2329
2198
 
2330
2199
  //#endregion
2331
2200
  //#region src/controllers/PreflightRunner.ts
2332
- const logger$26 = require_operators.getLogger();
2201
+ const logger$27 = require_operators.getLogger();
2333
2202
  const DEFAULT_MEDIA_TEST_DURATION_S = 10;
2334
2203
  const ICE_GATHERING_TIMEOUT_MS = 1e4;
2335
2204
  const SIGNALING_RTT_TIMEOUT_MS = 5e3;
@@ -2378,7 +2247,7 @@ var PreflightRunner = class extends Destroyable {
2378
2247
  if (!this._options.skipMediaTest) try {
2379
2248
  bandwidth = await this.testMediaBandwidth(destination);
2380
2249
  } catch (error) {
2381
- logger$26.warn("[PreflightRunner] Media bandwidth test failed:", error);
2250
+ logger$27.warn("[PreflightRunner] Media bandwidth test failed:", error);
2382
2251
  warnings.push("Media bandwidth test failed");
2383
2252
  }
2384
2253
  return {
@@ -2390,7 +2259,7 @@ var PreflightRunner = class extends Destroyable {
2390
2259
  warnings
2391
2260
  };
2392
2261
  } catch (error) {
2393
- logger$26.error("[PreflightRunner] Preflight test failed:", error);
2262
+ logger$27.error("[PreflightRunner] Preflight test failed:", error);
2394
2263
  throw new require_operators.PreflightError("preflight", error instanceof Error ? error : new Error(String(error)));
2395
2264
  } finally {
2396
2265
  this.destroy();
@@ -2421,7 +2290,7 @@ var PreflightRunner = class extends Destroyable {
2421
2290
  if (track.kind === "video" && track.readyState === "live") videoWorking = true;
2422
2291
  }
2423
2292
  } catch (error) {
2424
- logger$26.warn("[PreflightRunner] Device test failed:", error);
2293
+ logger$27.warn("[PreflightRunner] Device test failed:", error);
2425
2294
  } finally {
2426
2295
  if (audioStream) audioStream.getTracks().forEach((t) => t.stop());
2427
2296
  }
@@ -2479,7 +2348,7 @@ var PreflightRunner = class extends Destroyable {
2479
2348
  rttMs
2480
2349
  };
2481
2350
  } catch (error) {
2482
- logger$26.warn("[PreflightRunner] ICE connectivity test failed:", error);
2351
+ logger$27.warn("[PreflightRunner] ICE connectivity test failed:", error);
2483
2352
  return {
2484
2353
  type: "failed",
2485
2354
  turnReachable: false,
@@ -2526,7 +2395,7 @@ var PreflightRunner = class extends Destroyable {
2526
2395
 
2527
2396
  //#endregion
2528
2397
  //#region src/controllers/VisibilityController.ts
2529
- const logger$25 = require_operators.getLogger();
2398
+ const logger$26 = require_operators.getLogger();
2530
2399
  /**
2531
2400
  * Checks whether the document visibility API is available.
2532
2401
  */
@@ -2563,8 +2432,8 @@ var VisibilityController = class extends Destroyable {
2563
2432
  this._boundHandler = this._handleVisibilityChange.bind(this);
2564
2433
  if (this._hasVisibilityApi) {
2565
2434
  document.addEventListener("visibilitychange", this._boundHandler);
2566
- logger$25.debug("VisibilityController: listening for visibilitychange events");
2567
- } else logger$25.debug("VisibilityController: document visibility API not available, defaulting to visible");
2435
+ logger$26.debug("VisibilityController: listening for visibilitychange events");
2436
+ } else logger$26.debug("VisibilityController: document visibility API not available, defaulting to visible");
2568
2437
  }
2569
2438
  /**
2570
2439
  * Observable of the current visibility state.
@@ -2589,7 +2458,7 @@ var VisibilityController = class extends Destroyable {
2589
2458
  destroy() {
2590
2459
  if (this._hasVisibilityApi) {
2591
2460
  document.removeEventListener("visibilitychange", this._boundHandler);
2592
- logger$25.debug("VisibilityController: removed visibilitychange listener");
2461
+ logger$26.debug("VisibilityController: removed visibilitychange listener");
2593
2462
  }
2594
2463
  super.destroy();
2595
2464
  }
@@ -2607,7 +2476,7 @@ var VisibilityController = class extends Destroyable {
2607
2476
  timestamp: Date.now()
2608
2477
  };
2609
2478
  this._visibilityChange$.next(changeEvent);
2610
- logger$25.debug("VisibilityController: visibility changed", {
2479
+ logger$26.debug("VisibilityController: visibility changed", {
2611
2480
  from: previousState,
2612
2481
  to: newState
2613
2482
  });
@@ -2808,15 +2677,57 @@ const RPCEventAckResponse = (id) => makeRPCResponse({
2808
2677
  result: {}
2809
2678
  });
2810
2679
 
2680
+ //#endregion
2681
+ //#region src/utils/authRecovery.ts
2682
+ /**
2683
+ * Walk an error's `error`/`cause` chain looking for a {@link JSONRPCError}.
2684
+ * Errors thrown by call creation are wrapped (e.g. `CallCreateError`), so the
2685
+ * underlying signaling error is nested. Bounded by a visited set to guard
2686
+ * against cyclic causes.
2687
+ */
2688
+ function findJSONRPCError(error) {
2689
+ const seen = /* @__PURE__ */ new Set();
2690
+ let current = error;
2691
+ while (current instanceof Error && !seen.has(current)) {
2692
+ seen.add(current);
2693
+ if (current instanceof require_operators.JSONRPCError) return current;
2694
+ current = current.error ?? current.cause;
2695
+ }
2696
+ }
2697
+ /**
2698
+ * Whether an error is a session-recoverable authentication failure
2699
+ * (`-32002` authentication failed or `-32003` requester validation failed)
2700
+ * that a credential re-mint + retry can heal.
2701
+ */
2702
+ function isRecoverableAuthError(error) {
2703
+ const rpcError = findJSONRPCError(error);
2704
+ return rpcError !== void 0 && (rpcError.code === require_operators.RPC_ERROR_REQUESTER_VALIDATION_FAILED || rpcError.code === require_operators.RPC_ERROR_AUTHENTICATION_FAILED);
2705
+ }
2706
+ /**
2707
+ * Whether an error is specifically a requester-validation rejection
2708
+ * (`-32003`) — the server refusing the session's credential.
2709
+ *
2710
+ * Narrower than {@link isRecoverableAuthError} on purpose. `-32002` is
2711
+ * overloaded server-side: a rejected reattach arrives as `-32002` with
2712
+ * `cause: INVALID_MSG_UNSPECIFIED` and message `CALL ERROR`, which is a
2713
+ * call-level rejection and says nothing about the credential. Use this where
2714
+ * the decision must not be fooled by that, such as deciding whether retrying
2715
+ * an operation could possibly succeed.
2716
+ */
2717
+ function isRequesterValidationError(error) {
2718
+ return findJSONRPCError(error)?.code === require_operators.RPC_ERROR_REQUESTER_VALIDATION_FAILED;
2719
+ }
2720
+
2811
2721
  //#endregion
2812
2722
  //#region src/managers/AttachManager.ts
2813
- const logger$24 = require_operators.getLogger();
2723
+ const logger$25 = require_operators.getLogger();
2814
2724
  var AttachManager = class {
2815
- constructor(storage, deviceController, reconnectCallsTimeout, attachKey) {
2725
+ constructor(storage, deviceController, reconnectCallsTimeout, attachKey, credentialRecovered) {
2816
2726
  this.storage = storage;
2817
2727
  this.deviceController = deviceController;
2818
2728
  this.reconnectCallsTimeout = reconnectCallsTimeout;
2819
2729
  this.attachKey = attachKey;
2730
+ this.credentialRecovered = credentialRecovered;
2820
2731
  this.writeQueue = Promise.resolve();
2821
2732
  }
2822
2733
  async detachAll() {
@@ -2831,7 +2742,7 @@ var AttachManager = class {
2831
2742
  try {
2832
2743
  return await this.storage.getItem(this.attachKey) ?? {};
2833
2744
  } catch (error) {
2834
- logger$24.warn("[AttachManager] Failed to retrieve attached calls from storage", error);
2745
+ logger$25.warn("[AttachManager] Failed to retrieve attached calls from storage", error);
2835
2746
  return {};
2836
2747
  }
2837
2748
  }
@@ -2839,7 +2750,7 @@ var AttachManager = class {
2839
2750
  try {
2840
2751
  await this.storage.setItem(this.attachKey, attached);
2841
2752
  } catch (error) {
2842
- logger$24.warn("[AttachManager] Failed to write attached calls to storage", error);
2753
+ logger$25.warn("[AttachManager] Failed to write attached calls to storage", error);
2843
2754
  }
2844
2755
  }
2845
2756
  /**
@@ -2858,11 +2769,39 @@ var AttachManager = class {
2858
2769
  }
2859
2770
  async attach(call) {
2860
2771
  if (!call.to) {
2861
- logger$24.warn("[AttachManager] Skip attach for calls with no destination");
2772
+ logger$25.warn("[AttachManager] Skip attach for calls with no destination");
2862
2773
  return;
2863
2774
  }
2775
+ const attachment = this.buildAttachment(call, call.to);
2776
+ await this.mutate((attached) => ({
2777
+ ...attached,
2778
+ [call.id]: attachment
2779
+ }));
2780
+ }
2781
+ /**
2782
+ * Keep an already-stored call's reference alive and current — the periodic
2783
+ * refresh the `verto.ping` keepalive drives.
2784
+ *
2785
+ * Only ever updates: a call with no record is one nothing wants reattached,
2786
+ * and re-creating it here would undo a `detach`. That matters because a ping
2787
+ * can land in the window between `bye()` detaching and the call being torn
2788
+ * down, and a record revived there survives the hangup — so the next page
2789
+ * load dials a call nobody is on. The existence check and the write share
2790
+ * one {@link mutate} turn, so a concurrent detach cannot slip between them.
2791
+ */
2792
+ async refresh(call) {
2793
+ if (!call.to) return;
2864
2794
  const destination = call.to;
2865
- const attachment = {
2795
+ await this.mutate((attached) => {
2796
+ if (!Object.hasOwn(attached, call.id)) return attached;
2797
+ return {
2798
+ ...attached,
2799
+ [call.id]: this.buildAttachment(call, destination)
2800
+ };
2801
+ });
2802
+ }
2803
+ buildAttachment(call, destination) {
2804
+ return {
2866
2805
  nodeId: call.nodeId,
2867
2806
  destination,
2868
2807
  mediaDirections: call.mediaDirections,
@@ -2870,10 +2809,6 @@ var AttachManager = class {
2870
2809
  videoInputDevice: call.mediaDirections.video !== "inactive" ? this.deviceController.selectedVideoInputDevice : null,
2871
2810
  attachedAt: Date.now()
2872
2811
  };
2873
- await this.mutate((attached) => ({
2874
- ...attached,
2875
- [call.id]: attachment
2876
- }));
2877
2812
  }
2878
2813
  async detach(call) {
2879
2814
  await this.mutate((attached) => {
@@ -2896,8 +2831,14 @@ var AttachManager = class {
2896
2831
  * rejecting. Once that fix is deployed, this will work for both
2897
2832
  * page reloads and WebSocket reconnects.
2898
2833
  *
2899
- * Failed reattach attempts are handled gracefully the stale call
2900
- * reference is cleaned up from storage.
2834
+ * A failed reattach does NOT generally cost the stored reference. It is
2835
+ * discarded only when the server denied the reattach on a session whose
2836
+ * credential it had already accepted — a verified reauthentication followed
2837
+ * by a refusal is the server saying the call is gone, and that is the one
2838
+ * refusal worth acting on. Until then the credential may be what is being
2839
+ * refused, and the record is the only way a later reload can try again;
2840
+ * keeping it costs nothing, since `detachExpired` reaps it once it is older
2841
+ * than `reconnectCallsTimeout`.
2901
2842
  */
2902
2843
  async reattachCalls() {
2903
2844
  const attached = await this.readAttached();
@@ -2906,25 +2847,31 @@ var AttachManager = class {
2906
2847
  const { destination } = attachment;
2907
2848
  const options = this.buildCallOptions(attachment);
2908
2849
  let succeeded = false;
2850
+ let refusedOnCredentials = false;
2909
2851
  for (let attempt = 1; attempt <= 3; attempt++) try {
2910
2852
  await this.session.createOutboundCall(destination, {
2911
2853
  callId,
2912
2854
  ...options
2913
2855
  });
2914
- logger$24.info(`[AttachManager] Reattached call ${callId} (attempt ${attempt})`);
2856
+ logger$25.info(`[AttachManager] Reattached call ${callId} (attempt ${attempt})`);
2915
2857
  succeeded = true;
2916
2858
  break;
2917
2859
  } catch (error) {
2918
- logger$24.warn(`[AttachManager] Reattach attempt ${attempt}/3 failed for call ${callId}:`, error);
2860
+ logger$25.warn(`[AttachManager] Reattach attempt ${attempt}/3 failed for call ${callId}:`, error);
2861
+ if (isRequesterValidationError(error)) {
2862
+ refusedOnCredentials = true;
2863
+ logger$25.warn(`[AttachManager] Reattach of ${callId} was refused on credentials; not retrying.`);
2864
+ break;
2865
+ }
2919
2866
  if (attempt < 3) await new Promise((r) => setTimeout(r, (attempt + 1) * 1e3));
2920
2867
  }
2921
- if (!succeeded) {
2922
- logger$24.warn(`[AttachManager] Reattach failed after 3 attempts for call ${callId}, removing reference`);
2868
+ if (!succeeded) if (this.credentialRecovered() && !refusedOnCredentials) {
2869
+ logger$25.warn(`[AttachManager] Reattach of ${callId} was denied after a verified reauthentication, removing reference`);
2923
2870
  await this.detach({
2924
2871
  id: callId,
2925
2872
  mediaDirections: attachment.mediaDirections
2926
2873
  });
2927
- }
2874
+ } else logger$25.warn(`[AttachManager] Reattach failed for call ${callId}; keeping the reference (credential refused or never proven good)`);
2928
2875
  }
2929
2876
  }
2930
2877
  /**
@@ -3237,7 +3184,7 @@ function toggleHandraiseMethod(is) {
3237
3184
 
3238
3185
  //#endregion
3239
3186
  //#region src/core/entities/Participant.ts
3240
- const logger$23 = require_operators.getLogger();
3187
+ const logger$24 = require_operators.getLogger();
3241
3188
  const initialState = {};
3242
3189
  /**
3243
3190
  * Represents a participant in a call.
@@ -3247,9 +3194,9 @@ const initialState = {};
3247
3194
  * the local participant with additional device control.
3248
3195
  */
3249
3196
  var Participant = class extends Destroyable {
3250
- constructor(id, executeMethod, deviceController) {
3197
+ constructor(id, callExecuteMethod, deviceController) {
3251
3198
  super();
3252
- this.executeMethod = executeMethod;
3199
+ this.callExecuteMethod = callExecuteMethod;
3253
3200
  this.deviceController = deviceController;
3254
3201
  this._state$ = this.createBehaviorSubject(initialState);
3255
3202
  this.id = id;
@@ -3471,22 +3418,55 @@ var Participant = class extends Destroyable {
3471
3418
  get value() {
3472
3419
  return this._state$.value;
3473
3420
  }
3421
+ /**
3422
+ * Target triple for member RPCs, built from the participant's own state.
3423
+ * The backend locates the member's session by the target `call_id`/`node_id`,
3424
+ * so this must always be the participant's own call context — never the
3425
+ * local call's id (issue #19400).
3426
+ *
3427
+ * Reading it doubles as a readiness probe: it throws until the first full
3428
+ * member event (`member.joined`/`member.updated` or the `call.joined`
3429
+ * roster) arrives, and never regresses afterwards.
3430
+ *
3431
+ * @throws {ParticipantNotReadyError} If the member state has not been
3432
+ * received yet (e.g. a participant first seen via `member.talking`) — an
3433
+ * empty call context can never address the member, so fail fast instead of
3434
+ * sending a doomed RPC.
3435
+ */
3436
+ get target() {
3437
+ const { call_id, node_id } = this._state$.value;
3438
+ if (!call_id || !node_id) throw new require_operators.ParticipantNotReadyError(this.id);
3439
+ return {
3440
+ member_id: this.id,
3441
+ call_id,
3442
+ node_id
3443
+ };
3444
+ }
3445
+ /**
3446
+ * Executes a member RPC against this participant, injecting its own
3447
+ * {@link target} as the target.
3448
+ *
3449
+ * @throws {ParticipantNotReadyError} Via {@link target}, when the
3450
+ * member state has not been received yet.
3451
+ */
3452
+ async executeMethod(method, args) {
3453
+ return this.callExecuteMethod(this.target, method, args);
3454
+ }
3474
3455
  /** Toggles the deafened state (mutes/unmutes incoming audio). */
3475
3456
  async toggleDeaf() {
3476
- const method = toggleDeafMethod(this.deaf);
3477
- await this.executeMethod(this.id, method, {});
3457
+ await this.executeMethod(toggleDeafMethod(this.deaf), {});
3478
3458
  }
3479
3459
  /** Toggles the hand-raised state. */
3480
3460
  async toggleHandraise() {
3481
- await this.executeMethod(this.id, toggleHandraiseMethod(this.handraised), {});
3461
+ await this.executeMethod(toggleHandraiseMethod(this.handraised), {});
3482
3462
  }
3483
3463
  /** Mutes the participant's audio. */
3484
3464
  async mute() {
3485
- await this.executeMethod(this.id, "call.mute", { channels: ["audio"] });
3465
+ await this.executeMethod("call.mute", { channels: ["audio"] });
3486
3466
  }
3487
3467
  /** Unmutes the participant's audio. */
3488
3468
  async unmute() {
3489
- await this.executeMethod(this.id, "call.unmute", { channels: ["audio"] });
3469
+ await this.executeMethod("call.unmute", { channels: ["audio"] });
3490
3470
  }
3491
3471
  /** Toggles the participant's audio mute state. */
3492
3472
  async toggleMute() {
@@ -3494,11 +3474,11 @@ var Participant = class extends Destroyable {
3494
3474
  }
3495
3475
  /** Mutes the participant's video. */
3496
3476
  async muteVideo() {
3497
- await this.executeMethod(this.id, "call.mute", { channels: ["video"] });
3477
+ await this.executeMethod("call.mute", { channels: ["video"] });
3498
3478
  }
3499
3479
  /** Unmutes the participant's video. */
3500
3480
  async unmuteVideo() {
3501
- await this.executeMethod(this.id, "call.unmute", { channels: ["video"] });
3481
+ await this.executeMethod("call.unmute", { channels: ["video"] });
3502
3482
  }
3503
3483
  /** Toggles the participant's video mute state. */
3504
3484
  async toggleMuteVideo() {
@@ -3506,7 +3486,7 @@ var Participant = class extends Destroyable {
3506
3486
  }
3507
3487
  /** Toggles echo cancellation on the audio input. */
3508
3488
  async toggleEchoCancellation() {
3509
- await this.executeMethod(this.id, "call.audioflags.set", {
3489
+ await this.executeMethod("call.audioflags.set", {
3510
3490
  echo_cancellation: !this.echoCancellation,
3511
3491
  auto_gain: this.autoGain,
3512
3492
  noise_suppression: this.noiseSuppression
@@ -3514,7 +3494,7 @@ var Participant = class extends Destroyable {
3514
3494
  }
3515
3495
  /** Toggles automatic gain control on the audio input. */
3516
3496
  async toggleAudioInputAutoGain() {
3517
- await this.executeMethod(this.id, "call.audioflags.set", {
3497
+ await this.executeMethod("call.audioflags.set", {
3518
3498
  echo_cancellation: this.echoCancellation,
3519
3499
  auto_gain: !this.autoGain,
3520
3500
  noise_suppression: this.noiseSuppression
@@ -3522,7 +3502,7 @@ var Participant = class extends Destroyable {
3522
3502
  }
3523
3503
  /** Toggles noise suppression on the audio input. */
3524
3504
  async toggleNoiseSuppression() {
3525
- await this.executeMethod(this.id, "call.audioflags.set", {
3505
+ await this.executeMethod("call.audioflags.set", {
3526
3506
  echo_cancellation: this.echoCancellation,
3527
3507
  auto_gain: this.autoGain,
3528
3508
  noise_suppression: !this.noiseSuppression
@@ -3530,7 +3510,7 @@ var Participant = class extends Destroyable {
3530
3510
  }
3531
3511
  /** Toggles low-bitrate mode for this participant's media. */
3532
3512
  async toggleLowbitrate() {
3533
- await this.executeMethod(this.id, "call.lowbitrate.set", { lowbitrate: !this.lowbitrate });
3513
+ await this.executeMethod("call.lowbitrate.set", { lowbitrate: !this.lowbitrate });
3534
3514
  }
3535
3515
  /**
3536
3516
  * Adjusts the **conference-only** microphone energy gate / sensitivity level
@@ -3546,7 +3526,7 @@ var Participant = class extends Destroyable {
3546
3526
  * (integer, larger values are more sensitive).
3547
3527
  */
3548
3528
  async setAudioInputSensitivity(value) {
3549
- await this.executeMethod(this.id, "call.microphone.sensitivity.set", { sensitivity: value });
3529
+ await this.executeMethod("call.microphone.sensitivity.set", { sensitivity: value });
3550
3530
  }
3551
3531
  /**
3552
3532
  * Sets the **server-side** microphone volume on this participant's bridged
@@ -3559,7 +3539,7 @@ var Participant = class extends Destroyable {
3559
3539
  * @param value - Volume level (0-100).
3560
3540
  */
3561
3541
  async setAudioInputVolume(value) {
3562
- await this.executeMethod(this.id, "call.microphone.volume.set", { volume: value });
3542
+ await this.executeMethod("call.microphone.volume.set", { volume: value });
3563
3543
  }
3564
3544
  /**
3565
3545
  * Sets the **server-side** speaker volume on this participant's bridged call
@@ -3573,45 +3553,31 @@ var Participant = class extends Destroyable {
3573
3553
  * @param value - Volume level (0-100).
3574
3554
  */
3575
3555
  async setAudioOutputVolume(value) {
3576
- await this.executeMethod(this.id, "call.speaker.volume.set", { volume: value });
3556
+ await this.executeMethod("call.speaker.volume.set", { volume: value });
3577
3557
  }
3578
3558
  /**
3579
3559
  * Sets the participant's position in the video layout.
3580
3560
  *
3581
- * Requires the `member.position` capability. The gateway keys positions by the
3582
- * **target member's own** `call_id`/`node_id` (see issue #19400 and the legacy
3583
- * `setPositions` implementation), so this sends the participant's own call
3584
- * context matching {@link Participant.remove}. A resolved promise does not
3585
- * guarantee a visible change: the backend silently returns `200` (no-op) for
3586
- * non-conference targets.
3561
+ * Requires the `member.position` capability. The gateway requires a
3562
+ * `targets` array of `{ target, position }` entries (issue #19400). A
3563
+ * resolved promise does not guarantee a visible change: the backend silently
3564
+ * returns `200` (no-op) for non-conference targets.
3587
3565
  *
3588
3566
  * @param value - The {@link VideoPosition} to assign (e.g. `'auto'`, `'reserved-0'`).
3589
3567
  */
3590
3568
  async setPosition(value) {
3591
- const state = this._state$.value;
3592
- const target = {
3593
- member_id: this.id,
3594
- call_id: state.call_id ?? "",
3595
- node_id: state.node_id ?? ""
3596
- };
3597
- await this.executeMethod(target, "call.member.position.set", { targets: [{
3598
- target,
3569
+ await this.executeMethod("call.member.position.set", { targets: [{
3570
+ target: this.target,
3599
3571
  position: value
3600
3572
  }] });
3601
3573
  }
3602
3574
  /** Removes this participant from the call. */
3603
3575
  async remove() {
3604
- const state = this._state$.value;
3605
- const target = {
3606
- member_id: this.id,
3607
- call_id: state.call_id ?? "",
3608
- node_id: state.node_id ?? ""
3609
- };
3610
- await this.executeMethod(target, "call.member.remove", {});
3576
+ await this.executeMethod("call.member.remove", { targets: [this.target] });
3611
3577
  }
3612
3578
  /** Ends the call for this participant. */
3613
3579
  async end() {
3614
- await this.executeMethod(this.id, "call.end", {});
3580
+ await this.executeMethod("call.end", {});
3615
3581
  }
3616
3582
  /**
3617
3583
  * Replaces custom metadata for this participant.
@@ -3631,7 +3597,7 @@ var Participant = class extends Destroyable {
3631
3597
  }
3632
3598
  /** Destroys the participant, releasing all subscriptions and references. */
3633
3599
  destroy() {
3634
- this.executeMethod = void 0;
3600
+ this.callExecuteMethod = void 0;
3635
3601
  super.destroy();
3636
3602
  }
3637
3603
  };
@@ -3643,8 +3609,8 @@ var Participant = class extends Destroyable {
3643
3609
  */
3644
3610
  var SelfParticipant = class extends Participant {
3645
3611
  /** @internal */
3646
- constructor(id, executeMethod, vertoManager, deviceController) {
3647
- super(id, executeMethod, deviceController);
3612
+ constructor(id, callExecuteMethod, vertoManager, deviceController) {
3613
+ super(id, callExecuteMethod, deviceController);
3648
3614
  this.vertoManager = vertoManager;
3649
3615
  this._studioAudio$ = this.createBehaviorSubject(false);
3650
3616
  this.capabilities = new SelfCapabilities();
@@ -3668,7 +3634,7 @@ var SelfParticipant = class extends Participant {
3668
3634
  async enableStudioAudio() {
3669
3635
  if (this._studioAudio$.value) return;
3670
3636
  this._studioAudio$.next(true);
3671
- await this.executeMethod(this.id, "call.audioflags.set", {
3637
+ await this.executeMethod("call.audioflags.set", {
3672
3638
  echo_cancellation: false,
3673
3639
  auto_gain: false,
3674
3640
  noise_suppression: false
@@ -3681,18 +3647,37 @@ var SelfParticipant = class extends Participant {
3681
3647
  async disableStudioAudio() {
3682
3648
  if (!this._studioAudio$.value) return;
3683
3649
  this._studioAudio$.next(false);
3684
- await this.executeMethod(this.id, "call.audioflags.set", {
3650
+ await this.executeMethod("call.audioflags.set", {
3685
3651
  echo_cancellation: true,
3686
3652
  auto_gain: true,
3687
3653
  noise_suppression: true
3688
3654
  });
3689
3655
  }
3690
- /** Starts sharing the local screen. */
3691
- async startScreenShare() {
3656
+ /**
3657
+ * Starts sharing the local screen.
3658
+ *
3659
+ * A call carries at most one screen share. Read `screenShareStatus` before
3660
+ * calling and treat `'starting'`/`'stopping'` as busy.
3661
+ *
3662
+ * The call is unaffected when acquisition fails.
3663
+ *
3664
+ * @param options - Pass `{ audio: true }` to also request the shared
3665
+ * surface's audio. Defaults to video only.
3666
+ * @throws {ScreenShareAlreadyActiveError} When this call is already
3667
+ * sharing a screen. Call {@link stopScreenShare} before starting another.
3668
+ * @throws {AuxiliaryLegCancelledError} When {@link stopScreenShare} removes
3669
+ * the share before its leg finishes connecting.
3670
+ * @throws The raw `getDisplayMedia` error. A dismissed picker or a
3671
+ * permission denial rejects with a `NotAllowedError` `DOMException` —
3672
+ * inspect `error.name` to tell benign cancels apart from real failures.
3673
+ */
3674
+ async startScreenShare(options) {
3692
3675
  try {
3693
- await this.vertoManager.addScreenMedia();
3676
+ await this.vertoManager.addScreenMedia(options);
3694
3677
  } catch (error) {
3695
- logger$23.error("[Participant.startScreenShare] Screen share error:", error);
3678
+ if (error instanceof require_operators.AuxiliaryLegCancelledError) logger$24.debug("[Participant.startScreenShare] Screen share cancelled before connecting.");
3679
+ else logger$24.error("[Participant.startScreenShare] Screen share error:", error);
3680
+ throw error;
3696
3681
  }
3697
3682
  }
3698
3683
  /** Observable of the current screen share status. */
@@ -3707,12 +3692,24 @@ var SelfParticipant = class extends Participant {
3707
3692
  async stopScreenShare() {
3708
3693
  return this.vertoManager.removeScreenMedia();
3709
3694
  }
3710
- /** Adds an additional media input device to the call. */
3695
+ /**
3696
+ * Adds an additional media input device to the call.
3697
+ *
3698
+ * The call is unaffected when acquisition fails.
3699
+ *
3700
+ * @throws {AuxiliaryLegCancelledError} When {@link removeAdditionalDevice}
3701
+ * removes the device before its leg finishes connecting.
3702
+ * @throws The raw `getUserMedia` error (e.g. `NotAllowedError` on
3703
+ * permission denial) — inspect `error.name` to decide how to react — or
3704
+ * `AuxiliaryLegTimeoutError` if the leg does not connect in time.
3705
+ */
3711
3706
  async addAdditionalDevice(options) {
3712
3707
  try {
3713
3708
  await this.vertoManager.addInputDevice(options);
3714
3709
  } catch (error) {
3715
- logger$23.error("[Participant.startScreenShare] Screen share error:", error);
3710
+ if (error instanceof require_operators.AuxiliaryLegCancelledError) logger$24.debug("[Participant.addAdditionalDevice] Device removed before connecting.");
3711
+ else logger$24.error("[Participant.addAdditionalDevice] Additional device error:", error);
3712
+ throw error;
3716
3713
  }
3717
3714
  }
3718
3715
  /** Removes an additional media input device by ID. */
@@ -3746,22 +3743,31 @@ var SelfParticipant = class extends Participant {
3746
3743
  this.deviceController.selectAudioInputDevice(device);
3747
3744
  if (options.savePreference) PreferencesContainer.instance.preferredAudioInput = device;
3748
3745
  }
3749
- /** Updates the audio input track constraints for the active call. */
3746
+ /**
3747
+ * Updates the audio input track constraints for the active call.
3748
+ * @returns whether the constraints reached the media the call is sending.
3749
+ */
3750
3750
  async setAudioInputDeviceConstraints(constraints) {
3751
- await this.vertoManager.updateMediaConstraints({ audio: constraints });
3751
+ return this.vertoManager.updateMediaConstraints({ audio: constraints });
3752
3752
  }
3753
- /** Updates both audio and video input track constraints for the active call. */
3753
+ /**
3754
+ * Updates both audio and video input track constraints for the active call.
3755
+ * @returns whether both kinds took the constraints.
3756
+ */
3754
3757
  async setInputDevicesConstraints(constraints) {
3755
- await this.vertoManager.updateMediaConstraints(constraints);
3758
+ return this.vertoManager.updateMediaConstraints(constraints);
3756
3759
  }
3757
3760
  /** Selects the video input device for future calls. Optionally saves as a preference. */
3758
3761
  selectVideoInputDevice(device, options = {}) {
3759
3762
  this.deviceController.selectVideoInputDevice(device);
3760
3763
  if (options.savePreference) PreferencesContainer.instance.preferredVideoInput = device;
3761
3764
  }
3762
- /** Updates the video input track constraints for the active call. */
3765
+ /**
3766
+ * Updates the video input track constraints for the active call.
3767
+ * @returns whether the constraints reached the media the call is sending.
3768
+ */
3763
3769
  async setVideoInputDeviceConstraints(constraints) {
3764
- await this.vertoManager.updateMediaConstraints({ video: constraints });
3770
+ return this.vertoManager.updateMediaConstraints({ video: constraints });
3765
3771
  }
3766
3772
  /** Selects the audio output device. Optionally saves as a preference. */
3767
3773
  selectAudioOutputDevice(device, options = {}) {
@@ -3774,7 +3780,7 @@ var SelfParticipant = class extends Participant {
3774
3780
  */
3775
3781
  exitStudioModeIfActive() {
3776
3782
  if (this._studioAudio$.value) {
3777
- logger$23.debug("[SelfParticipant] Exiting studio audio mode due to individual flag toggle");
3783
+ logger$24.debug("[SelfParticipant] Exiting studio audio mode due to individual flag toggle");
3778
3784
  this._studioAudio$.next(false);
3779
3785
  }
3780
3786
  }
@@ -3798,7 +3804,7 @@ var SelfParticipant = class extends Participant {
3798
3804
  try {
3799
3805
  await super.mute();
3800
3806
  } catch (error) {
3801
- logger$23.warn("[Participant.toggleAudioInput] Server Error while muting audio input, proceeding with local toggle anyway", error);
3807
+ logger$24.warn("[Participant.toggleAudioInput] Server Error while muting audio input, proceeding with local toggle anyway", error);
3802
3808
  } finally {
3803
3809
  this.vertoManager.muteMainAudioInputDevice();
3804
3810
  }
@@ -3808,7 +3814,7 @@ var SelfParticipant = class extends Participant {
3808
3814
  try {
3809
3815
  await super.unmute();
3810
3816
  } catch (error) {
3811
- logger$23.warn("[Participant.toggleAudioInput] Server Error while unmuting audio input, proceeding with local toggle anyway", error);
3817
+ logger$24.warn("[Participant.toggleAudioInput] Server Error while unmuting audio input, proceeding with local toggle anyway", error);
3812
3818
  } finally {
3813
3819
  await this.vertoManager.unmuteMainAudioInputDevice();
3814
3820
  }
@@ -3818,7 +3824,7 @@ var SelfParticipant = class extends Participant {
3818
3824
  try {
3819
3825
  await super.muteVideo();
3820
3826
  } catch (error) {
3821
- logger$23.warn("[Participant.toggleVideoInput] Server Error while muting video input, proceeding with local toggle anyway", error);
3827
+ logger$24.warn("[Participant.toggleVideoInput] Server Error while muting video input, proceeding with local toggle anyway", error);
3822
3828
  } finally {
3823
3829
  this.vertoManager.muteMainVideoInputDevice();
3824
3830
  }
@@ -3828,7 +3834,7 @@ var SelfParticipant = class extends Participant {
3828
3834
  try {
3829
3835
  await super.unmuteVideo();
3830
3836
  } catch (error) {
3831
- logger$23.warn("[Participant.toggleVideoInput] Server Error while unmuting video input, proceeding with local toggle anyway", error);
3837
+ logger$24.warn("[Participant.toggleVideoInput] Server Error while unmuting video input, proceeding with local toggle anyway", error);
3832
3838
  } finally {
3833
3839
  await this.vertoManager.unmuteMainVideoInputDevice();
3834
3840
  }
@@ -3922,7 +3928,7 @@ function isLayoutChangedPayload(value) {
3922
3928
 
3923
3929
  //#endregion
3924
3930
  //#region src/managers/CallEventsManager.ts
3925
- const logger$22 = require_operators.getLogger();
3931
+ const logger$23 = require_operators.getLogger();
3926
3932
  const initialSessionState = {};
3927
3933
  /** @internal */
3928
3934
  var CallEventsManager = class extends Destroyable {
@@ -4026,7 +4032,7 @@ var CallEventsManager = class extends Destroyable {
4026
4032
  }
4027
4033
  initSubscriptions() {
4028
4034
  this.subscribeTo(this.callJoinedEvent$, (callJoinedEvent) => {
4029
- logger$22.debug("[CallEventsManager] Handling call.joined event for call/session IDs:", {
4035
+ logger$23.debug("[CallEventsManager] Handling call.joined event for call/session IDs:", {
4030
4036
  callId: callJoinedEvent.call_id,
4031
4037
  roomSessionId: callJoinedEvent.room_session_id
4032
4038
  });
@@ -4053,19 +4059,19 @@ var CallEventsManager = class extends Destroyable {
4053
4059
  if (this._self$.value?.capabilities.setLayout) this.updateLayouts();
4054
4060
  });
4055
4061
  this.subscribeTo(this.memberUpdates$, (member) => {
4056
- logger$22.debug("[CallEventsManager] Handling member update event for member ID:", member);
4062
+ logger$23.debug("[CallEventsManager] Handling member update event for member ID:", member);
4057
4063
  this.upsertParticipant(member);
4058
4064
  });
4059
4065
  this.subscribeTo(this.webRtcCallSession.memberLeft$, (memberLeftEvent) => {
4060
- logger$22.debug("[CallEventsManager] Handling member.left event for member ID:", memberLeftEvent.member.member_id);
4066
+ logger$23.debug("[CallEventsManager] Handling member.left event for member ID:", memberLeftEvent.member.member_id);
4061
4067
  const participants = { ...this._participants$.value };
4062
4068
  if (memberLeftEvent.member.member_id in participants) {
4063
4069
  delete participants[memberLeftEvent.member.member_id];
4064
4070
  this._participants$.next(participants);
4065
- } else logger$22.warn(`[CallEventsManager] Received member.left event for unknown member ID: ${memberLeftEvent.member.member_id}`);
4071
+ } else logger$23.warn(`[CallEventsManager] Received member.left event for unknown member ID: ${memberLeftEvent.member.member_id}`);
4066
4072
  });
4067
4073
  this.subscribeTo(this.webRtcCallSession.callUpdated$, (callUpdatedEvent) => {
4068
- logger$22.debug("[CallEventsManager] Handling call.updated event:", callUpdatedEvent);
4074
+ logger$23.debug("[CallEventsManager] Handling call.updated event:", callUpdatedEvent);
4069
4075
  const roomSession = callUpdatedEvent.room_session;
4070
4076
  this._sessionState$.next({
4071
4077
  ...this._sessionState$.value,
@@ -4080,7 +4086,7 @@ var CallEventsManager = class extends Destroyable {
4080
4086
  });
4081
4087
  });
4082
4088
  this.subscribeTo(this.layoutChangedEvent$, (layoutChangedEvent) => {
4083
- logger$22.debug("[CallEventsManager] Handling layout.changed event:", layoutChangedEvent);
4089
+ logger$23.debug("[CallEventsManager] Handling layout.changed event:", layoutChangedEvent);
4084
4090
  this._sessionState$.next({
4085
4091
  ...this._sessionState$.value,
4086
4092
  layout_name: layoutChangedEvent.id,
@@ -4090,10 +4096,10 @@ var CallEventsManager = class extends Destroyable {
4090
4096
  });
4091
4097
  }
4092
4098
  updateParticipantPositions(layoutChangedEvent) {
4093
- if (Object.keys(this._participants$.value).length > 0 && !layoutChangedEvent.layers.some((layer) => !!layer.member_id)) logger$22.warn("[CallEventsManager] No layers with member_id found in layout.changed event. Nothing to update.");
4099
+ if (Object.keys(this._participants$.value).length > 0 && !layoutChangedEvent.layers.some((layer) => !!layer.member_id)) logger$23.warn("[CallEventsManager] No layers with member_id found in layout.changed event. Nothing to update.");
4094
4100
  layoutChangedEvent.layers.filter((layer) => !!layer.member_id).filter((layer) => {
4095
4101
  if (!(layer.member_id in this._participants$.value)) {
4096
- logger$22.warn(`[CallEventsManager] Skipping layout layer for unknown member_id: ${layer.member_id}`);
4102
+ logger$23.warn(`[CallEventsManager] Skipping layout layer for unknown member_id: ${layer.member_id}`);
4097
4103
  return false;
4098
4104
  }
4099
4105
  return true;
@@ -4111,12 +4117,17 @@ var CallEventsManager = class extends Destroyable {
4111
4117
  updateLayouts() {
4112
4118
  if (!this.selfId) return;
4113
4119
  this.webRtcCallSession.executeMethod(this.selfId, "call.layout.list", {}).then((response) => {
4120
+ const layouts = response.result?.layouts;
4121
+ if (!layouts) {
4122
+ logger$23.warn("[CallEventsManager] Layout list response carried no layouts; keeping current layouts");
4123
+ return;
4124
+ }
4114
4125
  this._sessionState$.next({
4115
4126
  ...this._sessionState$.value,
4116
- layouts: response.result.layouts
4127
+ layouts
4117
4128
  });
4118
4129
  }).catch((error) => {
4119
- logger$22.error("[CallEventsManager] Error fetching layouts:", error);
4130
+ logger$23.error("[CallEventsManager] Error fetching layouts:", error);
4120
4131
  });
4121
4132
  }
4122
4133
  updateParticipants(members) {
@@ -4132,7 +4143,7 @@ var CallEventsManager = class extends Destroyable {
4132
4143
  }
4133
4144
  const participant = this._participants$.value[member.member_id];
4134
4145
  const oldValue = participant.value;
4135
- logger$22.debug("[CallEventsManager] Updating participant:", member.member_id, {
4146
+ logger$23.debug("[CallEventsManager] Updating participant:", member.member_id, {
4136
4147
  oldValue,
4137
4148
  newValue: member
4138
4149
  });
@@ -4145,17 +4156,17 @@ var CallEventsManager = class extends Destroyable {
4145
4156
  }
4146
4157
  get callJoinedEvent$() {
4147
4158
  return this.cachedObservable("callJoinedEvent$", () => this.webRtcCallSession.callEvent$.pipe((0, rxjs.filter)(isCallJoinedPayload), (0, rxjs.tap)((event) => {
4148
- logger$22.debug("[CallEventsManager] Call joined event:", event);
4159
+ logger$23.debug("[CallEventsManager] Call joined event:", event);
4149
4160
  })));
4150
4161
  }
4151
4162
  get layoutChangedEvent$() {
4152
4163
  return this.cachedObservable("layoutChangedEvent$", () => this.webRtcCallSession.callEvent$.pipe(require_operators.filterAs(isLayoutChangedPayload, "layout"), (0, rxjs.tap)((event) => {
4153
- logger$22.debug("[CallEventsManager] Layout changed event:", event);
4164
+ logger$23.debug("[CallEventsManager] Layout changed event:", event);
4154
4165
  })));
4155
4166
  }
4156
4167
  get memberUpdates$() {
4157
4168
  return this.cachedObservable("memberUpdates$", () => (0, rxjs.merge)(this.webRtcCallSession.memberJoined$, this.webRtcCallSession.memberUpdated$, this.webRtcCallSession.memberTalking$).pipe((0, rxjs.map)((event) => event.member), (0, rxjs.tap)((event) => {
4158
- logger$22.debug("[CallEventsManager] Member update event:", event);
4169
+ logger$23.debug("[CallEventsManager] Member update event:", event);
4159
4170
  })));
4160
4171
  }
4161
4172
  destroy() {
@@ -4174,6 +4185,111 @@ var CallEventsManager = class extends Destroyable {
4174
4185
  }
4175
4186
  };
4176
4187
 
4188
+ //#endregion
4189
+ //#region src/controllers/ConstraintFallbackHelper.ts
4190
+ /**
4191
+ * ConstraintFallbackHelper - Provides getUserMedia with automatic constraint
4192
+ * fallback on OverconstrainedError.
4193
+ *
4194
+ * When a specific device ID is requested, the helper tries progressively
4195
+ * looser constraints:
4196
+ * 1. `{ deviceId: { exact: deviceId } }` -- exact match
4197
+ * 2. `{ deviceId: deviceId }` -- preferred (browser may pick another)
4198
+ * 3. `{}` -- no constraint, browser default
4199
+ *
4200
+ * This prevents stale device IDs from blocking call setup.
4201
+ *
4202
+ * @see Section 5.8 and Section 11 of the Implementation Guide
4203
+ */
4204
+ const logger$22 = require_operators.getLogger();
4205
+ /**
4206
+ * Attempts getUserMedia with progressively looser constraints.
4207
+ *
4208
+ * The function tries three levels of constraint specificity for the given
4209
+ * device kind. Each level is only attempted if the previous one fails with
4210
+ * an OverconstrainedError. Non-OverconstrainedError failures (e.g.,
4211
+ * NotAllowedError) are thrown immediately without fallback.
4212
+ *
4213
+ * @param mediaDevices - Anything exposing `getUserMedia` (a full
4214
+ * `WebRTCMediaDevices`, or a shim wrapping one)
4215
+ * @param constraints - The full MediaStreamConstraints to use as a base
4216
+ * @param kind - Which track kind to apply fallback to ('audio' | 'video')
4217
+ * @param deviceId - The device ID to try (if undefined, calls getUserMedia as-is)
4218
+ * @returns The stream and the fallback level that succeeded
4219
+ * @throws When all fallback levels fail, or when a non-OverconstrainedError occurs
4220
+ */
4221
+ async function getUserMediaWithFallback(mediaDevices, constraints, kind, deviceId) {
4222
+ if (!deviceId) return {
4223
+ stream: await mediaDevices.getUserMedia(constraints),
4224
+ fallbackLevel: "default"
4225
+ };
4226
+ const baseConstraints = typeof constraints[kind] === "object" ? constraints[kind] : {};
4227
+ try {
4228
+ const exactConstraints = {
4229
+ ...constraints,
4230
+ [kind]: {
4231
+ ...baseConstraints,
4232
+ deviceId: { exact: deviceId }
4233
+ }
4234
+ };
4235
+ return {
4236
+ stream: await mediaDevices.getUserMedia(exactConstraints),
4237
+ fallbackLevel: "exact"
4238
+ };
4239
+ } catch (error) {
4240
+ if (!isOverconstrainedError(error)) throw error;
4241
+ logger$22.debug(`[ConstraintFallbackHelper] Exact constraint failed for ${kind}, trying preferred`, { deviceId });
4242
+ }
4243
+ try {
4244
+ const preferredConstraints = {
4245
+ ...constraints,
4246
+ [kind]: {
4247
+ ...baseConstraints,
4248
+ deviceId
4249
+ }
4250
+ };
4251
+ return {
4252
+ stream: await mediaDevices.getUserMedia(preferredConstraints),
4253
+ fallbackLevel: "preferred"
4254
+ };
4255
+ } catch (error) {
4256
+ if (!isOverconstrainedError(error)) throw error;
4257
+ logger$22.debug(`[ConstraintFallbackHelper] Preferred constraint failed for ${kind}, trying default`, { deviceId });
4258
+ }
4259
+ try {
4260
+ const defaultConstraints = {
4261
+ ...constraints,
4262
+ [kind]: { ...baseConstraints }
4263
+ };
4264
+ if (typeof defaultConstraints[kind] === "object") {
4265
+ const { deviceId: _removed, ...rest } = defaultConstraints[kind];
4266
+ defaultConstraints[kind] = rest;
4267
+ }
4268
+ const stream = await mediaDevices.getUserMedia(defaultConstraints);
4269
+ logger$22.warn(`[ConstraintFallbackHelper] Fell back to browser default for ${kind}`, { requestedDeviceId: deviceId });
4270
+ return {
4271
+ stream,
4272
+ fallbackLevel: "default"
4273
+ };
4274
+ } catch (error) {
4275
+ logger$22.error(`[ConstraintFallbackHelper] All fallback levels exhausted for ${kind}`, {
4276
+ deviceId,
4277
+ error
4278
+ });
4279
+ throw error;
4280
+ }
4281
+ }
4282
+ /**
4283
+ * Checks whether an error is an OverconstrainedError.
4284
+ *
4285
+ * Browsers may throw either a native OverconstrainedError or a DOMException
4286
+ * with a specific name.
4287
+ */
4288
+ function isOverconstrainedError(error) {
4289
+ if (error instanceof Error) return error.name === "OverconstrainedError" || error.name === "ConstraintNotSatisfiedError";
4290
+ return false;
4291
+ }
4292
+
4177
4293
  //#endregion
4178
4294
  //#region src/helpers/SDPHelper.ts
4179
4295
  /**
@@ -4315,7 +4431,7 @@ function reorderCodecs(sdp, preferredVideo = [], preferredAudio = []) {
4315
4431
  * // a=fmtp:111 minptime=10;useinbandfec=1;stereo=1;sprop-stereo=1;maxaveragebitrate=510000
4316
4432
  * ```
4317
4433
  */
4318
- function enableStereoOpus(sdp, maxBitrate = DEFAULT_STEREO_MAX_AVERAGE_BITRATE) {
4434
+ function enableStereoOpus(sdp, maxBitrate = require_operators.DEFAULT_STEREO_MAX_AVERAGE_BITRATE) {
4319
4435
  if (!sdp) return sdp;
4320
4436
  const opusPayloadType = findOpusPayloadType(sdp);
4321
4437
  if (opusPayloadType === null) return sdp;
@@ -4444,8 +4560,8 @@ var ICEGatheringController = class extends Destroyable {
4444
4560
  state: "new",
4445
4561
  validSDP: false
4446
4562
  });
4447
- this.iceCandidateTimeout = options.iceCandidateTimeout ?? DEFAULT_ICE_CANDIDATE_TIMEOUT_MS;
4448
- this.iceGatheringTimeout = options.iceGatheringTimeout ?? DEFAULT_ICE_GATHERING_TIMEOUT_MS;
4563
+ this.iceCandidateTimeout = options.iceCandidateTimeout ?? require_operators.DEFAULT_ICE_CANDIDATE_TIMEOUT_MS;
4564
+ this.iceGatheringTimeout = options.iceGatheringTimeout ?? require_operators.DEFAULT_ICE_GATHERING_TIMEOUT_MS;
4449
4565
  this.relayOnly = options.relayOnly ?? false;
4450
4566
  this.setupEventListeners();
4451
4567
  this.subscribeTo(this.peerConnectionControllerNegotiating$.pipe((0, rxjs.filter)((isNegotiating) => isNegotiating)), (isNegotiating) => {
@@ -4590,9 +4706,9 @@ var LocalAudioPipeline = class extends Destroyable {
4590
4706
  this._destination = this._audioContext.createMediaStreamDestination();
4591
4707
  this._gainNode.connect(this._analyser);
4592
4708
  this._analyser.connect(this._destination);
4593
- this._speakingThreshold = options.speakingThreshold ?? VAD_THRESHOLD;
4594
- this._speakingHoldMs = options.speakingHoldMs ?? VAD_HOLD_MS;
4595
- this._pollIntervalMs = options.pollIntervalMs ?? AUDIO_LEVEL_POLL_INTERVAL_MS;
4709
+ this._speakingThreshold = options.speakingThreshold ?? require_operators.VAD_THRESHOLD;
4710
+ this._speakingHoldMs = options.speakingHoldMs ?? require_operators.VAD_HOLD_MS;
4711
+ this._pollIntervalMs = options.pollIntervalMs ?? require_operators.AUDIO_LEVEL_POLL_INTERVAL_MS;
4596
4712
  const initial = options.initialGain ?? 1;
4597
4713
  this._gain$.next(initial);
4598
4714
  this.applyEffectiveGain();
@@ -4729,6 +4845,7 @@ var LocalStreamController = class extends Destroyable {
4729
4845
  this._localAudioTracks$ = this.createBehaviorSubject([]);
4730
4846
  this._localVideoTracks$ = this.createBehaviorSubject([]);
4731
4847
  this._mediaTrackEnded$ = this.createSubject();
4848
+ this._trackOrigins = /* @__PURE__ */ new WeakMap();
4732
4849
  }
4733
4850
  get localStream$() {
4734
4851
  return this._localStream$.asObservable().pipe((0, rxjs.takeUntil)(this.destroyed$));
@@ -4751,6 +4868,22 @@ var LocalStreamController = class extends Destroyable {
4751
4868
  get localVideoTracks() {
4752
4869
  return this._localVideoTracks$.value;
4753
4870
  }
4871
+ tagTracks(tracks, origin) {
4872
+ for (const track of tracks) this._trackOrigins.set(track, origin);
4873
+ }
4874
+ setTrackOrigin(track, origin) {
4875
+ this._trackOrigins.set(track, origin);
4876
+ }
4877
+ getTrackOrigin(track) {
4878
+ return this._trackOrigins.get(track);
4879
+ }
4880
+ /**
4881
+ * Fail-safe: an unrecorded track reads as not-a-device-capture, so a missed
4882
+ * tagging site leaves media alone rather than destroying it.
4883
+ */
4884
+ isDeviceCapture(track) {
4885
+ return this._trackOrigins.get(track) === "device";
4886
+ }
4754
4887
  /**
4755
4888
  * Build the local media stream based on the provided options.
4756
4889
  */
@@ -4760,13 +4893,16 @@ var LocalStreamController = class extends Destroyable {
4760
4893
  if (this.options.inputAudioStream ?? this.options.inputVideoStream) {
4761
4894
  const tracks = [...this.options.inputAudioStream?.getTracks() ?? [], ...this.options.inputVideoStream?.getTracks() ?? []];
4762
4895
  stream = new MediaStream(tracks);
4896
+ this.tagTracks(tracks, "application");
4763
4897
  } else if (this.options.propose === "screenshare") {
4764
- logger$19.debug("[LocalStreamController] Requesting display media for screen sharing with audio:", Boolean(this.options.inputAudioDeviceConstraints));
4898
+ const audio = this.options.screenShareAudio ?? false;
4899
+ logger$19.debug("[LocalStreamController] Requesting display media for screen sharing with audio:", audio);
4765
4900
  stream = await this.options.getDisplayMedia({
4766
4901
  video: true,
4767
- audio: Boolean(this.options.inputAudioDeviceConstraints)
4902
+ audio
4768
4903
  });
4769
4904
  logger$19.debug("[LocalStreamController] Screen share media obtained:", stream);
4905
+ this.tagTracks(stream.getTracks(), "display");
4770
4906
  } else {
4771
4907
  const constraints = {
4772
4908
  audio: this.options.inputAudioDeviceConstraints,
@@ -4775,6 +4911,7 @@ var LocalStreamController = class extends Destroyable {
4775
4911
  logger$19.debug("[LocalStreamController] Requesting user media with constraints:", constraints);
4776
4912
  stream = await this.options.getUserMedia(constraints);
4777
4913
  logger$19.debug("[LocalStreamController] User media obtained:", stream);
4914
+ this.tagTracks(stream.getTracks(), "device");
4778
4915
  }
4779
4916
  this._localStream$.next(stream);
4780
4917
  this._localAudioTracks$.next(stream.getAudioTracks());
@@ -4784,10 +4921,13 @@ var LocalStreamController = class extends Destroyable {
4784
4921
  /**
4785
4922
  * Add a local media track to the local stream.
4786
4923
  * @param track - The MediaStreamTrack to add
4924
+ * @param origin - Defaults to `'device'`; every internal caller passes a
4925
+ * fresh `getUserMedia` capture.
4787
4926
  * @returns The MediaStream (either existing or newly created)
4788
4927
  */
4789
- addTrack(track) {
4928
+ addTrack(track, origin = "device") {
4790
4929
  const localStream = this._localStream$.value ?? new MediaStream();
4930
+ this._trackOrigins.set(track, origin);
4791
4931
  track.addEventListener("ended", this.mediaTrackEndedHandler);
4792
4932
  localStream.addTrack(track);
4793
4933
  this._localStream$.next(localStream);
@@ -5057,15 +5197,27 @@ var TransceiverController = class extends Destroyable {
5057
5197
  for (let i = 0; i < Number(msStreamsNumber); i++) this.peerConnection.addTransceiver("video", { direction: "recvonly" });
5058
5198
  }
5059
5199
  }
5200
+ /**
5201
+ * @returns whether every live sender of the kind took the constraints. A
5202
+ * skipped non-device sender, an exhausted fallback, and having no live sender
5203
+ * at all all report `false` — `mediaParamsUpdated.applied` is built from this,
5204
+ * and an application told `true` cannot tell a working push from a no-op.
5205
+ */
5060
5206
  async updateSendersConstraints(kind, constraints) {
5061
5207
  if (!constraints) {
5062
5208
  this.stopTrackSender(kind);
5063
- return Promise.resolve();
5209
+ return false;
5064
5210
  }
5065
5211
  const senders = this.peerConnection.getSenders().filter((sender) => sender.track?.kind === kind && sender.track.readyState === "live");
5212
+ let applied = senders.length > 0;
5066
5213
  for (const sender of senders) {
5067
5214
  const { track } = sender;
5068
5215
  if (track) {
5216
+ if (!this.options.localStreamController.isDeviceCapture(track)) {
5217
+ logger$18.debug(`[TransceiverController] Skipping ${kind} constraints for a non-device track (origin: ${this.options.localStreamController.getTrackOrigin(track) ?? "unrecorded"}), track ${track.id}`);
5218
+ applied = false;
5219
+ continue;
5220
+ }
5069
5221
  const constraintsToApply = {
5070
5222
  ...track.getConstraints(),
5071
5223
  ...constraints
@@ -5081,33 +5233,39 @@ var TransceiverController = class extends Destroyable {
5081
5233
  } catch (fallbackError) {
5082
5234
  logger$18.warn(`[TransceiverController] Track replacement fallback also failed for ${kind} track:`, fallbackError);
5083
5235
  this.options.onError?.(new require_operators.MediaTrackError("updateSendersConstraints", kind, fallbackError));
5236
+ applied = false;
5084
5237
  }
5085
5238
  }
5086
5239
  }
5087
5240
  }
5241
+ return applied;
5088
5242
  }
5089
5243
  /**
5090
- * Fallback when applyConstraints fails: stop the current track, acquire a new
5091
- * one via getUserMedia with the merged constraints (preserving the current
5092
- * deviceId), replace the sender track, and update the localStream.
5244
+ * Fallback when applyConstraints fails, which on iOS Safari it silently does.
5093
5245
  *
5094
- * This is critical for iOS Safari where applyConstraints on audio tracks
5095
- * silently fails or throws.
5246
+ * Order matters: acquiring before stopping means a failed acquisition leaves
5247
+ * the existing media playing. The deviceId goes through the fallback ladder
5248
+ * rather than pinned `{ exact }`, so a stale id degrades instead of failing.
5096
5249
  */
5097
5250
  async replaceTrackFallback(sender, oldTrack, kind, mergedConstraints) {
5098
5251
  const { deviceId } = oldTrack.getSettings();
5099
- const constraintsWithDevice = {
5100
- ...mergedConstraints,
5101
- ...deviceId ? { deviceId: { exact: deviceId } } : {}
5102
- };
5103
- const trackId = oldTrack.id;
5252
+ const { stream, fallbackLevel } = await getUserMediaWithFallback({ getUserMedia: this.options.getUserMedia }, { [kind]: mergedConstraints }, kind, deviceId);
5253
+ const newTrack = stream.getTracks().find((t) => t.kind === kind);
5254
+ if (!newTrack) {
5255
+ stream.getTracks().forEach((t) => t.stop());
5256
+ throw new require_operators.MediaTrackError("replaceTrackFallback", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
5257
+ }
5258
+ try {
5259
+ await sender.replaceTrack(newTrack);
5260
+ } catch (error) {
5261
+ stream.getTracks().forEach((t) => t.stop());
5262
+ throw error;
5263
+ }
5264
+ const oldTrackId = oldTrack.id;
5265
+ this.options.localStreamController.removeTrack(oldTrackId);
5104
5266
  oldTrack.stop();
5105
- this.options.localStreamController.removeTrack(trackId);
5106
- const newTrack = (await this.options.getUserMedia({ [kind]: constraintsWithDevice })).getTracks().find((t) => t.kind === kind);
5107
- if (!newTrack) throw new require_operators.MediaTrackError("replaceTrackFallback", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
5108
- await sender.replaceTrack(newTrack);
5109
5267
  this.options.localStreamController.addTrack(newTrack);
5110
- logger$18.debug(`[TransceiverController] Track replacement fallback succeeded for ${kind}. New track: ${newTrack.id}`);
5268
+ logger$18.debug(`[TransceiverController] Track replacement fallback succeeded for ${kind} (deviceId fallback level: ${fallbackLevel}). New track: ${newTrack.id}`);
5111
5269
  }
5112
5270
  getMediaDirections() {
5113
5271
  if (this.peerConnection.connectionState === "connected") return this.peerConnection.getTransceivers().reduce((acc, transceiver) => {
@@ -5176,48 +5334,44 @@ var RTCPeerConnectionController = class extends Destroyable {
5176
5334
  this.negotiationNeeded$.next();
5177
5335
  };
5178
5336
  this.updateSelectedInputDevice = async (kind, deviceInfo) => {
5337
+ const { localStream } = this;
5338
+ if (!localStream) {
5339
+ logger$17.warn("[RTCPeerConnectionController] No local stream available to update input device.");
5340
+ return;
5341
+ }
5342
+ const currentTrack = localStream.getTracks().find((track) => track.kind === kind);
5343
+ if (!currentTrack) {
5344
+ logger$17.debug(`[RTCPeerConnectionController] No ${kind} track to switch.`);
5345
+ return;
5346
+ }
5347
+ if (!deviceInfo) {
5348
+ logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected: none`);
5349
+ this.stopTrackSender(kind);
5350
+ return;
5351
+ }
5352
+ const constraints = {
5353
+ ...currentTrack.getConstraints(),
5354
+ ...this.deviceController.deviceInfoToConstraints(deviceInfo)
5355
+ };
5179
5356
  try {
5180
- const { localStream } = this;
5181
- if (!localStream) {
5182
- logger$17.warn("[RTCPeerConnectionController] No local stream available to update input device.");
5183
- return;
5184
- }
5185
- logger$17.debug(`[RTCPeerConnectionController] Updating selected ${kind} input device:`, localStream.getTracks());
5186
- const track = localStream.getTracks().find((track$1) => track$1.kind === kind);
5187
- if (track) {
5188
- this.transceiverController?.stopTrackSender(kind);
5189
- this.localStreamController.removeTrack(track.id);
5190
- logger$17.debug(`[RTCPeerConnectionController] Stopped existing ${kind} track: ${track.id}`, localStream.getTracks());
5191
- if (!deviceInfo) {
5192
- logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected: none`);
5193
- return;
5194
- }
5195
- const streamTrack = (await this.getUserMedia({ [kind]: {
5196
- ...track.getConstraints(),
5197
- ...this.deviceController.deviceInfoToConstraints(deviceInfo)
5198
- } })).getTracks().find((t) => t.kind === kind);
5199
- if (streamTrack) {
5200
- logger$17.debug(`[RTCPeerConnectionController] Adding new ${kind} track: ${streamTrack.id}`);
5201
- this.localStreamController.addTrack(streamTrack);
5202
- await this.transceiverController?.replaceSenderTrack(kind, streamTrack);
5203
- logger$17.debug(`[RTCPeerConnectionController] Added new ${kind} track: ${streamTrack.id}`, this.localStream?.getTracks());
5204
- }
5205
- }
5206
- logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected:`, deviceInfo?.label);
5357
+ const newTrack = await this.acquireInputTrack(kind, constraints, deviceInfo, currentTrack);
5358
+ await this.attachInputTrack(kind, newTrack, currentTrack);
5359
+ logger$17.debug(`[RTCPeerConnectionController] ${kind} input device selected:`, deviceInfo.label, newTrack.id);
5207
5360
  } catch (error) {
5208
5361
  logger$17.error(`[RTCPeerConnectionController] Failed to select ${kind} input device:`, error);
5209
- this._errors$.next(toError(error));
5210
- throw error;
5362
+ this._errors$.next(new require_operators.MediaTrackError("updateSelectedInputDevice", kind, error));
5211
5363
  }
5212
5364
  };
5213
5365
  this._isNegotiating$ = this.createBehaviorSubject(false);
5214
5366
  this._memberId = null;
5367
+ this._nodeId = null;
5215
5368
  this._iceConnectionState$ = this.createReplaySubject(1);
5216
5369
  this._connectionState$ = this.createReplaySubject(1);
5217
5370
  this._signalingState$ = this.createReplaySubject(1);
5218
5371
  this._iceGatheringState$ = this.createReplaySubject(1);
5219
5372
  this._errors$ = this.createReplaySubject(1);
5220
5373
  this._iceCandidates$ = this.createReplaySubject(1);
5374
+ this._localMediaSettled$ = this.createReplaySubject(1);
5221
5375
  this._initialized$ = this.createReplaySubject(1);
5222
5376
  this._remoteDescription$ = this.createReplaySubject(1);
5223
5377
  this._remoteStream$ = this.createBehaviorSubject(null);
@@ -5250,6 +5404,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5250
5404
  inputVideoStream: this.options.inputVideoStream,
5251
5405
  inputAudioDeviceConstraints: this.inputAudioDeviceConstraints,
5252
5406
  inputVideoDeviceConstraints: this.inputVideoDeviceConstraints,
5407
+ screenShareAudio: this.options.screenShareAudio,
5253
5408
  getUserMedia: async (constraints) => this.getUserMedia(constraints),
5254
5409
  getDisplayMedia: async (options$1) => this.getDisplayMedia(options$1)
5255
5410
  });
@@ -5276,6 +5431,13 @@ var RTCPeerConnectionController = class extends Destroyable {
5276
5431
  get memberId() {
5277
5432
  return this._memberId;
5278
5433
  }
5434
+ /** The node this leg's invite landed on — auxiliary legs are placed independently. */
5435
+ setNodeId(nodeId) {
5436
+ this._nodeId = nodeId;
5437
+ }
5438
+ get nodeId() {
5439
+ return this._nodeId;
5440
+ }
5279
5441
  stopTrackSender(kind, options = { updateTransceiverDirection: false }) {
5280
5442
  const audioCovered = kind === "audio" || kind === "both";
5281
5443
  if (audioCovered && this._localAudioPipeline) this.stopRawAudioInputForPipeline();
@@ -5285,10 +5447,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5285
5447
  }
5286
5448
  stopRawAudioInputForPipeline() {
5287
5449
  const rawTracks = this.localStreamController.localAudioTracks;
5288
- for (const track of rawTracks) if (track.readyState === "live") {
5289
- track.stop();
5290
- this.localStreamController.removeTrack(track.id);
5291
- }
5450
+ for (const track of rawTracks) if (track.readyState === "live") this.localStreamController.removeTrack(track.id);
5292
5451
  this._localAudioPipeline?.setInputTrack(null);
5293
5452
  }
5294
5453
  get isNegotiating$() {
@@ -5321,6 +5480,10 @@ var RTCPeerConnectionController = class extends Destroyable {
5321
5480
  get remoteDescription$() {
5322
5481
  return this.cachedObservable("remoteDescription$", () => this._remoteDescription$.asObservable().pipe((0, rxjs.takeUntil)(this.destroyed$)));
5323
5482
  }
5483
+ /** Emits once local media is settled — acquired, or knowingly receive-only. */
5484
+ get localMediaSettled$() {
5485
+ return this.cachedObservable("localMediaSettled$", () => this._localMediaSettled$.asObservable().pipe((0, rxjs.takeUntil)(this.destroyed$)));
5486
+ }
5324
5487
  get localStream$() {
5325
5488
  return this.cachedObservable("localStream$", () => this.localStreamController.localStream$.pipe((0, rxjs.takeUntil)(this.destroyed$)));
5326
5489
  }
@@ -5348,6 +5511,9 @@ var RTCPeerConnectionController = class extends Destroyable {
5348
5511
  get propose() {
5349
5512
  return this.options.propose ?? "main";
5350
5513
  }
5514
+ get connectionState() {
5515
+ return this.peerConnection?.connectionState;
5516
+ }
5351
5517
  get isAdditionalDevice() {
5352
5518
  return this.propose === "additional-device";
5353
5519
  }
@@ -5428,7 +5594,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5428
5594
  case "main":
5429
5595
  default: return {
5430
5596
  ...options,
5431
- offerToReceiveAudio: true,
5597
+ offerToReceiveAudio: this.options.receiveAudio ?? true,
5432
5598
  offerToReceiveVideo: this.options.receiveVideo ?? Boolean(this.inputVideoDeviceConstraints)
5433
5599
  };
5434
5600
  }
@@ -5476,7 +5642,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5476
5642
  this._isNegotiating$.next(true);
5477
5643
  await this._setRemoteDescription(this.sdpInit);
5478
5644
  } else {
5479
- await this.setupTrackHandling();
5645
+ if (!await this.setupTrackHandling()) return;
5480
5646
  this._initialized$.next(true);
5481
5647
  }
5482
5648
  } catch (error) {
@@ -5582,13 +5748,14 @@ var RTCPeerConnectionController = class extends Destroyable {
5582
5748
  */
5583
5749
  async acceptInbound(mediaOverrides) {
5584
5750
  if (mediaOverrides) {
5585
- const { audio, video, receiveAudio, receiveVideo } = mediaOverrides;
5751
+ const { audio, video, receiveAudio, receiveVideo, fallbackToReceiveOnly } = mediaOverrides;
5586
5752
  this.options = {
5587
5753
  ...this.options,
5588
5754
  ...audio !== void 0 ? { audio } : {},
5589
5755
  ...video !== void 0 ? { video } : {},
5590
5756
  ...receiveAudio !== void 0 ? { receiveAudio } : {},
5591
- ...receiveVideo !== void 0 ? { receiveVideo } : {}
5757
+ ...receiveVideo !== void 0 ? { receiveVideo } : {},
5758
+ ...fallbackToReceiveOnly !== void 0 ? { fallbackToReceiveOnly } : {}
5592
5759
  };
5593
5760
  this.transceiverController?.updateOptions({
5594
5761
  receiveAudio: this.receiveAudio,
@@ -5599,7 +5766,10 @@ var RTCPeerConnectionController = class extends Destroyable {
5599
5766
  inputVideoDeviceConstraints: this.inputVideoDeviceConstraints
5600
5767
  });
5601
5768
  }
5602
- await this.setupLocalTracks();
5769
+ if (!await this.setupLocalTracks()) {
5770
+ logger$17.debug("[RTCPeerConnectionController] Inbound answer abandoned; the connection went away.");
5771
+ return;
5772
+ }
5603
5773
  const { answerOptions } = this;
5604
5774
  logger$17.debug("[RTCPeerConnectionController] Creating inbound answer with options:", answerOptions);
5605
5775
  await this.createAnswer(answerOptions);
@@ -5719,7 +5889,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5719
5889
  if (policyChanged) this.restoreIceTransportPolicy();
5720
5890
  throw error;
5721
5891
  }
5722
- if (policyChanged) (0, rxjs.firstValueFrom)((0, rxjs.race)(this._iceGatheringState$.pipe((0, rxjs.filter)((state) => state === "complete"), (0, rxjs.take)(1)), (0, rxjs.timer)(ICE_GATHERING_COMPLETE_TIMEOUT_MS).pipe((0, rxjs.map)(() => "timeout")))).then(() => this.restoreIceTransportPolicy()).catch((error) => {
5892
+ if (policyChanged) (0, rxjs.firstValueFrom)((0, rxjs.race)(this._iceGatheringState$.pipe((0, rxjs.filter)((state) => state === "complete"), (0, rxjs.take)(1)), (0, rxjs.timer)(require_operators.ICE_GATHERING_COMPLETE_TIMEOUT_MS).pipe((0, rxjs.map)(() => "timeout")))).then(() => this.restoreIceTransportPolicy()).catch((error) => {
5723
5893
  logger$17.warn("[RTCPeerConnectionController] Error waiting for ICE gathering to complete:", error);
5724
5894
  this.restoreIceTransportPolicy();
5725
5895
  });
@@ -5737,23 +5907,52 @@ var RTCPeerConnectionController = class extends Destroyable {
5737
5907
  }
5738
5908
  /**
5739
5909
  * Setup track handling for remote tracks.
5910
+ *
5911
+ * @returns `false` when the connection went away while local media was being
5912
+ * acquired — see {@link setupLocalTracks}.
5740
5913
  */
5741
5914
  async setupTrackHandling() {
5742
5915
  if (!this.peerConnection) throw new require_operators.DependencyError("RTCPeerConnection is not initialized");
5743
- await this.setupLocalTracks();
5916
+ if (!await this.setupLocalTracks()) return false;
5744
5917
  await this.setupRemoteTracks();
5918
+ return true;
5745
5919
  }
5920
+ /**
5921
+ * @returns `false` when the connection was torn down while getUserMedia was
5922
+ * in flight. The acquisition is not cancellable, so the caller must stop
5923
+ * rather than go on to touch a peer connection that is closed or gone.
5924
+ */
5746
5925
  async setupLocalTracks() {
5747
5926
  logger$17.debug("[RTCPeerConnectionController] Setting up local tracks/transceivers.");
5748
- const localStream = this.localStream ?? await this.localStreamController.buildLocalStream();
5927
+ if (this.hasNoLocalMediaToSend()) {
5928
+ if (!this.receiveAudio && !this.receiveVideo) throw new require_operators.InvalidParams("Call requests no media: enable audio/video or receiveAudio/receiveVideo");
5929
+ logger$17.debug("[RTCPeerConnectionController] No local media requested; negotiating receive-only.");
5930
+ this.setupReceiveOnlyTransceivers();
5931
+ this._localMediaSettled$.next();
5932
+ return true;
5933
+ }
5934
+ let localStream;
5935
+ try {
5936
+ localStream = this.localStream ?? await this.localStreamController.buildLocalStream();
5937
+ } catch (error) {
5938
+ this.handleLocalMediaFailure(error);
5939
+ this._localMediaSettled$.next();
5940
+ return true;
5941
+ }
5942
+ if (!this.peerConnection || this.peerConnection.signalingState === "closed") {
5943
+ logger$17.debug("[RTCPeerConnectionController] Local media arrived after teardown; releasing it.");
5944
+ localStream.getTracks().forEach((track) => track.stop());
5945
+ return false;
5946
+ }
5947
+ this._localMediaSettled$.next();
5749
5948
  if (this.transceiverController?.useAddStream ?? false) {
5750
5949
  logger$17.warn("[RTCPeerConnectionController] Using deprecated addStream API to add local stream.");
5751
- this.peerConnection?.addStream(localStream);
5950
+ this.peerConnection.addStream(localStream);
5752
5951
  if (!this.isNegotiating) {
5753
5952
  logger$17.debug("[RTCPeerConnectionController] Forcing negotiationneeded after local tracks setup.");
5754
5953
  this.negotiationNeeded$.next();
5755
5954
  }
5756
- return;
5955
+ return true;
5757
5956
  }
5758
5957
  for (const kind of ["audio", "video"]) {
5759
5958
  const tracks = (kind === "audio" ? localStream.getAudioTracks() : localStream.getVideoTracks()).map((track, index) => ({
@@ -5767,10 +5966,53 @@ var RTCPeerConnectionController = class extends Destroyable {
5767
5966
  await this.transceiverController?.setupTransceiverSender(track, localStream, transceivers[index]);
5768
5967
  } else {
5769
5968
  logger$17.debug(`[RTCPeerConnectionController] Using addTrack for local ${kind} track:`, track.id);
5770
- this.peerConnection?.addTrack(track, localStream);
5969
+ this.peerConnection.addTrack(track, localStream);
5771
5970
  }
5772
5971
  }
5773
5972
  }
5973
+ return true;
5974
+ }
5975
+ /** True for a main connection with no local media to send. */
5976
+ hasNoLocalMediaToSend() {
5977
+ const hasInputStreams = Boolean(this.options.inputAudioStream ?? this.options.inputVideoStream);
5978
+ return this.propose === "main" && !this.localStream && !hasInputStreams && !this.inputAudioDeviceConstraints && !this.inputVideoDeviceConstraints;
5979
+ }
5980
+ /** The media kinds this connection wants to send: 'audiovideo' | 'video' | 'audio'. */
5981
+ get requestedMediaKinds() {
5982
+ const wantsAudio = Boolean(this.inputAudioDeviceConstraints);
5983
+ const wantsVideo = Boolean(this.inputVideoDeviceConstraints);
5984
+ if (wantsAudio && wantsVideo) return "audiovideo";
5985
+ return wantsVideo ? "video" : "audio";
5986
+ }
5987
+ /**
5988
+ * Handle a local media acquisition failure with a typed, semantically
5989
+ * accurate MediaAccessError created at the acquisition site:
5990
+ * - Auxiliary connections (screenshare / additional-device) throw a
5991
+ * non-fatal error — VertoManager surfaces it and the call is unaffected.
5992
+ * - The main connection degrades to receive-only when allowed (default),
5993
+ * otherwise fails with a fatal error.
5994
+ */
5995
+ handleLocalMediaFailure(error) {
5996
+ if (this.propose === "screenshare") throw new require_operators.MediaAccessError("startScreenShare", "screen", error, false);
5997
+ if (this.propose === "additional-device") throw new require_operators.MediaAccessError("addInputDevice", this.requestedMediaKinds, error, false);
5998
+ const canReceive = this.receiveAudio || this.receiveVideo;
5999
+ if (!((this.options.fallbackToReceiveOnly ?? true) && canReceive)) throw new require_operators.MediaAccessError("acquireLocalMedia", this.requestedMediaKinds, error, true);
6000
+ logger$17.warn("[RTCPeerConnectionController] Local media unavailable; continuing receive-only:", error);
6001
+ this._errors$.next(new require_operators.MediaAccessError("acquireLocalMedia", this.requestedMediaKinds, error, false));
6002
+ this.setupReceiveOnlyTransceivers();
6003
+ }
6004
+ /**
6005
+ * Negotiate receive-only m-lines when there are no local tracks to send.
6006
+ * Only offer-type connections add transceivers — answer-type connections
6007
+ * reuse the transceivers created from the remote offer.
6008
+ */
6009
+ setupReceiveOnlyTransceivers() {
6010
+ if (this.type !== "offer") return;
6011
+ if (this.transceiverController?.useAddTransceivers ?? false) {
6012
+ this.peerConnection?.addTransceiver("audio", { direction: this.receiveAudio ? "recvonly" : "inactive" });
6013
+ this.peerConnection?.addTransceiver("video", { direction: this.receiveVideo ? "recvonly" : "inactive" });
6014
+ }
6015
+ if (!this.isNegotiating) this.negotiationNeeded$.next();
5774
6016
  }
5775
6017
  async getUserMedia(constraints) {
5776
6018
  return (this.options.webRTCApiProvider?.mediaDevices ?? navigator.mediaDevices).getUserMedia(constraints);
@@ -5808,7 +6050,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5808
6050
  stream = await this.getUserMedia({ audio: constraints });
5809
6051
  } catch (error) {
5810
6052
  logger$17.error("[RTCPeerConnectionController] Failed to re-acquire mic for pipeline restore:", error);
5811
- this._errors$.next(toError(error));
6053
+ this._errors$.next(new require_operators.MediaTrackError("restoreAudioPipelineInput", "audio", error));
5812
6054
  return;
5813
6055
  }
5814
6056
  const newTrack = stream.getAudioTracks().at(0);
@@ -5817,6 +6059,61 @@ var RTCPeerConnectionController = class extends Destroyable {
5817
6059
  this._localAudioPipeline.setInputTrack(newTrack);
5818
6060
  }
5819
6061
  /**
6062
+ * Capture the newly selected device, leaving the current capture running.
6063
+ *
6064
+ * A rejection must leave the current track sending, so nothing is released
6065
+ * until the replacement is in hand. The one exception is hardware that admits
6066
+ * a single opener — a phone's front and back cameras, typically — which
6067
+ * rejects the second capture until the first is closed.
6068
+ */
6069
+ async acquireInputTrack(kind, constraints, deviceInfo, currentTrack) {
6070
+ try {
6071
+ return await this.captureTrack(kind, constraints, deviceInfo.deviceId);
6072
+ } catch (error) {
6073
+ if (!require_operators.isMediaDeviceInUse(error)) throw error;
6074
+ logger$17.warn(`[RTCPeerConnectionController] ${kind} device is held exclusively; releasing the current capture to retry:`, error);
6075
+ const previousDeviceId = currentTrack.getSettings().deviceId;
6076
+ this.stopTrackSender(kind);
6077
+ try {
6078
+ return await this.captureTrack(kind, constraints, deviceInfo.deviceId);
6079
+ } catch (retryError) {
6080
+ await this.restorePreviousInputTrack(kind, constraints, previousDeviceId, currentTrack);
6081
+ throw retryError;
6082
+ }
6083
+ }
6084
+ }
6085
+ async captureTrack(kind, constraints, deviceId) {
6086
+ const { stream, fallbackLevel } = await getUserMediaWithFallback({ getUserMedia: async (c) => this.getUserMedia(c) }, { [kind]: constraints }, kind, deviceId);
6087
+ const track = stream.getTracks().find((t) => t.kind === kind);
6088
+ if (!track) {
6089
+ stream.getTracks().forEach((t) => t.stop());
6090
+ throw new require_operators.MediaTrackError("updateSelectedInputDevice", kind, /* @__PURE__ */ new Error("getUserMedia returned no track of the requested kind"));
6091
+ }
6092
+ if (fallbackLevel !== "exact") logger$17.warn(`[RTCPeerConnectionController] ${kind} device acquired at fallback level '${fallbackLevel}'; the capture may not be the requested device.`);
6093
+ return track;
6094
+ }
6095
+ /** Best-effort return to the device that was released for an exclusive retry. */
6096
+ async restorePreviousInputTrack(kind, constraints, previousDeviceId, releasedTrack) {
6097
+ try {
6098
+ const restored = await this.captureTrack(kind, constraints, previousDeviceId);
6099
+ await this.attachInputTrack(kind, restored, releasedTrack);
6100
+ } catch (error) {
6101
+ logger$17.error(`[RTCPeerConnectionController] Failed to restore the previous ${kind} device:`, error);
6102
+ }
6103
+ }
6104
+ async attachInputTrack(kind, newTrack, oldTrack) {
6105
+ const pipelineOwnsAudio = kind === "audio" && this._localAudioPipeline;
6106
+ if (!pipelineOwnsAudio) try {
6107
+ await this.transceiverController?.replaceSenderTrack(kind, newTrack);
6108
+ } catch (error) {
6109
+ newTrack.stop();
6110
+ throw error;
6111
+ }
6112
+ this.localStreamController.removeTrack(oldTrack.id);
6113
+ this.localStreamController.addTrack(newTrack);
6114
+ if (pipelineOwnsAudio) this._localAudioPipeline?.setInputTrack(newTrack);
6115
+ }
6116
+ /**
5820
6117
  * Return the lazily-created {@link LocalAudioPipeline}, constructing it on
5821
6118
  * first access. On creation the current audio sender's track is routed
5822
6119
  * through the pipeline (input → gain → analyser → destination) and the
@@ -5849,6 +6146,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5849
6146
  const sender = (this.transceiverController?.audioTransceivers.at(0))?.sender ?? this.peerConnection.getSenders().find((s) => s.track?.kind === "audio");
5850
6147
  if (!sender || !raw) return;
5851
6148
  try {
6149
+ this.localStreamController.setTrackOrigin(this._localAudioPipeline.outputTrack, "processed");
5852
6150
  await sender.replaceTrack(this._localAudioPipeline.outputTrack);
5853
6151
  } catch (error) {
5854
6152
  logger$17.warn("[RTCPeerConnectionController] Failed to route audio sender through pipeline:", error);
@@ -5870,7 +6168,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5870
6168
  logger$17.debug(`[RTCPeerConnectionController] ${track.kind} track added:`, track.id);
5871
6169
  } catch (error) {
5872
6170
  logger$17.error(`[RTCPeerConnectionController] Failed to add ${track.kind} track:`, error);
5873
- this._errors$.next(toError(error));
6171
+ this._errors$.next(new require_operators.MediaTrackError("addLocalTrack", track.kind, error));
5874
6172
  throw error;
5875
6173
  }
5876
6174
  }
@@ -5895,7 +6193,7 @@ var RTCPeerConnectionController = class extends Destroyable {
5895
6193
  logger$17.debug(`[RTCPeerConnectionController] ${sender.track?.kind} track removed:`, trackId);
5896
6194
  } catch (error) {
5897
6195
  logger$17.error(`[RTCPeerConnectionController] Failed to remove ${sender.track?.kind} track:`, error);
5898
- this._errors$.next(toError(error));
6196
+ this._errors$.next(new require_operators.MediaTrackError("removeLocalTrack", sender.track?.kind ?? "unknown", error));
5899
6197
  throw error;
5900
6198
  }
5901
6199
  }
@@ -5909,37 +6207,68 @@ var RTCPeerConnectionController = class extends Destroyable {
5909
6207
  for (const existingTrack of existingTracks) this.removeLocalTrack(existingTrack.id);
5910
6208
  this.addLocalTrack(track);
5911
6209
  }
6210
+ /**
6211
+ * @returns whether the constraints reached the media the leg is sending.
6212
+ *
6213
+ * With the pipeline engaged the audio sender carries the processed
6214
+ * destination track, so the sender scan would find nothing it may touch and
6215
+ * every audio constraint API would silently no-op. The constraints belong to
6216
+ * the pipeline's device source, which is the capture that sender ultimately
6217
+ * carries.
6218
+ */
5912
6219
  async updateSendersConstraints(kind, constraints) {
5913
- await this.transceiverController?.updateSendersConstraints(kind, constraints);
6220
+ if (kind === "audio" && this._localAudioPipeline) {
6221
+ if (!constraints) {
6222
+ this.stopTrackSender("audio");
6223
+ return false;
6224
+ }
6225
+ return this.applyPipelineSourceConstraints(constraints);
6226
+ }
6227
+ return await this.transceiverController?.updateSendersConstraints(kind, constraints) ?? false;
5914
6228
  }
5915
6229
  /**
5916
- * Replace the current audio track with a new one using the given constraints.
5917
- * Used for server-pushed audio constraint changes where applyConstraints
5918
- * fails on iOS Safari. Stops the current track, acquires a new one via
5919
- * getUserMedia, and replaces the sender track.
6230
+ * Mirror of the sender path for a piped audio leg: same merge, same fallback
6231
+ * ladder, same device-capture invariant but the swap target is the pipeline
6232
+ * input, so the sender keeps emitting the pipeline's output track and its
6233
+ * identity survives the change.
5920
6234
  */
5921
- async replaceAudioTrackWithConstraints(constraints) {
5922
- const senders = this.peerConnection?.getSenders().filter((s) => s.track?.kind === "audio" && s.track.readyState === "live");
5923
- if (!senders || senders.length === 0) {
5924
- logger$17.warn("[RTCPeerConnectionController] No live audio sender to replace");
5925
- return;
6235
+ async applyPipelineSourceConstraints(constraints) {
6236
+ const pipeline = this._localAudioPipeline;
6237
+ const source = this.localStreamController.localAudioTracks.at(0);
6238
+ if (!pipeline || !source) {
6239
+ logger$17.debug("[RTCPeerConnectionController] No pipeline input to constrain.");
6240
+ return false;
5926
6241
  }
5927
- for (const sender of senders) {
5928
- const oldTrack = sender.track;
5929
- if (!oldTrack) continue;
5930
- const { deviceId } = oldTrack.getSettings();
5931
- const mergedConstraints = {
5932
- ...oldTrack.getConstraints(),
5933
- ...constraints,
5934
- ...deviceId ? { deviceId: { exact: deviceId } } : {}
5935
- };
5936
- const trackId = oldTrack.id;
5937
- oldTrack.stop();
5938
- this.localStreamController.removeTrack(trackId);
5939
- const newTrack = (await this.getUserMedia({ audio: mergedConstraints })).getAudioTracks()[0];
5940
- await sender.replaceTrack(newTrack);
6242
+ if (!this.localStreamController.isDeviceCapture(source)) {
6243
+ logger$17.debug(`[RTCPeerConnectionController] Skipping audio constraints for a non-device pipeline input (origin: ${this.localStreamController.getTrackOrigin(source) ?? "unrecorded"}).`);
6244
+ return false;
6245
+ }
6246
+ const merged = {
6247
+ ...source.getConstraints(),
6248
+ ...constraints
6249
+ };
6250
+ try {
6251
+ await source.applyConstraints(merged);
6252
+ logger$17.debug("[RTCPeerConnectionController] Pipeline input constraints updated:", merged);
6253
+ return true;
6254
+ } catch (error) {
6255
+ logger$17.warn("[RTCPeerConnectionController] applyConstraints failed on the pipeline input, re-acquiring:", error);
6256
+ }
6257
+ try {
6258
+ const { stream } = await getUserMediaWithFallback({ getUserMedia: async (c) => this.getUserMedia(c) }, { audio: merged }, "audio", source.getSettings().deviceId);
6259
+ const newTrack = stream.getAudioTracks().at(0);
6260
+ if (!newTrack) {
6261
+ stream.getTracks().forEach((track) => track.stop());
6262
+ throw new Error("getUserMedia returned no audio track");
6263
+ }
6264
+ this.localStreamController.removeTrack(source.id);
5941
6265
  this.localStreamController.addTrack(newTrack);
5942
- logger$17.debug(`[RTCPeerConnectionController] Audio track replaced for server-pushed params. New track: ${newTrack.id}`);
6266
+ pipeline.setInputTrack(newTrack);
6267
+ return true;
6268
+ } catch (error) {
6269
+ logger$17.warn("[RTCPeerConnectionController] Failed to re-acquire the pipeline input for constraints:", error);
6270
+ this._errors$.next(new require_operators.MediaTrackError("updateSendersConstraints", "audio", error));
6271
+ return false;
5943
6272
  }
5944
6273
  }
5945
6274
  /**
@@ -6058,10 +6387,52 @@ const logger$16 = require_operators.getLogger();
6058
6387
  function resolveInviteNodeId(args) {
6059
6388
  return args.isInvite && !args.reattach && !args.explicitNodeId ? "" : args.currentNodeId ?? "";
6060
6389
  }
6061
- var VertoManager = class extends Destroyable {
6062
- constructor(callSession) {
6063
- super();
6064
- this.callSession = callSession;
6390
+ /**
6391
+ * Surface the real outcome of a `webrtc.verto` reply.
6392
+ *
6393
+ * A webrtc.verto response nests several envelopes, each keyed by a verto-style
6394
+ * string `code` ("200" ok, "400"/etc. fail) rather than a JSON-RPC `error`. An outer
6395
+ * layer reports only whether the frame was delivered; an inner layer carries the op's
6396
+ * own outcome:
6397
+ *
6398
+ * response.result = { code:"200", result:{…} } ← delivery acknowledgement
6399
+ * .result = { jsonrpc, id, result:{…} } ← the reply payload
6400
+ * .result = { code:"400", message:"Bad request" } ← the actual op outcome
6401
+ *
6402
+ * A failure can appear at any layer (delivery refused, or the op itself rejected
6403
+ * deeper down), so walk every nested `.result` object and return the FIRST non-2xx
6404
+ * `code` with its message. Returns null when every `code` seen is 2xx or absent —
6405
+ * i.e. the op succeeded. This is the only way to detect that e.g. a mute/kick was
6406
+ * rejected, since the outer delivery `code` is "200" (delivered) even then.
6407
+ *
6408
+ * Pure function — exported for unit testing.
6409
+ */
6410
+ function findNestedVertoFailure(response) {
6411
+ let node = response;
6412
+ while (node !== null && typeof node === "object") {
6413
+ const obj = node;
6414
+ const err = obj.error;
6415
+ if (err !== null && typeof err === "object") {
6416
+ const e = err;
6417
+ const errCode = typeof e.code === "string" || typeof e.code === "number" ? String(e.code) : void 0;
6418
+ if (errCode !== void 0 && !/^2\d\d$/.test(errCode)) return {
6419
+ code: errCode,
6420
+ message: typeof e.message === "string" ? e.message : void 0
6421
+ };
6422
+ }
6423
+ const code = typeof obj.code === "string" || typeof obj.code === "number" ? String(obj.code) : void 0;
6424
+ if (code !== void 0 && !/^2\d\d$/.test(code)) return {
6425
+ code,
6426
+ message: typeof obj.message === "string" ? obj.message : void 0
6427
+ };
6428
+ node = obj.result !== null && typeof obj.result === "object" ? obj.result : null;
6429
+ }
6430
+ return null;
6431
+ }
6432
+ var VertoManager = class extends Destroyable {
6433
+ constructor(callSession) {
6434
+ super();
6435
+ this.callSession = callSession;
6065
6436
  }
6066
6437
  destroy() {
6067
6438
  this.callSession = void 0;
@@ -6080,7 +6451,7 @@ var WebRTCVertoManager = class extends VertoManager {
6080
6451
  this._signalingStatus$ = this.createReplaySubject(1);
6081
6452
  this._screenShareStatus$ = this.createBehaviorSubject("none");
6082
6453
  this._rtcPeerConnectionsMap = /* @__PURE__ */ new Map();
6083
- this._screenShareTimeoutMs = 5e4;
6454
+ this._legErrors$ = this.createSubject();
6084
6455
  this._nodeId$ = this.createBehaviorSubject(options.nodeId ?? null);
6085
6456
  this.onError = options.onError;
6086
6457
  this.onModifyFailed = options.onModifyFailed;
@@ -6128,6 +6499,10 @@ var WebRTCVertoManager = class extends VertoManager {
6128
6499
  get selfId$() {
6129
6500
  return this._selfId$.asObservable();
6130
6501
  }
6502
+ /** Separates the media phase of call creation from the signalling phase. */
6503
+ get localMediaSettled$() {
6504
+ return this.mainPeerConnection.localMediaSettled$;
6505
+ }
6131
6506
  get localStream() {
6132
6507
  return this._rtcPeerConnectionsMap.get(this.webRtcCallSession.id)?.localStream ?? null;
6133
6508
  }
@@ -6184,35 +6559,95 @@ var WebRTCVertoManager = class extends VertoManager {
6184
6559
  const { mediaParams, callID } = event;
6185
6560
  const rtcPeerConnController = this._rtcPeerConnectionsMap.get(callID);
6186
6561
  const { audio, video } = mediaParams;
6187
- (async () => {
6188
- try {
6189
- if (audio && rtcPeerConnController) await rtcPeerConnController.replaceAudioTrackWithConstraints(audio);
6190
- if (video) await rtcPeerConnController?.updateSendersConstraints("video", video);
6191
- this.webRtcCallSession.emitMediaParamsUpdated({
6192
- audio,
6193
- video,
6194
- timestamp: Date.now()
6195
- });
6196
- } catch (error) {
6197
- logger$16.warn("[WebRTCManager] Error applying server-pushed media params:", error);
6198
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
6199
- }
6200
- })();
6562
+ if (!rtcPeerConnController) {
6563
+ logger$16.warn(`[WebRTCManager] Ignoring server-pushed media params for unknown leg ${callID}`);
6564
+ return;
6565
+ }
6566
+ this.applyServerMediaParams(rtcPeerConnController, audio, video);
6201
6567
  });
6202
6568
  this.subscribeTo(this.vertoPing$, (vertoPing) => {
6203
- this.attachManager.attach(this.buildAttachableCall());
6569
+ this.attachManager.refresh(this.buildAttachableCall());
6204
6570
  this.sendVertoPong(vertoPing);
6205
6571
  });
6206
6572
  }
6207
6573
  /**
6574
+ * An auxiliary-leg failure must never destroy the call; main-leg and
6575
+ * call-level errors keep `CallFactory.isFatalError`'s classification.
6576
+ *
6577
+ * Every site holding a peer connection reports through here, so the invariant
6578
+ * is structural rather than per-call-site — which is how cloud-product#20523
6579
+ * happened, with only one of fourteen sites passing `{ fatal: false }`.
6580
+ *
6581
+ * `override` composes rather than replaces: a caller may force non-fatal for a
6582
+ * reason of its own (a `verto.info` frame is best-effort whichever leg carries
6583
+ * it), and an auxiliary leg stays non-fatal regardless.
6584
+ */
6585
+ reportLegError(error, rtcPeerConnController, override) {
6586
+ const leg = rtcPeerConnController?.propose;
6587
+ const legId = rtcPeerConnController?.id;
6588
+ const auxiliary = Boolean(rtcPeerConnController) && !rtcPeerConnController?.isMainDevice;
6589
+ this.onError?.(error, {
6590
+ ...override?.fatal === false || auxiliary ? { fatal: false } : {},
6591
+ ...leg ? { leg } : {},
6592
+ ...legId ? { legId } : {}
6593
+ });
6594
+ if (legId) this._legErrors$.next({
6595
+ legId,
6596
+ error
6597
+ });
6598
+ }
6599
+ /**
6600
+ * Errors reported for one leg, as a stream that fails with them.
6601
+ *
6602
+ * Signaling failures are reported, never thrown — so nothing that waits on a
6603
+ * leg's progress would otherwise learn of a rejected invite. Merging this in
6604
+ * lets the wait end with the reason the server gave.
6605
+ */
6606
+ legError$(legId) {
6607
+ return this._legErrors$.pipe((0, rxjs.filter)((report) => report.legId === legId), (0, rxjs.map)((report) => {
6608
+ throw report.error;
6609
+ }));
6610
+ }
6611
+ /**
6612
+ * Audio and video are applied independently so a failure in one cannot
6613
+ * suppress the other, and `mediaParamsUpdated` is emitted whatever happens —
6614
+ * an application should not be starved of the params by a constraint failure.
6615
+ */
6616
+ async applyServerMediaParams(rtcPeerConnController, audio, video) {
6617
+ const failures = [];
6618
+ let applied = true;
6619
+ if (audio) try {
6620
+ applied = await rtcPeerConnController.updateSendersConstraints("audio", audio) && applied;
6621
+ } catch (error) {
6622
+ applied = false;
6623
+ failures.push(toError(error));
6624
+ }
6625
+ if (video) try {
6626
+ applied = await rtcPeerConnController.updateSendersConstraints("video", video) && applied;
6627
+ } catch (error) {
6628
+ applied = false;
6629
+ failures.push(toError(error));
6630
+ }
6631
+ this.webRtcCallSession.emitMediaParamsUpdated({
6632
+ audio,
6633
+ video,
6634
+ timestamp: Date.now(),
6635
+ applied
6636
+ });
6637
+ for (const failure of failures) {
6638
+ logger$16.warn("[WebRTCManager] Error applying server-pushed media params:", failure);
6639
+ this.reportLegError(failure, rtcPeerConnController, { fatal: false });
6640
+ }
6641
+ }
6642
+ /**
6208
6643
  * Set node_id/selfId only when the current value is null.
6209
6644
  *
6210
6645
  * During reattach, `call.joined` and `verto.answer` events can deliver
6211
6646
  * these identifiers before the `verto.invite` RPC response (`CALL CREATED`)
6212
6647
  * arrives. These methods let early events populate them eagerly so that
6213
6648
  * downstream RPC calls (e.g. `call.layout.list`) don't fail with empty
6214
- * identifiers. `processInviteResponse()` remains the authoritative source
6215
- * and always overwrites unconditionally.
6649
+ * identifiers. `processInviteResponse()` remains the authoritative source and
6650
+ * overwrites unconditionally for selfId, on the main leg only.
6216
6651
  */
6217
6652
  setNodeIdIfNull(nodeId) {
6218
6653
  if (!this._nodeId$.value && nodeId) {
@@ -6235,24 +6670,33 @@ var WebRTCVertoManager = class extends VertoManager {
6235
6670
  this.onError?.(new require_operators.VertoPongError(error));
6236
6671
  }
6237
6672
  }
6673
+ /**
6674
+ * @returns whether the constraints reached the media the call is sending.
6675
+ * `false` is an outcome, not a failure: the leg may have no live sender of
6676
+ * the kind, or carry media the SDK did not capture and may not replace.
6677
+ * A failure behind it still reaches the call's `errors$`, so a caller that
6678
+ * ignores this value learns of it there.
6679
+ */
6238
6680
  async updateMediaConstraints(options = {}) {
6239
6681
  const { audio, video } = options;
6682
+ let applied = true;
6240
6683
  try {
6241
- if (audio) await this.mainPeerConnection.updateSendersConstraints("audio", audio);
6242
- if (video) await this.mainPeerConnection.updateSendersConstraints("video", video);
6684
+ if (audio) applied = await this.mainPeerConnection.updateSendersConstraints("audio", audio) && applied;
6685
+ if (video) applied = await this.mainPeerConnection.updateSendersConstraints("video", video) && applied;
6243
6686
  } catch (error) {
6244
6687
  logger$16.warn("[WebRTCManager] Error updating media constraints:", error);
6245
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
6688
+ this.reportLegError(toError(error), this.mainPeerConnection);
6246
6689
  throw error;
6247
6690
  }
6691
+ return applied;
6248
6692
  }
6249
6693
  get selfId() {
6250
6694
  return this._selfId$.value;
6251
6695
  }
6252
6696
  /** Build an AttachableCall from the current call state. */
6253
- buildAttachableCall(idOverride) {
6697
+ buildAttachableCall(idOverride, nodeIdOverride) {
6254
6698
  return {
6255
- nodeId: this.nodeId ?? void 0,
6699
+ nodeId: nodeIdOverride ?? this.nodeId ?? void 0,
6256
6700
  id: idOverride ?? this.webRtcCallSession.id,
6257
6701
  to: this.webRtcCallSession.to,
6258
6702
  mediaDirections: this.webRtcCallSession.mediaDirections
@@ -6382,24 +6826,49 @@ var WebRTCVertoManager = class extends VertoManager {
6382
6826
  get vertoPing$() {
6383
6827
  return this.cachedObservable("vertoPing$", () => this.webRtcCallSession.webrtcMessages$.pipe(require_operators.filterAs(isVertoPingInnerParams, "params"), (0, rxjs.takeUntil)(this.destroyed$)));
6384
6828
  }
6829
+ /**
6830
+ * Send a member-control op in-dialog via verto.info.
6831
+ *
6832
+ * The control payload rides in `params.command` — a sibling of `dialogParams`,
6833
+ * at the same level as `dtmf` in {@link sendDigits} — and the inner verto.info
6834
+ * is matched to this call's channel by `dialogParams.callID`, the in-dialog
6835
+ * convention for member-scoped frames. Because it is delivered on the dialog
6836
+ * itself, control lands on the call's own channel with no {node_id,call_id,member_id}
6837
+ * "self" tuple to get wrong. The outer webrtc.verto envelope (added by executeVerto)
6838
+ * still carries the own-leg callID + node_id for session routing.
6839
+ *
6840
+ * Keep `command` OUT of `dialogParams`: it is read at the params level, and
6841
+ * filterVertoParams rewrites/filters dialogParams keys but passes params-level
6842
+ * keys through verbatim.
6843
+ */
6844
+ async sendCallControl(method, params) {
6845
+ const response = await this.executeVerto(VertoInfo({
6846
+ dialogParams: { callID: this.webRtcCallSession.id },
6847
+ command: {
6848
+ method,
6849
+ params
6850
+ }
6851
+ }));
6852
+ const failure = findNestedVertoFailure(response);
6853
+ if (failure) throw new require_operators.JSONRPCError(Number.parseInt(failure.code, 10) || 0, `Call control "${method}" failed (code ${failure.code})${failure.message ? `: ${failure.message}` : ""}`, void 0);
6854
+ return response;
6855
+ }
6385
6856
  async executeVerto(message, optionals = {}) {
6386
- const webrtcVertoMessage = WebrtcVerto({
6857
+ const params = {
6387
6858
  callID: optionals.callID ?? this.webRtcCallSession.id,
6388
6859
  node_id: optionals.node_id ?? this._nodeId$.value ?? "",
6389
6860
  message,
6390
6861
  subscribe: optionals.subscribe
6391
- });
6862
+ };
6863
+ const webrtcVertoMessage = WebrtcVerto(params);
6392
6864
  const response = await this.webRtcCallSession.execute(webrtcVertoMessage);
6393
- if (response.error) {
6394
- const error = new require_operators.JSONRPCError(response.error.code, response.error.message, response.error.data);
6395
- this.onError?.(error);
6396
- return response;
6397
- }
6865
+ const nonFatal = message.method === "verto.info" ? { fatal: false } : void 0;
6398
6866
  const innerResult = require_operators.getValueFrom(response, "result.result");
6399
- if (innerResult?.error) {
6400
- const error = new require_operators.JSONRPCError(innerResult.error.code, innerResult.error.message, innerResult.error.data);
6401
- this.onError?.(error);
6402
- return response;
6867
+ const failure = response.error ?? innerResult?.error;
6868
+ if (failure) {
6869
+ const error = new require_operators.JSONRPCError(failure.code, failure.message, failure.data);
6870
+ if (message.method === "verto.invite" || message.method === "verto.answer") throw error;
6871
+ this.reportLegError(error, this._rtcPeerConnectionsMap.get(params.callID), nonFatal);
6403
6872
  }
6404
6873
  return response;
6405
6874
  }
@@ -6418,8 +6887,9 @@ var WebRTCVertoManager = class extends VertoManager {
6418
6887
  default:
6419
6888
  }
6420
6889
  } catch (error) {
6890
+ if (vertoMethod === "verto.answer") throw error;
6421
6891
  logger$16.error(`[WebRTCManager] Error sending Verto ${vertoMethod}:`, error);
6422
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
6892
+ this.reportLegError(toError(error), rtcPeerConnController);
6423
6893
  if (vertoMethod === "verto.modify") this.onModifyFailed?.();
6424
6894
  }
6425
6895
  }
@@ -6435,7 +6905,7 @@ var WebRTCVertoManager = class extends VertoManager {
6435
6905
  } catch (error) {
6436
6906
  logger$16.warn("[WebRTCManager] Error processing modify response:", error);
6437
6907
  const modifyError = error instanceof Error ? error : new Error(String(error), { cause: error });
6438
- this.onError?.(modifyError);
6908
+ this.reportLegError(modifyError, rtcPeerConnController);
6439
6909
  }
6440
6910
  }
6441
6911
  }
@@ -6448,37 +6918,35 @@ var WebRTCVertoManager = class extends VertoManager {
6448
6918
  status,
6449
6919
  signalingError
6450
6920
  });
6451
- this.onError?.(signalingError);
6921
+ this.reportLegError(signalingError, null, { fatal: false });
6452
6922
  return;
6453
6923
  }
6454
6924
  if (rtcPeerConnController.isMainDevice) this._signalingStatus$.next(status);
6455
6925
  }
6456
6926
  processInviteResponse(response, rtcPeerConnController) {
6457
- if (!response.error && require_operators.getValueFrom(response, "result.result.result.message") === "CALL CREATED") {
6927
+ if (require_operators.getValueFrom(response, "result.result.result.message") === "CALL CREATED") {
6458
6928
  this.emitMainSignalingStatus(rtcPeerConnController.id, "trying");
6459
- this._nodeId$.next(require_operators.getValueFrom(response, "result.node_id") ?? null);
6929
+ const nodeId = require_operators.getValueFrom(response, "result.node_id") ?? null;
6460
6930
  const memberId = require_operators.getValueFrom(response, "result.result.result.memberID") ?? null;
6461
- const callId = require_operators.getValueFrom(response, "result.result.result.callID") ?? null;
6931
+ const callId = require_operators.getValueFrom(response, "result.result.result.callID");
6462
6932
  logger$16.debug("[WebRTCManager] Verto invite response:", {
6463
6933
  callId,
6464
6934
  memberId,
6465
6935
  response
6466
6936
  });
6467
- this._selfId$.next(memberId);
6468
6937
  rtcPeerConnController.setMemberId(memberId);
6469
- if (callId) {
6470
- this.webRtcCallSession.addCallId(callId);
6471
- this.attachManager.attach(this.buildAttachableCall(callId));
6472
- } else logger$16.warn("[WebRTCManager] Cannot attach call, missing callId:", {
6473
- nodeId: this.nodeId,
6474
- callId
6475
- });
6938
+ rtcPeerConnController.setNodeId(nodeId);
6939
+ if (rtcPeerConnController.isMainDevice) {
6940
+ this._selfId$.next(memberId);
6941
+ this._nodeId$.next(nodeId);
6942
+ this.attachManager.attach(this.buildAttachableCall(callId, nodeId ?? void 0));
6943
+ }
6944
+ if (callId) this.webRtcCallSession.addCallId(callId);
6476
6945
  logger$16.info("[WebRTCManager] Verto invite successful");
6477
6946
  logger$16.debug(`[WebRTCManager] nodeid: ${this._nodeId$.value}, selfId: ${this._selfId$.value}`);
6478
6947
  } else {
6479
6948
  logger$16.error("[WebRTCManager] Verto invite failed:", response);
6480
- const inviteError = response.error ? new require_operators.JSONRPCError(response.error.code, response.error.message, response.error.data) : /* @__PURE__ */ new Error("Verto invite failed: unexpected response");
6481
- this.onError?.(inviteError);
6949
+ this.reportLegError(/* @__PURE__ */ new Error("Verto invite failed: unexpected response"), rtcPeerConnController);
6482
6950
  }
6483
6951
  }
6484
6952
  get RTCPeerConnectionConfig() {
@@ -6503,6 +6971,7 @@ var WebRTCVertoManager = class extends VertoManager {
6503
6971
  inputVideoStream: options.inputVideoStream,
6504
6972
  receiveAudio: options.receiveAudio,
6505
6973
  receiveVideo: options.receiveVideo,
6974
+ fallbackToReceiveOnly: options.fallbackToReceiveOnly,
6506
6975
  webRTCApiProvider: this.webRTCApiProvider,
6507
6976
  preferredVideoCodecs: options.preferredVideoCodecs,
6508
6977
  preferredAudioCodecs: options.preferredAudioCodecs,
@@ -6516,7 +6985,7 @@ var WebRTCVertoManager = class extends VertoManager {
6516
6985
  this._rtcPeerConnectionsMap.set(rtcPeerConnController.id, rtcPeerConnController);
6517
6986
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
6518
6987
  this.subscribeTo(rtcPeerConnController.errors$, (error) => {
6519
- this.onError?.(error);
6988
+ this.reportLegError(error, rtcPeerConnController);
6520
6989
  });
6521
6990
  if (options.initOffer) this.handleInboundAnswer(rtcPeerConnController);
6522
6991
  }
@@ -6545,7 +7014,7 @@ var WebRTCVertoManager = class extends VertoManager {
6545
7014
  await rtcPeerConnController.acceptInbound(answerOptions);
6546
7015
  } catch (error) {
6547
7016
  logger$16.error("[WebRTCManager] Error creating inbound answer:", error);
6548
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
7017
+ this.reportLegError(toError(error), rtcPeerConnController);
6549
7018
  }
6550
7019
  }
6551
7020
  }
@@ -6617,7 +7086,7 @@ var WebRTCVertoManager = class extends VertoManager {
6617
7086
  isInvite: isVertoInviteMessage(vertoMessage),
6618
7087
  reattach: this.webRtcCallSession.options.reattach === true,
6619
7088
  explicitNodeId: this.webRtcCallSession.options.nodeId,
6620
- currentNodeId: this._nodeId$.value
7089
+ currentNodeId: rtcPeerConnController.nodeId ?? this._nodeId$.value
6621
7090
  }),
6622
7091
  subscribe
6623
7092
  };
@@ -6649,7 +7118,7 @@ var WebRTCVertoManager = class extends VertoManager {
6649
7118
  await this.attachManager.attach(this.buildAttachableCall());
6650
7119
  } catch (error) {
6651
7120
  logger$16.error("[WebRTCManager] Error sending Verto answer:", error);
6652
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
7121
+ this.reportLegError(toError(error), rtcPeerConnectionController);
6653
7122
  await rtcPeerConnectionController.updateAnswerStatus({ status: "failed" });
6654
7123
  }
6655
7124
  }
@@ -6674,7 +7143,7 @@ var WebRTCVertoManager = class extends VertoManager {
6674
7143
  screenShare: rtcPeerConnectionController.isScreenShare,
6675
7144
  additionalDevice: rtcPeerConnectionController.isAdditionalDevice,
6676
7145
  pingSupported: true,
6677
- version: INVITE_VERSION
7146
+ version: require_operators.INVITE_VERSION
6678
7147
  };
6679
7148
  }
6680
7149
  muteMainAudioInputDevice() {
@@ -6721,17 +7190,23 @@ var WebRTCVertoManager = class extends VertoManager {
6721
7190
  await this.mainPeerConnection.restoreTrackSender(deviceKind);
6722
7191
  } else {
6723
7192
  const error = new require_operators.InvalidParams("No valid device to be added");
6724
- this.onError?.(error);
7193
+ this.reportLegError(error, this.mainPeerConnection);
6725
7194
  throw error;
6726
7195
  }
6727
7196
  }
6728
- async addScreenMedia(options = { audio: false }) {
6729
- await this.initAdditionalPeerConnection("screenshare", options);
7197
+ async addScreenMedia(options = {}) {
7198
+ await this.initAdditionalPeerConnection("screenshare", {
7199
+ audio: false,
7200
+ screenShareAudio: options.audio ?? false
7201
+ });
6730
7202
  }
6731
7203
  async initAdditionalPeerConnection(propose, options) {
7204
+ const isScreenShare = propose === "screenshare";
7205
+ if (isScreenShare && this._screenShareId && this._rtcPeerConnectionsMap.has(this._screenShareId)) throw new require_operators.ScreenShareAlreadyActiveError(this._screenShareId);
7206
+ let firstPeerConnectionError;
6732
7207
  let rtcPeerConnController = null;
6733
7208
  try {
6734
- this._screenShareStatus$.next("starting");
7209
+ if (isScreenShare) this._screenShareStatus$.next("starting");
6735
7210
  rtcPeerConnController = new RTCPeerConnectionController({
6736
7211
  ...options,
6737
7212
  ...this.RTCPeerConnectionConfig,
@@ -6739,21 +7214,42 @@ var WebRTCVertoManager = class extends VertoManager {
6739
7214
  webRTCApiProvider: this.webRTCApiProvider
6740
7215
  }, void 0, this.deviceController);
6741
7216
  this.setupLocalDescriptionHandler(rtcPeerConnController);
6742
- if (propose === "screenshare") this._screenShareId = rtcPeerConnController.id;
7217
+ if (isScreenShare) this._screenShareId = rtcPeerConnController.id;
6743
7218
  this._rtcPeerConnectionsMap.set(rtcPeerConnController.id, rtcPeerConnController);
6744
7219
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
6745
7220
  this.subscribeTo(rtcPeerConnController.errors$, (error) => {
6746
- this.onError?.(error);
7221
+ firstPeerConnectionError ??= error;
7222
+ this.reportLegError(error, rtcPeerConnController);
6747
7223
  });
6748
- await (0, rxjs.firstValueFrom)(rtcPeerConnController.connectionState$.pipe((0, rxjs.filter)((state) => state === "connected"), (0, rxjs.take)(1), (0, rxjs.timeout)(this._screenShareTimeoutMs), (0, rxjs.takeUntil)(this.destroyed$)));
6749
- this._screenShareStatus$.next("started");
6750
- logger$16.info("[WebRTCManager] Screen share started successfully.");
7224
+ const pc = rtcPeerConnController;
7225
+ await (0, rxjs.firstValueFrom)((0, rxjs.merge)(pc.localMediaSettled$.pipe((0, rxjs.take)(1), (0, rxjs.switchMap)(() => pc.connectionState$.pipe((0, rxjs.filter)((state) => state === "connected"), (0, rxjs.take)(1), (0, rxjs.timeout)(require_operators.DEFAULT_AUX_LEG_CONNECT_TIMEOUT_MS)))), this.legError$(pc.id)).pipe((0, rxjs.takeUntil)((0, rxjs.merge)(this.destroyed$, pc.destroyed$))));
7226
+ if (isScreenShare) this._screenShareStatus$.next("started");
7227
+ logger$16.info(`[WebRTCManager] Additional peer connection connected (${propose}).`);
6751
7228
  return rtcPeerConnController.id;
6752
7229
  } catch (error) {
6753
- logger$16.warn("[WebRTCManager] Error initializing additional peer connection:", error);
6754
- this.onError?.(error instanceof Error ? error : new Error(String(error), { cause: error }));
6755
- if (rtcPeerConnController) rtcPeerConnController.destroy();
6756
- this._screenShareStatus$.next("none");
7230
+ const cancelled = error instanceof require_operators.AuxiliaryLegCancelledError;
7231
+ const aborted = error instanceof rxjs.EmptyError && !firstPeerConnectionError;
7232
+ if (!cancelled && !aborted) logger$16.warn("[WebRTCManager] Error initializing additional peer connection:", error);
7233
+ if (rtcPeerConnController && this._rtcPeerConnectionsMap.has(rtcPeerConnController.id)) {
7234
+ rtcPeerConnController.destroy();
7235
+ this._rtcPeerConnectionsMap.delete(rtcPeerConnController.id);
7236
+ this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
7237
+ }
7238
+ if (isScreenShare) {
7239
+ this._screenShareId = void 0;
7240
+ this._screenShareStatus$.next("none");
7241
+ }
7242
+ if (cancelled) {
7243
+ logger$16.debug("[WebRTCManager] Additional peer connection removed before connecting.");
7244
+ throw error;
7245
+ }
7246
+ if (firstPeerConnectionError) throw firstPeerConnectionError instanceof require_operators.MediaAccessError && firstPeerConnectionError.originalError instanceof Error ? firstPeerConnectionError.originalError : firstPeerConnectionError;
7247
+ if (error instanceof rxjs.EmptyError) {
7248
+ logger$16.debug("[WebRTCManager] Additional peer connection aborted before connecting.");
7249
+ return;
7250
+ }
7251
+ if (error instanceof rxjs.TimeoutError) throw new require_operators.AuxiliaryLegTimeoutError(propose, error);
7252
+ throw error instanceof Error ? error : new Error(String(error), { cause: error });
6757
7253
  }
6758
7254
  }
6759
7255
  async removeInputDevices(id) {
@@ -6769,7 +7265,10 @@ var WebRTCVertoManager = class extends VertoManager {
6769
7265
  if (removeTrack) return this.mainPeerConnection.stopTrackSender(removeTrack, { updateTransceiverDirection: true });
6770
7266
  }
6771
7267
  async removeScreenMedia() {
6772
- if (!["starting", "started"].includes(this._screenShareStatus$.value)) logger$16.warn("[WebRTCManager] No active screen share to stop.");
7268
+ if (!["starting", "started"].includes(this._screenShareStatus$.value)) {
7269
+ logger$16.warn("[WebRTCManager] No active screen share to stop.");
7270
+ return;
7271
+ }
6773
7272
  if (!this._screenShareId) {
6774
7273
  logger$16.debug("[WebRTCManager] No screen share peer connection found.");
6775
7274
  return;
@@ -6784,6 +7283,10 @@ var WebRTCVertoManager = class extends VertoManager {
6784
7283
  try {
6785
7284
  if (rtcPeerConnController) await this.executeVertoBye(rtcPeerConnController);
6786
7285
  } finally {
7286
+ if (rtcPeerConnController && rtcPeerConnController.connectionState !== "connected") this._legErrors$.next({
7287
+ legId: id,
7288
+ error: new require_operators.AuxiliaryLegCancelledError(rtcPeerConnController.propose)
7289
+ });
6787
7290
  rtcPeerConnController?.destroy();
6788
7291
  this._rtcPeerConnectionsMap.delete(id);
6789
7292
  this._rtcPeerConnections$.next(Array.from(this._rtcPeerConnectionsMap.values()));
@@ -6798,7 +7301,10 @@ var WebRTCVertoManager = class extends VertoManager {
6798
7301
  await this.executeVerto(VertoBye({
6799
7302
  ...causeParams,
6800
7303
  dialogParams: this.dialogParams(rtcPeerConnController)
6801
- }));
7304
+ }), {
7305
+ callID: rtcPeerConnController.id,
7306
+ node_id: rtcPeerConnController.nodeId ?? void 0
7307
+ });
6802
7308
  } catch (error) {
6803
7309
  logger$16.warn("[WebRTCManager] Call might already be disconnected, error sending Verto bye:", error);
6804
7310
  throw error;
@@ -6868,7 +7374,7 @@ var RemoteAudioMeter = class extends Destroyable {
6868
7374
  this._analyser.fftSize = 2048;
6869
7375
  this._analyser.smoothingTimeConstant = .3;
6870
7376
  this._analyserBuffer = new Uint8Array(new ArrayBuffer(this._analyser.fftSize));
6871
- this._pollIntervalMs = options.pollIntervalMs ?? AUDIO_LEVEL_POLL_INTERVAL_MS;
7377
+ this._pollIntervalMs = options.pollIntervalMs ?? require_operators.AUDIO_LEVEL_POLL_INTERVAL_MS;
6872
7378
  }
6873
7379
  /** RMS level of the remote audio, 0..1. 0 when no stream is attached. */
6874
7380
  get level$() {
@@ -7681,14 +8187,35 @@ function mosToQualityLevel(mos) {
7681
8187
  }
7682
8188
 
7683
8189
  //#endregion
7684
- //#region src/core/entities/Call.ts
7685
- const logger$12 = require_operators.getLogger();
8190
+ //#region src/utils/unwrapVertoReply.ts
7686
8191
  /**
7687
- * Verto method for setting member layout positions. Its gateway DTO requires a
7688
- * `targets` array whose entries are `{ target, position }` (NOT bare targets),
7689
- * so {@link WebRTCCall.buildMethodParams} special-cases it. See issue #19400.
8192
+ * Unwrap a method's own reply from a `webrtc.verto` envelope.
8193
+ *
8194
+ * A control verb sent in-dialog comes back nested two levels deep:
8195
+ *
8196
+ * ```
8197
+ * { result: { node_id, code, result: { jsonrpc, id, result: <method payload> } } }
8198
+ * ```
8199
+ *
8200
+ * whereas the routed transport resolves the method payload directly under
8201
+ * `.result`. Readers want the latter shape, and the difference is silent when it
8202
+ * is wrong — `response.result.layouts` simply evaluates to `undefined` against the
8203
+ * envelope, so the data arrives, nothing throws, and the caller sees an empty
8204
+ * value. That exact failure produced an empty layout dropdown with a successful
8205
+ * request behind it.
8206
+ *
8207
+ * Accepting both shapes here keeps every reader indifferent to which transport
8208
+ * produced the response. Anything that is not a nested envelope (a plain ack, or
8209
+ * an already-unwrapped reply) passes through untouched.
7690
8210
  */
7691
- const POSITION_SET_METHOD = "call.member.position.set";
8211
+ function unwrapVertoReply(response) {
8212
+ const inner = require_operators.getValueFrom(response, "result.result");
8213
+ return inner && typeof inner === "object" && "result" in inner ? inner : response;
8214
+ }
8215
+
8216
+ //#endregion
8217
+ //#region src/core/entities/Call.ts
8218
+ const logger$12 = require_operators.getLogger();
7692
8219
  /**
7693
8220
  * Ratio between the critical and warning RTT spike multipliers.
7694
8221
  * Warning threshold = baseline * warningMultiplier (default 3x)
@@ -7784,8 +8311,11 @@ var WebRTCCall = class extends Destroyable {
7784
8311
  emitError(callError) {
7785
8312
  if (this._status$.value === "destroyed" || this._status$.value === "failed") return;
7786
8313
  this._errors$.next(callError);
7787
- if (callError.fatal) {
8314
+ if (callError.fatal && this._status$.value !== "disconnecting") {
7788
8315
  this._status$.next("failed");
8316
+ this.vertoManager.bye().catch((error) => {
8317
+ logger$12.debug("[Call] fatal-teardown bye failed (signaling likely already dead):", error);
8318
+ });
7789
8319
  this.destroy();
7790
8320
  }
7791
8321
  }
@@ -7840,7 +8370,7 @@ var WebRTCCall = class extends Destroyable {
7840
8370
  /** Toggles the call lock state, preventing or allowing new participants from joining. */
7841
8371
  async toggleLock() {
7842
8372
  const method = this.locked ? "call.unlock" : "call.lock";
7843
- await this.executeMethod(this.selfId ?? "", method, {});
8373
+ await this.executeMethod(this.callSelf, method, {});
7844
8374
  }
7845
8375
  /**
7846
8376
  * Toggles the hold state of the call (pauses/resumes local media transmission).
@@ -7889,14 +8419,25 @@ var WebRTCCall = class extends Destroyable {
7889
8419
  *
7890
8420
  * Constructs call context (node_id, call_id, member_id) and sends the RPC request.
7891
8421
  *
7892
- * @param target - Target member ID string, or a {@link MemberTarget} object.
8422
+ * @param target - Target {@link MemberTarget} triple, or the local member's
8423
+ * ID string for self-operations (any other string is rejected — a bare
8424
+ * member id cannot carry the remote member's own call context).
7893
8425
  * @param method - Verto method name (e.g. `'call.mute'`, `'call.member.remove'`).
7894
8426
  * @param args - Parameters for the RPC method.
7895
8427
  * @returns The RPC response.
8428
+ * @throws {CallNotReadyError} If the call has no self member context yet.
8429
+ * @throws {InvalidParams} If a string target is not the local member's ID.
7896
8430
  * @throws {JSONRPCError} If the RPC call returns an error.
7897
8431
  */
7898
8432
  async executeMethod(target, method, args) {
7899
- const params = this.buildMethodParams(target, args, method);
8433
+ const self = this.callSelf;
8434
+ if (typeof target === "string" && target !== self.member_id) throw new require_operators.InvalidParams(`Target member ID ${target} does not match call's self member ID ${self.member_id}`);
8435
+ if (this.clientSession.callControl === "in-dialog") return this.executeMethodInDialog(target, method, args);
8436
+ const params = {
8437
+ ...args,
8438
+ self,
8439
+ target: typeof target === "string" ? self : target
8440
+ };
7900
8441
  const request = buildRPCRequest({
7901
8442
  method,
7902
8443
  params
@@ -7910,29 +8451,98 @@ var WebRTCCall = class extends Destroyable {
7910
8451
  throw error;
7911
8452
  }
7912
8453
  }
7913
- buildMethodParams(target, args, method) {
7914
- const self = {
7915
- node_id: this.nodeId ?? "",
7916
- call_id: this.id,
7917
- member_id: this.vertoManager.selfId ?? ""
7918
- };
7919
- if (method === POSITION_SET_METHOD) return {
7920
- ...args,
7921
- self
7922
- };
7923
- if (typeof target === "object") return {
7924
- ...args,
7925
- self,
7926
- targets: [target]
7927
- };
7928
- return {
7929
- ...args,
7930
- self,
7931
- target: {
7932
- node_id: this.nodeId ?? "",
8454
+ /**
8455
+ * `executeMethod` for a call opened with `callControl: 'in-dialog'`.
8456
+ *
8457
+ * Translates the routed transport's calling convention into the in-dialog one. No
8458
+ * `self` tuple is sent, but a `target` is — the same {call_id, member_id} the routed
8459
+ * transport puts in `target` (minus node_id), for self-ops and cross-member ops alike.
8460
+ *
8461
+ * Target shapes are per-verb and irregular, so they are centralised here rather
8462
+ * than left to callers: most verbs take a singular `target`, `call.member.remove`
8463
+ * takes a plural `targets` array, and `call.member.position.set` takes a flat
8464
+ * `targets` of `{call_id, position}` the one verb keyed on call_id rather than
8465
+ * member_id, so the member triple `Participant.setPosition` built is unwrapped.
8466
+ */
8467
+ async executeMethodInDialog(target, method, args) {
8468
+ const control = { ...args };
8469
+ if (method === "call.member.position.set") control.targets = (args.targets ?? []).map((entry) => ({
8470
+ call_id: entry.target?.call_id ?? entry.call_id,
8471
+ position: entry.position
8472
+ }));
8473
+ else {
8474
+ const member = typeof target === "object" ? {
8475
+ call_id: target.call_id,
8476
+ member_id: target.member_id
8477
+ } : {
7933
8478
  call_id: this.id,
7934
8479
  member_id: target
7935
- }
8480
+ };
8481
+ if (method === "call.member.remove") control.targets = [member];
8482
+ else control.target = member;
8483
+ }
8484
+ return this.sendCommand(method, control);
8485
+ }
8486
+ /**
8487
+ * Sends a `call.*` control verb **in-dialog** via `verto.info`, as an alternative
8488
+ * to the routed {@link executeMethod} transport.
8489
+ *
8490
+ * Why both exist: `executeMethod` addresses the member with an explicit
8491
+ * `{node_id, call_id, member_id}` tuple, which does not resolve for every conference,
8492
+ * so the op can fail. An in-dialog frame carries the verb on the member's own
8493
+ * signaling channel instead, so control works without the client needing to know how
8494
+ * the conference is hosted.
8495
+ *
8496
+ * The trade-off is reach: the in-dialog transport is only accepted for calls that
8497
+ * join a conference over SWML (e.g. an SWML `join_conference`); use the routed
8498
+ * default otherwise.
8499
+ *
8500
+ * `params` are sent verbatim — nothing is built for you, which includes the target.
8501
+ * **A self-directed op still needs one**, or it is refused; name yourself explicitly:
8502
+ *
8503
+ * ```ts
8504
+ * const { call_id, member_id } = call.self.target;
8505
+ * await call.sendCommand('call.mute', { channels: ['audio'], target: { call_id, member_id } });
8506
+ * ```
8507
+ *
8508
+ * Never include `node_id` — only the two ids. The shapes are per-verb: most take a
8509
+ * singular `target`, `call.member.remove` takes a plural `targets` array, and
8510
+ * `call.member.position.set` takes a flat `targets: [{call_id, position}]` (the one
8511
+ * verb keyed on `call_id` rather than `member_id`). Verbs that act on the call as a
8512
+ * whole, or that the SDK does not wrap at all, take no target.
8513
+ *
8514
+ * For the typed alternative that handles all of this, create the client with
8515
+ * `callControl: 'in-dialog'` and use the ordinary `Call`/`Participant` methods.
8516
+ *
8517
+ * @internal Not part of the supported surface while the in-dialog transport is still
8518
+ * rolling out. `WebRTCCall` is exported from the package entry, so without this tag
8519
+ * TypeDoc publishes the method — and the example above — as public API.
8520
+ *
8521
+ * @param method - A `call.*` method name (e.g. `'call.mute'`).
8522
+ * @param params - Method parameters, sent verbatim.
8523
+ * @returns The method's own reply, unwrapped from the `verto.info` envelope.
8524
+ * @throws {JSONRPCError} If the control op fails.
8525
+ */
8526
+ async sendCommand(method, params = {}) {
8527
+ return unwrapVertoReply(await this.vertoManager.sendCallControl(method, params));
8528
+ }
8529
+ /**
8530
+ * The local leg's member triple — sent as `self` in every member RPC
8531
+ * envelope, and as the `target` of call-scoped self-operations (e.g. lock,
8532
+ * layout).
8533
+ *
8534
+ * @throws {CallNotReadyError} Before `call.joined` delivers the self member
8535
+ * context (`selfId`/`nodeId`) — an RPC without it cannot be routed, so fail
8536
+ * fast instead of sending a doomed request.
8537
+ */
8538
+ get callSelf() {
8539
+ const node_id = this.nodeId;
8540
+ const member_id = this.vertoManager.selfId;
8541
+ if (!node_id || !member_id) throw new require_operators.CallNotReadyError(this.id);
8542
+ return {
8543
+ node_id,
8544
+ call_id: this.id,
8545
+ member_id
7936
8546
  };
7937
8547
  }
7938
8548
  /** Observable of the current call status (e.g. `'ringing'`, `'connected'`). */
@@ -8088,13 +8698,13 @@ var WebRTCCall = class extends Destroyable {
8088
8698
  get bandwidthConstrained$() {
8089
8699
  return this.deferEmission(this._bandwidthConstrained$.asObservable());
8090
8700
  }
8091
- /** Observable that emits when server-pushed media params are applied. */
8701
+ /** Observable that emits when the server pushes media params. */
8092
8702
  get mediaParamsUpdated$() {
8093
8703
  return this.deferEmission(this._mediaParamsUpdated$.asObservable());
8094
8704
  }
8095
8705
  /**
8096
8706
  * @internal Emit a media params update event.
8097
- * Called by the VertoManager when server-pushed media params are applied.
8707
+ * Called by the VertoManager when the server pushes media params.
8098
8708
  */
8099
8709
  emitMediaParamsUpdated(event) {
8100
8710
  this._mediaParamsUpdated$.next(event);
@@ -8248,13 +8858,13 @@ var WebRTCCall = class extends Destroyable {
8248
8858
  async waitForPeerConnectionConnected() {
8249
8859
  const pc = this.rtcPeerConnection;
8250
8860
  if (!pc) return false;
8251
- const deadline = Date.now() + PEER_CONNECTION_RECOVERY_WAIT_MS;
8861
+ const deadline = Date.now() + require_operators.PEER_CONNECTION_RECOVERY_WAIT_MS;
8252
8862
  for (;;) {
8253
8863
  const state = pc.connectionState;
8254
8864
  if (state === "connected") return true;
8255
8865
  if (state === "failed" || state === "closed") return false;
8256
8866
  if (Date.now() >= deadline) return false;
8257
- await new Promise((resolve) => setTimeout(resolve, PEER_CONNECTION_RECOVERY_POLL_MS));
8867
+ await new Promise((resolve) => setTimeout(resolve, require_operators.PEER_CONNECTION_RECOVERY_POLL_MS));
8258
8868
  }
8259
8869
  }
8260
8870
  /**
@@ -8302,6 +8912,10 @@ var WebRTCCall = class extends Destroyable {
8302
8912
  get selfId$() {
8303
8913
  return this.vertoManager.selfId$;
8304
8914
  }
8915
+ /** @internal Lets call creation bound the media and signalling phases apart. */
8916
+ get localMediaSettled$() {
8917
+ return this.vertoManager.localMediaSettled$;
8918
+ }
8305
8919
  /** Local participant's member ID, or `null` if not joined. */
8306
8920
  get selfId() {
8307
8921
  return this.vertoManager.selfId;
@@ -8495,12 +9109,17 @@ var WebRTCCall = class extends Destroyable {
8495
9109
  *
8496
9110
  * **These operations are NOT atomic.** The layout is applied first, then each
8497
9111
  * member position sequentially, so members may briefly flash into their
8498
- * default slots before being moved to the requested positions.
9112
+ * default slots before being moved to the requested positions. Targeted
9113
+ * members are validated upfront, though: when any of them has no
9114
+ * {@link Participant.target | member call context} yet, the whole call
9115
+ * rejects before any request is sent and the layout is left unchanged.
8499
9116
  *
8500
9117
  * @param layout - Layout name (must be one of {@link layouts}).
8501
9118
  * @param positions - Optional map of member IDs to {@link VideoPosition} values.
8502
9119
  * When omitted or empty, only the layout is changed.
8503
9120
  * @throws {InvalidParams} If the layout is not in the available {@link layouts}.
9121
+ * @throws {ParticipantNotReadyError} If a targeted member's call context has
9122
+ * not been received yet — thrown before any request is sent.
8504
9123
  *
8505
9124
  * @example
8506
9125
  * ```ts
@@ -8511,18 +9130,19 @@ var WebRTCCall = class extends Destroyable {
8511
9130
  */
8512
9131
  async setLayout(layout, positions) {
8513
9132
  if (!this.layouts.includes(layout)) throw new require_operators.InvalidParams(`Layout ${layout} is not available in the current call layouts: ${this.layouts.join(", ")}`);
8514
- const selfId = await (0, rxjs.firstValueFrom)(this.selfId$.pipe((0, rxjs.filter)((id) => id !== null)));
8515
- await this.executeMethod(selfId, "call.layout.set", { layout });
8516
- const positionEntries = Object.entries(positions ?? {});
8517
- if (positionEntries.length === 0) return;
8518
- for (const [memberId, position] of positionEntries) {
9133
+ const targets = [];
9134
+ for (const [memberId, position] of Object.entries(positions ?? {})) {
8519
9135
  const participant = this.participants.find((p) => p.id === memberId);
8520
9136
  if (!participant) {
8521
9137
  logger$12.warn(`[Call] setLayout: member ${memberId} not found in participants; skipping position ${position}`);
8522
9138
  continue;
8523
9139
  }
8524
- await participant.setPosition(position);
9140
+ participant.target;
9141
+ targets.push([participant, position]);
8525
9142
  }
9143
+ const selfId = await (0, rxjs.firstValueFrom)(this.selfId$.pipe((0, rxjs.filter)((id) => id !== null)));
9144
+ await this.executeMethod(selfId, "call.layout.set", { layout });
9145
+ for (const [participant, position] of targets) await participant.setPosition(position);
8526
9146
  }
8527
9147
  /**
8528
9148
  * Transfers the call to another destination.
@@ -8624,17 +9244,28 @@ var WebRTCCall = class extends Destroyable {
8624
9244
  * (notably iOS Safari) fall back to re-acquiring the track with the new
8625
9245
  * constraint set and plumbing the replacement through the local audio
8626
9246
  * pipeline if one is active.
9247
+ *
9248
+ * @returns whether the constraint reached the microphone. `false` is an
9249
+ * outcome rather than an error — a leg sending media the SDK did not capture
9250
+ * is left alone — so a UI that reflects the toggle must read it. Any failure
9251
+ * behind a `false` is also reported on {@link errors$}.
8627
9252
  */
8628
9253
  async setEchoCancellation(enabled) {
8629
- await this.vertoManager.updateMediaConstraints({ audio: { echoCancellation: enabled } });
9254
+ return this.vertoManager.updateMediaConstraints({ audio: { echoCancellation: enabled } });
8630
9255
  }
8631
- /** Toggle browser noise suppression on the local mic at runtime. */
9256
+ /**
9257
+ * Toggle browser noise suppression on the local mic at runtime.
9258
+ * @returns whether the constraint reached the microphone.
9259
+ */
8632
9260
  async setNoiseSuppression(enabled) {
8633
- await this.vertoManager.updateMediaConstraints({ audio: { noiseSuppression: enabled } });
9261
+ return this.vertoManager.updateMediaConstraints({ audio: { noiseSuppression: enabled } });
8634
9262
  }
8635
- /** Toggle browser automatic gain control on the local mic at runtime. */
9263
+ /**
9264
+ * Toggle browser automatic gain control on the local mic at runtime.
9265
+ * @returns whether the constraint reached the microphone.
9266
+ */
8636
9267
  async setAutoGainControl(enabled) {
8637
- await this.vertoManager.updateMediaConstraints({ audio: { autoGainControl: enabled } });
9268
+ return this.vertoManager.updateMediaConstraints({ audio: { autoGainControl: enabled } });
8638
9269
  }
8639
9270
  /**
8640
9271
  * Observable of the aggregate remote audio level, 0..1 RMS. The server
@@ -8691,11 +9322,15 @@ var WebRTCCall = class extends Destroyable {
8691
9322
  /**
8692
9323
  * Infers the semantic error category from a raw Error thrown by VertoManager
8693
9324
  * or an RTCPeerConnection layer.
9325
+ *
9326
+ * Pure function — exported for unit testing.
9327
+ * @internal
8694
9328
  */
8695
9329
  function inferCallErrorKind(error) {
8696
9330
  if (error instanceof require_operators.RPCTimeoutError) return "timeout";
8697
9331
  if (error instanceof require_operators.JSONRPCError) return "signaling";
8698
9332
  if (error instanceof require_operators.MediaTrackError) return "media";
9333
+ if (error instanceof require_operators.MediaAccessError) return "media";
8699
9334
  if (error instanceof require_operators.WebSocketConnectionError || error instanceof require_operators.TransportConnectionError) return "network";
8700
9335
  return "internal";
8701
9336
  }
@@ -8704,14 +9339,22 @@ function inferCallErrorKind(error) {
8704
9339
  * destroy the call, because the session will reauthenticate and any pending
8705
9340
  * RPC can then be retried. */
8706
9341
  const RECOVERABLE_RPC_CODES = new Set([
8707
- RPC_ERROR_REQUESTER_VALIDATION_FAILED,
8708
- RPC_ERROR_AUTHENTICATION_FAILED,
8709
- RPC_ERROR_INVALID_PARAMS
9342
+ require_operators.RPC_ERROR_REQUESTER_VALIDATION_FAILED,
9343
+ require_operators.RPC_ERROR_AUTHENTICATION_FAILED,
9344
+ require_operators.RPC_ERROR_INVALID_PARAMS
8710
9345
  ]);
8711
- /** Determines whether an error should be fatal (destroy the call). */
9346
+ /**
9347
+ * A *fallback*: callers knowing which leg failed pass an explicit `fatal` and
9348
+ * never reach here, so the default-fatal branch only sees call- and main-leg
9349
+ * errors. Auxiliary legs go through `WebRTCVertoManager.reportLegError`.
9350
+ *
9351
+ * Pure function — exported for unit testing.
9352
+ * @internal
9353
+ */
8712
9354
  function isFatalError(error) {
8713
9355
  if (error instanceof require_operators.VertoPongError) return false;
8714
9356
  if (error instanceof require_operators.MediaTrackError) return false;
9357
+ if (error instanceof require_operators.MediaAccessError) return error.fatal;
8715
9358
  if (error instanceof require_operators.RPCTimeoutError) return false;
8716
9359
  if (error instanceof require_operators.JSONRPCError && RECOVERABLE_RPC_CODES.has(error.code)) return false;
8717
9360
  return true;
@@ -8737,12 +9380,14 @@ var CallFactory = class {
8737
9380
  return {
8738
9381
  vertoManager: new WebRTCVertoManager(callInstance, this.attachManager, this.deviceController, this.webRTCApiProvider, {
8739
9382
  nodeId: options.nodeId,
8740
- onError: (error) => {
9383
+ onError: (error, options$1) => {
8741
9384
  const callError = {
8742
9385
  kind: inferCallErrorKind(error),
8743
- fatal: isFatalError(error),
9386
+ fatal: options$1?.fatal ?? isFatalError(error),
8744
9387
  error,
8745
- callId: callInstance.id
9388
+ callId: callInstance.id,
9389
+ ...options$1?.leg ? { leg: options$1.leg } : {},
9390
+ ...options$1?.legId ? { legId: options$1.legId } : {}
8746
9391
  };
8747
9392
  callInstance.emitError(callError);
8748
9393
  },
@@ -9199,6 +9844,41 @@ var PendingRPC = class PendingRPC {
9199
9844
  //#endregion
9200
9845
  //#region src/managers/ClientSessionManager.ts
9201
9846
  const logger$9 = require_operators.getLogger();
9847
+ /**
9848
+ * Decide whether an error emitted on `call.errors$` during dial should
9849
+ * abort the dial. A non-fatal MediaAccessError means the call degraded to
9850
+ * receive-only and still connects — everything else rejects `dial()` with
9851
+ * the real cause.
9852
+ *
9853
+ * Pure function — exported for unit testing.
9854
+ */
9855
+ function shouldAbortDial(callError) {
9856
+ return callError.fatal || !(callError.error instanceof require_operators.MediaAccessError);
9857
+ }
9858
+ /**
9859
+ * Wait for a dialed call to be ready, or for the failure that stops it.
9860
+ *
9861
+ * Local media acquisition is deliberately unbounded: a permission prompt or a
9862
+ * device picker is human time, and `getUserMedia` cannot be cancelled anyway.
9863
+ * The clock starts only once acquisition settles, so a slow human never spends
9864
+ * the server's budget.
9865
+ *
9866
+ * `merge` rather than `race`, because the two legs settle asymmetrically. A
9867
+ * fatal acquisition failure reports the error and then destroys the call in the
9868
+ * same synchronous step; `errors$` defers delivery by a microtask while
9869
+ * `localMediaSettled$` completes immediately. Under `race` that bare completion
9870
+ * ended the wait first and `dial()` rejected with an RxJS `EmptyError`, burying
9871
+ * the `NotAllowedError` applications are told to inspect. Under `merge` the
9872
+ * completed leg is simply spent, and the queued error — enqueued before the
9873
+ * completion, so delivered before it — arrives to reject the wait. A dial
9874
+ * abandoned with no error at all still ends both legs, and the resulting
9875
+ * `EmptyError` remains the benign-cancel signal.
9876
+ *
9877
+ * Exported for unit testing.
9878
+ */
9879
+ async function awaitDialReady(session, signalingTimeoutMs) {
9880
+ return (0, rxjs.firstValueFrom)((0, rxjs.merge)(session.localMediaSettled$.pipe((0, rxjs.take)(1), (0, rxjs.switchMap)(() => session.selfId$.pipe((0, rxjs.filter)((id) => Boolean(id)), (0, rxjs.take)(1), (0, rxjs.timeout)(signalingTimeoutMs)))), session.errors$.pipe((0, rxjs.filter)(shouldAbortDial), (0, rxjs.take)(1), (0, rxjs.switchMap)((callError) => (0, rxjs.throwError)(() => callError.error)))));
9881
+ }
9202
9882
  const getAddressSearchURI = (options) => {
9203
9883
  const to = options.to?.split("?")[0];
9204
9884
  const from$8 = options.from?.startsWith("subscriber://") ? options.from.replace("subscriber://", "") : options.from;
@@ -9215,7 +9895,6 @@ var ClientSessionManager = class extends Destroyable {
9215
9895
  this.authorizationStateKey = authorizationStateKey;
9216
9896
  this.attachManager = attachManager;
9217
9897
  this.dpopManager = dpopManager;
9218
- this.callCreateTimeout = 6e3;
9219
9898
  this.agent = `signalwire-js/4.0.0`;
9220
9899
  this.eventAcks = true;
9221
9900
  this.authorizationState$ = this.createReplaySubject(1);
@@ -9224,6 +9903,7 @@ var ClientSessionManager = class extends Destroyable {
9224
9903
  minor: 0,
9225
9904
  revision: 0
9226
9905
  };
9906
+ this.callControl = "routed";
9227
9907
  this._authorization$ = this.createBehaviorSubject(void 0);
9228
9908
  this._errors$ = this.createReplaySubject(1);
9229
9909
  this._authState$ = this.createBehaviorSubject({ kind: "unauthenticated" });
@@ -9424,21 +10104,18 @@ var ClientSessionManager = class extends Destroyable {
9424
10104
  }
9425
10105
  async handleAuthenticationError(error) {
9426
10106
  logger$9.error("Authentication error:", error);
9427
- const isRecoverableAuthError = error instanceof require_operators.JSONRPCError && (error.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === RPC_ERROR_INVALID_PARAMS || error.code === RPC_ERROR_AUTHENTICATION_FAILED);
10107
+ const isRecoverableAuthError$1 = error instanceof require_operators.JSONRPCError && (error.code === require_operators.RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === require_operators.RPC_ERROR_INVALID_PARAMS || error.code === require_operators.RPC_ERROR_AUTHENTICATION_FAILED);
9428
10108
  const hasStoredState = await (0, rxjs.firstValueFrom)(this.authorizationState$.pipe((0, rxjs.take)(1))) !== void 0;
9429
- if (isRecoverableAuthError && hasStoredState) {
10109
+ if (isRecoverableAuthError$1 && hasStoredState) {
9430
10110
  logger$9.debug("[Session] Recoverable auth error — cleaning up stored state and reconnecting fresh");
9431
- try {
9432
- await this.cleanupStoredConnectionParams();
9433
- } catch (cleanupError) {
9434
- logger$9.error("Failed to cleanup stored connection params:", cleanupError);
9435
- } finally {
9436
- this.transport.reconnect();
9437
- }
10111
+ await this.discardResumeStateAndReconnect();
9438
10112
  } else this._errors$.next(error);
9439
10113
  }
9440
10114
  /**
9441
- * Clear the resume state (authorization_state + protocol) only.
10115
+ * Clear the resume state (authorization_state + protocol) and ask the
10116
+ * transport to reconnect. The `connected` event re-triggers
10117
+ * `authenticate()`, which now has no stored state and so performs a fresh
10118
+ * connect.
9442
10119
  *
9443
10120
  * This is the stale-auth-state recovery helper used by handleAuthError:
9444
10121
  * the server rejected a reconnect, so the resume state is discarded and a
@@ -9446,9 +10123,24 @@ var ClientSessionManager = class extends Destroyable {
9446
10123
  * session lives on through the reconnect and reattachCalls() needs the
9447
10124
  * stored call references afterwards. Do NOT add detachAll() here.
9448
10125
  *
10126
+ * Connect-time recovery only. A *request* refused on an already
10127
+ * authenticated session is never healed here: dropping the resume state
10128
+ * destroys the association between the socket and the previous session,
10129
+ * which is what reattach depends on. That path mints a fresh credential and
10130
+ * reauthenticates instead (see `SignalWire.recoverAndRetry`).
10131
+ *
9449
10132
  * For public teardown (disconnect/destroy), use {@link teardownSessionState}
9450
10133
  * instead, which clears the attach records as well.
9451
10134
  */
10135
+ async discardResumeStateAndReconnect() {
10136
+ try {
10137
+ await this.cleanupStoredConnectionParams();
10138
+ } catch (cleanupError) {
10139
+ logger$9.error("Failed to cleanup stored connection params:", cleanupError);
10140
+ } finally {
10141
+ this.transport.reconnect();
10142
+ }
10143
+ }
9452
10144
  async cleanupStoredConnectionParams() {
9453
10145
  await this.transport.setProtocol(void 0);
9454
10146
  await this.updateAuthorizationStateInStorage(void 0);
@@ -9522,11 +10214,15 @@ var ClientSessionManager = class extends Destroyable {
9522
10214
  const isReconnect = hasReconnectState && storedToken;
9523
10215
  let dpopToken;
9524
10216
  if (isReconnect) logger$9.debug("[Session] Reconnecting with stored jwt_token + authorization_state");
9525
- else if (this.onBeforeReconnect && this.clientBound) {
9526
- logger$9.debug("[Session] Refreshing credentials before fresh connect");
9527
- await this.onBeforeReconnect();
10217
+ else {
10218
+ const credential = this.getCredential();
10219
+ const credentialExpired = credential.expiry_at !== void 0 && credential.expiry_at <= Date.now() + require_operators.CREDENTIAL_EXPIRY_SKEW_MS;
10220
+ if (this.onBeforeReconnect && (this.clientBound || credentialExpired)) {
10221
+ logger$9.debug("[Session] Refreshing credentials before fresh connect");
10222
+ await this.onBeforeReconnect();
10223
+ }
9528
10224
  }
9529
- if ((!isReconnect || this.clientBound) && this.dpopManager?.initialized) try {
10225
+ if (this.dpopManager?.initialized) try {
9530
10226
  dpopToken = await this.dpopManager.createRpcProof({ method: "signalwire.connect" });
9531
10227
  } catch (error) {
9532
10228
  if (this.clientBound) throw error;
@@ -9559,6 +10255,7 @@ var ClientSessionManager = class extends Destroyable {
9559
10255
  });
9560
10256
  if (response.protocol) await this.transport.setProtocol(response.protocol);
9561
10257
  this._authorization$.next(response.authorization);
10258
+ if (response.authorization.cnf?.jkt) this._wasClientBound = true;
9562
10259
  this._iceServers$.next(response.ice_servers ?? []);
9563
10260
  this._authState$.next({ kind: "authenticated" });
9564
10261
  logger$9.debug("[Session] Authentication completed successfully");
@@ -9625,7 +10322,7 @@ var ClientSessionManager = class extends Destroyable {
9625
10322
  to: destinationURI,
9626
10323
  ...options
9627
10324
  });
9628
- await (0, rxjs.firstValueFrom)((0, rxjs.race)(callSession.selfId$.pipe((0, rxjs.filter)((id) => Boolean(id)), (0, rxjs.take)(1), (0, rxjs.timeout)(this.callCreateTimeout)), callSession.errors$.pipe((0, rxjs.take)(1), (0, rxjs.switchMap)((callError) => (0, rxjs.throwError)(() => callError.error)))));
10325
+ await awaitDialReady(callSession, require_operators.DEFAULT_CALL_SIGNALING_TIMEOUT_MS);
9629
10326
  this._calls$.next({
9630
10327
  [`${callSession.id}`]: callSession,
9631
10328
  ...this._calls$.value
@@ -9678,12 +10375,23 @@ var ClientSessionWrapper = class {
9678
10375
  get authenticated() {
9679
10376
  return this.clientSessionManager.authenticated;
9680
10377
  }
10378
+ /**
10379
+ * Whether the session is using a Client Bound SAT (DPoP). Sticky — set
10380
+ * when the binding is established or restored from a resumed session's
10381
+ * server authorization.
10382
+ */
10383
+ get clientBound() {
10384
+ return this.clientSessionManager.clientBound;
10385
+ }
9681
10386
  get signalingEvent$() {
9682
10387
  return this.clientSessionManager.signalingEvent$;
9683
10388
  }
9684
10389
  get iceServers() {
9685
10390
  return this.clientSessionManager.iceServers;
9686
10391
  }
10392
+ get callControl() {
10393
+ return this.clientSessionManager.callControl;
10394
+ }
9687
10395
  async execute(request, options) {
9688
10396
  return this.clientSessionManager.execute(request, options);
9689
10397
  }
@@ -9789,7 +10497,7 @@ function resolveExpiresAt(data) {
9789
10497
  if (data.expires_at) return data.expires_at;
9790
10498
  if (data.expires_in) return Math.floor(Date.now() / 1e3) + data.expires_in;
9791
10499
  logger$7.warn("[DeviceToken] Could not determine token expiry, using default");
9792
- return Math.floor(Date.now() / 1e3) + DEVICE_TOKEN_DEFAULT_EXPIRE_IN;
10500
+ return Math.floor(Date.now() / 1e3) + require_operators.DEVICE_TOKEN_DEFAULT_EXPIRE_IN;
9793
10501
  }
9794
10502
  /**
9795
10503
  * Resolves the token TTL in seconds from a fresh response.
@@ -9802,7 +10510,7 @@ function resolveExpiresAt(data) {
9802
10510
  function resolveExpireIn(data) {
9803
10511
  if (data.expires_in) return data.expires_in;
9804
10512
  if (data.expires_at) return Math.max(data.expires_at - Math.floor(Date.now() / 1e3), 1);
9805
- return DEVICE_TOKEN_DEFAULT_EXPIRE_IN;
10513
+ return require_operators.DEVICE_TOKEN_DEFAULT_EXPIRE_IN;
9806
10514
  }
9807
10515
  /**
9808
10516
  * Manages the Client Bound SAT lifecycle: activation, token exchange,
@@ -9822,10 +10530,10 @@ var DeviceTokenManager = class extends Destroyable {
9822
10530
  this._currentToken$ = this.createBehaviorSubject(null);
9823
10531
  this._refreshInProgress = false;
9824
10532
  this._paused = false;
9825
- this._effectiveExpireIn = DEVICE_TOKEN_DEFAULT_EXPIRE_IN;
10533
+ this._effectiveExpireIn = require_operators.DEVICE_TOKEN_DEFAULT_EXPIRE_IN;
9826
10534
  this.subscribeTo(this._currentToken$.pipe((0, rxjs.filter)(Boolean), (0, rxjs.switchMap)((tokenData) => {
9827
10535
  const expiresAt = resolveExpiresAt(tokenData);
9828
- const refreshIn = Math.max(expiresAt * 1e3 - Date.now() - DEVICE_TOKEN_REFRESH_BUFFER_MS, 1e3);
10536
+ const refreshIn = Math.max(expiresAt * 1e3 - Date.now() - require_operators.DEVICE_TOKEN_REFRESH_BUFFER_MS, 1e3);
9829
10537
  logger$7.debug(`[DeviceToken] Scheduling Client Bound SAT refresh in ${refreshIn}ms`);
9830
10538
  return (0, rxjs.timer)(refreshIn);
9831
10539
  })), () => {
@@ -9853,7 +10561,7 @@ var DeviceTokenManager = class extends Destroyable {
9853
10561
  */
9854
10562
  async activate(user, session, updateCredential) {
9855
10563
  const { satClaims } = user;
9856
- if (!satClaims?.scope?.includes(SAT_REFRESH_SCOPE)) {
10564
+ if (!satClaims?.scope?.includes(require_operators.SAT_REFRESH_SCOPE)) {
9857
10565
  logger$7.debug("[DeviceToken] No sat:refresh scope, skipping Client Bound SAT activation");
9858
10566
  return {
9859
10567
  activated: false,
@@ -9878,7 +10586,7 @@ var DeviceTokenManager = class extends Destroyable {
9878
10586
  await session.reauthenticate(tokenData.token, rpcProof, { clientBound: true });
9879
10587
  updateCredential({ token: tokenData.token });
9880
10588
  logger$7.info("[DeviceToken] Client Bound SAT activated successfully");
9881
- this._currentToken$.next(tokenData);
10589
+ this.emitCurrentToken(tokenData);
9882
10590
  return { activated: true };
9883
10591
  } catch (error) {
9884
10592
  logger$7.error("[DeviceToken] Failed to activate Client Bound SAT:", error);
@@ -9890,13 +10598,28 @@ var DeviceTokenManager = class extends Destroyable {
9890
10598
  }
9891
10599
  }
9892
10600
  /**
10601
+ * Emit a freshly received token to the reactive pipeline, stamping an
10602
+ * absolute `expires_at` when the response carried only `expires_in`.
10603
+ * Resolving the expiry at RECEIVE time (not at read time) is what lets
10604
+ * {@link refreshNowIfDue} detect due-ness on resume: a bare `expires_in`
10605
+ * re-resolved later would always compute a full TTL from "now" and never
10606
+ * cross the refresh buffer.
10607
+ */
10608
+ emitCurrentToken(token) {
10609
+ const stamped = token.expires_at ? token : {
10610
+ ...token,
10611
+ expires_at: resolveExpiresAt(token)
10612
+ };
10613
+ this._currentToken$.next(stamped);
10614
+ }
10615
+ /**
9893
10616
  * Returns true when the cached token has enough headroom before expiry to
9894
10617
  * be safely reused on reactivation. The headroom matches the refresh
9895
10618
  * buffer, so a token within the refresh window is treated as stale (the
9896
10619
  * reactive pipeline is about to refresh it anyway).
9897
10620
  */
9898
10621
  isTokenFresh(token) {
9899
- return resolveExpiresAt(token) * 1e3 - Date.now() > DEVICE_TOKEN_REFRESH_BUFFER_MS;
10622
+ return resolveExpiresAt(token) * 1e3 - Date.now() > require_operators.DEVICE_TOKEN_REFRESH_BUFFER_MS;
9900
10623
  }
9901
10624
  /**
9902
10625
  * Obtains a Client Bound SAT from `/api/fabric/subscriber/devices/token`.
@@ -9905,14 +10628,14 @@ var DeviceTokenManager = class extends Destroyable {
9905
10628
  async obtainToken() {
9906
10629
  const dpopProof = await this.dpopManager.createHttpProof({
9907
10630
  method: "POST",
9908
- uri: DEVICE_TOKEN_ENDPOINT
10631
+ uri: require_operators.DEVICE_TOKEN_ENDPOINT
9909
10632
  });
9910
- const response = await this.http.request({
9911
- url: DEVICE_TOKEN_ENDPOINT,
10633
+ const response = await this.http().request({
10634
+ url: require_operators.DEVICE_TOKEN_ENDPOINT,
9912
10635
  ...POST_PARAMS,
9913
10636
  body: JSON.stringify({
9914
10637
  dpop_token: dpopProof,
9915
- expire_in: DEVICE_TOKEN_DEFAULT_EXPIRE_IN
10638
+ expire_in: require_operators.DEVICE_TOKEN_DEFAULT_EXPIRE_IN
9916
10639
  })
9917
10640
  });
9918
10641
  if (!response.ok || !response.body) throw new require_operators.DeviceTokenError(`Failed to obtain device token: ${response.status} ${response.statusText}`);
@@ -9931,11 +10654,11 @@ var DeviceTokenManager = class extends Destroyable {
9931
10654
  logger$7.debug("[DeviceToken] Refreshing Client Bound SAT");
9932
10655
  const dpopProof = await this.dpopManager.createHttpProof({
9933
10656
  method: "POST",
9934
- uri: DEVICE_REFRESH_ENDPOINT,
10657
+ uri: require_operators.DEVICE_REFRESH_ENDPOINT,
9935
10658
  accessToken: currentToken
9936
10659
  });
9937
- const response = await this.http.request({
9938
- url: DEVICE_REFRESH_ENDPOINT,
10660
+ const response = await this.http().request({
10661
+ url: require_operators.DEVICE_REFRESH_ENDPOINT,
9939
10662
  ...POST_PARAMS,
9940
10663
  body: JSON.stringify({
9941
10664
  dpop_token: dpopProof,
@@ -9982,7 +10705,7 @@ var DeviceTokenManager = class extends Destroyable {
9982
10705
  const currentToken = this.getCredential().token;
9983
10706
  if (!currentToken) throw new require_operators.TokenRefreshError("No current token available for refresh");
9984
10707
  const newTokenData = await this.retryRefresh(session, currentToken, updateCredential);
9985
- this._currentToken$.next(newTokenData);
10708
+ this.emitCurrentToken(newTokenData);
9986
10709
  } catch (error) {
9987
10710
  logger$7.error("[DeviceToken] Automatic Client Bound SAT refresh failed:", error);
9988
10711
  this.errorHandler(error instanceof require_operators.TokenRefreshError ? error : new require_operators.TokenRefreshError("Automatic token refresh failed", error));
@@ -9996,12 +10719,12 @@ var DeviceTokenManager = class extends Destroyable {
9996
10719
  */
9997
10720
  async retryRefresh(session, currentToken, updateCredential) {
9998
10721
  let lastError;
9999
- for (let attempt = 0; attempt < DEVICE_TOKEN_REFRESH_MAX_RETRIES; attempt++) try {
10722
+ for (let attempt = 0; attempt < require_operators.DEVICE_TOKEN_REFRESH_MAX_RETRIES; attempt++) try {
10000
10723
  return await this.refreshToken(session, currentToken, updateCredential);
10001
10724
  } catch (error) {
10002
10725
  lastError = error;
10003
- if (attempt < DEVICE_TOKEN_REFRESH_MAX_RETRIES - 1) {
10004
- const delay = DEVICE_TOKEN_REFRESH_RETRY_BASE_MS * Math.pow(2, attempt);
10726
+ if (attempt < require_operators.DEVICE_TOKEN_REFRESH_MAX_RETRIES - 1) {
10727
+ const delay = require_operators.DEVICE_TOKEN_REFRESH_RETRY_BASE_MS * Math.pow(2, attempt);
10005
10728
  logger$7.warn(`[DeviceToken] Refresh attempt ${attempt + 1} failed, retrying in ${delay}ms`);
10006
10729
  await new Promise((resolve) => setTimeout(resolve, delay));
10007
10730
  }
@@ -10009,6 +10732,22 @@ var DeviceTokenManager = class extends Destroyable {
10009
10732
  throw lastError instanceof Error ? lastError : new require_operators.TokenRefreshError("All refresh retries exhausted", lastError);
10010
10733
  }
10011
10734
  /**
10735
+ * Force an immediate refresh when the cached Client Bound SAT is already
10736
+ * past its refresh window. Called on resume from suspension where
10737
+ * background-tab throttling can delay the reactive timer past the buffer.
10738
+ * A no-op when no token is cached or it still has headroom; the normal
10739
+ * {@link executeRefresh} guards (paused / in-progress / unauthenticated)
10740
+ * still apply.
10741
+ */
10742
+ refreshNowIfDue() {
10743
+ const token = this._currentToken$.value;
10744
+ if (!token) return;
10745
+ if (resolveExpiresAt(token) * 1e3 - Date.now() <= require_operators.DEVICE_TOKEN_REFRESH_BUFFER_MS) {
10746
+ logger$7.debug("[DeviceToken] Resume: cached SAT past refresh window; refreshing now");
10747
+ this.executeRefresh();
10748
+ }
10749
+ }
10750
+ /**
10012
10751
  * Stops the reactive refresh pipeline from firing. Use when the underlying
10013
10752
  * session is being torn down (e.g., during {@link SignalWire.disconnect})
10014
10753
  * so a scheduled refresh cannot fire against a destroyed session.
@@ -10059,6 +10798,7 @@ var CredentialRefreshCoordinator = class extends Destroyable {
10059
10798
  this.deps = deps;
10060
10799
  this._activating = false;
10061
10800
  this._activationGeneration = 0;
10801
+ this._developerRefreshInProgress = false;
10062
10802
  if (dpopManager?.initialized) this._deviceTokenManager = (deps.deviceTokenManagerFactory ?? defaultDeviceTokenManagerFactory)(dpopManager, deps.http, (error) => deps.notifier.onError(error), () => deps.store.read());
10063
10803
  }
10064
10804
  /** True when the Client Bound SAT path is available (DPoP initialized). */
@@ -10077,28 +10817,120 @@ var CredentialRefreshCoordinator = class extends Destroyable {
10077
10817
  * invokes `deps.onRefreshExhausted` so the orchestrator can disconnect.
10078
10818
  */
10079
10819
  scheduleDeveloperRefresh(provider, expiresAt, attempt = 0) {
10820
+ this._activeProvider = provider;
10080
10821
  if (this._developerTimerId !== void 0) clearTimeout(this._developerTimerId);
10081
- 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);
10082
- this._developerTimerId = setTimeout(async () => {
10822
+ const refreshInterval = attempt === 0 ? Math.max(expiresAt - Date.now() - require_operators.CREDENTIAL_REFRESH_BUFFER_MS, 1e3) : Math.min(require_operators.CREDENTIAL_REFRESH_RETRY_BASE_MS * Math.pow(2, attempt) * (.5 + Math.random() * .5), require_operators.CREDENTIAL_REFRESH_MAX_DELAY_MS);
10823
+ this._developerTimerId = setTimeout(() => {
10824
+ this._developerTimerId = void 0;
10825
+ this.executeDeveloperRefresh(provider, expiresAt, attempt);
10826
+ }, refreshInterval);
10827
+ }
10828
+ /**
10829
+ * Runs the developer-provided refresh once: mints a new credential, stores
10830
+ * and persists it, reauthenticates the live session (via the notifier), and
10831
+ * reschedules against the new expiry. On failure retries with backoff up to
10832
+ * {@link CREDENTIAL_REFRESH_MAX_RETRIES}, then signals exhaustion.
10833
+ *
10834
+ * Shared by the scheduled timer tick and {@link forceRefreshIfDue}. The
10835
+ * `_developerRefreshInProgress` guard prevents the two from overlapping.
10836
+ */
10837
+ async executeDeveloperRefresh(provider, expiresAt, attempt) {
10838
+ if (this._developerRefreshInProgress) {
10839
+ logger$6.debug("[Coordinator] Developer refresh already in progress; skipping");
10840
+ return;
10841
+ }
10842
+ this._developerRefreshInProgress = true;
10843
+ try {
10844
+ const newCredentials = await this.refreshCredential(provider);
10845
+ this.deps.store.write(newCredentials);
10846
+ this.deps.store.persist(newCredentials);
10083
10847
  try {
10084
- if (!provider.refresh) throw new require_operators.InvalidCredentialsError("Credential provider does not support refresh");
10085
- const newCredentials = await provider.refresh();
10086
- this.deps.store.write(newCredentials);
10087
- this.deps.store.persist(newCredentials);
10088
- logger$6.info("[Coordinator] Credentials refreshed successfully.");
10089
- if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
10090
- } catch (error) {
10091
- const nextAttempt = attempt + 1;
10092
- logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
10093
- this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
10094
- if (nextAttempt < CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
10095
- else {
10096
- logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
10097
- this.deps.notifier.onError(new require_operators.TokenRefreshError("Credential refresh failed after max retries"));
10098
- this.deps.notifier.onRefreshExhausted();
10099
- }
10848
+ await this.deps.notifier.onCredentialRefreshed(newCredentials);
10849
+ } catch (reauthError) {
10850
+ logger$6.warn("[Coordinator] onCredentialRefreshed rejected (non-fatal):", reauthError);
10100
10851
  }
10101
- }, refreshInterval);
10852
+ logger$6.info("[Coordinator] Credentials refreshed successfully.");
10853
+ if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
10854
+ } catch (error) {
10855
+ const nextAttempt = attempt + 1;
10856
+ logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${require_operators.CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
10857
+ this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
10858
+ if (nextAttempt < require_operators.CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
10859
+ else {
10860
+ logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
10861
+ this.deps.notifier.onError(new require_operators.TokenRefreshError("Credential refresh failed after max retries"));
10862
+ this.deps.notifier.onRefreshExhausted();
10863
+ }
10864
+ } finally {
10865
+ this._developerRefreshInProgress = false;
10866
+ }
10867
+ }
10868
+ /**
10869
+ * Force an immediate refresh when the current credential is already past its
10870
+ * scheduled refresh window. Called on resume from suspension, where
10871
+ * background-tab timer throttling can delay the armed refresh well past
10872
+ * expiry, leaving the live session stale.
10873
+ *
10874
+ * Routes to whichever mechanism is armed: the developer timer if armed,
10875
+ * otherwise the Client Bound SAT pipeline. A no-op when nothing is due.
10876
+ */
10877
+ forceRefreshIfDue() {
10878
+ if (this._developerTimerId !== void 0 && this._activeProvider) {
10879
+ const expiry = this.deps.store.read().expiry_at;
10880
+ if (expiry !== void 0 && Date.now() >= expiry - require_operators.CREDENTIAL_REFRESH_BUFFER_MS) {
10881
+ logger$6.debug("[Coordinator] Resume: credential past refresh window; forcing refresh");
10882
+ clearTimeout(this._developerTimerId);
10883
+ this._developerTimerId = void 0;
10884
+ this.executeDeveloperRefresh(this._activeProvider, expiry, 0);
10885
+ }
10886
+ return;
10887
+ }
10888
+ this._deviceTokenManager?.refreshNowIfDue();
10889
+ }
10890
+ /**
10891
+ * Sync the credential's expiry from the server-provided authorization (the
10892
+ * `signalwire.connect` result). SATs are opaque JWE, so
10893
+ * `fabric_subscriber.expires_at` is the authoritative expiry of the token
10894
+ * the session actually connected with — the provider-reported `expiry_at`
10895
+ * is only a hint (and may be wrong or absent). Corrects the stored
10896
+ * credential and re-arms the developer refresh timer against the real
10897
+ * deadline when the provider supports `refresh()`.
10898
+ */
10899
+ syncExpiryFromAuthorization(authorization, provider) {
10900
+ const expiresAtSec = authorization?.fabric_subscriber?.expires_at;
10901
+ if (!expiresAtSec) return;
10902
+ const expiryAt = expiresAtSec * 1e3;
10903
+ const credential = this.deps.store.read();
10904
+ if (credential.expiry_at === expiryAt) return;
10905
+ logger$6.debug(`[Coordinator] Correcting credential expiry from server authorization: ${new Date(expiryAt).toISOString()}`);
10906
+ const updated = {
10907
+ ...credential,
10908
+ expiry_at: expiryAt
10909
+ };
10910
+ this.deps.store.write(updated);
10911
+ this.deps.store.persist(updated);
10912
+ if (provider?.refresh) this.scheduleDeveloperRefresh(provider, expiryAt);
10913
+ }
10914
+ /**
10915
+ * Invoke `provider.refresh()` deduped against any concurrent developer
10916
+ * refresh. Concurrent callers — the scheduled tick, a resume-forced refresh,
10917
+ * and the orchestrator's -32003 recovery / reconnect re-mint — share one
10918
+ * in-flight promise, so a provider backed by one-time-use rotating refresh
10919
+ * tokens is never invoked twice in parallel.
10920
+ *
10921
+ * The caller owns applying the returned credential (store write, session
10922
+ * reauth, rescheduling); this method only serializes the network call.
10923
+ */
10924
+ async refreshCredential(provider) {
10925
+ if (this._refreshInFlight) return this._refreshInFlight;
10926
+ if (!provider.refresh) throw new require_operators.InvalidCredentialsError("Credential provider does not support refresh");
10927
+ const run = provider.refresh();
10928
+ this._refreshInFlight = run;
10929
+ const clear = () => {
10930
+ if (this._refreshInFlight === run) this._refreshInFlight = void 0;
10931
+ };
10932
+ run.then(clear, clear);
10933
+ return run;
10102
10934
  }
10103
10935
  /**
10104
10936
  * Cancels any scheduled developer-provided refresh. Idempotent.
@@ -10195,7 +11027,7 @@ var CredentialRefreshCoordinator = class extends Destroyable {
10195
11027
  activated: false,
10196
11028
  reason: "activation-timeout"
10197
11029
  });
10198
- }, CREDENTIAL_ACTIVATE_TIMEOUT_MS);
11030
+ }, require_operators.CREDENTIAL_ACTIVATE_TIMEOUT_MS);
10199
11031
  inner.then((result) => {
10200
11032
  clearTimeout(timer$3);
10201
11033
  resolve(result);
@@ -10878,6 +11710,13 @@ var TransportManager = class extends Destroyable {
10878
11710
  //#endregion
10879
11711
  //#region src/clients/SignalWire.ts
10880
11712
  const logger$1 = require_operators.getLogger();
11713
+ /**
11714
+ * Storage key for the client-bound marker. The SAT and authorization_state are
11715
+ * both opaque to the SDK, so on a page reload the preflight recovery — which
11716
+ * runs before any session exists — has no other way to know the session was
11717
+ * client-bound. See {@link SignalWire.persistClientBoundMarker}.
11718
+ */
11719
+ const CLIENT_BOUND_STORAGE_KEY = "sw:client_bound";
10881
11720
  const buildOptionsFromDestination = (destination) => {
10882
11721
  if (typeof destination === "string") {
10883
11722
  const queryStartIndex = destination.indexOf("?");
@@ -10921,6 +11760,7 @@ var SignalWire = class extends Destroyable {
10921
11760
  this.preferences = new ClientPreferences();
10922
11761
  this._user$ = this.createBehaviorSubject(void 0);
10923
11762
  this._directory$ = this.createBehaviorSubject(void 0);
11763
+ this._credentialRecovered = false;
10924
11764
  this._isConnected$ = this.createBehaviorSubject(false);
10925
11765
  this._isRegistered$ = this.createBehaviorSubject(false);
10926
11766
  this._errors$ = this.createReplaySubject(1);
@@ -10959,6 +11799,16 @@ var SignalWire = class extends Destroyable {
10959
11799
  });
10960
11800
  }
10961
11801
  /**
11802
+ * Build the refresh path's own HTTP controller, against whatever host is current.
11803
+ *
11804
+ * Called on first use rather than up front, so `apiHost` already reflects the
11805
+ * token's `ch` claim. Same credential source as the container's controller — only
11806
+ * the instance, and therefore its observable streams, is separate.
11807
+ */
11808
+ createRefreshHttpController() {
11809
+ return new HTTPRequestController(this._deps.apiHost, () => this._deps.credential);
11810
+ }
11811
+ /**
10962
11812
  * Initializes DPoP if not already set up. Returns the fingerprint on success.
10963
11813
  */
10964
11814
  async initDPoP() {
@@ -10985,11 +11835,19 @@ var SignalWire = class extends Destroyable {
10985
11835
  async resolveCredentials() {
10986
11836
  const fingerprint = await this.initDPoP();
10987
11837
  this._refreshCoordinator = new CredentialRefreshCoordinator(this._dpopManager, {
10988
- http: this._deps.http,
11838
+ http: () => {
11839
+ if (!this._refreshHttp || this._refreshHttpHost !== this._deps.apiHost) {
11840
+ this._refreshHttp?.destroy();
11841
+ this._refreshHttp = this.createRefreshHttpController();
11842
+ this._refreshHttpHost = this._deps.apiHost;
11843
+ }
11844
+ return this._refreshHttp;
11845
+ },
10989
11846
  notifier: {
10990
11847
  onError: (error) => this._errors$.next(error),
10991
11848
  onWarning: (warning) => this._warnings$.next(warning),
10992
- onRefreshExhausted: () => void this.disconnect()
11849
+ onRefreshExhausted: () => void this.disconnect(),
11850
+ onCredentialRefreshed: async (credential) => this.reauthenticateLiveSession(credential)
10993
11851
  },
10994
11852
  store: {
10995
11853
  read: () => this._deps.credential,
@@ -11001,6 +11859,7 @@ var SignalWire = class extends Destroyable {
11001
11859
  ...this._deps.credential,
11002
11860
  ...partial
11003
11861
  };
11862
+ this.persistCredential(this._deps.credential);
11004
11863
  },
11005
11864
  persist: (credential) => this.persistCredential(credential)
11006
11865
  }
@@ -11046,20 +11905,181 @@ var SignalWire = class extends Destroyable {
11046
11905
  }
11047
11906
  this._deps.credential = _credentials;
11048
11907
  this.persistCredential(_credentials);
11049
- if (this.isConnected && this._clientSession.authenticated && _credentials.token) try {
11050
- await this._clientSession.reauthenticate(_credentials.token);
11908
+ await this.reauthenticateLiveSession(_credentials);
11909
+ }
11910
+ /**
11911
+ * Reauthenticate the currently-open session with a freshly obtained
11912
+ * credential so the new token takes effect on the live socket immediately —
11913
+ * not just on the next reconnect. No-op when the session is not
11914
+ * connected/authenticated or the credential carries no token (e.g. an
11915
+ * authorization-state-only refresh). Non-fatal: reauth failures surface on
11916
+ * `errors$` without aborting the refresh that triggered this.
11917
+ */
11918
+ async reauthenticateLiveSession(credential) {
11919
+ if (!this.isConnected || !this._clientSession.authenticated || !credential.token) return;
11920
+ try {
11921
+ await this._clientSession.reauthenticate(credential.token);
11051
11922
  logger$1.info("[SignalWire] Session refreshed with new credentials.");
11052
11923
  } catch (error) {
11053
11924
  logger$1.error("[SignalWire] Failed to refresh session with new credentials:", error);
11054
11925
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
11055
11926
  }
11056
11927
  }
11928
+ /**
11929
+ * Recover a session the server is refusing: mint a fresh credential,
11930
+ * reauthenticate the live session with it, and retry the operation.
11931
+ *
11932
+ * The connection is deliberately kept. A reload authenticates the new socket
11933
+ * against the persisted `authorization_state`, and that handshake is what
11934
+ * associates the socket with the previous session — the association reattach
11935
+ * depends on. `signalwire.reauthenticate` swaps the credential *on that same
11936
+ * session*, so recovery never touches the resume state. Discarding it would
11937
+ * heal the credential by destroying the very thing the caller is trying to
11938
+ * get back to.
11939
+ *
11940
+ * The operation is still the verdict, never the RPC. Reauthenticating with
11941
+ * the in-memory token is accepted by a resume even while requests stay
11942
+ * refused, because the persisted `authorization_state` short-circuits token
11943
+ * validation — and `signalwire.reauthenticate` with a *freshly minted* token
11944
+ * has also been observed accepted while `subscriber.online` keeps being
11945
+ * refused (staging run 33826974634). Both look like success and are not.
11946
+ *
11947
+ * @returns the operation's value, or the reason recovery could not deliver
11948
+ * one. `error` is undefined when there was no way to mint at all.
11949
+ */
11950
+ async recoverAndRetry(operation) {
11951
+ if (!await this.remintAndReauthenticate()) return { ok: false };
11952
+ try {
11953
+ const value = await operation();
11954
+ this._credentialRecovered = true;
11955
+ return {
11956
+ ok: true,
11957
+ value
11958
+ };
11959
+ } catch (error) {
11960
+ logger$1.warn("[SignalWire] Reauthentication was accepted but the operation is still refused:", error);
11961
+ return {
11962
+ ok: false,
11963
+ error
11964
+ };
11965
+ }
11966
+ }
11967
+ /**
11968
+ * Re-mint a credential and adopt it only if the live session accepts it.
11969
+ *
11970
+ * The mechanism follows the binding: a client-bound session re-mints a bound
11971
+ * base SAT through `authenticate()` with the DPoP fingerprint, because the
11972
+ * developer refresh handler would hand back an unbound token and silently
11973
+ * degrade the session. An unbound session uses the refresh handler. Rotation
11974
+ * cost is not a reason to skip this — the only reason is having no mechanism.
11975
+ *
11976
+ * @returns whether the session is now running on a freshly accepted credential.
11977
+ */
11978
+ async remintAndReauthenticate() {
11979
+ const provider = this._credentialProvider;
11980
+ if (!provider) return false;
11981
+ const { clientBound } = this._clientSession;
11982
+ if (!clientBound && !provider.refresh) {
11983
+ logger$1.debug("[SignalWire] [SW-NO-REFRESH-HANDLER] Unbound session with no refresh handler; cannot re-mint.");
11984
+ return false;
11985
+ }
11986
+ try {
11987
+ const newCredentials = clientBound ? await provider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0) : await this.remintCredential(provider);
11988
+ if (!newCredentials.token) {
11989
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the current one.");
11990
+ return false;
11991
+ }
11992
+ await this._clientSession.reauthenticate(newCredentials.token);
11993
+ this._deps.credential = newCredentials;
11994
+ this.persistCredential(newCredentials);
11995
+ if (newCredentials.expiry_at && provider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(provider, newCredentials.expiry_at);
11996
+ return true;
11997
+ } catch (error) {
11998
+ logger$1.warn("[SignalWire] Re-mint recovery failed:", error);
11999
+ return false;
12000
+ }
12001
+ }
12002
+ /**
12003
+ * Re-mint a credential via `provider.refresh()`, routed through the
12004
+ * coordinator's shared in-flight guard so concurrent re-mint paths (a
12005
+ * scheduled/resume refresh, -32003 recovery, and reconnect) never fire a
12006
+ * second `provider.refresh()` in parallel — which rotating one-time-use
12007
+ * refresh tokens reject. Falls back to a direct call only if the coordinator
12008
+ * has not been constructed yet.
12009
+ */
12010
+ async remintCredential(provider) {
12011
+ if (this._refreshCoordinator) return this._refreshCoordinator.refreshCredential(provider);
12012
+ if (!provider.refresh) throw new require_operators.InvalidCredentialsError("Credential provider does not support refresh");
12013
+ return provider.refresh();
12014
+ }
12015
+ /**
12016
+ * Re-mint credentials before a fresh (re)connect (`onBeforeReconnect` hook).
12017
+ * The session invokes this only when it is client-bound OR the in-memory
12018
+ * token is expired. The re-mint mechanism depends on the binding:
12019
+ * - Client-bound: `authenticate()` with the DPoP fingerprint to obtain a
12020
+ * fresh base SAT the upcoming reconnect can re-bind (the
12021
+ * DeviceTokenManager re-activates afterwards).
12022
+ * - Unbound: the developer's non-interactive `refresh()` handler.
12023
+ * `authenticate()` is deliberately NOT used here — it may be interactive
12024
+ * (a login prompt) and must not fire on a background reconnect.
12025
+ *
12026
+ * Rejects on failure so the session aborts the reconnect rather than
12027
+ * replaying a stale token.
12028
+ */
12029
+ async refreshCredentialForReconnect() {
12030
+ if (!this._credentialProvider) return;
12031
+ try {
12032
+ let newCredentials;
12033
+ if (this._clientSession?.clientBound ?? await this.wasClientBound()) {
12034
+ logger$1.debug("[SignalWire] Re-minting client-bound base SAT before reconnect");
12035
+ newCredentials = await this._credentialProvider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0);
12036
+ } else if (this._credentialProvider.refresh) {
12037
+ logger$1.debug("[SignalWire] Refreshing unbound credential before reconnect");
12038
+ newCredentials = await this.remintCredential(this._credentialProvider);
12039
+ } else {
12040
+ logger$1.warn("[SignalWire] [SW-NO-REFRESH-HANDLER] Token expired on reconnect but no refresh handler; reconnecting with the existing token.");
12041
+ return;
12042
+ }
12043
+ if (!newCredentials.token) {
12044
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the existing credential for reconnect.");
12045
+ return;
12046
+ }
12047
+ this._deps.credential = newCredentials;
12048
+ this.persistCredential(newCredentials);
12049
+ if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
12050
+ logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
12051
+ } catch (error) {
12052
+ logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
12053
+ this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
12054
+ throw error;
12055
+ }
12056
+ }
11057
12057
  /** Persist credential to localStorage when persistSession is enabled. */
11058
12058
  persistCredential(credential) {
11059
12059
  if (!credential.token) return;
11060
12060
  this._deps.storage.setItem("sw:cached_credential", credential);
11061
12061
  if (this._deps.persistSession) this._deps.storage.setItem("sw:cached_credential", credential, "local");
11062
12062
  }
12063
+ /**
12064
+ * Persist whether the session is client-bound, mirroring the credential's
12065
+ * storage scopes so it survives a reload. The preflight recovery reads it
12066
+ * before any session exists to decide whether to re-bind via `authenticate()`
12067
+ * or refresh an unbound token; the marker tracks the latest binding, so an
12068
+ * unbound reconnect clears a stale marker from an earlier client-bound login.
12069
+ */
12070
+ persistClientBoundMarker(bound) {
12071
+ const scopes = this._deps.persistSession ? ["session", "local"] : ["session"];
12072
+ for (const scope of scopes) if (bound) this._deps.storage.setItem(CLIENT_BOUND_STORAGE_KEY, true, scope);
12073
+ else this._deps.storage.removeItem(CLIENT_BOUND_STORAGE_KEY, scope);
12074
+ }
12075
+ /** Read the persisted client-bound marker (see {@link persistClientBoundMarker}). */
12076
+ async wasClientBound() {
12077
+ const scopes = this._deps.persistSession ? ["local", "session"] : ["session"];
12078
+ for (const scope of scopes) try {
12079
+ if (await this._deps.storage.getItem(CLIENT_BOUND_STORAGE_KEY, scope)) return true;
12080
+ } catch {}
12081
+ return false;
12082
+ }
11063
12083
  async init() {
11064
12084
  this._user$.next(new User(this._deps.http));
11065
12085
  if (!this._options.skipConnection) await this.connect();
@@ -11083,6 +12103,42 @@ var SignalWire = class extends Destroyable {
11083
12103
  } catch (error) {
11084
12104
  logger$1.error("[SignalWire] Failed to reattach calls:", error);
11085
12105
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
12106
+ } finally {
12107
+ this._credentialRecovered = false;
12108
+ }
12109
+ }
12110
+ /**
12111
+ * Fetch the authenticated user profile, recovering a stale credential.
12112
+ *
12113
+ * On a reload the persisted credential can be expired. Unlike the WS resume —
12114
+ * which the server accepts against the persisted `authorization_state` even
12115
+ * with an expired token — this REST preflight has no such short-circuit and is
12116
+ * refused (401). There is no session yet to reauthenticate, so recovery
12117
+ * re-mints the credential through the provider ({@link refreshCredentialForReconnect})
12118
+ * and retries with a FRESH {@link User}: Fetchable memoizes its result
12119
+ * (shareReplay), so reusing the instance would replay the 401 instead of
12120
+ * re-fetching with the new token. Without the user id the transport/session —
12121
+ * and the reattach a reload is trying to preserve — cannot even be addressed.
12122
+ */
12123
+ async fetchUserOrRecover() {
12124
+ const fetchUser = async (user$1) => {
12125
+ if (!await (0, rxjs.firstValueFrom)(user$1.fetched$)) throw new require_operators.UnexpectedError("Failed to fetch user information - fetched$ emitted false");
12126
+ this._deps.user = user$1;
12127
+ };
12128
+ const user = this._user$.value;
12129
+ if (!user) throw new require_operators.UnexpectedError("User not initialized before connect");
12130
+ try {
12131
+ await fetchUser(user);
12132
+ } catch (firstError) {
12133
+ logger$1.error(`[SignalWire] Failed to fetch user information: ${firstError instanceof Error ? firstError.message : "Unknown error"}. This usually means the user token is invalid or expired. Re-minting the credential and retrying.`);
12134
+ try {
12135
+ await this.refreshCredentialForReconnect();
12136
+ const refetched = new User(this._deps.http);
12137
+ await fetchUser(refetched);
12138
+ this._user$.next(refetched);
12139
+ } catch (retryError) {
12140
+ throw new require_operators.UnexpectedError("Error fetching user information", { cause: retryError });
12141
+ }
11086
12142
  }
11087
12143
  }
11088
12144
  /**
@@ -11124,40 +12180,23 @@ var SignalWire = class extends Destroyable {
11124
12180
  */
11125
12181
  async connect() {
11126
12182
  await this.teardownTransportAndSession();
11127
- try {
11128
- const user = this._user$.value;
11129
- if (!user) throw new require_operators.UnexpectedError("User not initialized before connect");
11130
- if (!await (0, rxjs.firstValueFrom)(user.fetched$)) throw new require_operators.UnexpectedError("Failed to fetch user information - fetched$ emitted false");
11131
- this._deps.user = user;
11132
- } catch (error) {
11133
- 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.`);
11134
- throw new require_operators.UnexpectedError("Error fetching user information", { cause: error });
11135
- }
12183
+ await this.fetchUserOrRecover();
11136
12184
  const errorHandler = (error) => {
11137
12185
  this._errors$.next(error);
11138
12186
  };
11139
12187
  this._transport = new TransportManager(this._deps.storage, this._deps.protocolKey, this._deps.WebSocket, PreferencesContainer.instance.relayHost ?? this._deps.relayHost, errorHandler);
11140
- this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey);
12188
+ this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey, () => this._credentialRecovered);
11141
12189
  this._clientSession = new ClientSessionManager(() => this._deps.credential, this._transport, this._deps.storage, this._deps.authorizationStateKey, this._deps.deviceController, this._attachManager, this._deps.webRTCApiProvider, this._dpopManager, this._networkMonitor?.networkChange$);
11142
12190
  this._publicSession = new ClientSessionWrapper(this._clientSession);
11143
- this._clientSession.onBeforeReconnect = async () => {
11144
- if (!this._credentialProvider) return;
11145
- try {
11146
- const fingerprint = this._dpopManager?.initialized ? this._dpopManager.fingerprint : void 0;
11147
- logger$1.debug("[SignalWire] Credential expired, refreshing before reconnect");
11148
- const newCredentials = await this._credentialProvider.authenticate(fingerprint ? { fingerprint } : void 0);
11149
- this._deps.credential = newCredentials;
11150
- if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
11151
- logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
11152
- } catch (error) {
11153
- logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
11154
- this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
11155
- throw error;
11156
- }
11157
- };
12191
+ this._clientSession.callControl = this._options.callControl ?? "routed";
12192
+ this._clientSession.onBeforeReconnect = async () => this.refreshCredentialForReconnect();
11158
12193
  this.subscribeTo(this._clientSession.errors$, (error) => {
11159
12194
  this._errors$.next(error);
11160
12195
  });
12196
+ this.subscribeTo(this._clientSession.authorization$, (authorization) => {
12197
+ this._refreshCoordinator?.syncExpiryFromAuthorization(authorization, this._credentialProvider);
12198
+ this.persistClientBoundMarker(Boolean(authorization?.cnf?.jkt));
12199
+ });
11161
12200
  await this._clientSession.connect();
11162
12201
  await this._refreshCoordinator?.activate(this._deps.user, this._clientSession);
11163
12202
  this.subscribeTo(this._clientSession.authenticated$.pipe((0, rxjs.skip)(1), (0, rxjs.filter)(Boolean)), async () => {
@@ -11320,6 +12359,13 @@ var SignalWire = class extends Destroyable {
11320
12359
  }
11321
12360
  try {
11322
12361
  this._visibilityController = new VisibilityController();
12362
+ this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, rxjs.filter)((event) => event.to === "visible")), () => {
12363
+ try {
12364
+ this._refreshCoordinator?.forceRefreshIfDue();
12365
+ } catch (error) {
12366
+ logger$1.warn("[SignalWire] Resume credential revalidation failed (non-fatal):", error);
12367
+ }
12368
+ });
11323
12369
  this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, rxjs.filter)((event) => event.to === "visible" && PreferencesContainer.instance.refreshDevicesOnVisible)), () => {
11324
12370
  logger$1.debug("[SignalWire] Page visible, re-enumerating devices");
11325
12371
  try {
@@ -11331,7 +12377,7 @@ var SignalWire = class extends Destroyable {
11331
12377
  logger$1.warn("[SignalWire] Failed to initialize VisibilityController:", error);
11332
12378
  }
11333
12379
  try {
11334
- this._diagnosticsCollector = new DiagnosticsCollector({ sdkVersion: "4.0.0-rc.1" });
12380
+ this._diagnosticsCollector = new DiagnosticsCollector({ sdkVersion: "4.0.0-rc.3" });
11335
12381
  } catch (error) {
11336
12382
  logger$1.warn("[SignalWire] Failed to initialize DiagnosticsCollector:", error);
11337
12383
  }
@@ -11402,21 +12448,22 @@ var SignalWire = class extends Destroyable {
11402
12448
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
11403
12449
  throw error;
11404
12450
  }
11405
- logger$1.debug("[SignalWire] Failed to register user, trying reauthentication...");
11406
- try {
11407
- await this._clientSession.reauthenticate(this._deps.credential.token);
11408
- logger$1.debug("[SignalWire] Reauthentication successful, retrying register()");
12451
+ logger$1.debug("[SignalWire] Failed to register user, attempting credential recovery...");
12452
+ const outcome = await this.recoverAndRetry(async () => {
11409
12453
  await this._transport.execute(RPCExecute({
11410
12454
  method: "subscriber.online",
11411
12455
  params: {}
11412
12456
  }));
12457
+ });
12458
+ if (outcome.ok) {
12459
+ logger$1.debug("[SignalWire] Recovery restored registration");
11413
12460
  this._isRegistered$.next(true);
11414
- } catch (reauthError) {
11415
- logger$1.error("[SignalWire] Reauthentication failed during register():", reauthError);
11416
- const registerError = new require_operators.InvalidCredentialsError("Failed to register user, and reauthentication attempt also failed. Please check your credentials.", { cause: reauthError instanceof Error ? reauthError : new Error(String(reauthError), { cause: reauthError }) });
11417
- this._errors$.next(registerError);
11418
- throw registerError;
12461
+ return;
11419
12462
  }
12463
+ const failureCause = outcome.error ?? error;
12464
+ const registerError = new require_operators.InvalidCredentialsError("Failed to register user, and credential recovery also failed. Please check your credentials.", { cause: failureCause instanceof Error ? failureCause : new Error(String(failureCause), { cause: failureCause }) });
12465
+ this._errors$.next(registerError);
12466
+ throw registerError;
11420
12467
  }
11421
12468
  }
11422
12469
  /**
@@ -11447,6 +12494,11 @@ var SignalWire = class extends Destroyable {
11447
12494
  * Returns a {@link Call} in `'ringing'` state. Subscribe to {@link Call.status$}
11448
12495
  * to track progression through `'connected'` → `'disconnected'`.
11449
12496
  *
12497
+ * Local media acquisition is deliberately unbounded: an unanswered permission
12498
+ * prompt leaves this promise pending indefinitely, so apply your own bound if
12499
+ * your UI needs one. The 12 s signaling budget starts only once acquisition
12500
+ * settles.
12501
+ *
11450
12502
  * @param destination - Address URI string (e.g. `'/public/my-room'`) or {@link Address} instance.
11451
12503
  * @param options - Media and dial options (audio/video, device constraints). Overrides defaults.
11452
12504
  * @returns The created {@link Call} instance.
@@ -11469,7 +12521,18 @@ var SignalWire = class extends Destroyable {
11469
12521
  };
11470
12522
  await this.waitAuthentication();
11471
12523
  logger$1.debug("[SignalWire] Dialing with options:", computed_options);
11472
- return this._clientSession.createOutboundCall(destination, computed_options);
12524
+ try {
12525
+ return await this._clientSession.createOutboundCall(destination, computed_options);
12526
+ } catch (error) {
12527
+ if (!isRecoverableAuthError(error)) throw error;
12528
+ logger$1.debug("[SignalWire] Dial hit a recoverable auth error; recovering the session and retrying");
12529
+ const outcome = await this.recoverAndRetry(async () => {
12530
+ await this.waitAuthentication();
12531
+ return this._clientSession.createOutboundCall(destination, computed_options);
12532
+ });
12533
+ if (outcome.ok) return outcome.value;
12534
+ throw outcome.error ?? error;
12535
+ }
11473
12536
  }
11474
12537
  /**
11475
12538
  * Runs a multi-phase connectivity test against the given destination.
@@ -11750,10 +12813,13 @@ var SignalWire = class extends Destroyable {
11750
12813
  destroy() {
11751
12814
  this._refreshCoordinator?.destroy();
11752
12815
  this._refreshCoordinator = void 0;
12816
+ this._refreshHttp?.destroy();
12817
+ this._refreshHttp = void 0;
11753
12818
  this._dpopManager?.destroy();
11754
- this._clientSession.teardownSessionState();
11755
- this._transport.destroy();
11756
- this._clientSession.destroy();
12819
+ const session = this._clientSession;
12820
+ session?.teardownSessionState();
12821
+ this._transport?.destroy();
12822
+ session?.destroy();
11757
12823
  try {
11758
12824
  this._networkMonitor?.destroy();
11759
12825
  } catch {}
@@ -11865,7 +12931,7 @@ var StaticCredentialProvider = class {
11865
12931
  /**
11866
12932
  * Library version from package.json, injected at build time.
11867
12933
  */
11868
- const version = "4.0.0-rc.1";
12934
+ const version = "4.0.0-rc.3";
11869
12935
  /**
11870
12936
  * Flag indicating the library has been loaded and is ready to use.
11871
12937
  * For UMD builds: `window.SignalWire.ready`
@@ -11887,7 +12953,7 @@ const ready = true;
11887
12953
  */
11888
12954
  const emitReadyEvent = () => {
11889
12955
  if (typeof window !== "undefined") {
11890
- const event = new CustomEvent("signalwire:js:ready", { detail: { version: "4.0.0-rc.1" } });
12956
+ const event = new CustomEvent("signalwire:js:ready", { detail: { version: "4.0.0-rc.3" } });
11891
12957
  window.dispatchEvent(event);
11892
12958
  }
11893
12959
  };
@@ -11895,19 +12961,25 @@ emitReadyEvent();
11895
12961
 
11896
12962
  //#endregion
11897
12963
  exports.Address = Address;
12964
+ exports.AuxiliaryLegCancelledError = require_operators.AuxiliaryLegCancelledError;
12965
+ exports.AuxiliaryLegTimeoutError = require_operators.AuxiliaryLegTimeoutError;
11898
12966
  exports.CallCreateError = require_operators.CallCreateError;
12967
+ exports.CallNotReadyError = require_operators.CallNotReadyError;
11899
12968
  exports.ClientPreferences = ClientPreferences;
11900
12969
  exports.CollectionFetchError = require_operators.CollectionFetchError;
11901
12970
  exports.DPoPInitError = require_operators.DPoPInitError;
11902
12971
  exports.DeviceTokenError = require_operators.DeviceTokenError;
11903
12972
  exports.EmbedTokenCredentialProvider = EmbedTokenCredentialProvider;
11904
12973
  exports.InvalidCredentialsError = require_operators.InvalidCredentialsError;
12974
+ exports.MediaAccessError = require_operators.MediaAccessError;
11905
12975
  exports.MediaTrackError = require_operators.MediaTrackError;
11906
12976
  exports.MessageParseError = require_operators.MessageParseError;
11907
12977
  exports.OverconstrainedFallbackError = require_operators.OverconstrainedFallbackError;
11908
12978
  exports.Participant = Participant;
12979
+ exports.ParticipantNotReadyError = require_operators.ParticipantNotReadyError;
11909
12980
  exports.PreflightError = require_operators.PreflightError;
11910
12981
  exports.RecoveryError = require_operators.RecoveryError;
12982
+ exports.ScreenShareAlreadyActiveError = require_operators.ScreenShareAlreadyActiveError;
11911
12983
  exports.SelfCapabilities = SelfCapabilities;
11912
12984
  exports.SelfParticipant = SelfParticipant;
11913
12985
  exports.SignalWire = SignalWire;