@coventia/softphone 0.4.0 → 0.5.0

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 (57) hide show
  1. package/dist/{call-C6YpT6uR.d.cts → call-FqcvDg1t.d.cts} +105 -12
  2. package/dist/{call-C6YpT6uR.d.ts → call-FqcvDg1t.d.ts} +105 -12
  3. package/dist/{chunk-EYEDPD7Y.js → chunk-6RR6G35E.js} +92 -17
  4. package/dist/chunk-6RR6G35E.js.map +1 -0
  5. package/dist/{chunk-2ZSOGGPO.js → chunk-FRXHEHQA.js} +15 -2
  6. package/dist/chunk-FRXHEHQA.js.map +1 -0
  7. package/dist/{chunk-UHNLEBBO.js → chunk-I3RSC52R.js} +28 -3
  8. package/dist/chunk-I3RSC52R.js.map +1 -0
  9. package/dist/chunk-M76SAXXM.js +13 -0
  10. package/dist/chunk-M76SAXXM.js.map +1 -0
  11. package/dist/{chunk-7R6LOOAI.js → chunk-N4PLEYFJ.js} +4 -2
  12. package/dist/chunk-N4PLEYFJ.js.map +1 -0
  13. package/dist/element/index.d.ts +142 -6
  14. package/dist/element/index.js +138 -43
  15. package/dist/element/index.js.map +1 -1
  16. package/dist/{engine-DPft99Wj.d.ts → engine-CsNRD7B7.d.ts} +1 -1
  17. package/dist/{engine-CDdaSojx.d.cts → engine-CzOCrfY9.d.cts} +1 -1
  18. package/dist/index.cjs +579 -47
  19. package/dist/index.cjs.map +1 -1
  20. package/dist/index.d.cts +5 -5
  21. package/dist/index.d.ts +5 -5
  22. package/dist/index.js +447 -37
  23. package/dist/index.js.map +1 -1
  24. package/dist/react/index.cjs +26 -0
  25. package/dist/react/index.cjs.map +1 -1
  26. package/dist/react/index.d.cts +24 -5
  27. package/dist/react/index.d.ts +24 -5
  28. package/dist/react/index.js +2 -2
  29. package/dist/{softphone-CY5d2WWY.d.cts → softphone-By9sizlV.d.ts} +42 -3
  30. package/dist/{softphone-DXgmTyrX.d.ts → softphone-JZvJdzU3.d.cts} +42 -3
  31. package/dist/{sources-ONYQW733.js → sources-QL2LRZVH.js} +19 -2
  32. package/dist/sources-QL2LRZVH.js.map +1 -0
  33. package/dist/testing/index.cjs +13 -0
  34. package/dist/testing/index.cjs.map +1 -1
  35. package/dist/testing/index.d.cts +2 -2
  36. package/dist/testing/index.d.ts +2 -2
  37. package/dist/testing/index.js +1 -1
  38. package/dist/ui/index.cjs +92 -2
  39. package/dist/ui/index.cjs.map +1 -1
  40. package/dist/ui/index.d.cts +2 -2
  41. package/dist/ui/index.d.ts +2 -2
  42. package/dist/ui/index.js +69 -4
  43. package/dist/ui/index.js.map +1 -1
  44. package/dist/zadarma/index.cjs +111 -14
  45. package/dist/zadarma/index.cjs.map +1 -1
  46. package/dist/zadarma/index.d.cts +5 -6
  47. package/dist/zadarma/index.d.ts +5 -6
  48. package/dist/zadarma/index.js +3 -2
  49. package/dist/zadarma-engine-TIUE7PZ7.js +6 -0
  50. package/dist/{zadarma-engine-L5DEFXUS.js.map → zadarma-engine-TIUE7PZ7.js.map} +1 -1
  51. package/package.json +1 -1
  52. package/dist/chunk-2ZSOGGPO.js.map +0 -1
  53. package/dist/chunk-7R6LOOAI.js.map +0 -1
  54. package/dist/chunk-EYEDPD7Y.js.map +0 -1
  55. package/dist/chunk-UHNLEBBO.js.map +0 -1
  56. package/dist/sources-ONYQW733.js.map +0 -1
  57. package/dist/zadarma-engine-L5DEFXUS.js +0 -5
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { isBrowser } from './chunk-KAXEMJ7E.js';
2
2
  export { isBrowser, isWebRtcSupported } from './chunk-KAXEMJ7E.js';
3
- export { CallEvent, SoftphoneEvent } from './chunk-7R6LOOAI.js';
3
+ export { CallEvent, SoftphoneEvent } from './chunk-N4PLEYFJ.js';
4
4
  export { isOutboundNumberRefused } from './chunk-5E5OEXS2.js';
5
- import { isFatal } from './chunk-2ZSOGGPO.js';
6
- export { ERROR_CATALOGUE, isFatal } from './chunk-2ZSOGGPO.js';
5
+ import { normalizeApiBase } from './chunk-M76SAXXM.js';
6
+ import { createError, isSoftphoneError, isFatal } from './chunk-FRXHEHQA.js';
7
+ export { ERROR_CATALOGUE, isFatal } from './chunk-FRXHEHQA.js';
7
8
  import { cachedTokenSource } from './chunk-MHOJHG5E.js';
