@signalwire/js 4.0.0-dev-20260714201720 → 4.0.0-dev-20260721161304

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.
@@ -9055,6 +9055,14 @@ const CREDENTIAL_REFRESH_MAX_DELAY_MS = 3e4;
9055
9055
  /** Buffer in milliseconds before token expiry to trigger refresh. */
9056
9056
  const CREDENTIAL_REFRESH_BUFFER_MS = 5e3;
9057
9057
  /**
9058
+ * Clock-skew allowance (ms) for treating an in-memory credential as expired
9059
+ * when deciding whether a fresh (re)connect must re-mint the token before
9060
+ * authenticating. A token within this window of its `expiry_at` is treated as
9061
+ * stale so the reconnect re-mints via the credential provider instead of
9062
+ * replaying a dead token (which the server rejects with -32003).
9063
+ */
9064
+ const CREDENTIAL_EXPIRY_SKEW_MS = 3e4;
9065
+ /**
9058
9066
  * Maximum time the coordinator will wait for `DeviceTokenManager.activate()`
9059
9067
  * to resolve before treating the activation as failed and falling back to
9060
9068
  * the developer-provided refresh path. Prevents a wedged HTTP layer from
@@ -9287,6 +9295,20 @@ var CallCreateError = class extends Error {
9287
9295
  this.name = "CallCreateError";
9288
9296
  }
9289
9297
  };
9298
+ var CallNotReadyError = class extends Error {
9299
+ constructor(callId, options) {
9300
+ super(`Call "${callId}" has no self member context yet: selfId/nodeId have not been received from the server`, options);
9301
+ this.callId = callId;
9302
+ this.name = "CallNotReadyError";
9303
+ }
9304
+ };
9305
+ var ParticipantNotReadyError = class extends Error {
9306
+ constructor(memberId, options) {
9307
+ super(`Participant "${memberId}" has no call context yet: its member state (call_id/node_id) has not been received from the server`, options);
9308
+ this.memberId = memberId;
9309
+ this.name = "ParticipantNotReadyError";
9310
+ }
9311
+ };
9290
9312
  var JSONRPCError = class extends Error {
9291
9313
  constructor(code, message, data, options, requestId) {
9292
9314
  super(message, options);
@@ -13650,9 +13672,9 @@ const initialState = {};
13650
13672
  * the local participant with additional device control.
13651
13673
  */
