@coventia/softphone 0.4.0 → 0.5.1

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.
Files changed (60) hide show
  1. package/dist/{call-C6YpT6uR.d.cts → call-iGvr2ZnO.d.cts} +130 -13
  2. package/dist/{call-C6YpT6uR.d.ts → call-iGvr2ZnO.d.ts} +130 -13
  3. package/dist/chunk-3UTUWGVG.js +1891 -0
  4. package/dist/chunk-3UTUWGVG.js.map +1 -0
  5. package/dist/chunk-DZRSSZWB.js +39 -0
  6. package/dist/chunk-DZRSSZWB.js.map +1 -0
  7. package/dist/{chunk-2ZSOGGPO.js → chunk-FRXHEHQA.js} +15 -2
  8. package/dist/chunk-FRXHEHQA.js.map +1 -0
  9. package/dist/{chunk-UHNLEBBO.js → chunk-I3RSC52R.js} +28 -3
  10. package/dist/chunk-I3RSC52R.js.map +1 -0
  11. package/dist/chunk-M76SAXXM.js +13 -0
  12. package/dist/chunk-M76SAXXM.js.map +1 -0
  13. package/dist/{chunk-7R6LOOAI.js → chunk-N4PLEYFJ.js} +4 -2
  14. package/dist/chunk-N4PLEYFJ.js.map +1 -0
  15. package/dist/element/index.d.ts +167 -7
  16. package/dist/element/index.js +138 -43
  17. package/dist/element/index.js.map +1 -1
  18. package/dist/{engine-DPft99Wj.d.ts → engine-BKoT_Pj7.d.cts} +10 -1
  19. package/dist/{engine-CDdaSojx.d.cts → engine-CeGh5Yai.d.ts} +10 -1
  20. package/dist/index.cjs +1596 -145
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +5 -5
  23. package/dist/index.d.ts +5 -5
  24. package/dist/index.js +618 -42
  25. package/dist/index.js.map +1 -1
  26. package/dist/react/index.cjs +26 -0
  27. package/dist/react/index.cjs.map +1 -1
  28. package/dist/react/index.d.cts +24 -5
  29. package/dist/react/index.d.ts +24 -5
  30. package/dist/react/index.js +2 -2
  31. package/dist/{softphone-CY5d2WWY.d.cts → softphone-D0ao5W4z.d.ts} +42 -3
  32. package/dist/{softphone-DXgmTyrX.d.ts → softphone-DRoS0jJR.d.cts} +42 -3
  33. package/dist/{sources-ONYQW733.js → sources-LGFJG7W2.js} +27 -38
  34. package/dist/sources-LGFJG7W2.js.map +1 -0
  35. package/dist/testing/index.cjs +13 -0
  36. package/dist/testing/index.cjs.map +1 -1
  37. package/dist/testing/index.d.cts +2 -2
  38. package/dist/testing/index.d.ts +2 -2
  39. package/dist/testing/index.js +1 -1
  40. package/dist/ui/index.cjs +92 -2
  41. package/dist/ui/index.cjs.map +1 -1
  42. package/dist/ui/index.d.cts +2 -2
  43. package/dist/ui/index.d.ts +2 -2
  44. package/dist/ui/index.js +69 -4
  45. package/dist/ui/index.js.map +1 -1
  46. package/dist/zadarma/index.cjs +950 -104
  47. package/dist/zadarma/index.cjs.map +1 -1
  48. package/dist/zadarma/index.d.cts +40 -14
  49. package/dist/zadarma/index.d.ts +40 -14
  50. package/dist/zadarma/index.js +4 -2
  51. package/dist/zadarma-engine-VYIXLBQG.js +7 -0
  52. package/dist/{zadarma-engine-L5DEFXUS.js.map → zadarma-engine-VYIXLBQG.js.map} +1 -1
  53. package/package.json +1 -1
  54. package/dist/chunk-2ZSOGGPO.js.map +0 -1
  55. package/dist/chunk-7R6LOOAI.js.map +0 -1
  56. package/dist/chunk-EYEDPD7Y.js +0 -1071
  57. package/dist/chunk-EYEDPD7Y.js.map +0 -1
  58. package/dist/chunk-UHNLEBBO.js.map +0 -1
  59. package/dist/sources-ONYQW733.js.map +0 -1
  60. package/dist/zadarma-engine-L5DEFXUS.js +0 -5
@@ -361,6 +361,19 @@ var ERROR_CATALOGUE = {
361
361
  message: "The provider does not support this operation.",
362
362
  solution: "Guard the UI on the operation before offering it."
363
363
  },
364
+ // ── Line owner (docs/specs/line-owner.md §5) ────────────────────────────────
365
+ // What `takeLine()` rejects with — and `call()` from `standby`, which takes the line first. Never
366
+ // emitted on `SoftphoneEvent.Error`: the promise IS the answer, and nothing about the phone is broken.
367
+ "line/claim-refused": {
368
+ fatal: false,
369
+ message: "The line was not moved here: the user is on a call on the softphone that has it.",
370
+ solution: "Nothing to fix. The line never moves during a call; take it again once that call has ended. Incoming calls keep ringing where they ring now."
371
+ },
372
+ "line/unavailable": {
373
+ fatal: false,
374
+ message: "The line could not be taken: nothing decided where it is.",
375
+ solution: "The presence connection is down, the backend predates the line owner, or it did not answer in time. Retry in a moment; incoming calls keep ringing where they ring now."
376
+ },
364
377
  // ── Provider ────────────────────────────────────────────────────────────────
365
378
  "provider/provision-failed": {
366
379
  fatal: true,
@@ -390,11 +403,47 @@ function createError(code, cause) {
390
403
  return new CatalogueError(code, cause);
391
404
  }
392
405
 
406
+ // src/sources.ts
407
+ function toDigits(number) {
408
+ return String(number ?? "").replace(/\D/g, "");
409
+ }
410
+
393
411
  // src/transports/sip/ua.ts
394
412
  var DEFAULT_ICE_CAP_MS = 3e3;
395
413
  var REGISTER_EXPIRES = 580;
396
- var SIGNAL_REFRESH_DEBOUNCE_MS = 3e4;
397
414
  var ERROR_DEBOUNCE_MS = 3e4;
415
+ var OFFER_FORGOTTEN_MS = 12e4;
416
+ var DECLINE_GUARD_CEILING_MS = 12e4;
417
+ var OFFER_PUSH_SLACK_MS = 1e4;
418
+ var INVITE_TRANSACTION_MAX_MS = 32e3;
419
+ var ICT_PROCEEDING = 2;
420
+ var ICT_CALLING = 3;
421
+ function inviteTransactionOf(session) {
422
+ const tx = session._request?.transaction;
423
+ return tx && typeof tx.state === "number" && typeof tx.on === "function" ? tx : null;
424
+ }
425
+ function awaitsAnswer(tx) {
426
+ return tx.state === ICT_CALLING || tx.state === ICT_PROCEEDING;
427
+ }
428
+ function deferredCancelOf(session) {
429
+ const flag = session._is_canceled;
430
+ return typeof flag === "boolean" ? flag : null;
431
+ }
432
+ var SUFFIX_MATCH_MIN_DIGITS = 9;
433
+ function sameNumber(a, b) {
434
+ if (!a || !b) return false;
435
+ if (a === b) return true;
436
+ const [shorter, longer] = a.length < b.length ? [a, b] : [b, a];
437
+ return shorter.length >= SUFFIX_MATCH_MIN_DIGITS && longer.endsWith(shorter);
438
+ }
439
+ function isRefusal(e) {
440
+ const status = e?.response?.status_code;
441
+ if (typeof status === "number") return status === 401 || status === 403;
442
+ return e?.cause === "Authentication Error" || e?.cause === "Rejected";
443
+ }
444
+ function debug(message) {
445
+ console.debug(`[softphone] ${message}`);
446
+ }
398
447
  var CALL_END_CODES = {
399
448
  busy: "call/busy",
400
449
  "no-answer": "call/no-answer",
@@ -416,9 +465,44 @@ var SipCallEngine = class {
416
465
  provider;
417
466
  factory;
418
467
  #state = "stopped";
468
+ /**
469
+ * Which `start()`…`stop()` cycle this is — bumped by `stop()`, which ends one. Everything that awaits
470
+ * and then acts (builds a UA, subscribes to the signal, sets the state) captures it first and gives up
471
+ * if a stop has happened since. Not bumped by `start()`: that would cancel work begun just before it,
472
+ * such as a call placed while the softphone is still starting.
473
+ *
474
+ * Without it a stopped phone came back to life: a provisioning or a signal connect still in flight
475
+ * when `stop()` ran finished afterwards and REGISTERed — and since the PBX routes a call to whichever
476
+ * client registered last, it quietly took calls from the user's other device while its UI said off.
477
+ */
478
+ #run = 0;
419
479
  #ua = null;
420
480
  /** In-flight `#createUA()`. THE concurrency guard — see `#ensureUA`. */
421
481
  #uaInFlight = null;
482
+ /**
483
+ * Bumped by every `#teardownUA`. A UA still being provisioned when its reason to exist went away — the offer
484
+ * ended, the leak guard gave up — is then not built (`#createUA`): built anyway, it would REGISTER for nobody
485
+ * and nothing would ever tear it down.
486
+ */
487
+ #uaEpoch = 0;
488
+ /**
489
+ * Dials waiting for the current UA to register (`#callWhenRegistered`): dialled by its `registered`, failed by
490
+ * `#teardownUA` — but kept when the UA is only being rebuilt on fresh credentials (`#retryOnFreshCredentials`).
491
+ */
492
+ #awaitingRegistration = /* @__PURE__ */ new Set();
493
+ /** `placeCall`s in progress, from before their UA exists: no offer's teardown may pull it from under them. */
494
+ #dialing = 0;
495
+ /**
496
+ * Where the current UA's registration stands: `connecting` (its REGISTER goes out once the WSS is up),
497
+ * `registering` (a REGISTER is out, unanswered), `registered` (the WSS is up and no REGISTER of ours is out — a
498
+ * refresh that failed under a live call leaves it here too). What decides how an offer gets a REGISTER sent
499
+ * after it (`#registerAfterOffer`).
500
+ */
501
+ #registration = "connecting";
502
+ /** An offer arrived while a REGISTER sent BEFORE it was still unanswered: another goes out once it is. */
503
+ #registerAgain = false;
504
+ /** A UA has already been rebuilt on fresh credentials since the last registration that worked: not again. */
505
+ #freshCredentialsTried = false;
422
506
  #domain = "";
423
507
  #activeCall = null;
424
508
  /**
@@ -437,17 +521,52 @@ var SipCallEngine = class {
437
521
  #errorListeners = /* @__PURE__ */ new Set();
438
522
  #signalUnsub = null;
439
523
  #signalConnectionUnsub = null;
524
+ /** The offer waiting for its INVITE, if any (§4.3). */
525
+ #offerWait = null;
526
+ /**
527
+ * The latest offer pushed while a call was in progress. It used to be dropped for good — the caller then waited
528
+ * for the provider to offer it again. Now, when the call ends, it is registered for at once if it is still
529
+ * within its wait (no `offer-ended` for it, inside the leak guard), so it rings as soon as the line is free.
530
+ */
531
+ #pendingOffer = null;
532
+ /** Armed at the REGISTER's 200 OK while an offer waits (`noInviteAfterRegisterMs`). */
440
533
  #noInviteTimer;
441
- #rejectGuardUntil = 0;
442
- #signalRefreshAfter = 0;
534
+ /**
535
+ * What the backend has said about the calls offered to this line (`noteOffer`, §5), by `callId` — ended ones
536
+ * included, for a while. Only for telling, at a decline, which call was declined and whether it is still alive.
537
+ */
538
+ #offers = /* @__PURE__ */ new Map();
539
+ /** Declined calls whose re-offers are being ignored (§4.4). Pruned as they lapse. */
540
+ #declined = [];
541
+ /** CANCELs JsSIP still owes for outbound legs that ended before any ringback (§4.7, `#watchOwedCancel`). */
542
+ #owedCancels = [];
543
+ /** UAs let go of while owing one: un-REGISTERed, still connected — with the cap that stops them regardless. */
544
+ #lingering = /* @__PURE__ */ new Map();
443
545
  #errorSentAfter = /* @__PURE__ */ new Map();
444
546
  now;
445
547
  #resolvedFactory = null;
548
+ /** The loader in flight, shared: the pre-load at `start()` and a first UA racing it fetch the module once. */
549
+ #factoryLoad = null;
446
550
  async #resolveFactory() {
447
551
  if (this.#resolvedFactory) return this.#resolvedFactory;
448
- this.#resolvedFactory = typeof this.factory === "function" ? await this.factory() : this.factory;
552
+ if (typeof this.factory !== "function") return this.#resolvedFactory = this.factory;
553
+ const loader = this.factory;
554
+ this.#factoryLoad ??= Promise.resolve().then(() => loader()).finally(() => {
555
+ this.#factoryLoad = null;
556
+ });
557
+ this.#resolvedFactory = await this.#factoryLoad;
449
558
  return this.#resolvedFactory;
450
559
  }
560
+ /**
561
+ * Fetch the SIP stack now, not on the first call. It is loaded lazily (`import('jssip')`, SSR-safe — SPEC §10.2),
562
+ * and that used to happen on the first UA: between the first push after a page load and its REGISTER, measured
563
+ * 33–109 ms on localhost and more on a slow network or a throttled tab. Fire-and-forget: a failure here only
564
+ * leaves the lazy path to try again when a UA is built (incoming-call-reliability.md §4.5).
565
+ */
566
+ #preloadFactory() {
567
+ this.#resolveFactory().catch(() => {
568
+ });
569
+ }
451
570
  get state() {
452
571
  return this.#state;
453
572
  }
