@coventia/softphone 0.1.0 → 0.2.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 (54) hide show
  1. package/dist/{call-BTYlT2SU.d.cts → call-VKq1I8yP.d.cts} +158 -5
  2. package/dist/{call-BTYlT2SU.d.ts → call-VKq1I8yP.d.ts} +158 -5
  3. package/dist/{chunk-N2N2EQQJ.js → chunk-7R6LOOAI.js} +4 -2
  4. package/dist/chunk-7R6LOOAI.js.map +1 -0
  5. package/dist/{chunk-5BGEHMPP.js → chunk-EYEDPD7Y.js} +146 -20
  6. package/dist/chunk-EYEDPD7Y.js.map +1 -0
  7. package/dist/{chunk-3V2L6UTK.js → chunk-LMJXX4YM.js} +17 -3
  8. package/dist/chunk-LMJXX4YM.js.map +1 -0
  9. package/dist/element/index.d.ts +162 -7
  10. package/dist/element/index.js +866 -421
  11. package/dist/element/index.js.map +1 -1
  12. package/dist/{engine-D9nbeY08.d.cts → engine-BlQBo2Ys.d.cts} +1 -1
  13. package/dist/{engine-Doa6Brkm.d.ts → engine-C0BOkIcW.d.ts} +1 -1
  14. package/dist/index.cjs +586 -28
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +5 -5
  17. package/dist/index.d.ts +5 -5
  18. package/dist/index.js +335 -8
  19. package/dist/index.js.map +1 -1
  20. package/dist/react/index.cjs +15 -0
  21. package/dist/react/index.cjs.map +1 -1
  22. package/dist/react/index.d.cts +14 -3
  23. package/dist/react/index.d.ts +14 -3
  24. package/dist/react/index.js +2 -2
  25. package/dist/{softphone-DhuhcKCy.d.cts → softphone-Cyq3hiIM.d.ts} +17 -2
  26. package/dist/{softphone-DHmhotbf.d.ts → softphone-DlGV12Xp.d.cts} +17 -2
  27. package/dist/softphone.css +477 -35
  28. package/dist/{sources-N4BOEDRL.js → sources-IRKNQPWW.js} +108 -5
  29. package/dist/sources-IRKNQPWW.js.map +1 -0
  30. package/dist/testing/index.cjs +41 -5
  31. package/dist/testing/index.cjs.map +1 -1
  32. package/dist/testing/index.d.cts +8 -2
  33. package/dist/testing/index.d.ts +8 -2
  34. package/dist/testing/index.js +41 -5
  35. package/dist/testing/index.js.map +1 -1
  36. package/dist/ui/index.cjs +846 -410
  37. package/dist/ui/index.cjs.map +1 -1
  38. package/dist/ui/index.d.cts +17 -6
  39. package/dist/ui/index.d.ts +17 -6
  40. package/dist/ui/index.js +878 -456
  41. package/dist/ui/index.js.map +1 -1
  42. package/dist/zadarma/index.cjs +144 -18
  43. package/dist/zadarma/index.cjs.map +1 -1
  44. package/dist/zadarma/index.d.cts +2 -2
  45. package/dist/zadarma/index.d.ts +2 -2
  46. package/dist/zadarma/index.js +1 -1
  47. package/dist/zadarma-engine-L5DEFXUS.js +5 -0
  48. package/dist/{zadarma-engine-DN2SZSQ3.js.map → zadarma-engine-L5DEFXUS.js.map} +1 -1
  49. package/package.json +1 -1
  50. package/dist/chunk-3V2L6UTK.js.map +0 -1
  51. package/dist/chunk-5BGEHMPP.js.map +0 -1
  52. package/dist/chunk-N2N2EQQJ.js.map +0 -1
  53. package/dist/sources-N4BOEDRL.js.map +0 -1
  54. package/dist/zadarma-engine-DN2SZSQ3.js +0 -5