13652
13674
  var Participant = class extends Destroyable {
13653
- constructor(id, executeMethod, deviceController) {
13675
+ constructor(id, callExecuteMethod, deviceController) {
13654
13676
  super();
13655
- this.executeMethod = executeMethod;
13677
+ this.callExecuteMethod = callExecuteMethod;
13656
13678
  this.deviceController = deviceController;
13657
13679
  this._state$ = this.createBehaviorSubject(initialState);
13658
13680
  this.id = id;
@@ -13874,22 +13896,55 @@ var Participant = class extends Destroyable {
13874
13896
  get value() {
13875
13897
  return this._state$.value;
13876
13898
  }
13899
+ /**
13900
+ * Target triple for member RPCs, built from the participant's own state.
13901
+ * The backend locates the member's session by the target `call_id`/`node_id`,
13902
+ * so this must always be the participant's own call context — never the
13903
+ * local call's id (issue #19400).
13904
+ *
13905
+ * Reading it doubles as a readiness probe: it throws until the first full
13906
+ * member event (`member.joined`/`member.updated` or the `call.joined`
13907
+ * roster) arrives, and never regresses afterwards.
13908
+ *
13909
+ * @throws {ParticipantNotReadyError} If the member state has not been
13910
+ * received yet (e.g. a participant first seen via `member.talking`) — an
13911
+ * empty call context can never address the member, so fail fast instead of
13912
+ * sending a doomed RPC.
13913
+ */
13914
+ get target() {
13915
+ const { call_id, node_id } = this._state$.value;
13916
+ if (!call_id || !node_id) throw new ParticipantNotReadyError(this.id);
13917
+ return {
13918
+ member_id: this.id,
13919
+ call_id,
13920
+ node_id
13921
+ };
13922
+ }
13923
+ /**
13924
+ * Executes a member RPC against this participant, injecting its own
13925
+ * {@link target} as the target.
13926
+ *
13927
+ * @throws {ParticipantNotReadyError} Via {@link target}, when the
13928
+ * member state has not been received yet.
13929
+ */
13930
+ async executeMethod(method, args) {
13931
+ return this.callExecuteMethod(this.target, method, args);
13932
+ }
13877
13933
  /** Toggles the deafened state (mutes/unmutes incoming audio). */
13878
13934
  async toggleDeaf() {
13879
- const method = toggleDeafMethod(this.deaf);
13880
- await this.executeMethod(this.id, method, {});
13935
+ await this.executeMethod(toggleDeafMethod(this.deaf), {});
13881
13936
  }
13882
13937
  /** Toggles the hand-raised state. */
13883
13938
  async toggleHandraise() {
13884
- await this.executeMethod(this.id, toggleHandraiseMethod(this.handraised), {});
13939
+ await this.executeMethod(toggleHandraiseMethod(this.handraised), {});
13885
13940
  }
13886
13941
  /** Mutes the participant's audio. */
13887
13942
  async mute() {
13888
- await this.executeMethod(this.id, "call.mute", { channels: ["audio"] });
13943
+ await this.executeMethod("call.mute", { channels: ["audio"] });
13889
13944
  }
13890
13945
  /** Unmutes the participant's audio. */
13891
13946
  async unmute() {
13892
- await this.executeMethod(this.id, "call.unmute", { channels: ["audio"] });
13947
+ await this.executeMethod("call.unmute", { channels: ["audio"] });
13893
13948
  }
13894
13949
  /** Toggles the participant's audio mute state. */
13895
13950
  async toggleMute() {
@@ -13897,11 +13952,11 @@ var Participant = class extends Destroyable {
13897
13952
  }
13898
13953
  /** Mutes the participant's video. */
13899
13954
  async muteVideo() {
13900
- await this.executeMethod(this.id, "call.mute", { channels: ["video"] });
13955
+ await this.executeMethod("call.mute", { channels: ["video"] });
13901
13956
  }
13902
13957
  /** Unmutes the participant's video. */
13903
13958
  async unmuteVideo() {
13904
- await this.executeMethod(this.id, "call.unmute", { channels: ["video"] });
13959
+ await this.executeMethod("call.unmute", { channels: ["video"] });
13905
13960
  }
13906
13961
  /** Toggles the participant's video mute state. */
13907
13962
  async toggleMuteVideo() {
@@ -13909,7 +13964,7 @@ var Participant = class extends Destroyable {
13909
13964
  }
13910
13965
  /** Toggles echo cancellation on the audio input. */
13911
13966
  async toggleEchoCancellation() {
13912
- await this.executeMethod(this.id, "call.audioflags.set", {
13967
+ await this.executeMethod("call.audioflags.set", {
13913
13968
  echo_cancellation: !this.echoCancellation,
13914
13969
  auto_gain: this.autoGain,
13915
13970
  noise_suppression: this.noiseSuppression
@@ -13917,7 +13972,7 @@ var Participant = class extends Destroyable {
13917
13972
  }
13918
13973
  /** Toggles automatic gain control on the audio input. */
13919
13974
  async toggleAudioInputAutoGain() {
13920
- await this.executeMethod(this.id, "call.audioflags.set", {
13975
+ await this.executeMethod("call.audioflags.set", {
13921
13976
  echo_cancellation: this.echoCancellation,
13922
13977
  auto_gain: !this.autoGain,
13923
13978
  noise_suppression: this.noiseSuppression
@@ -13925,7 +13980,7 @@ var Participant = class extends Destroyable {
13925
13980
  }
13926
13981
  /** Toggles noise suppression on the audio input. */
13927
13982
  async toggleNoiseSuppression() {
13928
- await this.executeMethod(this.id, "call.audioflags.set", {
13983
+ await this.executeMethod("call.audioflags.set", {
13929
13984
  echo_cancellation: this.echoCancellation,
13930
13985
  auto_gain: this.autoGain,
13931
13986
  noise_suppression: !this.noiseSuppression
@@ -13933,7 +13988,7 @@ var Participant = class extends Destroyable {
13933
13988
  }
13934
13989
  /** Toggles low-bitrate mode for this participant's media. */
13935
13990
  async toggleLowbitrate() {
13936
- await this.executeMethod(this.id, "call.lowbitrate.set", { lowbitrate: !this.lowbitrate });
13991
+ await this.executeMethod("call.lowbitrate.set", { lowbitrate: !this.lowbitrate });
13937
13992
  }
13938
13993
  /**
13939
13994
  * Adjusts the **conference-only** microphone energy gate / sensitivity level
@@ -13949,7 +14004,7 @@ var Participant = class extends Destroyable {
13949
14004
  * (integer, larger values are more sensitive).
13950
14005
  */
13951
14006
  async setAudioInputSensitivity(value) {
13952
- await this.executeMethod(this.id, "call.microphone.sensitivity.set", { sensitivity: value });
14007
+ await this.executeMethod("call.microphone.sensitivity.set", { sensitivity: value });
13953
14008
  }
13954
14009
  /**
13955
14010
  * Sets the **server-side** microphone volume on this participant's bridged
@@ -13962,7 +14017,7 @@ var Participant = class extends Destroyable {
13962
14017
  * @param value - Volume level (0-100).
13963
14018
  */
13964
14019
  async setAudioInputVolume(value) {
13965
- await this.executeMethod(this.id, "call.microphone.volume.set", { volume: value });
14020
+ await this.executeMethod("call.microphone.volume.set", { volume: value });
13966
14021
  }
13967
14022
  /**
13968
14023
  * Sets the **server-side** speaker volume on this participant's bridged call
@@ -13976,45 +14031,31 @@ var Participant = class extends Destroyable {
13976
14031
  * @param value - Volume level (0-100).
13977
14032
  */
13978
14033
  async setAudioOutputVolume(value) {
13979
- await this.executeMethod(this.id, "call.speaker.volume.set", { volume: value });
14034
+ await this.executeMethod("call.speaker.volume.set", { volume: value });
13980
14035
  }
13981
14036
  /**
13982
14037
  * Sets the participant's position in the video layout.
13983
14038
  *
13984
- * Requires the `member.position` capability. The gateway keys positions by the
13985
- * **target member's own** `call_id`/`node_id` (see issue #19400 and the legacy
13986
- * `setPositions` implementation), so this sends the participant's own call
13987
- * context — matching {@link Participant.remove}. A resolved promise does not
13988
- * guarantee a visible change: the backend silently returns `200` (no-op) for
13989
- * non-conference targets.
14039
+ * Requires the `member.position` capability. The gateway requires a
14040
+ * `targets` array of `{ target, position }` entries (issue #19400). A
14041
+ * resolved promise does not guarantee a visible change: the backend silently
14042
+ * returns `200` (no-op) for non-conference targets.
13990
14043
  *
13991
14044
  * @param value - The {@link VideoPosition} to assign (e.g. `'auto'`, `'reserved-0'`).
13992
14045
  */
13993
14046
  async setPosition(value) {
13994
- const state = this._state$.value;
13995
- const target = {
13996
- member_id: this.id,
13997
- call_id: state.call_id ?? "",
13998
- node_id: state.node_id ?? ""
13999
- };
14000
- await this.executeMethod(target, "call.member.position.set", { targets: [{
14001
- target,
14047
+ await this.executeMethod("call.member.position.set", { targets: [{
14048
+ target: this.target,
14002
14049
  position: value
14003
14050
  }] });
14004
14051
  }
14005
14052
  /** Removes this participant from the call. */
14006
14053
  async remove() {
14007
- const state = this._state$.value;
14008
- const target = {
14009
- member_id: this.id,
14010
- call_id: state.call_id ?? "",
14011
- node_id: state.node_id ?? ""
14012
- };
14013
- await this.executeMethod(target, "call.member.remove", {});
14054
+ await this.executeMethod("call.member.remove", { targets: [this.target] });
14014
14055
  }
14015
14056
  /** Ends the call for this participant. */
14016
14057
  async end() {
14017
- await this.executeMethod(this.id, "call.end", {});
14058
+ await this.executeMethod("call.end", {});
14018
14059
  }
14019
14060
  /**
14020
14061
  * Replaces custom metadata for this participant.
@@ -14034,7 +14075,7 @@ var Participant = class extends Destroyable {
14034
14075
  }
14035
14076
  /** Destroys the participant, releasing all subscriptions and references. */
14036
14077
  destroy() {
14037
- this.executeMethod = void 0;
14078
+ this.callExecuteMethod = void 0;
14038
14079
  super.destroy();
14039
14080
  }
14040
14081
  };
@@ -14046,8 +14087,8 @@ var Participant = class extends Destroyable {
14046
14087
  */
14047
14088
  var SelfParticipant = class extends Participant {
14048
14089
  /** @internal */
14049
- constructor(id, executeMethod, vertoManager, deviceController) {
14050
- super(id, executeMethod, deviceController);
14090
+ constructor(id, callExecuteMethod, vertoManager, deviceController) {
14091
+ super(id, callExecuteMethod, deviceController);
14051
14092
  this.vertoManager = vertoManager;
14052
14093
  this._studioAudio$ = this.createBehaviorSubject(false);
14053
14094
  this.capabilities = new SelfCapabilities();
@@ -14071,7 +14112,7 @@ var SelfParticipant = class extends Participant {
14071
14112
  async enableStudioAudio() {
14072
14113
  if (this._studioAudio$.value) return;
14073
14114
  this._studioAudio$.next(true);
14074
- await this.executeMethod(this.id, "call.audioflags.set", {
14115
+ await this.executeMethod("call.audioflags.set", {
14075
14116
  echo_cancellation: false,
14076
14117
  auto_gain: false,
14077
14118
  noise_suppression: false
@@ -14084,7 +14125,7 @@ var SelfParticipant = class extends Participant {
14084
14125
  async disableStudioAudio() {
14085
14126
  if (!this._studioAudio$.value) return;
14086
14127
  this._studioAudio$.next(false);
14087
- await this.executeMethod(this.id, "call.audioflags.set", {
14128
+ await this.executeMethod("call.audioflags.set", {
14088
14129
  echo_cancellation: true,
14089
14130
  auto_gain: true,
14090
14131
  noise_suppression: true
@@ -18305,12 +18346,6 @@ function mosToQualityLevel(mos) {
18305
18346
  var import_cjs$11 = require_cjs();
18306
18347
  const logger$12 = getLogger();
18307
18348
  /**
18308
- * Verto method for setting member layout positions. Its gateway DTO requires a
18309
- * `targets` array whose entries are `{ target, position }` (NOT bare targets),
18310
- * so {@link WebRTCCall.buildMethodParams} special-cases it. See issue #19400.
18311
- */
18312
- const POSITION_SET_METHOD = "call.member.position.set";
18313
- /**
18314
18349
  * Ratio between the critical and warning RTT spike multipliers.
18315
18350
  * Warning threshold = baseline * warningMultiplier (default 3x)
18316
18351
  * Critical threshold = baseline * warningMultiplier * RTT_CRITICAL_TO_WARNING_RATIO
@@ -18461,7 +18496,7 @@ var WebRTCCall = class extends Destroyable {
18461
18496
  /** Toggles the call lock state, preventing or allowing new participants from joining. */
18462
18497
  async toggleLock() {
18463
18498
  const method = this.locked ? "call.unlock" : "call.lock";
18464
- await this.executeMethod(this.selfId ?? "", method, {});
18499
+ await this.executeMethod(this.callSelf, method, {});
18465
18500
  }
18466
18501
  /**
18467
18502
  * Toggles the hold state of the call (pauses/resumes local media transmission).
@@ -18510,14 +18545,24 @@ var WebRTCCall = class extends Destroyable {
18510
18545
  *
18511
18546
  * Constructs call context (node_id, call_id, member_id) and sends the RPC request.
18512
18547
  *
18513
- * @param target - Target member ID string, or a {@link MemberTarget} object.
18548
+ * @param target - Target {@link MemberTarget} triple, or the local member's
18549
+ * ID string for self-operations (any other string is rejected — a bare
18550
+ * member id cannot carry the remote member's own call context).
18514
18551
  * @param method - Verto method name (e.g. `'call.mute'`, `'call.member.remove'`).
18515
18552
  * @param args - Parameters for the RPC method.
18516
18553
  * @returns The RPC response.
18554
+ * @throws {CallNotReadyError} If the call has no self member context yet.
18555
+ * @throws {InvalidParams} If a string target is not the local member's ID.
18517
18556
  * @throws {JSONRPCError} If the RPC call returns an error.
18518
18557
  */
18519
18558
  async executeMethod(target, method, args) {
18520
- const params = this.buildMethodParams(target, args, method);
18559
+ const self = this.callSelf;
18560
+ if (typeof target === "string" && target !== self.member_id) throw new InvalidParams(`Target member ID ${target} does not match call's self member ID ${self.member_id}`);
18561
+ const params = {
18562
+ ...args,
18563
+ self,
18564
+ target: typeof target === "string" ? self : target
18565
+ };
18521
18566
  const request = buildRPCRequest({
18522
18567
  method,
18523
18568
  params
@@ -18531,29 +18576,23 @@ var WebRTCCall = class extends Destroyable {
18531
18576
  throw error;
18532
18577
  }
18533
18578
  }
18534
- buildMethodParams(target, args, method) {
18535
- const self = {
18536
- node_id: this.nodeId ?? "",
18537
- call_id: this.id,
18538
- member_id: this.vertoManager.selfId ?? ""
18539
- };
18540
- if (method === POSITION_SET_METHOD) return {
18541
- ...args,
18542
- self
18543
- };
18544
- if (typeof target === "object") return {
18545
- ...args,
18546
- self,
18547
- targets: [target]
18548
- };
18579
+ /**
18580
+ * The local leg's member triple — sent as `self` in every member RPC
18581
+ * envelope, and as the `target` of call-scoped self-operations (e.g. lock,
18582
+ * layout).
18583
+ *
18584
+ * @throws {CallNotReadyError} Before `call.joined` delivers the self member
18585
+ * context (`selfId`/`nodeId`) — an RPC without it cannot be routed, so fail
18586
+ * fast instead of sending a doomed request.
18587
+ */
18588
+ get callSelf() {
18589
+ const node_id = this.nodeId;
18590
+ const member_id = this.vertoManager.selfId;
18591
+ if (!node_id || !member_id) throw new CallNotReadyError(this.id);
18549
18592
  return {
18550
- ...args,
18551
- self,
18552
- target: {
18553
- node_id: this.nodeId ?? "",
18554
- call_id: this.id,
18555
- member_id: target
18556
- }
18593
+ node_id,
18594
+ call_id: this.id,
18595
+ member_id
18557
18596
  };
18558
18597
  }
18559
18598
  /** Observable of the current call status (e.g. `'ringing'`, `'connected'`). */
@@ -19116,12 +19155,17 @@ var WebRTCCall = class extends Destroyable {
19116
19155
  *
19117
19156
  * **These operations are NOT atomic.** The layout is applied first, then each
19118
19157
  * member position sequentially, so members may briefly flash into their
19119
- * default slots before being moved to the requested positions.
19158
+ * default slots before being moved to the requested positions. Targeted
19159
+ * members are validated upfront, though: when any of them has no
19160
+ * {@link Participant.target | member call context} yet, the whole call
19161
+ * rejects before any request is sent and the layout is left unchanged.
19120
19162
  *
19121
19163
  * @param layout - Layout name (must be one of {@link layouts}).
19122
19164
  * @param positions - Optional map of member IDs to {@link VideoPosition} values.
19123
19165
  * When omitted or empty, only the layout is changed.
19124
19166
  * @throws {InvalidParams} If the layout is not in the available {@link layouts}.
19167
+ * @throws {ParticipantNotReadyError} If a targeted member's call context has
19168
+ * not been received yet — thrown before any request is sent.
19125
19169
  *
19126
19170
  * @example
19127
19171
  * ```ts
@@ -19132,18 +19176,19 @@ var WebRTCCall = class extends Destroyable {
19132
19176
  */
19133
19177
  async setLayout(layout, positions) {
19134
19178
  if (!this.layouts.includes(layout)) throw new InvalidParams(`Layout ${layout} is not available in the current call layouts: ${this.layouts.join(", ")}`);
19135
- const selfId = await (0, import_cjs$11.firstValueFrom)(this.selfId$.pipe((0, import_cjs$11.filter)((id) => id !== null)));
19136
- await this.executeMethod(selfId, "call.layout.set", { layout });
19137
- const positionEntries = Object.entries(positions ?? {});
19138
- if (positionEntries.length === 0) return;
19139
- for (const [memberId, position] of positionEntries) {
19179
+ const targets = [];
19180
+ for (const [memberId, position] of Object.entries(positions ?? {})) {
19140
19181
  const participant = this.participants.find((p) => p.id === memberId);
19141
19182
  if (!participant) {
19142
19183
  logger$12.warn(`[Call] setLayout: member ${memberId} not found in participants; skipping position ${position}`);
19143
19184
  continue;
19144
19185
  }
19145
- await participant.setPosition(position);
19186
+ participant.target;
19187
+ targets.push([participant, position]);
19146
19188
  }
19189
+ const selfId = await (0, import_cjs$11.firstValueFrom)(this.selfId$.pipe((0, import_cjs$11.filter)((id) => id !== null)));
19190
+ await this.executeMethod(selfId, "call.layout.set", { layout });
19191
+ for (const [participant, position] of targets) await participant.setPosition(position);
19147
19192
  }
19148
19193
  /**
19149
19194
  * Transfers the call to another destination.
@@ -20062,9 +20107,9 @@ var ClientSessionManager = class extends Destroyable {
20062
20107
  }
20063
20108
  async handleAuthenticationError(error) {
20064
20109
  logger$9.error("Authentication error:", error);
20065
- const isRecoverableAuthError = error instanceof JSONRPCError && (error.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === RPC_ERROR_INVALID_PARAMS || error.code === RPC_ERROR_AUTHENTICATION_FAILED);
20110
+ const isRecoverableAuthError$1 = error instanceof JSONRPCError && (error.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || error.code === RPC_ERROR_INVALID_PARAMS || error.code === RPC_ERROR_AUTHENTICATION_FAILED);
20066
20111
  const hasStoredState = await (0, import_cjs$7.firstValueFrom)(this.authorizationState$.pipe((0, import_cjs$7.take)(1))) !== void 0;
20067
- if (isRecoverableAuthError && hasStoredState) {
20112
+ if (isRecoverableAuthError$1 && hasStoredState) {
20068
20113
  logger$9.debug("[Session] Recoverable auth error — cleaning up stored state and reconnecting fresh");
20069
20114
  try {
20070
20115
  await this.cleanupStoredConnectionParams();
@@ -20160,9 +20205,13 @@ var ClientSessionManager = class extends Destroyable {
20160
20205
  const isReconnect = hasReconnectState && storedToken;
20161
20206
  let dpopToken;
20162
20207
  if (isReconnect) logger$9.debug("[Session] Reconnecting with stored jwt_token + authorization_state");
20163
- else if (this.onBeforeReconnect && this.clientBound) {
20164
- logger$9.debug("[Session] Refreshing credentials before fresh connect");
20165
- await this.onBeforeReconnect();
20208
+ else {
20209
+ const credential = this.getCredential();
20210
+ const credentialExpired = credential.expiry_at !== void 0 && credential.expiry_at <= Date.now() + CREDENTIAL_EXPIRY_SKEW_MS;
20211
+ if (this.onBeforeReconnect && (this.clientBound || credentialExpired)) {
20212
+ logger$9.debug("[Session] Refreshing credentials before fresh connect");
20213
+ await this.onBeforeReconnect();
20214
+ }
20166
20215
  }
20167
20216
  if ((!isReconnect || this.clientBound) && this.dpopManager?.initialized) try {
20168
20217
  dpopToken = await this.dpopManager.createRpcProof({ method: "signalwire.connect" });
@@ -20518,7 +20567,7 @@ var DeviceTokenManager = class extends Destroyable {
20518
20567
  await session.reauthenticate(tokenData.token, rpcProof, { clientBound: true });
20519
20568
  updateCredential({ token: tokenData.token });
20520
20569
  logger$7.info("[DeviceToken] Client Bound SAT activated successfully");
20521
- this._currentToken$.next(tokenData);
20570
+ this.emitCurrentToken(tokenData);
20522
20571
  return { activated: true };
20523
20572
  } catch (error) {
20524
20573
  logger$7.error("[DeviceToken] Failed to activate Client Bound SAT:", error);
@@ -20530,6 +20579,21 @@ var DeviceTokenManager = class extends Destroyable {
20530
20579
  }
20531
20580
  }
20532
20581
  /**
20582
+ * Emit a freshly received token to the reactive pipeline, stamping an
20583
+ * absolute `expires_at` when the response carried only `expires_in`.
20584
+ * Resolving the expiry at RECEIVE time (not at read time) is what lets
20585
+ * {@link refreshNowIfDue} detect due-ness on resume: a bare `expires_in`
20586
+ * re-resolved later would always compute a full TTL from "now" and never
20587
+ * cross the refresh buffer.
20588
+ */
20589
+ emitCurrentToken(token) {
20590
+ const stamped = token.expires_at ? token : {
20591
+ ...token,
20592
+ expires_at: resolveExpiresAt(token)
20593
+ };
20594
+ this._currentToken$.next(stamped);
20595
+ }
20596
+ /**
20533
20597
  * Returns true when the cached token has enough headroom before expiry to
20534
20598
  * be safely reused on reactivation. The headroom matches the refresh
20535
20599
  * buffer, so a token within the refresh window is treated as stale (the
@@ -20622,7 +20686,7 @@ var DeviceTokenManager = class extends Destroyable {
20622
20686
  const currentToken = this.getCredential().token;
20623
20687
  if (!currentToken) throw new TokenRefreshError("No current token available for refresh");
20624
20688
  const newTokenData = await this.retryRefresh(session, currentToken, updateCredential);
20625
- this._currentToken$.next(newTokenData);
20689
+ this.emitCurrentToken(newTokenData);
20626
20690
  } catch (error) {
20627
20691
  logger$7.error("[DeviceToken] Automatic Client Bound SAT refresh failed:", error);
20628
20692
  this.errorHandler(error instanceof TokenRefreshError ? error : new TokenRefreshError("Automatic token refresh failed", error));
@@ -20649,6 +20713,22 @@ var DeviceTokenManager = class extends Destroyable {
20649
20713
  throw lastError instanceof Error ? lastError : new TokenRefreshError("All refresh retries exhausted", lastError);
20650
20714
  }
20651
20715
  /**
20716
+ * Force an immediate refresh when the cached Client Bound SAT is already
20717
+ * past its refresh window. Called on resume from suspension where
20718
+ * background-tab throttling can delay the reactive timer past the buffer.
20719
+ * A no-op when no token is cached or it still has headroom; the normal
20720
+ * {@link executeRefresh} guards (paused / in-progress / unauthenticated)
20721
+ * still apply.
20722
+ */
20723
+ refreshNowIfDue() {
20724
+ const token = this._currentToken$.value;
20725
+ if (!token) return;
20726
+ if (resolveExpiresAt(token) * 1e3 - Date.now() <= DEVICE_TOKEN_REFRESH_BUFFER_MS) {
20727
+ logger$7.debug("[DeviceToken] Resume: cached SAT past refresh window; refreshing now");
20728
+ this.executeRefresh();
20729
+ }
20730
+ }
20731
+ /**
20652
20732
  * Stops the reactive refresh pipeline from firing. Use when the underlying
20653
20733
  * session is being torn down (e.g., during {@link SignalWire.disconnect})
20654
20734
  * so a scheduled refresh cannot fire against a destroyed session.
@@ -20699,6 +20779,7 @@ var CredentialRefreshCoordinator = class extends Destroyable {
20699
20779
  this.deps = deps;
20700
20780
  this._activating = false;
20701
20781
  this._activationGeneration = 0;
20782
+ this._developerRefreshInProgress = false;
20702
20783
  if (dpopManager?.initialized) this._deviceTokenManager = (deps.deviceTokenManagerFactory ?? defaultDeviceTokenManagerFactory)(dpopManager, deps.http, (error) => deps.notifier.onError(error), () => deps.store.read());
20703
20784
  }
20704
20785
  /** True when the Client Bound SAT path is available (DPoP initialized). */
@@ -20717,28 +20798,120 @@ var CredentialRefreshCoordinator = class extends Destroyable {
20717
20798
  * invokes `deps.onRefreshExhausted` so the orchestrator can disconnect.
20718
20799
  */
20719
20800
  scheduleDeveloperRefresh(provider, expiresAt, attempt = 0) {
20801
+ this._activeProvider = provider;
20720
20802
  if (this._developerTimerId !== void 0) clearTimeout(this._developerTimerId);
20721
20803
  const refreshInterval = attempt === 0 ? Math.max(expiresAt - Date.now() - CREDENTIAL_REFRESH_BUFFER_MS, 1e3) : Math.min(CREDENTIAL_REFRESH_RETRY_BASE_MS * Math.pow(2, attempt) * (.5 + Math.random() * .5), CREDENTIAL_REFRESH_MAX_DELAY_MS);
20722
- this._developerTimerId = setTimeout(async () => {
20804
+ this._developerTimerId = setTimeout(() => {
20805
+ this._developerTimerId = void 0;
20806
+ this.executeDeveloperRefresh(provider, expiresAt, attempt);
20807
+ }, refreshInterval);
20808
+ }
20809
+ /**
20810
+ * Runs the developer-provided refresh once: mints a new credential, stores
20811
+ * and persists it, reauthenticates the live session (via the notifier), and
20812
+ * reschedules against the new expiry. On failure retries with backoff up to
20813
+ * {@link CREDENTIAL_REFRESH_MAX_RETRIES}, then signals exhaustion.
20814
+ *
20815
+ * Shared by the scheduled timer tick and {@link forceRefreshIfDue}. The
20816
+ * `_developerRefreshInProgress` guard prevents the two from overlapping.
20817
+ */
20818
+ async executeDeveloperRefresh(provider, expiresAt, attempt) {
20819
+ if (this._developerRefreshInProgress) {
20820
+ logger$6.debug("[Coordinator] Developer refresh already in progress; skipping");
20821
+ return;
20822
+ }
20823
+ this._developerRefreshInProgress = true;
20824
+ try {
20825
+ const newCredentials = await this.refreshCredential(provider);
20826
+ this.deps.store.write(newCredentials);
20827
+ this.deps.store.persist(newCredentials);
20723
20828
  try {
20724
- if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
20725
- const newCredentials = await provider.refresh();
20726
- this.deps.store.write(newCredentials);
20727
- this.deps.store.persist(newCredentials);
20728
- logger$6.info("[Coordinator] Credentials refreshed successfully.");
20729
- if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
20730
- } catch (error) {
20731
- const nextAttempt = attempt + 1;
20732
- logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
20733
- this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
20734
- if (nextAttempt < CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
20735
- else {
20736
- logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
20737
- this.deps.notifier.onError(new TokenRefreshError("Credential refresh failed after max retries"));
20738
- this.deps.notifier.onRefreshExhausted();
20739
- }
20829
+ await this.deps.notifier.onCredentialRefreshed(newCredentials);
20830
+ } catch (reauthError) {
20831
+ logger$6.warn("[Coordinator] onCredentialRefreshed rejected (non-fatal):", reauthError);
20740
20832
  }
20741
- }, refreshInterval);
20833
+ logger$6.info("[Coordinator] Credentials refreshed successfully.");
20834
+ if (newCredentials.expiry_at) this.scheduleDeveloperRefresh(provider, newCredentials.expiry_at, 0);
20835
+ } catch (error) {
20836
+ const nextAttempt = attempt + 1;
20837
+ logger$6.error(`[Coordinator] Credential refresh failed (attempt ${nextAttempt}/${CREDENTIAL_REFRESH_MAX_RETRIES}):`, error);
20838
+ this.deps.notifier.onError(error instanceof Error ? error : new Error(String(error), { cause: error }));
20839
+ if (nextAttempt < CREDENTIAL_REFRESH_MAX_RETRIES) this.scheduleDeveloperRefresh(provider, expiresAt, nextAttempt);
20840
+ else {
20841
+ logger$6.error("[Coordinator] Credential refresh exhausted all retries. Disconnecting.");
20842
+ this.deps.notifier.onError(new TokenRefreshError("Credential refresh failed after max retries"));
20843
+ this.deps.notifier.onRefreshExhausted();
20844
+ }
20845
+ } finally {
20846
+ this._developerRefreshInProgress = false;
20847
+ }
20848
+ }
20849
+ /**
20850
+ * Force an immediate refresh when the current credential is already past its
20851
+ * scheduled refresh window. Called on resume from suspension, where
20852
+ * background-tab timer throttling can delay the armed refresh well past
20853
+ * expiry, leaving the live session stale.
20854
+ *
20855
+ * Routes to whichever mechanism is armed: the developer timer if armed,
20856
+ * otherwise the Client Bound SAT pipeline. A no-op when nothing is due.
20857
+ */
20858
+ forceRefreshIfDue() {
20859
+ if (this._developerTimerId !== void 0 && this._activeProvider) {
20860
+ const expiry = this.deps.store.read().expiry_at;
20861
+ if (expiry !== void 0 && Date.now() >= expiry - CREDENTIAL_REFRESH_BUFFER_MS) {
20862
+ logger$6.debug("[Coordinator] Resume: credential past refresh window; forcing refresh");
20863
+ clearTimeout(this._developerTimerId);
20864
+ this._developerTimerId = void 0;
20865
+ this.executeDeveloperRefresh(this._activeProvider, expiry, 0);
20866
+ }
20867
+ return;
20868
+ }
20869
+ this._deviceTokenManager?.refreshNowIfDue();
20870
+ }
20871
+ /**
20872
+ * Sync the credential's expiry from the server-provided authorization (the
20873
+ * `signalwire.connect` result). SATs are opaque JWE, so
20874
+ * `fabric_subscriber.expires_at` is the authoritative expiry of the token
20875
+ * the session actually connected with — the provider-reported `expiry_at`
20876
+ * is only a hint (and may be wrong or absent). Corrects the stored
20877
+ * credential and re-arms the developer refresh timer against the real
20878
+ * deadline when the provider supports `refresh()`.
20879
+ */
20880
+ syncExpiryFromAuthorization(authorization, provider) {
20881
+ const expiresAtSec = authorization?.fabric_subscriber?.expires_at;
20882
+ if (!expiresAtSec) return;
20883
+ const expiryAt = expiresAtSec * 1e3;
20884
+ const credential = this.deps.store.read();
20885
+ if (credential.expiry_at === expiryAt) return;
20886
+ logger$6.debug(`[Coordinator] Correcting credential expiry from server authorization: ${new Date(expiryAt).toISOString()}`);
20887
+ const updated = {
20888
+ ...credential,
20889
+ expiry_at: expiryAt
20890
+ };
20891
+ this.deps.store.write(updated);
20892
+ this.deps.store.persist(updated);
20893
+ if (provider?.refresh) this.scheduleDeveloperRefresh(provider, expiryAt);
20894
+ }
20895
+ /**
20896
+ * Invoke `provider.refresh()` deduped against any concurrent developer
20897
+ * refresh. Concurrent callers — the scheduled tick, a resume-forced refresh,
20898
+ * and the orchestrator's -32003 recovery / reconnect re-mint — share one
20899
+ * in-flight promise, so a provider backed by one-time-use rotating refresh
20900
+ * tokens is never invoked twice in parallel.
20901
+ *
20902
+ * The caller owns applying the returned credential (store write, session
20903
+ * reauth, rescheduling); this method only serializes the network call.
20904
+ */
20905
+ async refreshCredential(provider) {
20906
+ if (this._refreshInFlight) return this._refreshInFlight;
20907
+ if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
20908
+ const run = provider.refresh();
20909
+ this._refreshInFlight = run;
20910
+ const clear = () => {
20911
+ if (this._refreshInFlight === run) this._refreshInFlight = void 0;
20912
+ };
20913
+ run.then(clear, clear);
20914
+ return run;
20742
20915
  }
20743
20916
  /**
20744
20917
  * Cancels any scheduled developer-provided refresh. Idempotent.
@@ -21518,6 +21691,33 @@ var TransportManager = class extends Destroyable {
21518
21691
  }
21519
21692
  };
21520
21693
 
21694
+ //#endregion
21695
+ //#region src/utils/authRecovery.ts
21696
+ /**
21697
+ * Walk an error's `error`/`cause` chain looking for a {@link JSONRPCError}.
21698
+ * Errors thrown by call creation are wrapped (e.g. `CallCreateError`), so the
21699
+ * underlying signaling error is nested. Bounded by a visited set to guard
21700
+ * against cyclic causes.
21701
+ */
21702
+ function findJSONRPCError(error) {
21703
+ const seen = /* @__PURE__ */ new Set();
21704
+ let current = error;
21705
+ while (current instanceof Error && !seen.has(current)) {
21706
+ seen.add(current);
21707
+ if (current instanceof JSONRPCError) return current;
21708
+ current = current.error ?? current.cause;
21709
+ }
21710
+ }
21711
+ /**
21712
+ * Whether an error is a session-recoverable authentication failure
21713
+ * (`-32002` authentication failed or `-32003` requester validation failed)
21714
+ * that a credential re-mint + retry can heal.
21715
+ */
21716
+ function isRecoverableAuthError(error) {
21717
+ const rpcError = findJSONRPCError(error);
21718
+ return rpcError !== void 0 && (rpcError.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || rpcError.code === RPC_ERROR_AUTHENTICATION_FAILED);
21719
+ }
21720
+
21521
21721
  //#endregion
21522
21722
  //#region src/clients/SignalWire.ts
21523
21723
  var import_cjs$1 = require_cjs();
@@ -21633,7 +21833,8 @@ var SignalWire = class extends Destroyable {
21633
21833
  notifier: {
21634
21834
  onError: (error) => this._errors$.next(error),
21635
21835
  onWarning: (warning) => this._warnings$.next(warning),
21636
- onRefreshExhausted: () => void this.disconnect()
21836
+ onRefreshExhausted: () => void this.disconnect(),
21837
+ onCredentialRefreshed: async (credential) => this.reauthenticateLiveSession(credential)
21637
21838
  },
21638
21839
  store: {
21639
21840
  read: () => this._deps.credential,
@@ -21690,14 +21891,123 @@ var SignalWire = class extends Destroyable {
21690
21891
  }
21691
21892
  this._deps.credential = _credentials;
21692
21893
  this.persistCredential(_credentials);
21693
- if (this.isConnected && this._clientSession.authenticated && _credentials.token) try {
21694
- await this._clientSession.reauthenticate(_credentials.token);
21894
+ await this.reauthenticateLiveSession(_credentials);
21895
+ }
21896
+ /**
21897
+ * Reauthenticate the currently-open session with a freshly obtained
21898
+ * credential so the new token takes effect on the live socket immediately —
21899
+ * not just on the next reconnect. No-op when the session is not
21900
+ * connected/authenticated or the credential carries no token (e.g. an
21901
+ * authorization-state-only refresh). Non-fatal: reauth failures surface on
21902
+ * `errors$` without aborting the refresh that triggered this.
21903
+ */
21904
+ async reauthenticateLiveSession(credential) {
21905
+ if (!this.isConnected || !this._clientSession.authenticated || !credential.token) return;
21906
+ try {
21907
+ await this._clientSession.reauthenticate(credential.token);
21695
21908
  logger$1.info("[SignalWire] Session refreshed with new credentials.");
21696
21909
  } catch (error) {
21697
21910
  logger$1.error("[SignalWire] Failed to refresh session with new credentials:", error);
21698
21911
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
21699
21912
  }
21700
21913
  }
21914
+ /**
21915
+ * One-shot recovery for a live session that failed with a recoverable auth
21916
+ * error (`-32002`/`-32003`) because its token went stale — e.g. a refresh
21917
+ * timer that was throttled while the tab was backgrounded.
21918
+ *
21919
+ * Two steps, first that succeeds wins:
21920
+ * 1. Reauthenticate with the in-memory token (a fresh DPoP proof is
21921
+ * generated automatically). Heals a session whose token was already
21922
+ * refreshed by the coordinator but never applied to the open socket,
21923
+ * and a client-bound session whose server-side auth merely drifted.
21924
+ * 2. Re-mint via the developer provider's `refresh()` and reauthenticate.
21925
+ * Skipped for client-bound sessions (the DeviceTokenManager owns their
21926
+ * refresh; re-minting a base SAT here would drop the DPoP binding).
21927
+ *
21928
+ * @param allowRemint - Whether step 2 (provider re-mint) may run. Callers
21929
+ * pass `false` for non-auth failures so a transient network error never
21930
+ * escalates to a token re-mint; step 1 (in-memory reauth) always runs.
21931
+ * @returns `true` if the session was reauthenticated, `false` otherwise.
21932
+ */
21933
+ async recoverStaleCredential(allowRemint = true) {
21934
+ const currentToken = this._deps.credential.token;
21935
+ if (currentToken) try {
21936
+ await this._clientSession.reauthenticate(currentToken);
21937
+ return true;
21938
+ } catch (error) {
21939
+ logger$1.debug("[SignalWire] In-memory reauth failed during recovery:", error);
21940
+ }
21941
+ if (!allowRemint || this._clientSession.clientBound || !this._credentialProvider?.refresh) return false;
21942
+ try {
21943
+ const newCredentials = await this.remintCredential(this._credentialProvider);
21944
+ if (!newCredentials.token) return false;
21945
+ this._deps.credential = newCredentials;
21946
+ this.persistCredential(newCredentials);
21947
+ await this._clientSession.reauthenticate(newCredentials.token);
21948
+ if (newCredentials.expiry_at) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
21949
+ return true;
21950
+ } catch (error) {
21951
+ logger$1.error("[SignalWire] Credential recovery failed:", error);
21952
+ return false;
21953
+ }
21954
+ }
21955
+ /**
21956
+ * Re-mint a credential via `provider.refresh()`, routed through the
21957
+ * coordinator's shared in-flight guard so concurrent re-mint paths (a
21958
+ * scheduled/resume refresh, -32003 recovery, and reconnect) never fire a
21959
+ * second `provider.refresh()` in parallel — which rotating one-time-use
21960
+ * refresh tokens reject. Falls back to a direct call only if the coordinator
21961
+ * has not been constructed yet.
21962
+ */
21963
+ async remintCredential(provider) {
21964
+ if (this._refreshCoordinator) return this._refreshCoordinator.refreshCredential(provider);
21965
+ if (!provider.refresh) throw new InvalidCredentialsError("Credential provider does not support refresh");
21966
+ return provider.refresh();
21967
+ }
21968
+ /**
21969
+ * Re-mint credentials before a fresh (re)connect (`onBeforeReconnect` hook).
21970
+ * The session invokes this only when it is client-bound OR the in-memory
21971
+ * token is expired. The re-mint mechanism depends on the binding:
21972
+ * - Client-bound: `authenticate()` with the DPoP fingerprint to obtain a
21973
+ * fresh base SAT the upcoming reconnect can re-bind (the
21974
+ * DeviceTokenManager re-activates afterwards).
21975
+ * - Unbound: the developer's non-interactive `refresh()` handler.
21976
+ * `authenticate()` is deliberately NOT used here — it may be interactive
21977
+ * (a login prompt) and must not fire on a background reconnect.
21978
+ *
21979
+ * Rejects on failure so the session aborts the reconnect rather than
21980
+ * replaying a stale token.
21981
+ */
21982
+ async refreshCredentialForReconnect() {
21983
+ if (!this._credentialProvider) return;
21984
+ try {
21985
+ let newCredentials;
21986
+ if (this._clientSession.clientBound) {
21987
+ const fingerprint = this._dpopManager?.initialized ? this._dpopManager.fingerprint : void 0;
21988
+ logger$1.debug("[SignalWire] Re-minting client-bound base SAT before reconnect");
21989
+ newCredentials = await this._credentialProvider.authenticate(fingerprint ? { fingerprint } : void 0);
21990
+ } else if (this._credentialProvider.refresh) {
21991
+ logger$1.debug("[SignalWire] Refreshing unbound credential before reconnect");
21992
+ newCredentials = await this.remintCredential(this._credentialProvider);
21993
+ } else {
21994
+ logger$1.warn("[SignalWire] [SW-NO-REFRESH-HANDLER] Token expired on reconnect but no refresh handler; reconnecting with the existing token.");
21995
+ return;
21996
+ }
21997
+ if (!newCredentials.token) {
21998
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the existing credential for reconnect.");
21999
+ return;
22000
+ }
22001
+ this._deps.credential = newCredentials;
22002
+ this.persistCredential(newCredentials);
22003
+ if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
22004
+ logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
22005
+ } catch (error) {
22006
+ logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
22007
+ this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22008
+ throw error;
22009
+ }
22010
+ }
21701
22011
  /** Persist credential to localStorage when persistSession is enabled. */
21702
22012
  persistCredential(credential) {
21703
22013
  if (!credential.token) return;
@@ -21784,24 +22094,13 @@ var SignalWire = class extends Destroyable {
21784
22094
  this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey);
21785
22095
  this._clientSession = new ClientSessionManager(() => this._deps.credential, this._transport, this._deps.storage, this._deps.authorizationStateKey, this._deps.deviceController, this._attachManager, this._deps.webRTCApiProvider, this._dpopManager, this._networkMonitor?.networkChange$);
21786
22096
  this._publicSession = new ClientSessionWrapper(this._clientSession);
21787
- this._clientSession.onBeforeReconnect = async () => {
21788
- if (!this._credentialProvider) return;
21789
- try {
21790
- const fingerprint = this._dpopManager?.initialized ? this._dpopManager.fingerprint : void 0;
21791
- logger$1.debug("[SignalWire] Credential expired, refreshing before reconnect");
21792
- const newCredentials = await this._credentialProvider.authenticate(fingerprint ? { fingerprint } : void 0);
21793
- this._deps.credential = newCredentials;
21794
- if (newCredentials.expiry_at && this._credentialProvider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
21795
- logger$1.debug("[SignalWire] Credential refreshed successfully for reconnect");
21796
- } catch (error) {
21797
- logger$1.error("[SignalWire] Failed to refresh credentials for reconnect:", error);
21798
- this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
21799
- throw error;
21800
- }
21801
- };
22097
+ this._clientSession.onBeforeReconnect = async () => this.refreshCredentialForReconnect();
21802
22098
  this.subscribeTo(this._clientSession.errors$, (error) => {
21803
22099
  this._errors$.next(error);
21804
22100
  });
22101
+ this.subscribeTo(this._clientSession.authorization$, (authorization) => {
22102
+ this._refreshCoordinator?.syncExpiryFromAuthorization(authorization, this._credentialProvider);
22103
+ });
21805
22104
  await this._clientSession.connect();
21806
22105
  await this._refreshCoordinator?.activate(this._deps.user, this._clientSession);
21807
22106
  this.subscribeTo(this._clientSession.authenticated$.pipe((0, import_cjs$1.skip)(1), (0, import_cjs$1.filter)(Boolean)), async () => {
@@ -21964,6 +22263,13 @@ var SignalWire = class extends Destroyable {
21964
22263
  }
21965
22264
  try {
21966
22265
  this._visibilityController = new VisibilityController();
22266
+ this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, import_cjs$1.filter)((event) => event.to === "visible")), () => {
22267
+ try {
22268
+ this._refreshCoordinator?.forceRefreshIfDue();
22269
+ } catch (error) {
22270
+ logger$1.warn("[SignalWire] Resume credential revalidation failed (non-fatal):", error);
22271
+ }
22272
+ });
21967
22273
  this.subscribeTo(this._visibilityController.visibilityChange$.pipe((0, import_cjs$1.filter)((event) => event.to === "visible" && PreferencesContainer.instance.refreshDevicesOnVisible)), () => {
21968
22274
  logger$1.debug("[SignalWire] Page visible, re-enumerating devices");
21969
22275
  try {
@@ -21975,7 +22281,7 @@ var SignalWire = class extends Destroyable {
21975
22281
  logger$1.warn("[SignalWire] Failed to initialize VisibilityController:", error);
21976
22282
  }
21977
22283
  try {
21978
- this._diagnosticsCollector = new DiagnosticsCollector({ sdkVersion: "4.0.0-rc.1" });
22284
+ this._diagnosticsCollector = new DiagnosticsCollector({ sdkVersion: "4.0.0-rc.2" });
21979
22285
  } catch (error) {
21980
22286
  logger$1.warn("[SignalWire] Failed to initialize DiagnosticsCollector:", error);
21981
22287
  }
@@ -22046,21 +22352,23 @@ var SignalWire = class extends Destroyable {
22046
22352
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22047
22353
  throw error;
22048
22354
  }
22049
- logger$1.debug("[SignalWire] Failed to register user, trying reauthentication...");
22050
- try {
22051
- await this._clientSession.reauthenticate(this._deps.credential.token);
22052
- logger$1.debug("[SignalWire] Reauthentication successful, retrying register()");
22355
+ logger$1.debug("[SignalWire] Failed to register user, attempting credential recovery...");
22356
+ let failureCause = error;
22357
+ if (await this.recoverStaleCredential(isRecoverableAuthError(error))) try {
22358
+ logger$1.debug("[SignalWire] Recovery succeeded, retrying register()");
22053
22359
  await this._transport.execute(RPCExecute({
22054
22360
  method: "subscriber.online",
22055
22361
  params: {}
22056
22362
  }));
22057
22363
  this._isRegistered$.next(true);
22058
- } catch (reauthError) {
22059
- logger$1.error("[SignalWire] Reauthentication failed during register():", reauthError);
22060
- const registerError = new InvalidCredentialsError("Failed to register user, and reauthentication attempt also failed. Please check your credentials.", { cause: reauthError instanceof Error ? reauthError : new Error(String(reauthError), { cause: reauthError }) });
22061
- this._errors$.next(registerError);
22062
- throw registerError;
22364
+ return;
22365
+ } catch (retryError) {
22366
+ logger$1.error("[SignalWire] Register retry after recovery failed:", retryError);
22367
+ failureCause = retryError;
22063
22368
  }
22369
+ const registerError = new InvalidCredentialsError("Failed to register user, and credential recovery also failed. Please check your credentials.", { cause: failureCause instanceof Error ? failureCause : new Error(String(failureCause), { cause: failureCause }) });
22370
+ this._errors$.next(registerError);
22371
+ throw registerError;
22064
22372
  }
22065
22373
  }
22066
22374
  /**
@@ -22113,7 +22421,15 @@ var SignalWire = class extends Destroyable {
22113
22421
  };
22114
22422
  await this.waitAuthentication();
22115
22423
  logger$1.debug("[SignalWire] Dialing with options:", computed_options);
22116
- return this._clientSession.createOutboundCall(destination, computed_options);
22424
+ try {
22425
+ return await this._clientSession.createOutboundCall(destination, computed_options);
22426
+ } catch (error) {
22427
+ if (!isRecoverableAuthError(error)) throw error;
22428
+ logger$1.debug("[SignalWire] Dial hit a recoverable auth error; recovering credential and retrying once");
22429
+ if (!await this.recoverStaleCredential()) throw error;
22430
+ await this.waitAuthentication();
22431
+ return this._clientSession.createOutboundCall(destination, computed_options);
22432
+ }
22117
22433
  }
22118
22434
  /**
22119
22435
  * Runs a multi-phase connectivity test against the given destination.
@@ -22510,7 +22826,7 @@ var StaticCredentialProvider = class {
22510
22826
  /**
22511
22827
  * Library version from package.json, injected at build time.
22512
22828
  */
22513
- const version = "4.0.0-rc.1";
22829
+ const version = "4.0.0-rc.2";
22514
22830
  /**
22515
22831
  * Flag indicating the library has been loaded and is ready to use.
22516
22832
  * For UMD builds: `window.SignalWire.ready`
@@ -22532,7 +22848,7 @@ const ready = true;
22532
22848
  */
22533
22849
  const emitReadyEvent = () => {
22534
22850
  if (typeof window !== "undefined") {
22535
- const event = new CustomEvent("signalwire:js:ready", { detail: { version: "4.0.0-rc.1" } });
22851
+ const event = new CustomEvent("signalwire:js:ready", { detail: { version: "4.0.0-rc.2" } });
22536
22852
  window.dispatchEvent(event);
22537
22853
  }
22538
22854
  };
@@ -22558,6 +22874,7 @@ if (typeof process === "undefined") globalThis.process = { env: { NODE_ENV: "pro
22558
22874
  //#endregion
22559
22875
  exports.Address = Address;
22560
22876
  exports.CallCreateError = CallCreateError;
22877
+ exports.CallNotReadyError = CallNotReadyError;
22561
22878
  exports.ClientPreferences = ClientPreferences;
22562
22879
  exports.CollectionFetchError = CollectionFetchError;
22563
22880
  exports.DPoPInitError = DPoPInitError;
@@ -22569,6 +22886,7 @@ exports.MediaTrackError = MediaTrackError;
22569
22886
  exports.MessageParseError = MessageParseError;
22570
22887
  exports.OverconstrainedFallbackError = OverconstrainedFallbackError;
22571
22888
  exports.Participant = Participant;
22889
+ exports.ParticipantNotReadyError = ParticipantNotReadyError;
22572
22890
  exports.PreflightError = PreflightError;
22573
22891
  exports.RecoveryError = RecoveryError;
22574
22892
  exports.SelfCapabilities = SelfCapabilities;