@@ -501,15 +620,29 @@ var SipCallEngine = class {
501
620
  for (const listener of [...this.#errorListeners]) listener(error);
502
621
  }
503
622
  async start() {
623
+ const run = this.#run;
504
624
  this.#setState("starting");
505
625
  if (this.provider.registrationMode === "persistent") {
506
- await this.#ensureUA();
626
+ try {
627
+ await this.#ensureUA();
628
+ } catch (cause) {
629
+ if (run === this.#run) throw cause;
630
+ }
631
+ if (run !== this.#run) return;
507
632
  this.#setState("online");
508
633
  } else {
634
+ this.#preloadFactory();
509
635
  const signal = this.provider.incomingSignal;
510
636
  if (signal) {
511
- await signal.connect();
512
- this.#signalUnsub = signal.onSignal(() => void this.#onIncomingSignal());
637
+ try {
638
+ await signal.connect();
639
+ } catch (cause) {
640
+ if (run !== this.#run) return;
641
+ signal.disconnect();
642
+ throw cause;
643
+ }
644
+ if (run !== this.#run) return;
645
+ this.#signalUnsub = signal.onSignal((event) => this.#onIncomingSignal(event));
513
646
  this.#signalConnectionUnsub = signal.onConnectionChange?.(
514
647
  (connected) => this.#setState(connected ? "online" : "offline")
515
648
  ) ?? null;
@@ -518,6 +651,8 @@ var SipCallEngine = class {
518
651
  }
519
652
  }
520
653
  async stop() {
654
+ this.#run += 1;
655
+ this.#pendingOffer = null;
521
656
  this.#clearNoInviteTimer();
522
657
  this.#signalUnsub?.();
523
658
  this.#signalUnsub = null;
@@ -528,62 +663,271 @@ var SipCallEngine = class {
528
663
  this.#setState("stopped");
529
664
  }
530
665
  async placeCall(destination, _options) {
531
- const ua = await this.#ensureUA();
532
- const session = await this.#callWhenRegistered(ua, this.#buildTarget(destination));
533
- return this.#wrapCall(session);
666
+ this.#dialing += 1;
667
+ const run = this.#run;
668
+ try {
669
+ await this.#ensureUA();
670
+ return await this.#callWhenRegistered(this.#buildTarget(destination), run);
671
+ } finally {
672
+ this.#dialing -= 1;
673
+ }
674
+ }
675
+ /**
676
+ * What the backend says about calls offered to this line (incoming-call-reliability.md §5) — kept only to tell,
677
+ * at a decline, WHICH call was declined, and so how long its re-offers last (§4.4).
678
+ *
679
+ * Per `callId`, every message is idempotent, and they come duplicated, with no order at all — not even between
680
+ * the `start` and the `end` of one call, which reach different backend instances and are relayed through Redis,
681
+ * and a stored offer replayed after `hello` can race either. So `ended` is sticky: a `start` after its call's
682
+ * `end` never makes it live again. An `end` also reaches a softphone that is not the line's owner (a `start` only
683
+ * the owner): stored all the same, whatever state this engine is in. `null`: the channel went away, so no `end`
684
+ * can be counted on for the calls still live; a guard following one falls back to its window. What is known to
685
+ * have ended stays known.
686
+ */
687
+ noteOffer(offer) {
688
+ const now = this.now();
689
+ for (const [callId, known2] of this.#offers) {
690
+ if (now - known2.at >= OFFER_FORGOTTEN_MS) this.#offers.delete(callId);
691
+ }
692
+ if (offer === null) {
693
+ for (const [callId, known2] of this.#offers) if (known2.endedAt === null) this.#offers.delete(callId);
694
+ return;
695
+ }
696
+ const known = this.#offers.get(offer.callId);
697
+ this.#offers.delete(offer.callId);
698
+ const from = toDigits(offer.from ?? "") || (known?.from ?? "");
699
+ const fallbackMs = this.#declineFallbackMs();
700
+ if (offer.state === "end") {
701
+ this.#offers.set(offer.callId, { from, at: now, endedAt: known?.endedAt ?? now });
702
+ this.#declined = this.#declined.filter((declined) => declined.callId !== offer.callId);
703
+ if (from) {
704
+ this.#declined = this.#declined.map(
705
+ (declined) => declined.adopts && now >= declined.since && sameNumber(declined.caller, from) ? { ...declined, adopts: false } : declined
706
+ );
707
+ }
708
+ return;
709
+ }
710
+ const endedAt = known?.endedAt ?? null;
711
+ this.#offers.set(offer.callId, { from, at: now, endedAt });
712
+ if (!from) return;
713
+ if (endedAt !== null) {
714
+ const isThatCall = (declined) => declined.adopts && now - declined.at < fallbackMs && endedAt >= declined.since && sameNumber(declined.caller, from);
715
+ this.#declined = this.#declined.filter((declined) => !isThatCall(declined));
716
+ return;
717
+ }
718
+ this.#declined = this.#declined.map(
719
+ (declined) => declined.adopts && now - declined.at < fallbackMs && now <= declined.since + OFFER_PUSH_SLACK_MS && sameNumber(declined.caller, from) ? { ...declined, callId: offer.callId, adopts: false } : declined
720
+ );
534
721
  }
535
722
  // ── incoming (on-demand: push → register → INVITE) ────────────────────────
536
- async #onIncomingSignal() {
537
- if (this.now() < this.#rejectGuardUntil) return;
538
- if (this.#activeCall) return;
723
+ /**
724
+ * The push's own word on what it is (incoming-call-reliability.md §4.1, read by the provider): only an `offer`
725
+ * registers. Before this, every push did — `answered` after each hang-up included — and the UA it left
726
+ * registered is where the next call's INVITE never came (F2).
727
+ */
728
+ #onIncomingSignal(event) {
729
+ if (event.kind === "offer") void this.#onOffer(event.from);
730
+ else if (event.kind === "offer-ended") this.#onOfferEnded(event.from);
731
+ else debug(`push ignored: ${event.detail}`);
732
+ }
733
+ /** `pushedAt`: when its push came, for an offer kept while a call was in progress (`#pendingOffer`). */
734
+ async #onOffer(from, pushedAt = this.now()) {
735
+ if (this.#isDeclinedCaller(from)) {
736
+ debug("offer ignored: the caller just declined is being offered again");
737
+ return;
738
+ }
739
+ if (this.#activeCall) {
740
+ this.#pendingOffer = { from, at: pushedAt };
741
+ return;
742
+ }
743
+ this.#awaitOffer(from, pushedAt);
744
+ const ua = this.#ua;
745
+ if (ua) {
746
+ this.#registerAfterOffer(ua);
747
+ return;
748
+ }
749
+ const run = this.#run;
750
+ const epoch = this.#uaEpoch;
539
751
  try {
540
752
  await this.#ensureUA();
541
- this.#armNoInviteTeardown();
542
753
  } catch {
543
- this.#teardownUA();
754
+ if (run === this.#run && epoch === this.#uaEpoch) this.#teardownUA();
544
755
  }
545
756
  }
546
- #armNoInviteTeardown() {
547
- const ms = this.provider.quirks?.noInviteTeardownMs;
548
- if (!ms) return;
757
+ /**
758
+ * That offer is over — answered elsewhere, declined, cancelled, timed out (§4.1). A UA still waiting for its
759
+ * INVITE goes now instead of when a timer says so; never one carrying a call (`#maybeTeardownUA`). Only when the
760
+ * push names the same caller as the offer waited for: two offers can overlap, and an end that names nobody (a
761
+ * withheld number, a push with no `callerid`) could be anyone's — the timers bound that wait instead.
762
+ */
763
+ #onOfferEnded(from) {
764
+ const caller = toDigits(from ?? "");
765
+ if (!caller) return;
766
+ if (this.#pendingOffer && sameNumber(caller, toDigits(this.#pendingOffer.from ?? ""))) this.#pendingOffer = null;
767
+ const wait = this.#offerWait;
768
+ if (!wait || !sameNumber(caller, wait.from)) return;
769
+ this.#offerOver();
770
+ }
771
+ /**
772
+ * Starts waiting for this offer's INVITE: a leak guard from its push (§4.3), and no no-INVITE timer yet.
773
+ * `pushedAt` is now, except for an offer kept through a call (`#pendingOffer`).
774
+ */
775
+ #awaitOffer(from, pushedAt) {
776
+ this.#endOfferWait();
777
+ const ms = this.provider.quirks?.offerWaitMaxMs;
778
+ const leak = ms ? setTimeout(
779
+ () => {
780
+ if (this.#offerWait !== wait) return;
781
+ debug("offer abandoned: still no INVITE long after its push");
782
+ this.#offerOver();
783
+ },
784
+ Math.max(0, ms - (this.now() - pushedAt))
785
+ ) : void 0;
786
+ const wait = { from: toDigits(from ?? ""), at: pushedAt, leak };
787
+ this.#offerWait = wait;
788
+ }
789
+ /**
790
+ * The offer kept through the call that just ended, if it is still worth registering for: no `offer-ended` for
791
+ * it, and still inside the leak guard from its push. Taken: it is registered for once, at most.
792
+ */
793
+ #takePendingOffer() {
794
+ const pending = this.#pendingOffer;
795
+ this.#pendingOffer = null;
796
+ if (!pending || !this.#signalUnsub) return null;
797
+ const maxMs = this.provider.quirks?.offerWaitMaxMs;
798
+ if (maxMs && this.now() - pending.at >= maxMs) return null;
799
+ return pending;
800
+ }
801
+ /** No INVITE is coming for the offer being waited for: its UA goes, unless something else is on it. */
802
+ #offerOver() {
803
+ this.#endOfferWait();
804
+ this.#maybeTeardownUA();
805
+ }
806
+ #endOfferWait() {
807
+ if (this.#offerWait?.leak) clearTimeout(this.#offerWait.leak);
808
+ this.#offerWait = null;
809
+ this.#registerAgain = false;
549
810
  this.#clearNoInviteTimer();
550
- this.#noInviteTimer = setTimeout(() => this.#maybeTeardownUA(), ms);
551
811
  }
552
812
  /**
553
- * Rebuild the incoming-signal channel with fresh credentials, after a registration failure.
813
+ * A REGISTER sent AFTER this offer's push, on the UA that is already there (§4.2). Whether one is needed depends
814
+ * on where that UA's registration stands:
815
+ * - `connecting`: its REGISTER goes out when the WSS is up — after this push already.
816
+ * - `registering`: the REGISTER in flight went out BEFORE this push; another follows its answer.
817
+ * - `registered`: a refresh, now.
818
+ */
819
+ #registerAfterOffer(ua) {
820
+ if (this.#registration === "registering") this.#registerAgain = true;
821
+ else if (this.#registration === "registered") this.#refreshRegistration(ua);
822
+ }
823
+ /**
824
+ * A REGISTER refresh whose 200 OK is HEARD — the no-INVITE wait counts from it (§4.3).
554
825
  *
555
- * Registration fails when the credentials behind it have gone stale — and for Zadarma those come
556
- * from the same WebRTC key the push socket is holding. Tearing down the UA alone leaves that stale
557
- * key in place, so every subsequent attempt fails the same way and the phone is dead until the page
558
- * is reloaded. Reconnecting re-fetches the key. The legacy hook did exactly this from both of its
559
- * `registrationFailed` handlers.
826
+ * JsSIP's `register()` sends one, but announces `registered` only for a UA that was not registered
827
+ * (`Registrator.js`: `if (!this._registered)`), so a refresh's 200 OK would pass in silence.
828
+ * `registrator().onTransportClosed()` clears that flag and sends nothing — it is what JsSIP runs itself when its
829
+ * WSS drops, before registering again — so after it the refresh is announced like a first registration. On the
830
+ * wire nothing changes: the same refresh (same Call-ID, next CSeq) the lab saw answered with the INVITE
831
+ * (Q5). Only called with no REGISTER of ours in flight (`#registration`), so no answer is left orphaned.
560
832
  *
561
- * Debounced, which the legacy was not: a provider failing every attempt would otherwise become a
562
- * tight loop against our backend and theirs.
833
+ * A JsSIP without that hook still sends the refresh; its 200 OK just cannot be heard, so the wait starts now.
563
834
  */
564
- async #refreshSignal() {
565
- const signal = this.provider.incomingSignal;
566
- if (!signal) return;
567
- if (this.now() < this.#signalRefreshAfter) return;
568
- this.#signalRefreshAfter = this.now() + SIGNAL_REFRESH_DEBOUNCE_MS;
569
- try {
570
- signal.disconnect();
571
- await signal.connect();
572
- } catch {
573
- }
835
+ #refreshRegistration(ua) {
836
+ const registrator = ua.registrator?.();
837
+ const heard = typeof registrator?.onTransportClosed === "function";
838
+ if (heard) registrator.onTransportClosed();
839
+ this.#registration = heard ? "registering" : "registered";
840
+ ua.register();
841
+ if (!heard && this.#offerWait) this.#armNoInviteTimer();
842
+ }
843
+ /**
844
+ * Armed at the REGISTER's 200 OK, never at `ua.start()`: on a slow network the WSS alone outlasted the old 5 s,
845
+ * and the UA was gone before it ever registered (F3). The INVITE follows the 200 OK by ~0.1 s (§2).
846
+ */
847
+ #armNoInviteTimer() {
848
+ this.#clearNoInviteTimer();
849
+ const ms = this.provider.quirks?.noInviteAfterRegisterMs;
850
+ if (!ms) return;
851
+ this.#noInviteTimer = setTimeout(() => {
852
+ this.#noInviteTimer = void 0;
853
+ this.#offerOver();
854
+ }, ms);
574
855
  }
575
856
  #clearNoInviteTimer() {
576
857
  if (this.#noInviteTimer) clearTimeout(this.#noInviteTimer);
577
858
  this.#noInviteTimer = void 0;
578
859
  }
860
+ // ── the decline guard (SPEC §3.4, incoming-call-reliability.md §4.4) ───────
861
+ /**
862
+ * Whether an offer — or an INVITE — from this caller is a re-offer of a call just declined. The provider re-offers a
863
+ * declined call until its ring time ends, and nothing in a re-offer tells it from a new call. The guard used to
864
+ * swallow EVERY offer and INVITE for 35 s, whoever called: a new call in that window was lost (CWA-81, F1).
865
+ *
866
+ * Now it holds only for the declined caller, and only while that call lives: until the backend's `end` for it
867
+ * when it announced the call (`noteOffer`) — at most `DECLINE_GUARD_CEILING_MS` — else for the provider's
868
+ * fallback window after the decline. Anyone else always rings; so does a caller this cannot name.
869
+ */
870
+ #isDeclinedCaller(number) {
871
+ const fallbackMs = this.#declineFallbackMs();
872
+ if (!fallbackMs || this.#declined.length === 0) return false;
873
+ const now = this.now();
874
+ this.#declined = this.#declined.filter((declined) => {
875
+ const offer = declined.callId === null ? void 0 : this.#offers.get(declined.callId);
876
+ if (offer && offer.endedAt === null) return now - declined.at < DECLINE_GUARD_CEILING_MS;
877
+ return now - declined.at < fallbackMs;
878
+ });
879
+ const caller = toDigits(number ?? "");
880
+ return this.#declined.some((declined) => sameNumber(declined.caller, caller));
881
+ }
882
+ /** The decline guard's fallback window — and whether there is a guard at all: only a provider that re-offers. */
883
+ #declineFallbackMs() {
884
+ return this.provider.quirks?.declinedCallerGuardMs ?? 0;
885
+ }
886
+ /**
887
+ * The user declined `call`. Remembers the caller as its INVITE named them, and which offer the backend says that
888
+ * is (§4.4). Only what the backend said about THIS call counts: a call from the same caller that ended before
889
+ * this one was pushed (`since`) is another call — taking it for this one lost the callback after a second
890
+ * decline of the same caller.
891
+ * - a live offer from that caller, announced around this call's push or after: the guard follows it until
892
+ * its `end`;
893
+ * - an offer from that caller that ENDED since this call's push (the first leg of a transfer, a ring-group
894
+ * call someone else answered): the fallback window — whatever that caller sends next is not that call, so
895
+ * no later `start` is taken for it;
896
+ * - neither (its `start` lands after the decline): the fallback window, and a `start` from that caller that
897
+ * comes around this call's push is taken as this call's (`noteOffer`).
898
+ *
899
+ * An INVITE that names nobody (a withheld number) guards nobody. It used to borrow the caller of the latest push,
900
+ * which with two offers overlapping is someone else's — and that someone's calls were then ignored.
901
+ */
902
+ #onDecline(call, since) {
903
+ if (!this.#declineFallbackMs() || call.direction !== "inbound") return;
904
+ const caller = toDigits(call.remoteNumber);
905
+ if (!caller) return;
906
+ const now = this.now();
907
+ let latest = null;
908
+ for (const [callId, offer] of this.#offers) {
909
+ if (!sameNumber(offer.from, caller) || now - offer.at >= OFFER_FORGOTTEN_MS) continue;
910
+ if (offer.endedAt === null && offer.at >= since - OFFER_PUSH_SLACK_MS) latest = { callId, ended: false };
911
+ else if (offer.endedAt !== null && offer.endedAt >= since) latest = { callId, ended: true };
912
+ }
913
+ this.#declined.push(
914
+ latest && !latest.ended ? { caller, callId: latest.callId, adopts: false, at: now, since } : { caller, callId: null, adopts: latest === null, at: now, since }
915
+ );
916
+ }
579
917
  #onNewSession(session, originator) {
580
918
  if (originator !== "remote") return;
581
- this.#clearNoInviteTimer();
582
- if (this.#activeCall || this.now() < this.#rejectGuardUntil) {
919
+ if (this.#activeCall) {
920
+ session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
921
+ return;
922
+ }
923
+ if (this.#isDeclinedCaller(remoteNumberOf(session))) {
924
+ debug("INVITE declined: the caller just declined is being offered again");
583
925
  session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
584
926
  return;
585
927
  }
586
- const call = this.#wrapCall(session);
928
+ const since = this.#offerWait?.at ?? this.now();
929
+ this.#endOfferWait();
930
+ const call = this.#wrapCall(session, since);
587
931
  for (const listener of [...this.#incomingListeners]) listener(call);
588
932
  }
589
933
  // ── UA lifecycle ───────────────────────────────────────────────────────────
@@ -596,18 +940,23 @@ var SipCallEngine = class {
596
940
  * torn down. The visible symptom is two `get_webphone_data.php` calls at startup and a phone that
597
941
  * never registers.
598
942
  *
599
- * Concurrency is the normal case here, not an edge: the push socket can signal twice in a
600
- * heartbeat (SPEC §3.3 — a push is not an INVITE; it fires for transfers, retries and hangups),
601
- * and `placeCall` can land on top of one. So the in-flight promise IS the guard.
943
+ * Concurrency is the normal case here, not an edge: the push socket can offer twice in a heartbeat
944
+ * (a push seen on both sockets of a rebuild, two calls at once), and `placeCall` can land on top of
945
+ * one. So the in-flight promise IS the guard.
602
946
  */
603
947
  async #ensureUA() {
604
948
  if (this.#ua) return this.#ua;
605
- this.#uaInFlight ??= this.#createUA().finally(() => {
606
- this.#uaInFlight = null;
607
- });
949
+ if (!this.#uaInFlight) {
950
+ const inFlight = this.#createUA().finally(() => {
951
+ if (this.#uaInFlight === inFlight) this.#uaInFlight = null;
952
+ });
953
+ this.#uaInFlight = inFlight;
954
+ }
608
955
  return this.#uaInFlight;
609
956
  }
610
957
  async #createUA() {
958
+ const run = this.#run;
959
+ const epoch = this.#uaEpoch;
611
960
  const factory = await this.#resolveFactory();
612
961
  let credentials;
613
962
  try {
@@ -617,31 +966,97 @@ var SipCallEngine = class {
617
966
  else this.#reportError("provider/provision-failed", cause);
618
967
  throw cause;
619
968
  }
969
+ if (run !== this.#run) throw createError("engine/not-started");
970
+ if (epoch !== this.#uaEpoch) throw createError("call/failed", "the UA was let go of before it was built");
620
971
  this.#domain = domainOf(credentials);
621
972
  const config = this.#uaConfig(credentials, factory);
622
973
  const ua = factory.createUA(config);
623
- ua.on("newRTCSession", ({ session, originator }) => this.#onNewSession(session, originator));
974
+ let reached = false;
975
+ ua.on("newRTCSession", ({ session, originator }) => {
976
+ if (ua !== this.#ua) {
977
+ if (originator === "remote") session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
978
+ return;
979
+ }
980
+ this.#onNewSession(session, originator);
981
+ });
624
982
  ua.on("disconnected", ({ error }) => {
983
+ if (ua !== this.#ua) return;
984
+ this.#registration = "connecting";
625
985
  if (!error) return;
986
+ if (!reached && this.#retryOnFreshCredentials("unreachable", ua, credentials)) return;
626
987
  this.#setState("reconnecting");
627
988
  this.#reportError("net/transport-failed", error);
628
989
  });
629
- ua.on("connected", () => this.#setState("online"));
990
+ ua.on("connected", () => {
991
+ if (ua !== this.#ua) return;
992
+ reached = true;
993
+ this.#registration = "registering";
994
+ this.#setState("online");
995
+ });
996
+ ua.on("registered", () => {
997
+ if (ua !== this.#ua) return;
998
+ this.#registration = "registered";
999
+ this.#freshCredentialsTried = false;
1000
+ this.provider.credentialsWorked?.();
1001
+ for (const waiter of [...this.#awaitingRegistration]) waiter.dial(ua);
1002
+ if (this.#registerAgain) {
1003
+ this.#registerAgain = false;
1004
+ this.#refreshRegistration(ua);
1005
+ return;
1006
+ }
1007
+ if (this.#offerWait) this.#armNoInviteTimer();
1008
+ });
630
1009
  ua.on("registrationFailed", (e) => {
1010
+ if (run !== this.#run) return;
631
1011
  const detail = e;
1012
+ if (isRefusal(detail) && this.#retryOnFreshCredentials("rejected", ua, credentials)) return;
632
1013
  this.#reportError("net/registration-failed", {
633
1014
  cause: detail?.cause,
634
1015
  status: detail?.response?.status_code,
635
1016
  domain: this.#domain,
636
1017
  hint: "A bare 401 with no auth challenge usually means the registrar is in the wrong datacenter (SPEC \xA73.1)."
637
1018
  });
1019
+ if (ua !== this.#ua) return;
1020
+ if (this.#carriesCall()) {
1021
+ for (const waiter of [...this.#awaitingRegistration]) waiter.fail();
1022
+ this.#registration = "registered";
1023
+ const wait = this.#offerWait;
1024
+ if (wait) {
1025
+ this.#pendingOffer = { from: wait.from || null, at: wait.at };
1026
+ this.#endOfferWait();
1027
+ }
1028
+ return;
1029
+ }
638
1030
  this.#teardownUA();
639
- void this.#refreshSignal();
640
1031
  });
641
1032
  this.#ua = ua;
1033
+ this.#registration = "connecting";
642
1034
  ua.start();
643
1035
  return ua;
644
1036
  }
1037
+ /**
1038
+ * The credentials behind `ua` did not work. The provider hears it either way, with the credentials in question
1039
+ * (it forgets them unless it has moved on from them already; a provider whose signal shares them renews that
1040
+ * too); then, if it has new ones to give, the current UA is rebuilt on them — once until a registration works,
1041
+ * and never under a live call. Whoever waited on the old UA — an offer, a dial — waits on the new one. Returns
1042
+ * whether it was rebuilt.
1043
+ *
1044
+ * This replaces the engine's old `#refreshSignal` (a close-then-reopen of the push socket after ANY failed
1045
+ * registration): only the provider knows what its credentials and its signal share.
1046
+ */
1047
+ #retryOnFreshCredentials(reason, ua, credentials) {
1048
+ const fresh = this.provider.credentialsFailed?.(reason, credentials) ?? false;
1049
+ if (!fresh || ua !== this.#ua || this.#freshCredentialsTried) return false;
1050
+ if (this.#carriesCall()) return false;
1051
+ this.#freshCredentialsTried = true;
1052
+ const run = this.#run;
1053
+ this.#teardownUA({ replacing: true });
1054
+ const epoch = this.#uaEpoch;
1055
+ void this.#ensureUA().catch(() => {
1056
+ if (run === this.#run && epoch === this.#uaEpoch) this.#teardownUA();
1057
+ });
1058
+ return true;
1059
+ }
645
1060
  #uaConfig(credentials, factory) {
646
1061
  return {
647
1062
  sockets: [factory.createWebSocket(credentials.wsUrl)],
@@ -659,20 +1074,133 @@ var SipCallEngine = class {
659
1074
  user_agent: this.provider.quirks?.userAgent ?? "Coventia Softphone"
660
1075
  };
661
1076
  }
662
- #teardownUA() {
1077
+ /**
1078
+ * Lets the current UA go — and one still being provisioned (`#uaEpoch`). `replacing`: a successor is being
1079
+ * built on fresh credentials right away (`#retryOnFreshCredentials`), so the dials and the offer waiting on
1080
+ * this one carry over to it instead of ending here.
1081
+ */
1082
+ #teardownUA({ replacing = false } = {}) {
1083
+ this.#uaEpoch += 1;
663
1084
  this.#uaInFlight = null;
664
- if (!this.#ua) return;
665
- try {
666
- this.#ua.terminateSessions({ status_code: 487 });
667
- this.#ua.stop();
668
- } catch {
1085
+ this.#registration = "connecting";
1086
+ this.#registerAgain = false;
1087
+ if (!replacing) {
1088
+ for (const waiter of [...this.#awaitingRegistration]) waiter.fail();
1089
+ this.#endOfferWait();
669
1090
  }
1091
+ if (!this.#ua) return;
1092
+ const ua = this.#ua;
670
1093
  this.#ua = null;
671
1094
  this.#activeCall = null;
672
1095
  this.#consultCall = null;
1096
+ try {
1097
+ ua.terminateSessions({ status_code: 487 });
1098
+ } catch {
1099
+ }
1100
+ if (this.#owedCancels.some((owed) => owed.ua === ua)) {
1101
+ this.#linger(ua);
1102
+ return;
1103
+ }
1104
+ try {
1105
+ ua.stop();
1106
+ } catch {
1107
+ }
1108
+ }
1109
+ // ── the CANCEL a hang-up still owes (incoming-call-reliability.md §4.7, F6) ─────────────────────────────
1110
+ /**
1111
+ * Watches an outbound leg — a call or a consult leg — from its creation, for the CANCEL JsSIP may end up owing.
1112
+ *
1113
+ * JsSIP's `terminate()` between sending the INVITE and the first provisional response cannot cancel yet (RFC 3261
1114
+ * §9.1): it marks the session canceled, fails it locally with `Canceled`, and sends the CANCEL when a 1xx comes —
1115
+ * or ACKs and BYEs a late 2xx. Live, that 1xx came 17 s late, the engine had torn the UA down on the call's end, and
1116
+ * the callee's phone rang on for a caller who was gone: a ghost call. Hence: the INVITE went out (`sending`), no
1117
+ * provisional above 100 came (`progress`), and it failed locally `Canceled` → the CANCEL is owed, on this UA.
1118
+ *
1119
+ * Registered BEFORE the leg's own listeners: its `failed` must be on record before the leg's end tears the UA down.
1120
+ */
1121
+ #watchOwedCancel(session, ua) {
1122
+ let sent = false;
1123
+ let ringing = false;
1124
+ session.on("sending", () => sent = true);
1125
+ session.on("progress", () => ringing = true);
1126
+ session.on("failed", ({ originator, cause }) => {
1127
+ if (!sent || originator !== "local" || cause !== "Canceled") return;
1128
+ const tx = inviteTransactionOf(session);
1129
+ const owes = deferredCancelOf(session) ?? (tx ? tx.state === ICT_CALLING : !ringing);
1130
+ if (!owes || tx && !awaitsAnswer(tx)) return;
1131
+ const owed = { ua, session, tx };
1132
+ this.#owedCancels.push(owed);
1133
+ if (tx) this.#followInvite(owed, tx);
1134
+ });
1135
+ }
1136
+ /**
1137
+ * Follows the INVITE an owed CANCEL is waiting on, until it is over. Not just the first one: Zadarma challenges every
1138
+ * INVITE (407), and JsSIP re-sends it with credentials on a NEW transaction — canceled leg or not — in the same tick
1139
+ * that completes the first. Releasing on that first transaction stopped the UA two seconds later, and the re-sent
1140
+ * INVITE's 1xx landed nowhere: the ghost call again (found driving the real JsSIP). So once one is over, the leg's
1141
+ * current transaction is read again a moment later, and followed while it still waits for an answer.
1142
+ */
1143
+ #followInvite(owed, tx) {
1144
+ const settle = () => {
1145
+ if (awaitsAnswer(tx)) return;
1146
+ tx.removeListener?.("stateChanged", settle);
1147
+ queueMicrotask(() => {
1148
+ const latest = inviteTransactionOf(owed.session);
1149
+ if (latest && latest !== tx && awaitsAnswer(latest)) {
1150
+ owed.tx = latest;
1151
+ this.#followInvite(owed, latest);
1152
+ return;
1153
+ }
1154
+ this.#owedCancels = this.#owedCancels.filter((other) => other !== owed);
1155
+ this.#releaseIfSettled(owed.ua);
1156
+ });
1157
+ };
1158
+ tx.on("stateChanged", settle);
1159
+ }
1160
+ /**
1161
+ * Lets `ua` go while it owes a CANCEL: un-REGISTERed at once, as any teardown, but connected until every INVITE it
1162
+ * owes is over — COMPLETED (a final non-2xx: the 487 after the late 1xx's CANCEL), ACCEPTED (a late 2xx, which
1163
+ * JsSIP ACKs and BYEs) or TERMINATED (Timer B, a transport error) — and never longer than Timer B. Detached: the
1164
+ * engine has already moved on (a push or a dial builds a fresh UA), so no timer of its own, no teardown and no
1165
+ * "is a call on it" ever sees this one, and an INVITE reaching it is declined like any let-go UA's.
1166
+ */
1167
+ #linger(ua) {
1168
+ if (this.#lingering.has(ua)) return;
1169
+ this.#lingering.set(ua, setTimeout(() => this.#release(ua), INVITE_TRANSACTION_MAX_MS));
1170
+ ua.on("registered", () => {
1171
+ if (this.#lingering.has(ua)) this.#unregister(ua);
1172
+ });
1173
+ this.#unregister(ua);
1174
+ this.#releaseIfSettled(ua);
1175
+ }
1176
+ #unregister(ua) {
1177
+ try {
1178
+ ua.unregister();
1179
+ } catch {
1180
+ }
1181
+ }
1182
+ /**
1183
+ * Once nothing is owed on a lingering UA, it stops — on the next tick: a late 2xx is ACKed and BYEd in the very
1184
+ * tick that settled it, and JsSIP's own `stop()` then keeps the transport ≤ 2 s for that BYE's answer.
1185
+ */
1186
+ #releaseIfSettled(ua) {
1187
+ if (!this.#lingering.has(ua) || this.#owedCancels.some((owed) => owed.ua === ua)) return;
1188
+ setTimeout(() => this.#release(ua), 0);
1189
+ }
1190
+ #release(ua) {
1191
+ const cap = this.#lingering.get(ua);
1192
+ if (cap === void 0) return;
1193
+ clearTimeout(cap);
1194
+ this.#lingering.delete(ua);
1195
+ this.#owedCancels = this.#owedCancels.filter((owed) => owed.ua !== ua);
1196
+ try {
1197
+ ua.stop();
1198
+ } catch {
1199
+ }
673
1200
  }
674
- /** Place a call once the UA is registered (its 200 OK for REGISTER has arrived). */
675
1201
  /**
1202
+ * Place a call once the current UA is registered (its 200 OK for REGISTER has arrived).
1203
+ *
676
1204
  * **No `mediaStream`** — JsSIP calls `getUserMedia` itself, which is what opens a real capture.
677
1205
  *
678
1206
  * That is load-bearing, not incidental. Firefox restricts ICE gathering on a page with no active
@@ -682,20 +1210,46 @@ var SipCallEngine = class {
682
1210
  * contains no candidates. The legacy hook passed `mediaConstraints` and nothing else, for what
683
1211
  * turns out to be a very good reason.
684
1212
  */
685
- #callWhenRegistered(ua, target) {
1213
+ #callWhenRegistered(target, run) {
686
1214
  const options = { mediaConstraints: buildAudioConstraints(this.#inputDeviceId), pcConfig: PC_CONFIG };
687
- if (ua.isRegistered()) return Promise.resolve(ua.call(target, options));
688
- return new Promise((resolve) => {
689
- ua.on("registered", () => resolve(ua.call(target, options)));
1215
+ const refusal = () => createError(run === this.#run ? "call/failed" : "engine/not-started");
1216
+ const dial = (registered) => {
1217
+ const session = registered.call(target, options);
1218
+ this.#watchOwedCancel(session, registered);
1219
+ return this.#wrapCall(session);
1220
+ };
1221
+ const ua = this.#ua;
1222
+ if (!ua || run !== this.#run) return Promise.reject(refusal());
1223
+ if (ua.isRegistered()) return Promise.resolve(dial(ua));
1224
+ return new Promise((resolve, reject) => {
1225
+ const waiter = {
1226
+ dial: (registered) => {
1227
+ this.#awaitingRegistration.delete(waiter);
1228
+ try {
1229
+ resolve(dial(registered));
1230
+ } catch (cause) {
1231
+ reject(cause);
1232
+ }
1233
+ },
1234
+ fail: () => {
1235
+ this.#awaitingRegistration.delete(waiter);
1236
+ reject(refusal());
1237
+ }
1238
+ };
1239
+ this.#awaitingRegistration.add(waiter);
690
1240
  });
691
1241
  }
692
- #wrapCall(session) {
1242
+ /**
1243
+ * `since`, for an inbound call: when its push came (its INVITE, when none was seen) — what `#onDecline` needs to
1244
+ * tell this call from the caller's others.
1245
+ */
1246
+ #wrapCall(session, since = this.now()) {
693
1247
  const call = new SipEngineCall(session, {
694
1248
  id: nextCallId(),
695
1249
  buildTarget: (t) => this.#buildTarget(t),
696
1250
  placeConsult: (t) => this.#placeConsult(t),
697
1251
  // The guard is tied to the reject ACTION, not to `ended` — a normal hangup must not arm it.
698
- onReject: () => this.#startRejectGuard(),
1252
+ onReject: () => this.#onDecline(call, since),
699
1253
  iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS,
700
1254
  // Inbound answers open their own peer connection, so they need the same ICE servers the
701
1255
  // outbound leg got. Missing here, an ANSWERED call is the one that fails — the half nobody
@@ -712,17 +1266,27 @@ var SipCallEngine = class {
712
1266
  const code = missed ? "call/missed" : CALL_END_CODES[event.reason];
713
1267
  if (code) this.#reportError(code, event.cause);
714
1268
  if (this.#activeCall === call) this.#activeCall = null;
715
- this.#maybeTeardownUA();
1269
+ this.#legEnded();
716
1270
  }
717
1271
  });
718
1272
  return call;
719
1273
  }
1274
+ /**
1275
+ * A leg ended. The UA goes if nothing is left on it (`#maybeTeardownUA`); and once no leg at all is left, an
1276
+ * offer kept through the call (`#pendingOffer`, §4.3) gets its REGISTER — the line is free now.
1277
+ */
1278
+ #legEnded() {
1279
+ const pending = this.#activeCall || this.#consultCall ? null : this.#takePendingOffer();
1280
+ this.#maybeTeardownUA();
1281
+ if (pending) void this.#onOffer(pending.from, pending.at);
1282
+ }
720
1283
  async #placeConsult(target) {
721
1284
  const ua = await this.#ensureUA();
722
1285
  const consultSession = ua.call(this.#buildTarget(target), {
723
1286
  mediaConstraints: buildAudioConstraints(this.#inputDeviceId),
724
1287
  pcConfig: PC_CONFIG
725
1288
  });
1289
+ this.#watchOwedCancel(consultSession, ua);
726
1290
  const consultCall = new SipEngineCall(consultSession, {
727
1291
  id: nextCallId(),
728
1292
  buildTarget: (x) => this.#buildTarget(x),
@@ -733,7 +1297,7 @@ var SipCallEngine = class {
733
1297
  consultCall.onEvent((event) => {
734
1298
  if (event.type === "ended") {
735
1299
  if (this.#consultCall === consultCall) this.#consultCall = null;
736
- this.#maybeTeardownUA();
1300
+ this.#legEnded();
737
1301
  }
738
1302
  });
739
1303
  return consultCall;
@@ -746,19 +1310,29 @@ var SipCallEngine = class {
746
1310
  * session on it, via `terminateSessions`) the instant the main leg's `ended` fires killed that
747
1311
  * consult leg out from under the transfer before the bridge could use it, and the caller was simply
748
1312
  * gone. Two real call sites hit this independently (found live, 2026-08-04): the main leg's own
749
- * `ended` handler, and `#armNoInviteTeardown`'s 5s timer — the latter re-arms on EVERY push,
750
- * including unrelated "answered" pushes Zadarma sends around other account activity, so it can fire
751
- * minutes after a transfer with no `#activeCall` and no idea a consult leg is mid-handoff. Both now
752
- * route through here rather than checking `#activeCall` alone.
1313
+ * `ended` handler, and the no-INVITE timer — which then re-armed on EVERY push, including the
1314
+ * `answered` pushes Zadarma sends around other account activity, so it could fire minutes after a
1315
+ * transfer with no `#activeCall` and no idea a consult leg was mid-handoff. Every teardown an offer
1316
+ * asks for (its timers, its `offer-ended`) routes through here rather than checking `#activeCall` alone.
1317
+ *
1318
+ * A dial on its way (`#dialing`) counts too: its UA may not even exist yet, and an offer ending must not
1319
+ * take it. So does an offer still waiting for its INVITE: mid-transfer, with only the consult leg left, an
1320
+ * offer registers on this UA — and the consult leg ending used to tear it down and drop that offer, its call
1321
+ * ringing nowhere until the provider offered it again ~30 s later. The wait's own ending (its INVITE, its
1322
+ * `offer-ended`, the no-INVITE timer, the leak guard) clears `#offerWait` before it calls this, so nothing keeps
1323
+ * the UA past those (incoming-call-reliability.md §4.3).
753
1324
  */
754
1325
  #maybeTeardownUA() {
755
1326
  if (this.provider.registrationMode !== "on-demand") return;
756
- if (this.#activeCall || this.#consultCall) return;
1327
+ if (this.#activeCall || this.#consultCall || this.#dialing > 0 || this.#offerWait) return;
757
1328
  this.#teardownUA();
758
1329
  }
759
- #startRejectGuard() {
760
- const ms = this.provider.quirks?.rejectGuardMs;
761
- if (ms) this.#rejectGuardUntil = this.now() + ms;
1330
+ /**
1331
+ * A live leg is on the current UA: the call or a consult leg. An outbound call counts from the moment its INVITE
1332
+ * is created — `#callWhenRegistered` wraps it there and then — so no failure in between can end it unseen.
1333
+ */
1334
+ #carriesCall() {
1335
+ return this.#activeCall !== null || this.#consultCall !== null;
762
1336
  }
763
1337
  #buildTarget(target) {
764
1338
  return target.includes("@") ? target : `sip:${target}@${this.#domain}`;
@@ -789,6 +1363,8 @@ async function loadJsSipFactory() {
789
1363
  // src/transports/providers/zadarma.ts
790
1364
  var PING = { localMs: 3e4, globalMs: 6e4 };
791
1365
  var KEY_REFRESH_BUFFER_MS = 2 * 60 * 60 * 1e3;
1366
+ var KEY_EXPIRY_MARGIN_MS = 6e4;
1367
+ var REKEY_DEBOUNCE_MS = 3e4;
792
1368
  var REBUILD_CONNECT_TIMEOUT_MS = 1e4;
793
1369
  var REBUILD_RETRY_MS = 3e4;
794
1370
  var ZADARMA_QUIRKS = {
@@ -796,10 +1372,17 @@ var ZADARMA_QUIRKS = {
796
1372
  // after a valid digest challenge, indistinguishable from a wrong password (§3.1). The old CRM
797
1373
  // hook carried this string; the port dropped it, and registration failed for exactly that reason.
798
1374
  userAgent: "Zadarma webRTC",
799
- // Zadarma re-rings a declined call for ~30 s (return_timeout). Swallow those (§3.4).
1375
+ // Zadarma re-offers a declined call until its ring time ends (~30 s by default): the declined caller's offers
1376
+ // are ignored while that call lives — this long after the decline when the backend cannot say (§3.4,
1377
+ // incoming-call-reliability.md §4.4, Q1).
1378
+ declinedCallerGuardMs: 35e3,
1379
+ // The INVITE follows the REGISTER's 200 OK by ~0.1 s. Nothing 10 s after it: no call is coming to this UA.
1380
+ noInviteAfterRegisterMs: 1e4,
1381
+ // A leak guard only: an offer still waiting for its INVITE 2 min after its push is abandoned (§4.3).
1382
+ offerWaitMaxMs: 12e4,
1383
+ // Deprecated names of the two above, same values: this object is exported, and 0.5.1 is a patch. Never read.
800
1384
  rejectGuardMs: 35e3,
801
- // A push is not an INVITE (§3.3). If none arrives, tear the fresh UA down.
802
- noInviteTeardownMs: 5e3,
1385
+ noInviteTeardownMs: 1e4,
803
1386
  // A ceiling on ICE gathering, not a target: JsSIP sends as soon as gathering completes. 3 s leaves
804
1387
  // room for a TURN allocation (whose relay candidates arrive last) while still bounding the ~40 s
805
1388
  // Chrome can spend against an unreachable server. See `capIceGathering`.
@@ -809,11 +1392,154 @@ function buildPushUrl(key, sip) {
809
1392
  const params = new URLSearchParams({ key, integrationType: "site", sipId: sip });
810
1393
  return `wss://ws.zadarma.com/webrtc_pushes?${params.toString()}`;
811
1394
  }
812
- var ZadarmaPushSignal = class {
1395
+ function readPush(payload) {
1396
+ let data = payload;
1397
+ if (typeof data === "string") {
1398
+ try {
1399
+ data = JSON.parse(data);
1400
+ } catch {
1401
+ return { kind: "other", detail: "unreadable payload" };
1402
+ }
1403
+ }
1404
+ if (!data || typeof data !== "object") return { kind: "other", detail: "no payload" };
1405
+ const { action, callerid } = data;
1406
+ let from = null;
1407
+ if (typeof callerid === "string" && callerid) from = callerid;
1408
+ else if (typeof callerid === "number") from = String(callerid);
1409
+ if (action === "incoming") return { kind: "offer", from };
1410
+ if (action === "answered") return { kind: "offer-ended", from };
1411
+ return { kind: "other", detail: typeof action === "string" ? `action "${action}"` : "no action" };
1412
+ }
1413
+ function expiryOf(key) {
1414
+ if (!key.keyExpiresAt) return null;
1415
+ const at = new Date(key.keyExpiresAt).getTime();
1416
+ return Number.isFinite(at) ? at : null;
1417
+ }
1418
+ var ZadarmaCredentials = class {
813
1419
  constructor(deps) {
814
1420
  this.deps = deps;
815
1421
  }
816
1422
  deps;
1423
+ #key = null;
1424
+ #keyInFlight = null;
1425
+ #sip = null;
1426
+ #sipInFlight = null;
1427
+ /**
1428
+ * Bumped by `forget`, one per half: a fetch begun before it must not put back what was just forgotten — a
1429
+ * refused credential that comes back from a slow request is still refused.
1430
+ */
1431
+ #keyGeneration = 0;
1432
+ #sipGeneration = 0;
1433
+ /**
1434
+ * The SIP credentials were already refetched once because their WSS could not be reached: if the fresh ones
1435
+ * cannot reach it either, the network is the problem, and asking Zadarma again would not change it. Re-armed by
1436
+ * a registration that works (`worked`), and by forgetting everything (a refusal, a stop).
1437
+ */
1438
+ #unreachableRefetched = false;
1439
+ /** What is in memory right now — both halves, for one key — or `null`. Never fetches. */
1440
+ current() {
1441
+ const key = this.#key?.value;
1442
+ const sip = this.#sip;
1443
+ return key && sip && sip.forKey === key.key ? { key, pass: sip.pass, domain: sip.domain } : null;
1444
+ }
1445
+ /** Their WSS could not be reached: forget the SIP half — once, until `worked()`. Whether it was forgotten. */
1446
+ forgetUnreachable() {
1447
+ if (this.#unreachableRefetched) return false;
1448
+ this.forget("sip");
1449
+ this.#unreachableRefetched = true;
1450
+ return true;
1451
+ }
1452
+ /** A registration worked with what is in memory. */
1453
+ worked() {
1454
+ this.#unreachableRefetched = false;
1455
+ }
1456
+ /** The cached key while it is alive; otherwise a fresh one (and, in the background, its SIP credentials). */
1457
+ key() {
1458
+ const cached2 = this.#key;
1459
+ if (cached2 && (cached2.expiresAt === null || Date.now() < cached2.expiresAt - KEY_EXPIRY_MARGIN_MS)) {
1460
+ return Promise.resolve(cached2.value);
1461
+ }
1462
+ return this.#fetchKey();
1463
+ }
1464
+ /** A new key now, whatever is cached: the refresh ahead of expiry. The old one serves until this one lands. */
1465
+ renew() {
1466
+ return this.#fetchKey();
1467
+ }
1468
+ /** The key and the SIP credentials it bought, from memory when they are there. */
1469
+ async sip() {
1470
+ const key = await this.key();
1471
+ const { pass, domain } = await this.#sipFor(key);
1472
+ return { key, pass, domain };
1473
+ }
1474
+ /** Synchronous up to the request, so a prefetch and a `provision()` racing it share one exchange. */
1475
+ #sipFor(key) {
1476
+ const cached2 = this.#sip;
1477
+ if (cached2 && cached2.forKey === key.key) return Promise.resolve({ pass: cached2.pass, domain: cached2.domain });
1478
+ const inFlight = this.#sipInFlight;
1479
+ if (inFlight && inFlight.forKey === key.key) return inFlight.promise;
1480
+ const generation = this.#sipGeneration;
1481
+ let request;
1482
+ try {
1483
+ request = this.deps.exchangeWebphoneData(key.key, key.sip);
1484
+ } catch (cause) {
1485
+ request = Promise.reject(cause instanceof Error ? cause : new Error(String(cause)));
1486
+ }
1487
+ const promise = request.then((data) => {
1488
+ if (generation === this.#sipGeneration) this.#sip = { forKey: key.key, pass: data.pass, domain: data.domain };
1489
+ return data;
1490
+ });
1491
+ const entry = { forKey: key.key, promise };
1492
+ this.#sipInFlight = entry;
1493
+ void promise.finally(() => {
1494
+ if (this.#sipInFlight === entry) this.#sipInFlight = null;
1495
+ }).catch(() => {
1496
+ });
1497
+ return promise;
1498
+ }
1499
+ /**
1500
+ * Drop what stopped working. `all`: the registrar refused the credentials, so the key behind them may be the
1501
+ * stale part too. `sip`: their WSS could not be reached — the key is fine, the datacenter may not be.
1502
+ */
1503
+ forget(what) {
1504
+ this.#sipGeneration += 1;
1505
+ this.#sip = null;
1506
+ this.#sipInFlight = null;
1507
+ if (what === "sip") return;
1508
+ this.#keyGeneration += 1;
1509
+ this.#key = null;
1510
+ this.#keyInFlight = null;
1511
+ this.#unreachableRefetched = false;
1512
+ }
1513
+ #fetchKey() {
1514
+ if (this.#keyInFlight) return this.#keyInFlight;
1515
+ const generation = this.#keyGeneration;
1516
+ const inFlight = this.deps.fetchKey().then((value) => {
1517
+ if (generation === this.#keyGeneration) {
1518
+ this.#key = { value, expiresAt: expiryOf(value) };
1519
+ void this.#sipFor(value).catch(() => {
1520
+ });
1521
+ }
1522
+ return value;
1523
+ });
1524
+ this.#keyInFlight = inFlight;
1525
+ void inFlight.finally(() => {
1526
+ if (this.#keyInFlight === inFlight) this.#keyInFlight = null;
1527
+ }).catch(() => {
1528
+ });
1529
+ return inFlight;
1530
+ }
1531
+ };
1532
+ var ZadarmaPushSignal = class {
1533
+ /**
1534
+ * `credentials` is shared with the provider's `provision()`: one key for the push socket and the SIP
1535
+ * credentials, fetched once (§4.5). A signal on its own gets a cache of its own.
1536
+ */
1537
+ constructor(deps, credentials = new ZadarmaCredentials(deps)) {
1538
+ this.deps = deps;
1539
+ this.credentials = credentials;
1540
+ }
1541
+ deps;
1542
+ credentials;
817
1543
  #socket = null;
818
1544
  #connected = false;
819
1545
  #pingTimers = [];
@@ -826,6 +1552,20 @@ var ZadarmaPushSignal = class {
826
1552
  #retryTimer;
827
1553
  #unwatchBackground;
828
1554
  #closed = false;
1555
+ /**
1556
+ * Bumped by `disconnect()`. An open or a rebuild still awaiting its key when the signal is disconnected
1557
+ * must build nothing afterwards — and `#closed` alone cannot say so once a later `connect()` (stop, then
1558
+ * start again) has reset it: the late socket was built beside the new one, and nothing ever closed it.
1559
+ */
1560
+ #run = 0;
1561
+ /**
1562
+ * The key is due for its refresh (`#armRefresh`): the next rebuild fetches a new one instead of reusing the
1563
+ * cached one — and keeps trying, on every retry and every hidden-tab tick, until one arrives.
1564
+ */
1565
+ #renewDue = false;
1566
+ /** A rebuild asked for (`rekey`) while another was in flight: it runs once that one ends. */
1567
+ #rekeyQueued = false;
1568
+ #rekeyAfter = 0;
829
1569
  #listeners = /* @__PURE__ */ new Set();
830
1570
  #connectionListeners = /* @__PURE__ */ new Set();
831
1571
  async connect() {
@@ -834,9 +1574,26 @@ var ZadarmaPushSignal = class {
834
1574
  this.#unwatchBackground ??= this.deps.watchBackgroundReconnect?.(() => void this.#reopen());
835
1575
  await this.#open();
836
1576
  }
1577
+ /**
1578
+ * The registrar refused credentials bought with this socket's key, and the provider has forgotten them: the
1579
+ * socket is rebuilt on a fresh key, make-before-break like every rebuild (`#reopen`).
1580
+ *
1581
+ * Debounced: a registrar refusing every attempt must not become a tight loop against our backend and
1582
+ * Zadarma's. This rebuild used to be the engine's (`#refreshSignal`, a close-then-open); it lives here now
1583
+ * because only the provider knows the push socket and the SIP credentials share one key.
1584
+ */
1585
+ rekey() {
1586
+ if (this.#closed || !this.#socket) return;
1587
+ const now = Date.now();
1588
+ if (now < this.#rekeyAfter) return;
1589
+ this.#rekeyAfter = now + REKEY_DEBOUNCE_MS;
1590
+ if (this.#rebuilding) this.#rekeyQueued = true;
1591
+ else void this.#reopen();
1592
+ }
837
1593
  async #open() {
838
- const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
839
- if (this.#closed) return;
1594
+ const run = this.#run;
1595
+ const { key, sip, keyExpiresAt } = await this.credentials.key();
1596
+ if (run !== this.#run) return;
840
1597
  const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
841
1598
  this.#socket = socket;
842
1599
  this.#attach(socket);
@@ -846,8 +1603,8 @@ var ZadarmaPushSignal = class {
846
1603
  * Wires one socket's events. `this.#socket` is read when an event FIRES, not when it is wired: a socket that
847
1604
  * has been replaced must stop driving connectivity (our own `close()` of it emits `disconnect`, which would
848
1605
  * otherwise flip the state to offline right after a perfectly healthy swap). `update` is the exception — it is
849
- * forwarded from any socket not yet closed, because a push seen twice is harmless (the engine ignores a
850
- * repeated signal mid-call and reuses a UA that already exists) while one seen by neither is a missed call.
1606
+ * forwarded from any socket not yet closed, because a push seen twice is harmless (the engine ignores an offer
1607
+ * mid-call, and a second one only sends one more REGISTER) while one seen by neither is a missed call.
851
1608
  *
852
1609
  * With `hooks`, the socket is a rebuild's candidate: its first `connect` or failure goes to the rebuild instead
853
1610
  * of changing the connection state (it is not current yet).
@@ -859,7 +1616,7 @@ var ZadarmaPushSignal = class {
859
1616
  this.#setConnected(true);
860
1617
  this.#startPings(socket);
861
1618
  });
862
- socket.on("update", () => this.#emit());
1619
+ socket.on("update", (payload) => this.#emit(readPush(payload)));
863
1620
  const lost = () => {
864
1621
  if (hooks && socket === this.#candidate) return hooks.onFailure();
865
1622
  if (socket !== this.#socket) return;
@@ -898,13 +1655,19 @@ var ZadarmaPushSignal = class {
898
1655
  * given at construction. A tab left open past the key's 72 h lifetime would reconnect forever with
899
1656
  * a dead credential. Absolute, from the backend's `key_expires_at`, so it targets the real expiry
900
1657
  * rather than restarting a countdown on every reconnect.
1658
+ *
1659
+ * It is also the refresh of the shared credentials (incoming-call-reliability.md §4.5): the new key replaces
1660
+ * the cached one, and its SIP credentials are fetched with it, in the background — no call waits for either.
901
1661
  */
902
1662
  #armRefresh(keyExpiresAt) {
903
1663
  clearTimeout(this.#refreshTimer);
904
1664
  if (!keyExpiresAt) return;
905
1665
  const ms = new Date(keyExpiresAt).getTime() - Date.now() - KEY_REFRESH_BUFFER_MS;
906
1666
  if (!Number.isFinite(ms) || ms <= 0) return;
907
- this.#refreshTimer = setTimeout(() => void this.#reopen(), ms);
1667
+ this.#refreshTimer = setTimeout(() => {
1668
+ this.#renewDue = true;
1669
+ void this.#reopen();
1670
+ }, ms);
908
1671
  }
909
1672
  /**
910
1673
  * Replaces the push socket with a fresh one — MAKE-BEFORE-BREAK (presence-client-reporting.md §4.1, T8).
@@ -920,6 +1683,9 @@ var ZadarmaPushSignal = class {
920
1683
  * throws, the socket errors, or it has not connected within `REBUILD_CONNECT_TIMEOUT_MS` — keeps the old socket
921
1684
  * and retries, instead of leaving the tab disconnected. Zadarma does not evict the older of two sockets for one
922
1685
  * sip (multi-tab-leader-election.md §1), so the overlap is safe.
1686
+ *
1687
+ * The key comes from the shared cache — a hidden-tab tick makes no request — unless it is due for its refresh
1688
+ * (`#renewDue`) or has been forgotten after a refused registration (`rekey`).
923
1689
  */
924
1690
  async #reopen() {
925
1691
  if (this.#closed || this.#rebuilding) return;
@@ -931,9 +1697,11 @@ var ZadarmaPushSignal = class {
931
1697
  return;
932
1698
  }
933
1699
  this.#rebuilding = true;
1700
+ const run = this.#run;
934
1701
  try {
935
- const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
936
- if (this.#closed) return;
1702
+ const { key, sip, keyExpiresAt } = await (this.#renewDue ? this.credentials.renew() : this.credentials.key());
1703
+ if (run !== this.#run) return;
1704
+ this.#renewDue = false;
937
1705
  const swapped = await new Promise((resolve) => {
938
1706
  const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
939
1707
  this.#candidate = socket;
@@ -961,6 +1729,10 @@ var ZadarmaPushSignal = class {
961
1729
  } finally {
962
1730
  this.#rebuilding = false;
963
1731
  }
1732
+ if (this.#rekeyQueued) {
1733
+ this.#rekeyQueued = false;
1734
+ void this.#reopen();
1735
+ }
964
1736
  }
965
1737
  /**
966
1738
  * The moment of the swap — synchronous, called from the candidate's own `connect` event so nothing can run
@@ -984,8 +1756,17 @@ var ZadarmaPushSignal = class {
984
1756
  clearTimeout(this.#retryTimer);
985
1757
  this.#retryTimer = setTimeout(() => void this.#reopen(), REBUILD_RETRY_MS);
986
1758
  }
1759
+ /**
1760
+ * Closes everything — and forgets the credentials: this is how the engine stops, and a phone that is off holds
1761
+ * no SIP password. The next `connect()` fetches them again.
1762
+ */
987
1763
  disconnect() {
988
1764
  this.#closed = true;
1765
+ this.#run += 1;
1766
+ this.#renewDue = false;
1767
+ this.#rekeyQueued = false;
1768
+ this.#rekeyAfter = 0;
1769
+ this.credentials.forget("all");
989
1770
  this.#stopPings();
990
1771
  clearTimeout(this.#refreshTimer);
991
1772
  this.#refreshTimer = void 0;
@@ -1014,16 +1795,11 @@ var ZadarmaPushSignal = class {
1014
1795
  this.#connected = connected;
1015
1796
  for (const listener of [...this.#connectionListeners]) listener(connected);
1016
1797
  }
1017
- #emit() {
1018
- for (const listener of [...this.#listeners]) listener();
1798
+ #emit(event) {
1799
+ for (const listener of [...this.#listeners]) listener(event);
1019
1800
  }
1020
1801
  };
1021
1802
  var ZadarmaProvider = class {
1022
- constructor(deps) {
1023
- this.deps = deps;
1024
- this.incomingSignal = new ZadarmaPushSignal(deps);
1025
- }
1026
- deps;
1027
1803
  name = "zadarma";
1028
1804
  // Zadarma's PBX ignores a standing registration — it routes INVITEs only to a UA that registered
1029
1805
  // in response to a push (SPEC §3.2). Hence on-demand.
@@ -1031,21 +1807,60 @@ var ZadarmaProvider = class {
1031
1807
  quirks = ZADARMA_QUIRKS;
1032
1808
  incomingSignal;
1033
1809
  /**
1034
- * Per-registration credentialing: fetch a scoped key, then exchange it in the browser for
1035
- * ephemeral SIP creds. The key fetch is cheap and backend-cached; doing it here (rather than
1036
- * sharing state with the push signal) keeps the two paths independent.
1810
+ * One key and one set of SIP credentials for the push socket and every registration (§4.5). Its "unreachable
1811
+ * refetched once" mark lives there too, so a stop — which forgets the credentials — clears it as well.
1812
+ */
1813
+ #credentials;
1814
+ #signal;
1815
+ constructor(deps) {
1816
+ this.#credentials = new ZadarmaCredentials(deps);
1817
+ this.#signal = new ZadarmaPushSignal(deps, this.#credentials);
1818
+ this.incomingSignal = this.#signal;
1819
+ }
1820
+ /**
1821
+ * The SIP credentials, from memory (incoming-call-reliability.md §4.5). They were fetched when the push socket
1822
+ * opened — the key from our backend, then `{ pass, domain }` from Zadarma, in the browser for the geo-routing
1823
+ * (SPEC §3.1) — so a push goes straight to the WSS and the REGISTER. Fetched here only when they are not there:
1824
+ * the background fetch failed, or they were forgotten (`credentialsFailed`).
1037
1825
  */
1038
1826
  async provision() {
1039
- const { key, sip } = await this.deps.fetchKey();
1040
- const { pass, domain } = await this.deps.exchangeWebphoneData(key, sip);
1041
- return {
1042
- uri: `sip:${sip}@${domain}`,
1043
- authUser: sip,
1044
- password: pass,
1045
- wsUrl: `wss://${domain}:4443`
1046
- };
1827
+ return toSipCredentials(await this.#credentials.sip());
1828
+ }
1829
+ /**
1830
+ * `rejected` (401/403): the key may be the stale part, so both are forgotten, and the push socket — which
1831
+ * holds the same key — is rebuilt on a fresh one. `unreachable`: only the SIP credentials, and only once
1832
+ * until a registration works: Zadarma may have moved this browser to another datacenter (SPEC §3.1).
1833
+ *
1834
+ * Only if `credentials` — the ones that failed — are still what is in memory: a UA let go of earlier can report
1835
+ * late, when the cache has already moved on to fresh ones, and those must not be thrown away for it.
1836
+ */
1837
+ credentialsFailed(reason, credentials) {
1838
+ if (credentials && !this.#holds(credentials)) return true;
1839
+ if (reason === "rejected") {
1840
+ this.#credentials.forget("all");
1841
+ this.#signal.rekey();
1842
+ return true;
1843
+ }
1844
+ return this.#credentials.forgetUnreachable();
1845
+ }
1846
+ credentialsWorked() {
1847
+ this.#credentials.worked();
1848
+ }
1849
+ #holds(credentials) {
1850
+ const current = this.#credentials.current();
1851
+ if (!current) return false;
1852
+ const held = toSipCredentials(current);
1853
+ return held.uri === credentials.uri && held.authUser === credentials.authUser && held.password === credentials.password;
1047
1854
  }
1048
1855
  };
1856
+ function toSipCredentials({ key, pass, domain }) {
1857
+ return {
1858
+ uri: `sip:${key.sip}@${domain}`,
1859
+ authUser: key.sip,
1860
+ password: pass,
1861
+ wsUrl: `wss://${domain}:4443`
1862
+ };
1863
+ }
1049
1864
 
1050
1865
  // src/core/token-cache.ts
1051
1866
  var SKEW_MS = 6e4;
@@ -1086,6 +1901,23 @@ function invalidateToken(source) {
1086
1901
  // src/transports/providers/zadarma-browser.ts
1087
1902
  var UnauthorizedKeyError = class extends Error {
1088
1903
  };
1904
+ var KeyFetchError = class extends Error {
1905
+ constructor(message, status, retryAfterMs) {
1906
+ super(message);
1907
+ this.status = status;
1908
+ this.retryAfterMs = retryAfterMs;
1909
+ }
1910
+ status;
1911
+ retryAfterMs;
1912
+ };
1913
+ function retryAfterMsOf(res) {
1914
+ const value = res.headers?.get?.("retry-after");
1915
+ if (!value) return void 0;
1916
+ const seconds = Number(value);
1917
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
1918
+ const at = Date.parse(value);
1919
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : void 0;
1920
+ }
1089
1921
  function makeFetchKey(config) {
1090
1922
  const attempt = async () => {
1091
1923
  let token;
@@ -1101,7 +1933,7 @@ function makeFetchKey(config) {
1101
1933
  if (!res.ok) {
1102
1934
  const body = await res.json().catch(() => null);
1103
1935
  const message = body?.message ?? `webrtc/key HTTP ${res.status}`;
1104
- throw res.status === 401 ? new UnauthorizedKeyError(message) : new Error(message);
1936
+ throw res.status === 401 ? new UnauthorizedKeyError(message) : new KeyFetchError(message, res.status, retryAfterMsOf(res));
1105
1937
  }
1106
1938
  const data = await res.json();
1107
1939
  return {
@@ -1256,10 +2088,24 @@ function makeZadarmaBrowserDeps(config) {
1256
2088
  };
1257
2089
  }
1258
2090
 
2091
+ // src/core/api-base.ts
2092
+ function normalizeApiBase(url) {
2093
+ const trimmed = url.replace(/\/+$/, "");
2094
+ if (!trimmed.endsWith("/api/v1")) return trimmed;
2095
+ console.warn(
2096
+ "[softphone] apiBase should be Coventia's domain only, without /api/v1. Ignoring the /api/v1 suffix."
2097
+ );
2098
+ return trimmed.slice(0, -"/api/v1".length).replace(/\/+$/, "");
2099
+ }
2100
+
1259
2101
  // src/transports/zadarma-engine.ts
1260
2102
  function createZadarmaEngine(config) {
1261
2103
  const provider = new ZadarmaProvider(
1262
- makeZadarmaBrowserDeps({ ...config, tokenSource: cachedTokenSource(config.tokenSource) })
2104
+ makeZadarmaBrowserDeps({
2105
+ ...config,
2106
+ apiBase: normalizeApiBase(config.apiBase),
2107
+ tokenSource: cachedTokenSource(config.tokenSource)
2108
+ })
1263
2109
  );
1264
2110
  return new SipCallEngine(provider, loadJsSipFactory);
1265
2111
  }