@@ -421,6 +421,14 @@ var SipCallEngine = class {
421
421
  #uaInFlight = null;
422
422
  #domain = "";
423
423
  #activeCall = null;
424
+ /**
425
+ * The consult leg from `startConsult` (SPEC §5.6), if one is live on this UA right now.
426
+ *
427
+ * Tracked separately from `#activeCall` so `#maybeTeardownUA` can tell "both legs are actually gone"
428
+ * apart from "the main leg ended, but a consult is still up" — see that method's own comment for the
429
+ * bug this exists to prevent.
430
+ */
431
+ #consultCall = null;
424
432
  /** The chosen microphone, or `null` for the browser default. Read fresh by every new leg's
425
433
  * `mediaConstraints`, and pushed live to `#activeCall` by `setInputDevice`. */
426
434
  #inputDeviceId = null;
@@ -539,9 +547,7 @@ var SipCallEngine = class {
539
547
  const ms = this.provider.quirks?.noInviteTeardownMs;
540
548
  if (!ms) return;
541
549
  this.#clearNoInviteTimer();
542
- this.#noInviteTimer = setTimeout(() => {
543
- if (!this.#activeCall) this.#teardownUA();
544
- }, ms);
550
+ this.#noInviteTimer = setTimeout(() => this.#maybeTeardownUA(), ms);
545
551
  }
546
552
  /**
547
553
  * Rebuild the incoming-signal channel with fresh credentials, after a registration failure.
@@ -663,6 +669,7 @@ var SipCallEngine = class {
663
669
  }
664
670
  this.#ua = null;
665
671
  this.#activeCall = null;
672
+ this.#consultCall = null;
666
673
  }
667
674
  /** Place a call once the UA is registered (its 200 OK for REGISTER has arrived). */
668
675
  /**
@@ -705,7 +712,7 @@ var SipCallEngine = class {
705
712
  const code = missed ? "call/missed" : CALL_END_CODES[event.reason];
706
713
  if (code) this.#reportError(code, event.cause);
707
714
  if (this.#activeCall === call) this.#activeCall = null;
708
- if (this.provider.registrationMode === "on-demand") this.#teardownUA();
715
+ this.#maybeTeardownUA();
709
716
  }
710
717
  });
711
718
  return call;
@@ -716,12 +723,38 @@ var SipCallEngine = class {
716
723
  mediaConstraints: buildAudioConstraints(this.#inputDeviceId),
717
724
  pcConfig: PC_CONFIG
718
725
  });
719
- return new SipEngineCall(consultSession, {
726
+ const consultCall = new SipEngineCall(consultSession, {
720
727
  id: nextCallId(),
721
728
  buildTarget: (x) => this.#buildTarget(x),
722
729
  placeConsult: () => Promise.reject(new Error("cannot consult from a consult leg")),
723
730
  iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS
724
731
  });
732
+ this.#consultCall = consultCall;
733
+ consultCall.onEvent((event) => {
734
+ if (event.type === "ended") {
735
+ if (this.#consultCall === consultCall) this.#consultCall = null;
736
+ this.#maybeTeardownUA();
737
+ }
738
+ });
739
+ return consultCall;
740
+ }
741
+ /**
742
+ * On-demand registration (SPEC §3.3): the UA is disposable once NOTHING is left on it — but "the
743
+ * main leg ended" (or "no INVITE followed this push") is not the same fact as "nothing is left."
744
+ * `completeTransfer()` (SPEC §5.6) ends the main leg via a REFER…Replaces BYE *while the consult leg
745
+ * is still what the PBX needs to bridge the caller onto* — tearing the UA down (and with it, EVERY
746
+ * session on it, via `terminateSessions`) the instant the main leg's `ended` fires killed that
747
+ * consult leg out from under the transfer before the bridge could use it, and the caller was simply
748
+ * 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.
753
+ */
754
+ #maybeTeardownUA() {
755
+ if (this.provider.registrationMode !== "on-demand") return;
756
+ if (this.#activeCall || this.#consultCall) return;
757
+ this.#teardownUA();
725
758
  }
