@signalwire/js 4.0.0-dev-20260804221355 → 4.0.0-dev-20260908172838

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.mjs CHANGED
@@ -12374,15 +12374,57 @@ const RPCEventAckResponse = (id) => makeRPCResponse({
12374
12374
  result: {}
12375
12375
  });
12376
12376
 
12377
+ //#endregion
12378
+ //#region src/utils/authRecovery.ts
12379
+ /**
12380
+ * Walk an error's `error`/`cause` chain looking for a {@link JSONRPCError}.
12381
+ * Errors thrown by call creation are wrapped (e.g. `CallCreateError`), so the
12382
+ * underlying signaling error is nested. Bounded by a visited set to guard
12383
+ * against cyclic causes.
12384
+ */
12385
+ function findJSONRPCError(error) {
12386
+ const seen = /* @__PURE__ */ new Set();
12387
+ let current = error;
12388
+ while (current instanceof Error && !seen.has(current)) {
12389
+ seen.add(current);
12390
+ if (current instanceof JSONRPCError) return current;
12391
+ current = current.error ?? current.cause;
12392
+ }
12393
+ }
12394
+ /**
12395
+ * Whether an error is a session-recoverable authentication failure
12396
+ * (`-32002` authentication failed or `-32003` requester validation failed)
12397
+ * that a credential re-mint + retry can heal.
12398
+ */
12399
+ function isRecoverableAuthError(error) {
12400
+ const rpcError = findJSONRPCError(error);
12401
+ return rpcError !== void 0 && (rpcError.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || rpcError.code === RPC_ERROR_AUTHENTICATION_FAILED);
12402
+ }
12403
+ /**
12404
+ * Whether an error is specifically a requester-validation rejection
12405
+ * (`-32003`) — the server refusing the session's credential.
12406
+ *
12407
+ * Narrower than {@link isRecoverableAuthError} on purpose. `-32002` is
12408
+ * overloaded server-side: a rejected reattach arrives as `-32002` with
12409
+ * `cause: INVALID_MSG_UNSPECIFIED` and message `CALL ERROR`, which is a
12410
+ * call-level rejection and says nothing about the credential. Use this where
12411
+ * the decision must not be fooled by that, such as deciding whether retrying
12412
+ * an operation could possibly succeed.
12413
+ */
12414
+ function isRequesterValidationError(error) {
12415
+ return findJSONRPCError(error)?.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED;
12416
+ }
12417
+
12377
12418
  //#endregion
12378
12419
  //#region src/managers/AttachManager.ts
12379
12420
  const logger$25 = getLogger();