8
9
 
9
10
  // src/core/emitter.ts
@@ -285,7 +286,7 @@ function coventiaBackend(config, tokenSource, fallbackBaseUrl) {
285
286
  }
286
287
  let pending = null;
287
288
  const load = () => {
288
- pending ??= import('./sources-ONYQW733.js').then(
289
+ pending ??= import('./sources-QL2LRZVH.js').then(
289
290
  (m) => m.coventiaSources({ ...config, baseUrl, tokenSource })
290
291
  );
291
292
  return pending;
@@ -316,13 +317,15 @@ function isWebLocksSupported() {
316
317
  return isBrowser() && typeof navigator.locks?.request === "function";
317
318
  }
318
319
  function webLocksTabLock(lockName = LOCK_NAME) {
319
- const controller = new AbortController();
320
+ let controller;
320
321
  let releaseHold;
321
322
  return {
322
323
  acquireLeadership() {
323
324
  if (!isWebLocksSupported()) return Promise.resolve();
325
+ const { signal } = controller = new AbortController();
324
326
  return new Promise((resolveAcquired) => {
325
- navigator.locks.request(lockName, { signal: controller.signal }, () => {
327
+ navigator.locks.request(lockName, { signal }, () => {
328
+ if (signal.aborted) return Promise.resolve();
326
329
  resolveAcquired();
327
330
  return new Promise((resolveHold) => {
328
331
  releaseHold = resolveHold;
@@ -337,7 +340,7 @@ function webLocksTabLock(lockName = LOCK_NAME) {
337
340
  releaseHold = void 0;
338
341
  resolve();
339
342
  } else {
340
- controller.abort();
343
+ controller?.abort();
341
344
  }
342
345
  }
343
346
  };
@@ -422,13 +425,19 @@ function startTransferWatch(params) {
422
425
  var RECONNECT_BASE_MS = 1e3;
423
426
  var RECONNECT_MAX_MS = 3e4;
424
427
  var NOOP_WATCH = { reportCallState: () => {
425
- }, stop: () => {
428
+ }, claimLine: () => false, stop: () => {
426
429
  } };
427
430
  function startPresenceSocket(params) {
428
431
  const {
429
432
  source,
430
433
  getIsLeader,
431
434
  onAgentsChanged,
435
+ clientId,
436
+ onLine,
437
+ onClaimResult,
438
+ onLineUnsupported,
439
+ onConnectFailed,
440
+ onDisconnect,
432
441
  setTimeout: scheduleTimeout = globalThis.setTimeout,
433
442
  clearTimeout: cancelTimeout = globalThis.clearTimeout,
434
443
  random = Math.random
@@ -439,6 +448,8 @@ function startPresenceSocket(params) {
439
448
  let reconnectTimer = null;
440
449
  let agents = /* @__PURE__ */ new Map();
441
450
  let lastReportedOnCall = null;
451
+ let sentOnCall = null;
452
+ let line = null;
442
453
  let connection = null;
443
454
  let byeOnArrival = false;
444
455
  const stop = (options) => {
@@ -455,6 +466,12 @@ function startPresenceSocket(params) {
455
466
  connection?.close();
456
467
  connection = null;
457
468
  };
469
+ const send = (onCall) => {
470
+ if (!connection) return;
471
+ connection.reportCallState(onCall);
472
+ sentOnCall = onCall;
473
+ };
474
+ const mayReport = (onCall) => line !== "elsewhere" || !onCall && sentOnCall === true;
458
475
  const scheduleReconnect = () => {
459
476
  if (stopped) return;
460
477
  attempt += 1;
@@ -483,10 +500,23 @@ function startPresenceSocket(params) {
483
500
  if (connection === null) return;
484
501
  connection = null;
485
502
  scheduleReconnect();
503
+ onDisconnect?.();
504
+ },
505
+ onLine: (update) => {
506
+ if (stopped) return;
507
+ const was = line;
508
+ line = update.state;
509
+ if (was === "elsewhere" && line === "here" && lastReportedOnCall !== null) send(lastReportedOnCall);
510
+ onLine?.(update);
511
+ },
512
+ onClaimResult: (result) => {
513
+ if (!stopped) onClaimResult?.(result);
486
514
  }
487
515
  });
488
516
  } catch {
489
- if (!stopped) scheduleReconnect();
517
+ if (stopped) return;
518
+ scheduleReconnect();
519
+ onConnectFailed?.();
490
520
  return;
491
521
  }
492
522
  if (stopped) {
@@ -496,22 +526,63 @@ function startPresenceSocket(params) {
496
526
  }
497
527
  connection = opened;
498
528
  attempt = 0;
499
- if (lastReportedOnCall !== null) connection.reportCallState(lastReportedOnCall);
529
+ sentOnCall = null;
530
+ line = null;
531
+ if (clientId !== void 0 && opened.hello) opened.hello(clientId);
532
+ else onLineUnsupported?.();
533
+ if (lastReportedOnCall !== null) send(lastReportedOnCall);
500
534
  };
501
535
  void connect();
502
536
  return {
503
537
  reportCallState: (onCall) => {
504
538
  if (onCall === lastReportedOnCall) return;
505
539
  lastReportedOnCall = onCall;
506
- connection?.reportCallState(onCall);
540
+ if (mayReport(onCall)) send(onCall);
541
+ },
542
+ claimLine: () => {
543
+ if (!connection?.claimLine) return false;
544
+ connection.claimLine();
545
+ return true;
507
546
  },
508
547
  stop
509
548
  };
510
549
  }
511
550
 
551
+ // src/core/line.ts
552
+ var CLIENT_ID_KEY = "coventia-softphone:client-id";
553
+ var CLIENT_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/;
554
+ function lineClientId() {
555
+ const stored = readStoredId();
556
+ if (stored) return stored;
557
+ const id = freshClientId();
558
+ try {
559
+ if (isBrowser()) globalThis.sessionStorage?.setItem(CLIENT_ID_KEY, id);
560
+ } catch {
561
+ }
562
+ return id;
563
+ }
564
+ function readStoredId() {
565
+ if (!isBrowser()) return null;
566
+ try {
567
+ const value = globalThis.sessionStorage?.getItem(CLIENT_ID_KEY);
568
+ return value && CLIENT_ID_PATTERN.test(value) ? value : null;
569
+ } catch {
570
+ return null;
571
+ }
572
+ }
573
+ function freshClientId() {
574
+ const uuid = globalThis.crypto?.randomUUID?.();
575
+ if (uuid) return uuid;
576
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
577
+ }
578
+
512
579
  // src/core/softphone-impl.ts
513
580
  var liveInstance = false;
514
581
  var PRESENCE_TEARDOWN_GRACE_MS = 1e4;
582
+ var LINE_ANSWER_TIMEOUT_MS = 4e3;
583
+ var CALL_DECISION_TIMEOUT_MS = LINE_ANSWER_TIMEOUT_MS + 2e3;
584
+ var LINE_CLAIM_TIMEOUT_MS = 1e4;
585
+ var LINE_LAPSE_MS = 1e4;
515
586
  var BrowserAudioDevices = class {
516
587
  inputs = [];
517
588
  outputs = [];
@@ -619,6 +690,8 @@ var SoftphoneImpl = class extends Emitter {
619
690
  #activeCall = null;
620
691
  #destroyed = false;
621
692
  #startedHere = false;
693
+ /** Bumped by every `stop()`, so a `start()` still waiting to lead can tell it was stopped. */
694
+ #run = 0;
622
695
  #isLeader = false;
623
696
  #engine;
624
697
  #tabLock;
@@ -643,6 +716,52 @@ var SoftphoneImpl = class extends Emitter {
643
716
  /** A pending state-driven teardown of the watch — see `PRESENCE_TEARDOWN_GRACE_MS`. */
644
717
  #presenceTeardownTimer = null;
645
718
  #presenceAgents = [];
719
+ // ── The line owner (`docs/specs/line-owner.md` §5) ──────────────────────────────────────────────
720
+ // Heard over the presence watch: the server names ONE softphone per extension that listens, and this one
721
+ // runs its engine only while that is it (or nobody can say). `#engineOn` and `#standby` are the two halves
722
+ // of that decision, kept apart because neither is true while `start()` is still waiting to make it.
723
+ #line = "unknown";
724
+ #lineWhere = null;
725
+ /** How the last attempt to bring the line here went (`lineClaim`), whoever made it. */
726
+ #lineClaim = "idle";
727
+ /** This tab's id in `hello` (`lineClientId`), read once — one id for every reconnect of this softphone. */
728
+ #clientId = null;
729
+ /** What the line wants: the engine running. Set the moment the line decides; the engine follows (`#queueEngine`). */
730
+ #engineOn = false;
731
+ /**
732
+ * What the engine was last TOLD by the queue — started or stopped. Apart from `#engineOn` because the line
733
+ * can flip twice while a start is still running: the stop and start queued meanwhile then find nothing
734
+ * to do, instead of stopping the engine (and the softphone with it) for a moment.
735
+ */
736
+ #engineRunning = false;
737
+ /** Waiting in `standby`: the engine is stopped ON PURPOSE, so its own `stopped` is not the softphone's. */
738
+ #standby = false;
739
+ /**
740
+ * Every engine start and stop the line asks for, one after another: a promotion can follow a demotion
741
+ * within a moment, and an engine started while its own stop is still running can end up neither.
742
+ */
743
+ #engineSteps = Promise.resolve();
744
+ /** Dials in flight (`call()` awaiting `placeCall`): a call about to exist holds the line like one that does. */
745
+ #dialing = 0;
746
+ /**
747
+ * Open from `start()`'s first line until it has decided whether the engine runs — or given up (stopped,
748
+ * destroyed, failed). `call()` waits on it, so it never dials ahead of that decision: not on a phone
749
+ * stopped meanwhile, and not without the line.
750
+ */
751
+ #startDecision = null;
752
+ /**
753
+ * Who decides whether the engine runs. `off`: nobody — not started, a follower, or `stop()` has begun
754
+ * (whatever its teardown sets off, like a call ending, must not start anything). `deciding`: `start()`
755
+ * has leadership and owes the FIRST decision itself, since it must await the engine start — a line
756
+ * message meanwhile only records what it says. `on`: the line decides (`#applyLine`).
757
+ */
758
+ #lineMode = "off";
759
+ /** Ends `start()`'s wait for the server's first word on the line early (`#waitForFirstLine`). */
760
+ #releaseFirstLine = null;
761
+ /** The `takeLine()` in flight — one at a time; a second caller shares it. */
762
+ #claim = null;
763
+ /** Pending: what the server said about the line stops counting (`LINE_LAPSE_MS`). */
764
+ #lineLapseTimer = null;
646
765
  /**
647
766
  * `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
648
767
  * internal seam, not something an integrator tunes). Tests construct this class directly with a
@@ -660,7 +779,9 @@ var SoftphoneImpl = class extends Emitter {
660
779
  this.sources = config.coventia || config.apiBase ? coventiaBackend(config.coventia ?? {}, cachedTokenSource(config.tokenSource), config.apiBase) : null;
661
780
  this.#resolveContact = config.resolveContact ?? this.sources?.resolveContact;
662
781
  this.#engine.onIncoming((engineCall) => this.#onIncoming(engineCall));
663
- this.#engine.onStateChange((state) => this.#setState(state));
782
+ this.#engine.onStateChange((state) => {
783
+ if (!this.#standby) this.#setState(state);
784
+ });
664
785
  this.#engine.onError?.((error) => this.#onError(error));
665
786
  }
666
787
  get state() {
@@ -678,6 +799,15 @@ var SoftphoneImpl = class extends Emitter {
678
799
  get presence() {
679
800
  return this.#presenceAgents;
680
801
  }
802
+ get line() {
803
+ return this.#line;
804
+ }
805
+ get lineWhere() {
806
+ return this.#lineWhere;
807
+ }
808
+ get lineClaim() {
809
+ return this.#lineClaim;
810
+ }
681
811
  get canPlayAudio() {
682
812
  return true;
683
813
  }
@@ -693,15 +823,33 @@ var SoftphoneImpl = class extends Emitter {
693
823
  }
694
824
  liveInstance = true;
695
825
  this.#startedHere = true;
696
- this.#setState("starting");
697
- await this.#tabLock.acquireLeadership();
698
- if (this.#destroyed) return;
699
- this.#setLeader(true);
700
- await this.#engine.start();
826
+ const run = this.#run;
827
+ const decision = this.#openStartDecision();
828
+ try {
829
+ this.#setState("starting");
830
+ await this.#tabLock.acquireLeadership();
831
+ if (this.#destroyed || run !== this.#run) return;
832
+ this.#lineMode = "deciding";
833
+ this.#setLeader(true);
834
+ if (this.#presenceWatch && this.#line === "unknown") await this.#waitForFirstLine();
835
+ if (this.#destroyed || run !== this.#run) return;
836
+ this.#lineMode = "on";
837
+ const engineStart = this.#applyLine();
838
+ decision.end();
839
+ await engineStart;
840
+ } finally {
841
+ decision.end();
842
+ }
701
843
  }
702
844
  async stop() {
703
845
  if (!this.#startedHere) return;
846
+ this.#run += 1;
847
+ this.#lineMode = "off";
704
848
  this.#sayGoodbye();
849
+ this.#startDecision?.end();
850
+ this.#standby = false;
851
+ this.#engineOn = false;
852
+ this.#engineRunning = false;
705
853
  this.#tabLock.release();
706
854
  if (this.#isLeader) {
707
855
  await this.#engine.stop();
@@ -723,16 +871,50 @@ var SoftphoneImpl = class extends Emitter {
723
871
  this.#setState("destroyed");
724
872
  this.clear();
725
873
  }
874
+ async takeLine() {
875
+ this.#assertUsable();
876
+ if (this.#line === "here") return;
877
+ if (this.#claim) return this.#claim.promise;
878
+ const watch = this.#presenceWatch;
879
+ if (!watch) {
880
+ this.#setLineClaim("failed");
881
+ throw createError("line/unavailable");
882
+ }
883
+ const claim = this.#openClaim();
884
+ if (!watch.claimLine()) this.#settleClaim(createError("line/unavailable"));
885
+ return claim;
886
+ }
726
887
  async call(destination, options) {
727
888
  this.#assertUsable();
889
+ const run = this.#run;
890
+ if (this.#startDecision) {
891
+ let timer;
892
+ const decided = await Promise.race([
893
+ this.#startDecision.promise.then(() => true),
894
+ new Promise((resolve) => {
895
+ timer = setTimeout(() => resolve(false), CALL_DECISION_TIMEOUT_MS);
896
+ })
897
+ ]);
898
+ clearTimeout(timer);
899
+ if (!decided) throw createError("engine/not-started");
900
+ }
901
+ if (run === this.#run && this.#state === "standby") await this.takeLine();
902
+ this.#assertUsable();
903
+ if (run !== this.#run) throw createError("engine/not-started");
728
904
  if (this.#state !== "online" && this.#state !== "offline" && this.#state !== "starting") {
729
905
  throw new Error(`Cannot place a call while softphone is '${this.#state}'.`);
730
906
  }
731
- if (this.#activeCall) {
907
+ if (this.#activeCall || this.#dialing > 0) {
732
908
  throw new Error("A call is already in progress. Hang up before placing another.");
733
909
  }
734
- const engineCall = await this.#engine.placeCall(destination, options);
735
- return this.#adopt(engineCall);
910
+ this.#dialing += 1;
911
+ try {
912
+ const engineCall = await this.#engine.placeCall(destination, options);
913
+ return this.#adopt(engineCall);
914
+ } finally {
915
+ this.#dialing -= 1;
916
+ this.#syncLine();
917
+ }
736
918
  }
737
919
  // ── internals ───────────────────────────────────────────────────────────────
738
920
  #onIncoming(engineCall) {
@@ -761,6 +943,7 @@ var SoftphoneImpl = class extends Emitter {
761
943
  this.#activeCall = null;
762
944
  this.emit("activeCallChanged" /* ActiveCallChanged */, { call: null });
763
945
  this.#syncCallState();
946
+ this.#syncLine();
764
947
  }
765
948
  });
766
949
  if (this.#resolveContact) {
@@ -790,7 +973,8 @@ var SoftphoneImpl = class extends Emitter {
790
973
  /**
791
974
  * Reports the current on-a-call value to the backend. The watch de-duplicates (it only sends on change) and
792
975
  * remembers the latest value to replay after a reconnect, so this can be called on every transition without
793
- * ceremony. A no-op while there is no presence watch (not online, not the leader, no backend configured).
976
+ * ceremony — and holds it back while the line is elsewhere, since only the owner's reports count (line-owner
977
+ * R8). A no-op while there is no presence watch (stopped, not the leader, no backend configured).
794
978
  */
795
979
  #syncCallState() {
796
980
  this.#presenceWatch?.reportCallState(this.#isOnCall());
@@ -811,17 +995,22 @@ var SoftphoneImpl = class extends Emitter {
811
995
  * `presence-socket.ts`'s own `getIsLeader` is checked at start and before each reconnect attempt,
812
996
  * not on a poll tick the way `startTransferWatch`'s is — see that module's own doc comment for why.
813
997
  * This still passes `getIsLeader: () => this.#isLeader` for that narrower, defensive recheck; the
814
- * BROAD online/leader gate — whether to be connected AT ALL — is owned entirely here, the same
998
+ * BROAD started/leader gate — whether to be connected AT ALL — is owned entirely here, the same
815
999
  * split `#onTransfer` already has with `startTransferWatch`.
816
1000
  *
817
- * One exception to "tear down the moment `online` is lost": a leader that is merely `offline` or
818
- * `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a blip shorter than that never touches
819
- * the presence socket at all. Anything else (stopped, starting, destroyed, or no longer the leader) still
820
- * tears it down at once: those are not blips, and a follower must never hold a connection.
1001
+ * The watch runs for a leader that is `starting`, `online` or in `standby` — not only `online`, as it did
1002
+ * before the line owner (`docs/specs/line-owner.md` §5.4): while starting it is how this softphone learns
1003
+ * whether to listen at all, and in standby how it learns the line came back.
1004
+ *
1005
+ * A leader that is merely `offline` or `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a
1006
+ * blip shorter than that never touches the presence socket at all; a longer outage closes it, which lets
1007
+ * the server hand the line to a softphone that can still take calls. Anything else (stopped, destroyed, or
1008
+ * no longer the leader) tears it down at once: those are not blips, and a follower must never hold a
1009
+ * connection.
821
1010
  */
822
1011
  #syncPresenceWatch() {
823
1012
  const eligible = Boolean(this.sources) && this.#isLeader;
824
- const shouldRun = eligible && this.#state === "online";
1013
+ const shouldRun = eligible && this.#presenceRunsIn(this.#state);
825
1014
  const blip = eligible && (this.#state === "offline" || this.#state === "reconnecting");
826
1015
  if (!blip) this.#cancelPresenceTeardown();
827
1016
  if (blip && this.#presenceWatch !== null) {
@@ -830,13 +1019,22 @@ var SoftphoneImpl = class extends Emitter {
830
1019
  }
831
1020
  if (shouldRun === (this.#presenceWatch !== null)) return;
832
1021
  if (shouldRun) {
1022
+ this.#clientId ??= lineClientId();
833
1023
  this.#presenceWatch = startPresenceSocket({
834
1024
  source: this.sources.presence,
835
1025
  getIsLeader: () => this.#isLeader,
836
1026
  onAgentsChanged: (agents) => {
837
1027
  this.#presenceAgents = agents;
838
1028
  this.emit("presenceChanged" /* PresenceChanged */, { agents });
839
- }
1029
+ },
1030
+ clientId: this.#clientId,
1031
+ onLine: (update) => this.#onLine(update),
1032
+ onClaimResult: (result) => this.#onClaimResult(result),
1033
+ // A source that cannot take part will never answer, and one that cannot connect cannot answer yet:
1034
+ // `start()` need not wait out its 4 s for either (R7). A later answer still applies.
1035
+ onLineUnsupported: () => this.#releaseFirstLine?.(),
1036
+ onConnectFailed: () => this.#releaseFirstLine?.(),
1037
+ onDisconnect: () => this.#onPresenceDisconnect()
840
1038
  });
841
1039
  this.#syncCallState();
842
1040
  } else {
@@ -851,19 +1049,24 @@ var SoftphoneImpl = class extends Emitter {
851
1049
  if (this.#presenceTeardownTimer !== null) return;
852
1050
  this.#presenceTeardownTimer = setTimeout(() => {
853
1051
  this.#presenceTeardownTimer = null;
854
- if (this.#state === "online" && this.#isLeader) return;
1052
+ if (this.#isLeader && this.#presenceRunsIn(this.#state)) return;
855
1053
  this.#stopPresence();
856
1054
  }, PRESENCE_TEARDOWN_GRACE_MS);
857
1055
  }
1056
+ /** The states in which a leader keeps its presence watch with no clock running (`#syncPresenceWatch`). */
1057
+ #presenceRunsIn(state) {
1058
+ return state === "online" || state === "starting" || state === "standby";
1059
+ }
858
1060
  #cancelPresenceTeardown() {
859
1061
  if (this.#presenceTeardownTimer === null) return;
860
1062
  clearTimeout(this.#presenceTeardownTimer);
861
1063
  this.#presenceTeardownTimer = null;
862
1064
  }
863
1065
  /**
864
- * Stops the presence watch and forgets its roster. A STATE-DRIVEN teardown (the softphone left `online`
865
- * for good, or lost leadership) passes nothing and says no goodbye; only a deliberate stop passes `bye`.
866
- * Any pending grace-period teardown is cancelled — there is nothing left for it to do.
1066
+ * Stops the presence watch and forgets its roster, and the line. A STATE-DRIVEN teardown (an outage
1067
+ * outlasted the grace, the engine stopped, or leadership was lost) passes nothing and says no goodbye;
1068
+ * only a deliberate stop passes `bye`. Any pending grace-period teardown is cancelled — there is nothing
1069
+ * left for it to do.
867
1070
  */
868
1071
  #stopPresence(options) {
869
1072
  this.#cancelPresenceTeardown();
@@ -872,6 +1075,7 @@ var SoftphoneImpl = class extends Emitter {
872
1075
  this.#presenceWatch = null;
873
1076
  this.#presenceAgents = [];
874
1077
  this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
1078
+ this.#forgetLine();
875
1079
  }
876
1080
  /**
877
1081
  * A DELIBERATE stop (`stop()` / `destroy()`): say any call is over, then say goodbye, so the backend drops
@@ -881,6 +1085,195 @@ var SoftphoneImpl = class extends Emitter {
881
1085
  this.#presenceWatch?.reportCallState(false);
882
1086
  this.#stopPresence({ bye: true });
883
1087
  }
1088
+ // ── the line owner (`docs/specs/line-owner.md` §5) ──────────────────────────────────────────────
1089
+ /**
1090
+ * THE decision: from where the line is and whether a call is in progress, whether the engine runs, and
1091
+ * the state that says so. Synchronous, so the state moves the instant the line does — the waiting screen
1092
+ * goes the moment the line comes here, and a `call()` that took the line dials from `starting` without
1093
+ * waiting out the engine start. Only the engine start or stop is queued (`#queueEngine`). Returns that
1094
+ * queued step, or `null` when nothing had to change.
1095
+ */
1096
+ #applyLine() {
1097
+ if (this.#lineMode !== "on") return null;
1098
+ const run = this.#run;
1099
+ if (this.#line === "elsewhere") {
1100
+ if (this.#standby || this.#activeCall || this.#dialing > 0) return null;
1101
+ this.#standby = true;
1102
+ this.#setState("standby");
1103
+ if (!this.#engineOn) return null;
1104
+ this.#engineOn = false;
1105
+ return this.#queueEngine(run);
1106
+ }
1107
+ if (this.#engineOn) return null;
1108
+ this.#engineOn = true;
1109
+ if (this.#standby) {
1110
+ this.#standby = false;
1111
+ this.#setState("starting");
1112
+ }
1113
+ return this.#queueEngine(run);
1114
+ }
1115
+ /** `#applyLine` from an event — nobody to hand a failure to, and `#engineStartFailed` has reported it. */
1116
+ #syncLine() {
1117
+ void this.#applyLine()?.catch(() => {
1118
+ });
1119
+ }
1120
+ /**
1121
+ * Brings the engine to what the line wants (`#engineOn`) once every step already queued has run — and does
1122
+ * nothing when it is already there: the line can flip twice while a start is still running, and the stop
1123
+ * and start queued meanwhile must not take the engine (and the softphone's state) down and up for nothing.
1124
+ * Nothing at all once `stop()` has run since `run` was read: a stopped softphone's engine is `stop()`'s
1125
+ * alone. Rejects with a failed start's cause; the queue itself never rejects, so one failure blocks nothing.
1126
+ */
1127
+ #queueEngine(run) {
1128
+ const step = this.#engineSteps.then(async () => {
1129
+ if (run !== this.#run || this.#engineOn === this.#engineRunning) return;
1130
+ this.#engineRunning = this.#engineOn;
1131
+ if (this.#engineRunning) await this.#startEngine(run);
1132
+ else await this.#engine.stop();
1133
+ });
1134
+ this.#engineSteps = step.catch(() => {
1135
+ });
1136
+ return step;
1137
+ }
1138
+ async #startEngine(run) {
1139
+ const reported = this.#lastError;
1140
+ try {
1141
+ await this.#engine.start();
1142
+ } catch (cause) {
1143
+ if (run === this.#run) this.#engineStartFailed(cause, reported);
1144
+ throw cause;
1145
+ }
1146
+ }
1147
+ /**
1148
+ * An engine start the line asked for failed. Kept as it was, this softphone would stay the owner on the
1149
+ * server — `starting`, its presence socket renewing the line — with no push socket anywhere: nobody would
1150
+ * listen. So it gives the line up the ordinary way, by closing its presence socket without a goodbye (the
1151
+ * phone was not switched off): the server hands the line on after its grace (R4). The failure is reported
1152
+ * like any engine failure — `SoftphoneEvent.Error` and `lastError`, which is the widget's error gate and
1153
+ * its "Reintentar" (`stop()` then `start()`, which asks for the line again). Nothing retries on its own.
1154
+ */
1155
+ #engineStartFailed(cause, reportedBefore) {
1156
+ this.#engineOn = false;
1157
+ this.#engineRunning = false;
1158
+ this.#lineMode = "off";
1159
+ if (this.#lastError === reportedBefore) {
1160
+ this.#onError(isSoftphoneError(cause) && isFatal(cause) ? cause : createError("net/transport-failed", cause));
1161
+ }
1162
+ this.#stopPresence();
1163
+ this.#setState("offline");
1164
+ }
1165
+ /** `start()`'s decision (`#startDecision`): ended once, by whichever comes first — the decision or its end. */
1166
+ #openStartDecision() {
1167
+ let resolve;
1168
+ const promise = new Promise((r) => {
1169
+ resolve = r;
1170
+ });
1171
+ const decision = {
1172
+ promise,
1173
+ end: () => {
1174
+ resolve();
1175
+ if (this.#startDecision === decision) this.#startDecision = null;
1176
+ }
1177
+ };
1178
+ this.#startDecision = decision;
1179
+ return decision;
1180
+ }
1181
+ /**
1182
+ * `start()`'s wait for the server's first word on the line — released by that word, by a source that
1183
+ * will never say it, by the watch going away (`stop()` among them), or after `LINE_ANSWER_TIMEOUT_MS`.
1184
+ */
1185
+ #waitForFirstLine() {
1186
+ return new Promise((resolve) => {
1187
+ const release = () => {
1188
+ clearTimeout(timer);
1189
+ if (this.#releaseFirstLine === release) this.#releaseFirstLine = null;
1190
+ resolve();
1191
+ };
1192
+ const timer = setTimeout(release, LINE_ANSWER_TIMEOUT_MS);
1193
+ this.#releaseFirstLine = release;
1194
+ });
1195
+ }
1196
+ #onLine(update) {
1197
+ this.#cancelLineLapse();
1198
+ this.#setLine(update.state, update.state === "here" ? null : update.where);
1199
+ if (this.#releaseFirstLine) this.#releaseFirstLine();
1200
+ else this.#syncLine();
1201
+ if (update.state === "here") this.#settleClaim();
1202
+ }
1203
+ #onClaimResult(result) {
1204
+ if (result.ok) {
1205
+ if (this.#line === "here") this.#settleClaim();
1206
+ return;
1207
+ }
1208
+ this.#settleClaim(createError(result.reason === "on_call" ? "line/claim-refused" : "line/unavailable"));
1209
+ }
1210
+ /**
1211
+ * The presence socket dropped; the watch is reconnecting. A claim sent on it will never be answered, so
1212
+ * it fails now rather than on its timeout. What the server said about the line holds for `LINE_LAPSE_MS`
1213
+ * — a reconnect is normally answered well inside that — and then stops counting: `unknown`, which a
1214
+ * waiting softphone answers by listening again.
1215
+ */
1216
+ #onPresenceDisconnect() {
1217
+ this.#settleClaim(createError("line/unavailable"));
1218
+ if (this.#line === "unknown" || this.#lineLapseTimer !== null) return;
1219
+ this.#lineLapseTimer = setTimeout(() => {
1220
+ this.#lineLapseTimer = null;
1221
+ this.#setLine("unknown", null);
1222
+ this.#syncLine();
1223
+ }, LINE_LAPSE_MS);
1224
+ }
1225
+ #cancelLineLapse() {
1226
+ if (this.#lineLapseTimer === null) return;
1227
+ clearTimeout(this.#lineLapseTimer);
1228
+ this.#lineLapseTimer = null;
1229
+ }
1230
+ /**
1231
+ * The presence watch is gone, so nothing the server said about the line holds any more: it reads
1232
+ * `unknown`, and every wait on it ends. Not followed by `#applyLine` — this runs inside a state change
1233
+ * (`#syncPresenceWatch`) or a stop, and a softphone whose watch is gone is not in `standby` anyway: that
1234
+ * state keeps the watch.
1235
+ */
1236
+ #forgetLine() {
1237
+ this.#cancelLineLapse();
1238
+ this.#releaseFirstLine?.();
1239
+ this.#settleClaim(createError("line/unavailable"));
1240
+ this.#setLine("unknown", null);
1241
+ this.#setLineClaim("idle");
1242
+ }
1243
+ #setLine(line, where) {
1244
+ if (line === "here") this.#setLineClaim("idle");
1245
+ if (line === this.#line && where === this.#lineWhere) return;
1246
+ this.#line = line;
1247
+ this.#lineWhere = where;
1248
+ this.emit("lineChanged" /* LineChanged */, { line, where });
1249
+ }
1250
+ #setLineClaim(claim) {
1251
+ if (claim === this.#lineClaim) return;
1252
+ this.#lineClaim = claim;
1253
+ this.emit("lineClaimChanged" /* LineClaimChanged */, { claim });
1254
+ }
1255
+ #openClaim() {
1256
+ let settle;
1257
+ const promise = new Promise((resolve, reject) => {
1258
+ const timer = setTimeout(() => this.#settleClaim(createError("line/unavailable")), LINE_CLAIM_TIMEOUT_MS);
1259
+ settle = (error) => {
1260
+ clearTimeout(timer);
1261
+ if (error) reject(error);
1262
+ else resolve();
1263
+ };
1264
+ });
1265
+ this.#claim = { promise, settle };
1266
+ this.#setLineClaim("claiming");
1267
+ return promise;
1268
+ }
1269
+ /** Ends the claim in flight, if any — resolved without `error`, rejected with it. */
1270
+ #settleClaim(error) {
1271
+ const claim = this.#claim;
1272
+ if (!claim) return;
1273
+ this.#claim = null;
1274
+ this.#setLineClaim(!error ? "idle" : error.code === "line/claim-refused" ? "refused" : "failed");
1275
+ claim.settle(error);
1276
+ }
884
1277
  /**
885
1278
  * Phase A (transfer outcome relay, `docs/specs/transfer-outcome-relay.md`) — `CallImpl.transfer()`'s
886
1279
  * internal seam calls this. No-ops entirely when no Coventia backend is configured (`sources` is
@@ -951,11 +1344,12 @@ var LazyRelay = class {
951
1344
  function defaultZadarmaEngine(config) {
952
1345
  let real = null;
953
1346
  let pending = null;
1347
+ let run = 0;
954
1348
  const incoming = new LazyRelay();
955
1349
  const stateChange = new LazyRelay();
956
1350
  const error = new LazyRelay();
957
1351
  function load() {
958
- pending ??= import('./zadarma-engine-L5DEFXUS.js').then(({ createZadarmaEngine }) => {
1352
+ pending ??= import('./zadarma-engine-TIUE7PZ7.js').then(({ createZadarmaEngine }) => {
959
1353
  const engine = createZadarmaEngine(config);
960
1354
  incoming.attach({ on: (l) => engine.onIncoming(l) });
961
1355
  stateChange.attach({ on: (l) => engine.onStateChange(l) });
@@ -974,13 +1368,20 @@ function defaultZadarmaEngine(config) {
974
1368
  return real?.state ?? "stopped";
975
1369
  },
976
1370
  async start() {
977
- await (await load()).start();
1371
+ const current = run;
1372
+ const engine = await load();
1373
+ if (current !== run) return;
1374
+ await engine.start();
978
1375
  },
979
1376
  async stop() {
1377
+ run += 1;
980
1378
  if (real) await real.stop();
981
1379
  },
982
1380
  async placeCall(destination, options) {
983
- return (await load()).placeCall(destination, options);
1381
+ const current = run;
1382
+ const engine = await load();
1383
+ if (current !== run) throw createError("engine/not-started");
1384
+ return engine.placeCall(destination, options);
984
1385
  },
985
1386
  onIncoming: (listener) => incoming.subscribe(listener),
986
1387
  onStateChange: (listener) => stateChange.subscribe(listener),
@@ -990,7 +1391,16 @@ function defaultZadarmaEngine(config) {
990
1391
 
991
1392
  // src/core/create.ts
992
1393
  function createSoftphone(config) {
993
- return new SoftphoneImpl(resolveEngine(config), config);
1394
+ const resolved = withNormalizedBases(config);
1395
+ return new SoftphoneImpl(resolveEngine(resolved), resolved);
1396
+ }
1397
+ function withNormalizedBases(config) {
1398
+ const { apiBase, coventia } = config;
1399
+ return {
1400
+ ...config,
1401
+ ...apiBase ? { apiBase: normalizeApiBase(apiBase) } : {},
1402
+ ...coventia?.baseUrl ? { coventia: { ...coventia, baseUrl: normalizeApiBase(coventia.baseUrl) } } : {}
1403
+ };
994
1404
  }
995
1405
  function resolveEngine(config) {
996
1406
  if (config.provider !== void 0) {
@@ -1015,7 +1425,7 @@ function isCallEngine(value) {
1015
1425
  }
1016
1426
 
1017
1427
  // src/index.ts
1018
- var VERSION = "0.4.0";
1428
+ var VERSION = "0.5.0";
1019
1429
 
1020
1430
  export { CALL_TRANSITIONS, INITIAL_STATES, VERSION, canTransition, createSoftphone, isTerminal };
1021
1431
  //# sourceMappingURL=index.js.map