726
759
  #startRejectGuard() {
727
760
  const ms = this.provider.quirks?.rejectGuardMs;
@@ -756,6 +789,8 @@ async function loadJsSipFactory() {
756
789
  // src/transports/providers/zadarma.ts
757
790
  var PING = { localMs: 3e4, globalMs: 6e4 };
758
791
  var KEY_REFRESH_BUFFER_MS = 2 * 60 * 60 * 1e3;
792
+ var REBUILD_CONNECT_TIMEOUT_MS = 1e4;
793
+ var REBUILD_RETRY_MS = 3e4;
759
794
  var ZADARMA_QUIRKS = {
760
795
  // Zadarma's registrar REFUSES to register a WebRTC client with any other User-Agent — a clean 401
761
796
  // after a valid digest challenge, indistinguishable from a wrong password (§3.1). The old CRM
@@ -783,6 +818,12 @@ var ZadarmaPushSignal = class {
783
818
  #connected = false;
784
819
  #pingTimers = [];
785
820
  #refreshTimer;
821
+ /** The replacement socket of a rebuild in flight, not yet current. */
822
+ #candidate = null;
823
+ /** Abandons the rebuild in flight (closing its candidate). */
824
+ #abortRebuild = null;
825
+ #rebuilding = false;
826
+ #retryTimer;
786
827
  #unwatchBackground;
787
828
  #closed = false;
788
829
  #listeners = /* @__PURE__ */ new Set();
@@ -798,20 +839,35 @@ var ZadarmaPushSignal = class {
798
839
  if (this.#closed) return;
799
840
  const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
800
841
  this.#socket = socket;
842
+ this.#attach(socket);
843
+ this.#armRefresh(keyExpiresAt);
844
+ }
845
+ /**
846
+ * Wires one socket's events. `this.#socket` is read when an event FIRES, not when it is wired: a socket that
847
+ * has been replaced must stop driving connectivity (our own `close()` of it emits `disconnect`, which would
848
+ * 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.
851
+ *
852
+ * With `hooks`, the socket is a rebuild's candidate: its first `connect` or failure goes to the rebuild instead
853
+ * of changing the connection state (it is not current yet).
854
+ */
855
+ #attach(socket, hooks) {
801
856
  socket.on("connect", () => {
857
+ if (hooks && socket === this.#candidate) return hooks.onConnect();
858
+ if (socket !== this.#socket) return;
802
859
  this.#setConnected(true);
803
860
  this.#startPings(socket);
804
861
  });
805
862
  socket.on("update", () => this.#emit());
806
- socket.on("disconnect", () => {
863
+ const lost = () => {
864
+ if (hooks && socket === this.#candidate) return hooks.onFailure();
865
+ if (socket !== this.#socket) return;
807
866
  this.#stopPings();
808
867
  this.#setConnected(false);
809
- });
810
- socket.on("connect_error", () => {
811
- this.#stopPings();
812
- this.#setConnected(false);
813
- });
814
- this.#armRefresh(keyExpiresAt);
868
+ };
869
+ socket.on("disconnect", lost);
870
+ socket.on("connect_error", lost);
815
871
  }
816
872
  /**
817
873
  * Presence pings — **the reason an idle tab keeps receiving calls**.
@@ -850,22 +906,92 @@ var ZadarmaPushSignal = class {
850
906
  if (!Number.isFinite(ms) || ms <= 0) return;
851
907
  this.#refreshTimer = setTimeout(() => void this.#reopen(), ms);
852
908
  }
909
+ /**
910
+ * Replaces the push socket with a fresh one — MAKE-BEFORE-BREAK (presence-client-reporting.md §4.1, T8).
911
+ *
912
+ * Why it must happen at all: Zadarma's gateway decides the extension is alive from the raw Engine.IO ping, which
913
+ * a hidden tab's throttled timers silently stop sending, so while the tab is hidden the socket is rebuilt every
914
+ * 20 s (`push-socket-reliability.md` §5.3), and once before the key expires. Why it must not leave a gap: the
915
+ * old close-then-reopen reported `connected = false` for the ~0.5 s in between, which took the softphone
916
+ * `offline`, tore down the presence socket and left Zadarma with no socket to push an incoming call to.
917
+ *
918
+ * Now the OLD socket stays up and stays current until the NEW one has actually connected; only then do we swap,
919
+ * move the pings and close the old one. A healthy rebuild never changes `connected`. A failed one — the key fetch
920
+ * throws, the socket errors, or it has not connected within `REBUILD_CONNECT_TIMEOUT_MS` — keeps the old socket
921
+ * and retries, instead of leaving the tab disconnected. Zadarma does not evict the older of two sockets for one
922
+ * sip (multi-tab-leader-election.md §1), so the overlap is safe.
923
+ */
853
924
  async #reopen() {
854
- if (this.#closed) return;
855
- this.#stopPings();
856
- this.#socket?.close();
857
- this.#socket = null;
858
- this.#setConnected(false);
925
+ if (this.#closed || this.#rebuilding) return;
926
+ if (!this.#socket) {
927
+ try {
928
+ await this.#open();
929
+ } catch {
930
+ }
931
+ return;
932
+ }
933
+ this.#rebuilding = true;
859
934
  try {
860
- await this.#open();
935
+ const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
936
+ if (this.#closed) return;
937
+ const swapped = await new Promise((resolve) => {
938
+ const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
939
+ this.#candidate = socket;
940
+ let settled = false;
941
+ const finish = (ok) => {
942
+ if (settled) return;
943
+ settled = true;
944
+ clearTimeout(timer);
945
+ this.#abortRebuild = null;
946
+ if (ok && socket.connected) {
947
+ this.#swapIn(socket, keyExpiresAt);
948
+ } else {
949
+ this.#candidate = null;
950
+ socket.close();
951
+ }
952
+ resolve(ok && socket.connected);
953
+ };
954
+ const timer = setTimeout(() => finish(false), REBUILD_CONNECT_TIMEOUT_MS);
955
+ this.#abortRebuild = () => finish(false);
956
+ this.#attach(socket, { onConnect: () => finish(true), onFailure: () => finish(false) });
957
+ });
958
+ if (!swapped) this.#armRetry();
861
959
  } catch {
960
+ this.#armRetry();
961
+ } finally {
962
+ this.#rebuilding = false;
862
963
  }
863
964
  }
965
+ /**
966
+ * The moment of the swap — synchronous, called from the candidate's own `connect` event so nothing can run
967
+ * between "the new socket is up" and "it is the current one". Order matters: `#socket` moves first, so the old
968
+ * socket's `disconnect` (our own `close()` of it, just below) is ignored by `#attach`.
969
+ */
970
+ #swapIn(socket, keyExpiresAt) {
971
+ const previous = this.#socket;
972
+ this.#socket = socket;
973
+ this.#candidate = null;
974
+ clearTimeout(this.#retryTimer);
975
+ this.#retryTimer = void 0;
976
+ this.#stopPings();
977
+ this.#startPings(socket);
978
+ this.#setConnected(true);
979
+ previous?.close();
980
+ this.#armRefresh(keyExpiresAt);
981
+ }
982
+ #armRetry() {
983
+ if (this.#closed) return;
984
+ clearTimeout(this.#retryTimer);
985
+ this.#retryTimer = setTimeout(() => void this.#reopen(), REBUILD_RETRY_MS);
986
+ }
864
987
  disconnect() {
865
988
  this.#closed = true;
866
989
  this.#stopPings();
867
990
  clearTimeout(this.#refreshTimer);
868
991
  this.#refreshTimer = void 0;
992
+ clearTimeout(this.#retryTimer);
993
+ this.#retryTimer = void 0;
994
+ this.#abortRebuild?.();
869
995
  this.#unwatchBackground?.();
870
996
  this.#unwatchBackground = void 0;
871
997
  this.#socket?.close();