12380
12421
  var AttachManager = class {
12381
- constructor(storage, deviceController, reconnectCallsTimeout, attachKey) {
12422
+ constructor(storage, deviceController, reconnectCallsTimeout, attachKey, credentialRecovered) {
12382
12423
  this.storage = storage;
12383
12424
  this.deviceController = deviceController;
12384
12425
  this.reconnectCallsTimeout = reconnectCallsTimeout;
12385
12426
  this.attachKey = attachKey;
12427
+ this.credentialRecovered = credentialRecovered;
12386
12428
  this.writeQueue = Promise.resolve();
12387
12429
  }
12388
12430
  async detachAll() {
@@ -12462,8 +12504,14 @@ var AttachManager = class {
12462
12504
  * rejecting. Once that fix is deployed, this will work for both
12463
12505
  * page reloads and WebSocket reconnects.
12464
12506
  *
12465
- * Failed reattach attempts are handled gracefully — the stale call
12466
- * reference is cleaned up from storage.
12507
+ * A failed reattach does NOT generally cost the stored reference. It is
12508
+ * discarded only when the server denied the reattach on a session whose
12509
+ * credential it had already accepted — a verified reauthentication followed
12510
+ * by a refusal is the server saying the call is gone, and that is the one
12511
+ * refusal worth acting on. Until then the credential may be what is being
12512
+ * refused, and the record is the only way a later reload can try again;
12513
+ * keeping it costs nothing, since `detachExpired` reaps it once it is older
12514
+ * than `reconnectCallsTimeout`.
12467
12515
  */
12468
12516
  async reattachCalls() {
12469
12517
  const attached = await this.readAttached();
@@ -12472,6 +12520,7 @@ var AttachManager = class {
12472
12520
  const { destination } = attachment;
12473
12521
  const options = this.buildCallOptions(attachment);
12474
12522
  let succeeded = false;
12523
+ let refusedOnCredentials = false;
12475
12524
  for (let attempt = 1; attempt <= 3; attempt++) try {
12476
12525
  await this.session.createOutboundCall(destination, {
12477
12526
  callId,
@@ -12482,15 +12531,20 @@ var AttachManager = class {
12482
12531
  break;
12483
12532
  } catch (error) {
12484
12533
  logger$25.warn(`[AttachManager] Reattach attempt ${attempt}/3 failed for call ${callId}:`, error);
12534
+ if (isRequesterValidationError(error)) {
12535
+ refusedOnCredentials = true;
12536
+ logger$25.warn(`[AttachManager] Reattach of ${callId} was refused on credentials; not retrying.`);
12537
+ break;
12538
+ }
12485
12539
  if (attempt < 3) await new Promise((r) => setTimeout(r, (attempt + 1) * 1e3));
12486
12540
  }
12487
- if (!succeeded) {
12488
- logger$25.warn(`[AttachManager] Reattach failed after 3 attempts for call ${callId}, removing reference`);
12541
+ if (!succeeded) if (this.credentialRecovered() && !refusedOnCredentials) {
12542
+ logger$25.warn(`[AttachManager] Reattach of ${callId} was denied after a verified reauthentication, removing reference`);
12489
12543
  await this.detach({
12490
12544
  id: callId,
12491
12545
  mediaDirections: attachment.mediaDirections
12492
12546
  });
12493
- }
12547
+ } else logger$25.warn(`[AttachManager] Reattach failed for call ${callId}; keeping the reference (credential refused or never proven good)`);
12494
12548
  }
12495
12549
  }
12496
12550
  /**
@@ -14700,9 +14754,14 @@ var CallEventsManager = class extends Destroyable {
14700
14754
  updateLayouts() {
14701
14755
  if (!this.selfId) return;
14702
14756
  this.webRtcCallSession.executeMethod(this.selfId, "call.layout.list", {}).then((response) => {
14757
+ const layouts = response.result?.layouts;
14758
+ if (!layouts) {
14759
+ logger$22.warn("[CallEventsManager] Layout list response carried no layouts; keeping current layouts");
14760
+ return;
14761
+ }
14703
14762
  this._sessionState$.next({
14704
14763
  ...this._sessionState$.value,
14705
- layouts: response.result.layouts
14764
+ layouts
14706
14765
  });
14707
14766
  }).catch((error) => {
14708
14767
  logger$22.error("[CallEventsManager] Error fetching layouts:", error);
@@ -16707,6 +16766,48 @@ const logger$16 = getLogger();
16707
16766
  function resolveInviteNodeId(args) {
16708
16767
  return args.isInvite && !args.reattach && !args.explicitNodeId ? "" : args.currentNodeId ?? "";
16709
16768
  }
16769
+ /**
16770
+ * Surface the real outcome of a `webrtc.verto` reply.
16771
+ *
16772
+ * A webrtc.verto response nests several envelopes, each keyed by a verto-style
16773
+ * string `code` ("200" ok, "400"/etc. fail) rather than a JSON-RPC `error`. An outer
16774
+ * layer reports only whether the frame was delivered; an inner layer carries the op's
16775
+ * own outcome:
16776
+ *
16777
+ * response.result = { code:"200", result:{…} } ← delivery acknowledgement
16778
+ * .result = { jsonrpc, id, result:{…} } ← the reply payload
16779
+ * .result = { code:"400", message:"Bad request" } ← the actual op outcome
16780
+ *
16781
+ * A failure can appear at any layer (delivery refused, or the op itself rejected
16782
+ * deeper down), so walk every nested `.result` object and return the FIRST non-2xx
16783
+ * `code` with its message. Returns null when every `code` seen is 2xx or absent —
16784
+ * i.e. the op succeeded. This is the only way to detect that e.g. a mute/kick was
16785
+ * rejected, since the outer delivery `code` is "200" (delivered) even then.
16786
+ *
16787
+ * Pure function — exported for unit testing.
16788
+ */
16789
+ function findNestedVertoFailure(response) {
16790
+ let node = response;
16791
+ while (node !== null && typeof node === "object") {
16792
+ const obj = node;
16793
+ const err = obj.error;
16794
+ if (err !== null && typeof err === "object") {
16795
+ const e = err;
16796
+ const errCode = typeof e.code === "string" || typeof e.code === "number" ? String(e.code) : void 0;
16797
+ if (errCode !== void 0 && !/^2\d\d$/.test(errCode)) return {
16798
+ code: errCode,
16799
+ message: typeof e.message === "string" ? e.message : void 0
16800
+ };
16801
+ }
16802
+ const code = typeof obj.code === "string" || typeof obj.code === "number" ? String(obj.code) : void 0;
16803
+ if (code !== void 0 && !/^2\d\d$/.test(code)) return {
16804
+ code,
16805
+ message: typeof obj.message === "string" ? obj.message : void 0
16806
+ };
16807
+ node = obj.result !== null && typeof obj.result === "object" ? obj.result : null;
16808
+ }
16809
+ return null;
16810
+ }
16710
16811
  var VertoManager = class extends Destroyable {
16711
16812
  constructor(callSession) {
16712
16813
  super();
@@ -17031,6 +17132,33 @@ var WebRTCVertoManager = class extends VertoManager {
17031
17132
  get vertoPing$() {
17032
17133
  return this.cachedObservable("vertoPing$", () => this.webRtcCallSession.webrtcMessages$.pipe(filterAs(isVertoPingInnerParams, "params"), (0, import_cjs$15.takeUntil)(this.destroyed$)));
17033
17134
  }
17135
+ /**
17136
+ * Send a member-control op in-dialog via verto.info.
17137
+ *
17138
+ * The control payload rides in `params.command` — a sibling of `dialogParams`,
17139
+ * at the same level as `dtmf` in {@link sendDigits} — and the inner verto.info
17140
+ * is matched to this call's channel by `dialogParams.callID`, the in-dialog
17141
+ * convention for member-scoped frames. Because it is delivered on the dialog
17142
+ * itself, control lands on the call's own channel with no {node_id,call_id,member_id}
17143
+ * "self" tuple to get wrong. The outer webrtc.verto envelope (added by executeVerto)
17144
+ * still carries the own-leg callID + node_id for session routing.
17145
+ *
17146
+ * Keep `command` OUT of `dialogParams`: it is read at the params level, and
17147
+ * filterVertoParams rewrites/filters dialogParams keys but passes params-level
17148
+ * keys through verbatim.
17149
+ */
17150
+ async sendCallControl(method, params) {
17151
+ const response = await this.executeVerto(VertoInfo({
17152
+ dialogParams: { callID: this.webRtcCallSession.id },
17153
+ command: {
17154
+ method,
17155
+ params
17156
+ }
17157
+ }));
17158
+ const failure = findNestedVertoFailure(response);
17159
+ if (failure) throw new JSONRPCError(Number.parseInt(failure.code, 10) || 0, `Call control "${method}" failed (code ${failure.code})${failure.message ? `: ${failure.message}` : ""}`, void 0);
17160
+ return response;
17161
+ }
17034
17162
  async executeVerto(message, optionals = {}) {
17035
17163
  const webrtcVertoMessage = WebrtcVerto({
17036
17164
  callID: optionals.callID ?? this.webRtcCallSession.id,
@@ -17039,15 +17167,16 @@ var WebRTCVertoManager = class extends VertoManager {
17039
17167
  subscribe: optionals.subscribe
17040
17168
  });
17041
17169
  const response = await this.webRtcCallSession.execute(webrtcVertoMessage);
17170
+ const nonFatal = message.method === "verto.info" ? { fatal: false } : void 0;
17042
17171
  if (response.error) {
17043
17172
  const error = new JSONRPCError(response.error.code, response.error.message, response.error.data);
17044
- this.onError?.(error);
17173
+ this.onError?.(error, nonFatal);
17045
17174
  return response;
17046
17175
  }
17047
17176
  const innerResult = getValueFrom(response, "result.result");
17048
17177
  if (innerResult?.error) {
17049
17178
  const error = new JSONRPCError(innerResult.error.code, innerResult.error.message, innerResult.error.data);
17050
- this.onError?.(error);
17179
+ this.onError?.(error, nonFatal);
17051
17180
  return response;
17052
17181
  }
17053
17182
  return response;
@@ -18335,6 +18464,33 @@ function mosToQualityLevel(mos) {
18335
18464
  return "critical";
18336
18465
  }
18337
18466
 
18467
+ //#endregion
18468
+ //#region src/utils/unwrapVertoReply.ts
18469
+ /**
18470
+ * Unwrap a method's own reply from a `webrtc.verto` envelope.
18471
+ *
18472
+ * A control verb sent in-dialog comes back nested two levels deep:
18473
+ *
18474
+ * ```
18475
+ * { result: { node_id, code, result: { jsonrpc, id, result: <method payload> } } }
18476
+ * ```
18477
+ *
18478
+ * whereas the routed transport resolves the method payload directly under
18479
+ * `.result`. Readers want the latter shape, and the difference is silent when it
18480
+ * is wrong — `response.result.layouts` simply evaluates to `undefined` against the
18481
+ * envelope, so the data arrives, nothing throws, and the caller sees an empty
18482
+ * value. That exact failure produced an empty layout dropdown with a successful
18483
+ * request behind it.
18484
+ *
18485
+ * Accepting both shapes here keeps every reader indifferent to which transport
18486
+ * produced the response. Anything that is not a nested envelope (a plain ack, or
18487
+ * an already-unwrapped reply) passes through untouched.
18488
+ */
18489
+ function unwrapVertoReply(response) {
18490
+ const inner = getValueFrom(response, "result.result");
18491
+ return inner && typeof inner === "object" && "result" in inner ? inner : response;
18492
+ }
18493
+
18338
18494
  //#endregion
18339
18495
  //#region src/core/entities/Call.ts
18340
18496
  var import_cjs$11 = require_cjs();
@@ -18434,8 +18590,11 @@ var WebRTCCall = class extends Destroyable {
18434
18590
  emitError(callError) {
18435
18591
  if (this._status$.value === "destroyed" || this._status$.value === "failed") return;
18436
18592
  this._errors$.next(callError);
18437
- if (callError.fatal) {
18593
+ if (callError.fatal && this._status$.value !== "disconnecting") {
18438
18594
  this._status$.next("failed");
18595
+ this.vertoManager.bye().catch((error) => {
18596
+ logger$12.debug("[Call] fatal-teardown bye failed (signaling likely already dead):", error);
18597
+ });
18439
18598
  this.destroy();
18440
18599
  }
18441
18600
  }
@@ -18552,6 +18711,7 @@ var WebRTCCall = class extends Destroyable {
18552
18711
  async executeMethod(target, method, args) {
18553
18712
  const self = this.callSelf;
18554
18713
  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}`);
18714
+ if (this.clientSession.callControl === "in-dialog") return this.executeMethodInDialog(target, method, args);
18555
18715
  const params = {
18556
18716
  ...args,
18557
18717
  self,
@@ -18571,6 +18731,81 @@ var WebRTCCall = class extends Destroyable {
18571
18731
  }
18572
18732
  }
18573
18733
  /**
18734
+ * `executeMethod` for a call opened with `callControl: 'in-dialog'`.
18735
+ *
18736
+ * Translates the routed transport's calling convention into the in-dialog one. No
18737
+ * `self` tuple is sent, but a `target` is — the same {call_id, member_id} the routed
18738
+ * transport puts in `target` (minus node_id), for self-ops and cross-member ops alike.
18739
+ *
18740
+ * Target shapes are per-verb and irregular, so they are centralised here rather
18741
+ * than left to callers: most verbs take a singular `target`, `call.member.remove`
18742
+ * takes a plural `targets` array, and `call.member.position.set` takes a flat
18743
+ * `targets` of `{call_id, position}` — the one verb keyed on call_id rather than
18744
+ * member_id, so the member triple `Participant.setPosition` built is unwrapped.
18745
+ */
18746
+ async executeMethodInDialog(target, method, args) {
18747
+ const control = { ...args };
18748
+ if (method === "call.member.position.set") control.targets = (args.targets ?? []).map((entry) => ({
18749
+ call_id: entry.target?.call_id ?? entry.call_id,
18750
+ position: entry.position
18751
+ }));
18752
+ else {
18753
+ const member = typeof target === "object" ? {
18754
+ call_id: target.call_id,
18755
+ member_id: target.member_id
18756
+ } : {
18757
+ call_id: this.id,
18758
+ member_id: target
18759
+ };
18760
+ if (method === "call.member.remove") control.targets = [member];
18761
+ else control.target = member;
18762
+ }
18763
+ return this.sendCommand(method, control);
18764
+ }
18765
+ /**
18766
+ * Sends a `call.*` control verb **in-dialog** via `verto.info`, as an alternative
18767
+ * to the routed {@link executeMethod} transport.
18768
+ *
18769
+ * Why both exist: `executeMethod` addresses the member with an explicit
18770
+ * `{node_id, call_id, member_id}` tuple, which does not resolve for every conference,
18771
+ * so the op can fail. An in-dialog frame carries the verb on the member's own
18772
+ * signaling channel instead, so control works without the client needing to know how
18773
+ * the conference is hosted.
18774
+ *
18775
+ * The trade-off is reach: the in-dialog transport is only accepted for calls that
18776
+ * join a conference over SWML (e.g. an SWML `join_conference`); use the routed
18777
+ * default otherwise.
18778
+ *
18779
+ * `params` are sent verbatim — nothing is built for you, which includes the target.
18780
+ * **A self-directed op still needs one**, or it is refused; name yourself explicitly:
18781
+ *
18782
+ * ```ts
18783
+ * const { call_id, member_id } = call.self.target;
18784
+ * await call.sendCommand('call.mute', { channels: ['audio'], target: { call_id, member_id } });
18785
+ * ```
18786
+ *
18787
+ * Never include `node_id` — only the two ids. The shapes are per-verb: most take a
18788
+ * singular `target`, `call.member.remove` takes a plural `targets` array, and
18789
+ * `call.member.position.set` takes a flat `targets: [{call_id, position}]` (the one
18790
+ * verb keyed on `call_id` rather than `member_id`). Verbs that act on the call as a
18791
+ * whole, or that the SDK does not wrap at all, take no target.
18792
+ *
18793
+ * For the typed alternative that handles all of this, create the client with
18794
+ * `callControl: 'in-dialog'` and use the ordinary `Call`/`Participant` methods.
18795
+ *
18796
+ * @internal Not part of the supported surface while the in-dialog transport is still
18797
+ * rolling out. `WebRTCCall` is exported from the package entry, so without this tag
18798
+ * TypeDoc publishes the method — and the example above — as public API.
18799
+ *
18800
+ * @param method - A `call.*` method name (e.g. `'call.mute'`).
18801
+ * @param params - Method parameters, sent verbatim.
18802
+ * @returns The method's own reply, unwrapped from the `verto.info` envelope.
18803
+ * @throws {JSONRPCError} If the control op fails.
18804
+ */
18805
+ async sendCommand(method, params = {}) {
18806
+ return unwrapVertoReply(await this.vertoManager.sendCallControl(method, params));
18807
+ }
18808
+ /**
18574
18809
  * The local leg's member triple — sent as `self` in every member RPC
18575
18810
  * envelope, and as the `target` of call-scoped self-operations (e.g. lock,
18576
18811
  * layout).
@@ -19901,6 +20136,7 @@ var ClientSessionManager = class extends Destroyable {
19901
20136
  minor: 0,
19902
20137
  revision: 0
19903
20138
  };
20139
+ this.callControl = "routed";
19904
20140
  this._authorization$ = this.createBehaviorSubject(void 0);
19905
20141
  this._errors$ = this.createReplaySubject(1);
19906
20142
  this._authState$ = this.createBehaviorSubject({ kind: "unauthenticated" });
@@ -20105,17 +20341,14 @@ var ClientSessionManager = class extends Destroyable {
20105
20341
  const hasStoredState = await (0, import_cjs$7.firstValueFrom)(this.authorizationState$.pipe((0, import_cjs$7.take)(1))) !== void 0;
20106
20342
  if (isRecoverableAuthError$1 && hasStoredState) {
20107
20343
  logger$9.debug("[Session] Recoverable auth error — cleaning up stored state and reconnecting fresh");
20108
- try {
20109
- await this.cleanupStoredConnectionParams();
20110
- } catch (cleanupError) {
20111
- logger$9.error("Failed to cleanup stored connection params:", cleanupError);
20112
- } finally {
20113
- this.transport.reconnect();
20114
- }
20344
+ await this.discardResumeStateAndReconnect();
20115
20345
  } else this._errors$.next(error);
20116
20346
  }
20117
20347
  /**
20118
- * Clear the resume state (authorization_state + protocol) only.
20348
+ * Clear the resume state (authorization_state + protocol) and ask the
20349
+ * transport to reconnect. The `connected` event re-triggers
20350
+ * `authenticate()`, which now has no stored state and so performs a fresh
20351
+ * connect.
20119
20352
  *
20120
20353
  * This is the stale-auth-state recovery helper used by handleAuthError:
20121
20354
  * the server rejected a reconnect, so the resume state is discarded and a
@@ -20123,9 +20356,24 @@ var ClientSessionManager = class extends Destroyable {
20123
20356
  * session lives on through the reconnect and reattachCalls() needs the
20124
20357
  * stored call references afterwards. Do NOT add detachAll() here.
20125
20358
  *
20359
+ * Connect-time recovery only. A *request* refused on an already
20360
+ * authenticated session is never healed here: dropping the resume state
20361
+ * destroys the association between the socket and the previous session,
20362
+ * which is what reattach depends on. That path mints a fresh credential and
20363
+ * reauthenticates instead (see `SignalWire.recoverAndRetry`).
20364
+ *
20126
20365
  * For public teardown (disconnect/destroy), use {@link teardownSessionState}
20127
20366
  * instead, which clears the attach records as well.
20128
20367
  */
20368
+ async discardResumeStateAndReconnect() {
20369
+ try {
20370
+ await this.cleanupStoredConnectionParams();
20371
+ } catch (cleanupError) {
20372
+ logger$9.error("Failed to cleanup stored connection params:", cleanupError);
20373
+ } finally {
20374
+ this.transport.reconnect();
20375
+ }
20376
+ }
20129
20377
  async cleanupStoredConnectionParams() {
20130
20378
  await this.transport.setProtocol(void 0);
20131
20379
  await this.updateAuthorizationStateInStorage(void 0);
@@ -20374,6 +20622,9 @@ var ClientSessionWrapper = class {
20374
20622
  get iceServers() {
20375
20623
  return this.clientSessionManager.iceServers;
20376
20624
  }
20625
+ get callControl() {
20626
+ return this.clientSessionManager.callControl;
20627
+ }
20377
20628
  async execute(request, options) {
20378
20629
  return this.clientSessionManager.execute(request, options);
20379
20630
  }
@@ -20614,7 +20865,7 @@ var DeviceTokenManager = class extends Destroyable {
20614
20865
  method: "POST",
20615
20866
  uri: DEVICE_TOKEN_ENDPOINT
20616
20867
  });
20617
- const response = await this.http.request({
20868
+ const response = await this.http().request({
20618
20869
  url: DEVICE_TOKEN_ENDPOINT,
20619
20870
  ...POST_PARAMS,
20620
20871
  body: JSON.stringify({
@@ -20641,7 +20892,7 @@ var DeviceTokenManager = class extends Destroyable {
20641
20892
  uri: DEVICE_REFRESH_ENDPOINT,
20642
20893
  accessToken: currentToken
20643
20894
  });
20644
- const response = await this.http.request({
20895
+ const response = await this.http().request({
20645
20896
  url: DEVICE_REFRESH_ENDPOINT,
20646
20897
  ...POST_PARAMS,
20647
20898
  body: JSON.stringify({
@@ -21694,37 +21945,17 @@ var TransportManager = class extends Destroyable {
21694
21945
  }
21695
21946
  };
21696
21947
 
21697
- //#endregion
21698
- //#region src/utils/authRecovery.ts
21699
- /**
21700
- * Walk an error's `error`/`cause` chain looking for a {@link JSONRPCError}.
21701
- * Errors thrown by call creation are wrapped (e.g. `CallCreateError`), so the
21702
- * underlying signaling error is nested. Bounded by a visited set to guard
21703
- * against cyclic causes.
21704
- */
21705
- function findJSONRPCError(error) {
21706
- const seen = /* @__PURE__ */ new Set();
21707
- let current = error;
21708
- while (current instanceof Error && !seen.has(current)) {
21709
- seen.add(current);
21710
- if (current instanceof JSONRPCError) return current;
21711
- current = current.error ?? current.cause;
21712
- }
21713
- }
21714
- /**
21715
- * Whether an error is a session-recoverable authentication failure
21716
- * (`-32002` authentication failed or `-32003` requester validation failed)
21717
- * that a credential re-mint + retry can heal.
21718
- */
21719
- function isRecoverableAuthError(error) {
21720
- const rpcError = findJSONRPCError(error);
21721
- return rpcError !== void 0 && (rpcError.code === RPC_ERROR_REQUESTER_VALIDATION_FAILED || rpcError.code === RPC_ERROR_AUTHENTICATION_FAILED);
21722
- }
21723
-
21724
21948
  //#endregion
21725
21949
  //#region src/clients/SignalWire.ts
21726
21950
  var import_cjs$1 = require_cjs();
21727
21951
  const logger$1 = getLogger();
21952
+ /**
21953
+ * Storage key for the client-bound marker. The SAT and authorization_state are
21954
+ * both opaque to the SDK, so on a page reload the preflight recovery — which
21955
+ * runs before any session exists — has no other way to know the session was
21956
+ * client-bound. See {@link SignalWire.persistClientBoundMarker}.
21957
+ */
21958
+ const CLIENT_BOUND_STORAGE_KEY = "sw:client_bound";
21728
21959
  const buildOptionsFromDestination = (destination) => {
21729
21960
  if (typeof destination === "string") {
21730
21961
  const queryStartIndex = destination.indexOf("?");
@@ -21768,6 +21999,7 @@ var SignalWire = class extends Destroyable {
21768
21999
  this.preferences = new ClientPreferences();
21769
22000
  this._user$ = this.createBehaviorSubject(void 0);
21770
22001
  this._directory$ = this.createBehaviorSubject(void 0);
22002
+ this._credentialRecovered = false;
21771
22003
  this._isConnected$ = this.createBehaviorSubject(false);
21772
22004
  this._isRegistered$ = this.createBehaviorSubject(false);
21773
22005
  this._errors$ = this.createReplaySubject(1);
@@ -21806,6 +22038,16 @@ var SignalWire = class extends Destroyable {
21806
22038
  });
21807
22039
  }
21808
22040
  /**
22041
+ * Build the refresh path's own HTTP controller, against whatever host is current.
22042
+ *
22043
+ * Called on first use rather than up front, so `apiHost` already reflects the
22044
+ * token's `ch` claim. Same credential source as the container's controller — only
22045
+ * the instance, and therefore its observable streams, is separate.
22046
+ */
22047
+ createRefreshHttpController() {
22048
+ return new HTTPRequestController(this._deps.apiHost, () => this._deps.credential);
22049
+ }
22050
+ /**
21809
22051
  * Initializes DPoP if not already set up. Returns the fingerprint on success.
21810
22052
  */
21811
22053
  async initDPoP() {
@@ -21832,7 +22074,14 @@ var SignalWire = class extends Destroyable {
21832
22074
  async resolveCredentials() {
21833
22075
  const fingerprint = await this.initDPoP();
21834
22076
  this._refreshCoordinator = new CredentialRefreshCoordinator(this._dpopManager, {
21835
- http: this._deps.http,
22077
+ http: () => {
22078
+ if (!this._refreshHttp || this._refreshHttpHost !== this._deps.apiHost) {
22079
+ this._refreshHttp?.destroy();
22080
+ this._refreshHttp = this.createRefreshHttpController();
22081
+ this._refreshHttpHost = this._deps.apiHost;
22082
+ }
22083
+ return this._refreshHttp;
22084
+ },
21836
22085
  notifier: {
21837
22086
  onError: (error) => this._errors$.next(error),
21838
22087
  onWarning: (warning) => this._warnings$.next(warning),
@@ -21916,43 +22165,76 @@ var SignalWire = class extends Destroyable {
21916
22165
  }
21917
22166
  }
21918
22167
  /**
21919
- * One-shot recovery for a live session that failed with a recoverable auth
21920
- * error (`-32002`/`-32003`) because its token went stale — e.g. a refresh
21921
- * timer that was throttled while the tab was backgrounded.
22168
+ * Recover a session the server is refusing: mint a fresh credential,
22169
+ * reauthenticate the live session with it, and retry the operation.
21922
22170
  *
21923
- * Two steps, first that succeeds wins:
21924
- * 1. Reauthenticate with the in-memory token (a fresh DPoP proof is
21925
- * generated automatically). Heals a session whose token was already
21926
- * refreshed by the coordinator but never applied to the open socket,
21927
- * and a client-bound session whose server-side auth merely drifted.
21928
- * 2. Re-mint via the developer provider's `refresh()` and reauthenticate.
21929
- * Skipped for client-bound sessions (the DeviceTokenManager owns their
21930
- * refresh; re-minting a base SAT here would drop the DPoP binding).
22171
+ * The connection is deliberately kept. A reload authenticates the new socket
22172
+ * against the persisted `authorization_state`, and that handshake is what
22173
+ * associates the socket with the previous session — the association reattach
22174
+ * depends on. `signalwire.reauthenticate` swaps the credential *on that same
22175
+ * session*, so recovery never touches the resume state. Discarding it would
22176
+ * heal the credential by destroying the very thing the caller is trying to
22177
+ * get back to.
21931
22178
  *
21932
- * @param allowRemint - Whether step 2 (provider re-mint) may run. Callers
21933
- * pass `false` for non-auth failures so a transient network error never
21934
- * escalates to a token re-mint; step 1 (in-memory reauth) always runs.
21935
- * @returns `true` if the session was reauthenticated, `false` otherwise.
22179
+ * The operation is still the verdict, never the RPC. Reauthenticating with
22180
+ * the in-memory token is accepted by a resume even while requests stay
22181
+ * refused, because the persisted `authorization_state` short-circuits token
22182
+ * validation — and `signalwire.reauthenticate` with a *freshly minted* token
22183
+ * has also been observed accepted while `subscriber.online` keeps being
22184
+ * refused (staging run 33826974634). Both look like success and are not.
22185
+ *
22186
+ * @returns the operation's value, or the reason recovery could not deliver
22187
+ * one. `error` is undefined when there was no way to mint at all.
21936
22188
  */
21937
- async recoverStaleCredential(allowRemint = true) {
21938
- const currentToken = this._deps.credential.token;
21939
- if (currentToken) try {
21940
- await this._clientSession.reauthenticate(currentToken);
21941
- return true;
22189
+ async recoverAndRetry(operation) {
22190
+ if (!await this.remintAndReauthenticate()) return { ok: false };
22191
+ try {
22192
+ const value = await operation();
22193
+ this._credentialRecovered = true;
22194
+ return {
22195
+ ok: true,
22196
+ value
22197
+ };
21942
22198
  } catch (error) {
21943
- logger$1.debug("[SignalWire] In-memory reauth failed during recovery:", error);
22199
+ logger$1.warn("[SignalWire] Reauthentication was accepted but the operation is still refused:", error);
22200
+ return {
22201
+ ok: false,
22202
+ error
22203
+ };
22204
+ }
22205
+ }
22206
+ /**
22207
+ * Re-mint a credential and adopt it only if the live session accepts it.
22208
+ *
22209
+ * The mechanism follows the binding: a client-bound session re-mints a bound
22210
+ * base SAT through `authenticate()` with the DPoP fingerprint, because the
22211
+ * developer refresh handler would hand back an unbound token and silently
22212
+ * degrade the session. An unbound session uses the refresh handler. Rotation
22213
+ * cost is not a reason to skip this — the only reason is having no mechanism.
22214
+ *
22215
+ * @returns whether the session is now running on a freshly accepted credential.
22216
+ */
22217
+ async remintAndReauthenticate() {
22218
+ const provider = this._credentialProvider;
22219
+ if (!provider) return false;
22220
+ const { clientBound } = this._clientSession;
22221
+ if (!clientBound && !provider.refresh) {
22222
+ logger$1.debug("[SignalWire] [SW-NO-REFRESH-HANDLER] Unbound session with no refresh handler; cannot re-mint.");
22223
+ return false;
21944
22224
  }
21945
- if (!allowRemint || this._clientSession.clientBound || !this._credentialProvider?.refresh) return false;
21946
22225
  try {
21947
- const newCredentials = await this.remintCredential(this._credentialProvider);
21948
- if (!newCredentials.token) return false;
22226
+ const newCredentials = clientBound ? await provider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0) : await this.remintCredential(provider);
22227
+ if (!newCredentials.token) {
22228
+ logger$1.warn("[SignalWire] Re-minted credential has no token; keeping the current one.");
22229
+ return false;
22230
+ }
22231
+ await this._clientSession.reauthenticate(newCredentials.token);
21949
22232
  this._deps.credential = newCredentials;
21950
22233
  this.persistCredential(newCredentials);
21951
- await this._clientSession.reauthenticate(newCredentials.token);
21952
- if (newCredentials.expiry_at) this._refreshCoordinator?.scheduleDeveloperRefresh(this._credentialProvider, newCredentials.expiry_at);
22234
+ if (newCredentials.expiry_at && provider.refresh) this._refreshCoordinator?.scheduleDeveloperRefresh(provider, newCredentials.expiry_at);
21953
22235
  return true;
21954
22236
  } catch (error) {
21955
- logger$1.error("[SignalWire] Credential recovery failed:", error);
22237
+ logger$1.warn("[SignalWire] Re-mint recovery failed:", error);
21956
22238
  return false;
21957
22239
  }
21958
22240
  }
@@ -21987,10 +22269,9 @@ var SignalWire = class extends Destroyable {
21987
22269
  if (!this._credentialProvider) return;
21988
22270
  try {
21989
22271
  let newCredentials;
21990
- if (this._clientSession.clientBound) {
21991
- const fingerprint = this._dpopManager?.initialized ? this._dpopManager.fingerprint : void 0;
22272
+ if (this._clientSession?.clientBound ?? await this.wasClientBound()) {
21992
22273
  logger$1.debug("[SignalWire] Re-minting client-bound base SAT before reconnect");
21993
- newCredentials = await this._credentialProvider.authenticate(fingerprint ? { fingerprint } : void 0);
22274
+ newCredentials = await this._credentialProvider.authenticate(this._dpopManager?.initialized ? { fingerprint: this._dpopManager.fingerprint } : void 0);
21994
22275
  } else if (this._credentialProvider.refresh) {
21995
22276
  logger$1.debug("[SignalWire] Refreshing unbound credential before reconnect");
21996
22277
  newCredentials = await this.remintCredential(this._credentialProvider);
@@ -22018,6 +22299,26 @@ var SignalWire = class extends Destroyable {
22018
22299
  this._deps.storage.setItem("sw:cached_credential", credential);
22019
22300
  if (this._deps.persistSession) this._deps.storage.setItem("sw:cached_credential", credential, "local");
22020
22301
  }
22302
+ /**
22303
+ * Persist whether the session is client-bound, mirroring the credential's
22304
+ * storage scopes so it survives a reload. The preflight recovery reads it
22305
+ * before any session exists to decide whether to re-bind via `authenticate()`
22306
+ * or refresh an unbound token; the marker tracks the latest binding, so an
22307
+ * unbound reconnect clears a stale marker from an earlier client-bound login.
22308
+ */
22309
+ persistClientBoundMarker(bound) {
22310
+ const scopes = this._deps.persistSession ? ["session", "local"] : ["session"];
22311
+ for (const scope of scopes) if (bound) this._deps.storage.setItem(CLIENT_BOUND_STORAGE_KEY, true, scope);
22312
+ else this._deps.storage.removeItem(CLIENT_BOUND_STORAGE_KEY, scope);
22313
+ }
22314
+ /** Read the persisted client-bound marker (see {@link persistClientBoundMarker}). */
22315
+ async wasClientBound() {
22316
+ const scopes = this._deps.persistSession ? ["local", "session"] : ["session"];
22317
+ for (const scope of scopes) try {
22318
+ if (await this._deps.storage.getItem(CLIENT_BOUND_STORAGE_KEY, scope)) return true;
22319
+ } catch {}
22320
+ return false;
22321
+ }
22021
22322
  async init() {
22022
22323
  this._user$.next(new User(this._deps.http));
22023
22324
  if (!this._options.skipConnection) await this.connect();
@@ -22041,6 +22342,42 @@ var SignalWire = class extends Destroyable {
22041
22342
  } catch (error) {
22042
22343
  logger$1.error("[SignalWire] Failed to reattach calls:", error);
22043
22344
  this._errors$.next(error instanceof Error ? error : new Error(String(error), { cause: error }));
22345
+ } finally {
22346
+ this._credentialRecovered = false;
22347
+ }
22348
+ }
22349
+ /**
22350
+ * Fetch the authenticated user profile, recovering a stale credential.
22351
+ *
22352
+ * On a reload the persisted credential can be expired. Unlike the WS resume —
22353
+ * which the server accepts against the persisted `authorization_state` even
22354
+ * with an expired token — this REST preflight has no such short-circuit and is
22355
+ * refused (401). There is no session yet to reauthenticate, so recovery
22356
+ * re-mints the credential through the provider ({@link refreshCredentialForReconnect})
22357
+ * and retries with a FRESH {@link User}: Fetchable memoizes its result
22358
+ * (shareReplay), so reusing the instance would replay the 401 instead of
22359
+ * re-fetching with the new token. Without the user id the transport/session —
22360
+ * and the reattach a reload is trying to preserve — cannot even be addressed.
22361
+ */
22362
+ async fetchUserOrRecover() {
22363
+ const fetchUser = async (user$1) => {
22364
+ if (!await (0, import_cjs$1.firstValueFrom)(user$1.fetched$)) throw new UnexpectedError("Failed to fetch user information - fetched$ emitted false");
22365
+ this._deps.user = user$1;
22366
+ };
22367
+ const user = this._user$.value;
22368
+ if (!user) throw new UnexpectedError("User not initialized before connect");
22369
+ try {
22370
+ await fetchUser(user);
22371
+ } catch (firstError) {
22372
+ 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.`);
22373
+ try {
22374
+ await this.refreshCredentialForReconnect();
22375
+ const refetched = new User(this._deps.http);
22376
+ await fetchUser(refetched);
22377
+ this._user$.next(refetched);
22378
+ } catch (retryError) {
22379
+ throw new UnexpectedError("Error fetching user information", { cause: retryError });
22380
+ }
22044
22381
  }
22045
22382
  }
22046
22383
  /**
@@ -22082,28 +22419,22 @@ var SignalWire = class extends Destroyable {
22082
22419
  */
22083
22420
  async connect() {
22084
22421
  await this.teardownTransportAndSession();
22085
- try {
22086
- const user = this._user$.value;
22087
- if (!user) throw new UnexpectedError("User not initialized before connect");
22088
- if (!await (0, import_cjs$1.firstValueFrom)(user.fetched$)) throw new UnexpectedError("Failed to fetch user information - fetched$ emitted false");
22089
- this._deps.user = user;
22090
- } catch (error) {
22091
- 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.`);
22092
- throw new UnexpectedError("Error fetching user information", { cause: error });
22093
- }
22422
+ await this.fetchUserOrRecover();
22094
22423
  const errorHandler = (error) => {
22095
22424
  this._errors$.next(error);
22096
22425
  };
22097
22426
  this._transport = new TransportManager(this._deps.storage, this._deps.protocolKey, this._deps.WebSocket, PreferencesContainer.instance.relayHost ?? this._deps.relayHost, errorHandler);
22098
- this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey);
22427
+ this._attachManager = new AttachManager(this._deps.storage, this._deps.deviceController, PreferencesContainer.instance.reconnectCallsTimeout, this._deps.attachedCallsKey, () => this._credentialRecovered);
22099
22428
  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$);
22100
22429
  this._publicSession = new ClientSessionWrapper(this._clientSession);
22430
+ this._clientSession.callControl = this._options.callControl ?? "routed";
22101
22431
  this._clientSession.onBeforeReconnect = async () => this.refreshCredentialForReconnect();
22102
22432
  this.subscribeTo(this._clientSession.errors$, (error) => {
22103
22433
  this._errors$.next(error);
22104
22434
  });
22105
22435
  this.subscribeTo(this._clientSession.authorization$, (authorization) => {
22106
22436
  this._refreshCoordinator?.syncExpiryFromAuthorization(authorization, this._credentialProvider);
22437
+ this.persistClientBoundMarker(Boolean(authorization?.cnf?.jkt));
22107
22438
  });
22108
22439
  await this._clientSession.connect();
22109
22440
  await this._refreshCoordinator?.activate(this._deps.user, this._clientSession);
@@ -22357,19 +22688,18 @@ var SignalWire = class extends Destroyable {
22357
22688
  throw error;
22358
22689
  }
22359
22690
  logger$1.debug("[SignalWire] Failed to register user, attempting credential recovery...");
22360
- let failureCause = error;
22361
- if (await this.recoverStaleCredential(isRecoverableAuthError(error))) try {
22362
- logger$1.debug("[SignalWire] Recovery succeeded, retrying register()");
22691
+ const outcome = await this.recoverAndRetry(async () => {
22363
22692
  await this._transport.execute(RPCExecute({
22364
22693
  method: "subscriber.online",
22365
22694
  params: {}
22366
22695
  }));
22696
+ });
22697
+ if (outcome.ok) {
22698
+ logger$1.debug("[SignalWire] Recovery restored registration");
22367
22699
  this._isRegistered$.next(true);
22368
22700
  return;
22369
- } catch (retryError) {
22370
- logger$1.error("[SignalWire] Register retry after recovery failed:", retryError);
22371
- failureCause = retryError;
22372
22701
  }
22702
+ const failureCause = outcome.error ?? error;
22373
22703
  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 }) });
22374
22704
  this._errors$.next(registerError);
22375
22705
  throw registerError;
@@ -22429,10 +22759,13 @@ var SignalWire = class extends Destroyable {
22429
22759
  return await this._clientSession.createOutboundCall(destination, computed_options);
22430
22760
  } catch (error) {
22431
22761
  if (!isRecoverableAuthError(error)) throw error;
22432
- logger$1.debug("[SignalWire] Dial hit a recoverable auth error; recovering credential and retrying once");
22433
- if (!await this.recoverStaleCredential()) throw error;
22434
- await this.waitAuthentication();
22435
- return this._clientSession.createOutboundCall(destination, computed_options);
22762
+ logger$1.debug("[SignalWire] Dial hit a recoverable auth error; recovering the session and retrying");
22763
+ const outcome = await this.recoverAndRetry(async () => {
22764
+ await this.waitAuthentication();
22765
+ return this._clientSession.createOutboundCall(destination, computed_options);
22766
+ });
22767
+ if (outcome.ok) return outcome.value;
22768
+ throw outcome.error ?? error;
22436
22769
  }
22437
22770
  }
22438
22771
  /**
@@ -22714,6 +23047,8 @@ var SignalWire = class extends Destroyable {
22714
23047
  destroy() {
22715
23048
  this._refreshCoordinator?.destroy();
22716
23049
  this._refreshCoordinator = void 0;
23050
+ this._refreshHttp?.destroy();
23051
+ this._refreshHttp = void 0;
22717
23052
  this._dpopManager?.destroy();
22718
23053
  const session = this._clientSession;
22719
23054
  session?.teardownSessionState();