@coventia/softphone 0.3.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 (60) hide show
  1. package/dist/{call-CaPDWVk3.d.cts → call-FqcvDg1t.d.cts} +168 -12
  2. package/dist/{call-CaPDWVk3.d.ts → call-FqcvDg1t.d.ts} +168 -12
  3. package/dist/chunk-5E5OEXS2.js +14 -0
  4. package/dist/chunk-5E5OEXS2.js.map +1 -0
  5. package/dist/{chunk-EYEDPD7Y.js → chunk-6RR6G35E.js} +92 -17
  6. package/dist/chunk-6RR6G35E.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 +198 -6
  16. package/dist/element/index.js +1160 -489
  17. package/dist/element/index.js.map +1 -1
  18. package/dist/{engine-BgBsIcEa.d.ts → engine-CsNRD7B7.d.ts} +1 -1
  19. package/dist/{engine-CU2rEo3g.d.cts → engine-CzOCrfY9.d.cts} +1 -1
  20. package/dist/index.cjs +675 -53
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +16 -6
  23. package/dist/index.d.ts +16 -6
  24. package/dist/index.js +453 -37
  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-DEt0TtQw.d.ts → softphone-By9sizlV.d.ts} +44 -3
  32. package/dist/{softphone-dZMkW8V1.d.cts → softphone-JZvJdzU3.d.cts} +44 -3
  33. package/dist/softphone.css +254 -23
  34. package/dist/{sources-M3GYMS6V.js → sources-QL2LRZVH.js} +93 -8
  35. package/dist/sources-QL2LRZVH.js.map +1 -0
  36. package/dist/testing/index.cjs +13 -0
  37. package/dist/testing/index.cjs.map +1 -1
  38. package/dist/testing/index.d.cts +2 -2
  39. package/dist/testing/index.d.ts +2 -2
  40. package/dist/testing/index.js +1 -1
  41. package/dist/ui/index.cjs +670 -20
  42. package/dist/ui/index.cjs.map +1 -1
  43. package/dist/ui/index.d.cts +2 -2
  44. package/dist/ui/index.d.ts +2 -2
  45. package/dist/ui/index.js +642 -22
  46. package/dist/ui/index.js.map +1 -1
  47. package/dist/zadarma/index.cjs +111 -14
  48. package/dist/zadarma/index.cjs.map +1 -1
  49. package/dist/zadarma/index.d.cts +5 -6
  50. package/dist/zadarma/index.d.ts +5 -6
  51. package/dist/zadarma/index.js +3 -2
  52. package/dist/zadarma-engine-TIUE7PZ7.js +6 -0
  53. package/dist/{zadarma-engine-L5DEFXUS.js.map → zadarma-engine-TIUE7PZ7.js.map} +1 -1
  54. package/package.json +1 -1
  55. package/dist/chunk-2ZSOGGPO.js.map +0 -1
  56. package/dist/chunk-7R6LOOAI.js.map +0 -1
  57. package/dist/chunk-EYEDPD7Y.js.map +0 -1
  58. package/dist/chunk-UHNLEBBO.js.map +0 -1
  59. package/dist/sources-M3GYMS6V.js.map +0 -1
  60. package/dist/zadarma-engine-L5DEFXUS.js +0 -5
package/dist/index.js CHANGED
@@ -1,8 +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';
4
- import { isFatal } from './chunk-2ZSOGGPO.js';
5
- export { ERROR_CATALOGUE, isFatal } from './chunk-2ZSOGGPO.js';
3
+ export { CallEvent, SoftphoneEvent } from './chunk-N4PLEYFJ.js';
4
+ export { isOutboundNumberRefused } from './chunk-5E5OEXS2.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';
6
8
  import { cachedTokenSource } from './chunk-MHOJHG5E.js';
7
9
 
8
10
  // src/core/emitter.ts
@@ -284,7 +286,7 @@ function coventiaBackend(config, tokenSource, fallbackBaseUrl) {
284
286
  }
285
287
  let pending = null;
286
288
  const load = () => {
287
- pending ??= import('./sources-M3GYMS6V.js').then(
289
+ pending ??= import('./sources-QL2LRZVH.js').then(
288
290
  (m) => m.coventiaSources({ ...config, baseUrl, tokenSource })
289
291
  );
290
292
  return pending;
@@ -300,6 +302,11 @@ function coventiaBackend(config, tokenSource, fallbackBaseUrl) {
300
302
  get: async (signal) => (await load()).ringtones.get(signal),
301
303
  setMine: async (id) => (await load()).ringtones.setMine(id),
302
304
  cached: async () => (await load()).ringtones.cached?.() ?? null
305
+ },
306
+ outboundNumbers: {
307
+ get: async (signal) => (await load()).outboundNumbers.get(signal),
308
+ setMine: async (id) => (await load()).outboundNumbers.setMine(id),
309
+ cached: async () => (await load()).outboundNumbers.cached?.() ?? null
303
310
  }
304
311
  };
305
312
  }
@@ -310,13 +317,15 @@ function isWebLocksSupported() {
310
317
  return isBrowser() && typeof navigator.locks?.request === "function";
311
318
  }
312
319
  function webLocksTabLock(lockName = LOCK_NAME) {
313
- const controller = new AbortController();
320
+ let controller;
314
321
  let releaseHold;
315
322
  return {
316
323
  acquireLeadership() {
317
324
  if (!isWebLocksSupported()) return Promise.resolve();
325
+ const { signal } = controller = new AbortController();
318
326
  return new Promise((resolveAcquired) => {
319
- navigator.locks.request(lockName, { signal: controller.signal }, () => {
327
+ navigator.locks.request(lockName, { signal }, () => {
328
+ if (signal.aborted) return Promise.resolve();
320
329
  resolveAcquired();
321
330
  return new Promise((resolveHold) => {
322
331
  releaseHold = resolveHold;
@@ -331,7 +340,7 @@ function webLocksTabLock(lockName = LOCK_NAME) {
331
340
  releaseHold = void 0;
332
341
  resolve();
333
342
  } else {
334
- controller.abort();
343
+ controller?.abort();
335
344
  }
336
345
  }
337
346
  };
@@ -416,13 +425,19 @@ function startTransferWatch(params) {
416
425
  var RECONNECT_BASE_MS = 1e3;
417
426
  var RECONNECT_MAX_MS = 3e4;
418
427
  var NOOP_WATCH = { reportCallState: () => {
419
- }, stop: () => {
428
+ }, claimLine: () => false, stop: () => {
420
429
  } };
421
430
  function startPresenceSocket(params) {
422
431
  const {
423
432
  source,
424
433
  getIsLeader,
425
434
  onAgentsChanged,
435
+ clientId,
436
+ onLine,
437
+ onClaimResult,
438
+ onLineUnsupported,
439
+ onConnectFailed,
440
+ onDisconnect,
426
441
  setTimeout: scheduleTimeout = globalThis.setTimeout,
427
442
  clearTimeout: cancelTimeout = globalThis.clearTimeout,
428
443
  random = Math.random
@@ -433,6 +448,8 @@ function startPresenceSocket(params) {
433
448
  let reconnectTimer = null;
434
449
  let agents = /* @__PURE__ */ new Map();
435
450
  let lastReportedOnCall = null;
451
+ let sentOnCall = null;
452
+ let line = null;
436
453
  let connection = null;
437
454
  let byeOnArrival = false;
438
455
  const stop = (options) => {
@@ -449,6 +466,12 @@ function startPresenceSocket(params) {
449
466
  connection?.close();
450
467
  connection = null;
451
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;
452
475
  const scheduleReconnect = () => {
453
476
  if (stopped) return;
454
477
  attempt += 1;
@@ -477,10 +500,23 @@ function startPresenceSocket(params) {
477
500
  if (connection === null) return;
478
501
  connection = null;
479
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);
480
514
  }
481
515
  });
482
516
  } catch {
483
- if (!stopped) scheduleReconnect();
517
+ if (stopped) return;
518
+ scheduleReconnect();
519
+ onConnectFailed?.();
484
520
  return;
485
521
  }
486
522
  if (stopped) {
@@ -490,22 +526,63 @@ function startPresenceSocket(params) {
490
526
  }
491
527
  connection = opened;
492
528
  attempt = 0;
493
- 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);
494
534
  };
495
535
  void connect();
496
536
  return {
497
537
  reportCallState: (onCall) => {
498
538
  if (onCall === lastReportedOnCall) return;
499
539
  lastReportedOnCall = onCall;
500
- connection?.reportCallState(onCall);
540
+ if (mayReport(onCall)) send(onCall);
541
+ },
542
+ claimLine: () => {
543
+ if (!connection?.claimLine) return false;
544
+ connection.claimLine();
545
+ return true;
501
546
  },
502
547
  stop
503
548
  };
504
549
  }
505
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
+
506
579
  // src/core/softphone-impl.ts
507
580
  var liveInstance = false;
508
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;
509
586
  var BrowserAudioDevices = class {
510
587
  inputs = [];
511
588
  outputs = [];
@@ -613,6 +690,8 @@ var SoftphoneImpl = class extends Emitter {
613
690
  #activeCall = null;
614
691
  #destroyed = false;
615
692
  #startedHere = false;
693
+ /** Bumped by every `stop()`, so a `start()` still waiting to lead can tell it was stopped. */
694
+ #run = 0;
616
695
  #isLeader = false;
617
696
  #engine;
618
697
  #tabLock;
@@ -637,6 +716,52 @@ var SoftphoneImpl = class extends Emitter {
637
716
  /** A pending state-driven teardown of the watch — see `PRESENCE_TEARDOWN_GRACE_MS`. */
638
717
  #presenceTeardownTimer = null;
639
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;
640
765
  /**
641
766
  * `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
642
767
  * internal seam, not something an integrator tunes). Tests construct this class directly with a
@@ -654,7 +779,9 @@ var SoftphoneImpl = class extends Emitter {
654
779
  this.sources = config.coventia || config.apiBase ? coventiaBackend(config.coventia ?? {}, cachedTokenSource(config.tokenSource), config.apiBase) : null;
655
780
  this.#resolveContact = config.resolveContact ?? this.sources?.resolveContact;
656
781
  this.#engine.onIncoming((engineCall) => this.#onIncoming(engineCall));
657
- this.#engine.onStateChange((state) => this.#setState(state));
782
+ this.#engine.onStateChange((state) => {
783
+ if (!this.#standby) this.#setState(state);
784
+ });
658
785
  this.#engine.onError?.((error) => this.#onError(error));
659
786
  }
660
787
  get state() {
@@ -672,6 +799,15 @@ var SoftphoneImpl = class extends Emitter {
672
799
  get presence() {
673
800
  return this.#presenceAgents;
674
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
+ }
675
811
  get canPlayAudio() {
676
812
  return true;
677
813
  }
@@ -687,15 +823,33 @@ var SoftphoneImpl = class extends Emitter {
687
823
  }
688
824
  liveInstance = true;
689
825
  this.#startedHere = true;
690
- this.#setState("starting");
691
- await this.#tabLock.acquireLeadership();
692
- if (this.#destroyed) return;
693
- this.#setLeader(true);
694
- 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
+ }
695
843
  }
696
844
  async stop() {
697
845
  if (!this.#startedHere) return;
846
+ this.#run += 1;
847
+ this.#lineMode = "off";
698
848
  this.#sayGoodbye();
849
+ this.#startDecision?.end();
850
+ this.#standby = false;
851
+ this.#engineOn = false;
852
+ this.#engineRunning = false;
699
853
  this.#tabLock.release();
700
854
  if (this.#isLeader) {
701
855
  await this.#engine.stop();
@@ -717,16 +871,50 @@ var SoftphoneImpl = class extends Emitter {
717
871
  this.#setState("destroyed");
718
872
  this.clear();
719
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
+ }
720
887
  async call(destination, options) {
721
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");
722
904
  if (this.#state !== "online" && this.#state !== "offline" && this.#state !== "starting") {
723
905
  throw new Error(`Cannot place a call while softphone is '${this.#state}'.`);
724
906
  }
725
- if (this.#activeCall) {
907
+ if (this.#activeCall || this.#dialing > 0) {
726
908
  throw new Error("A call is already in progress. Hang up before placing another.");
727
909
  }
728
- const engineCall = await this.#engine.placeCall(destination, options);
729
- 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
+ }
730
918
  }
731
919
  // ── internals ───────────────────────────────────────────────────────────────
732
920
  #onIncoming(engineCall) {
@@ -755,6 +943,7 @@ var SoftphoneImpl = class extends Emitter {
755
943
  this.#activeCall = null;
756
944
  this.emit("activeCallChanged" /* ActiveCallChanged */, { call: null });
757
945
  this.#syncCallState();
946
+ this.#syncLine();
758
947
  }
759
948
  });
760
949
  if (this.#resolveContact) {
@@ -784,7 +973,8 @@ var SoftphoneImpl = class extends Emitter {
784
973
  /**
785
974
  * Reports the current on-a-call value to the backend. The watch de-duplicates (it only sends on change) and
786
975
  * remembers the latest value to replay after a reconnect, so this can be called on every transition without
787
- * 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).
788
978
  */
789
979
  #syncCallState() {
790
980
  this.#presenceWatch?.reportCallState(this.#isOnCall());
@@ -805,17 +995,22 @@ var SoftphoneImpl = class extends Emitter {
805
995
  * `presence-socket.ts`'s own `getIsLeader` is checked at start and before each reconnect attempt,
806
996
  * not on a poll tick the way `startTransferWatch`'s is — see that module's own doc comment for why.
807
997
  * This still passes `getIsLeader: () => this.#isLeader` for that narrower, defensive recheck; the
808
- * 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
809
999
  * split `#onTransfer` already has with `startTransferWatch`.
810
1000
  *
811
- * One exception to "tear down the moment `online` is lost": a leader that is merely `offline` or
812
- * `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a blip shorter than that never touches
813
- * the presence socket at all. Anything else (stopped, starting, destroyed, or no longer the leader) still
814
- * 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.
815
1010
  */
816
1011
  #syncPresenceWatch() {
817
1012
  const eligible = Boolean(this.sources) && this.#isLeader;
818
- const shouldRun = eligible && this.#state === "online";
1013
+ const shouldRun = eligible && this.#presenceRunsIn(this.#state);
819
1014
  const blip = eligible && (this.#state === "offline" || this.#state === "reconnecting");
820
1015
  if (!blip) this.#cancelPresenceTeardown();
821
1016
  if (blip && this.#presenceWatch !== null) {
@@ -824,13 +1019,22 @@ var SoftphoneImpl = class extends Emitter {
824
1019
  }
825
1020
  if (shouldRun === (this.#presenceWatch !== null)) return;
826
1021
  if (shouldRun) {
1022
+ this.#clientId ??= lineClientId();
827
1023
  this.#presenceWatch = startPresenceSocket({
828
1024
  source: this.sources.presence,
829
1025
  getIsLeader: () => this.#isLeader,
830
1026
  onAgentsChanged: (agents) => {
831
1027
  this.#presenceAgents = agents;
832
1028
  this.emit("presenceChanged" /* PresenceChanged */, { agents });
833
- }
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()
834
1038
  });
835
1039
  this.#syncCallState();
836
1040
  } else {
@@ -845,19 +1049,24 @@ var SoftphoneImpl = class extends Emitter {
845
1049
  if (this.#presenceTeardownTimer !== null) return;
846
1050
  this.#presenceTeardownTimer = setTimeout(() => {
847
1051
  this.#presenceTeardownTimer = null;
848
- if (this.#state === "online" && this.#isLeader) return;
1052
+ if (this.#isLeader && this.#presenceRunsIn(this.#state)) return;
849
1053
  this.#stopPresence();
850
1054
  }, PRESENCE_TEARDOWN_GRACE_MS);
851
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
+ }
852
1060
  #cancelPresenceTeardown() {
853
1061
  if (this.#presenceTeardownTimer === null) return;
854
1062
  clearTimeout(this.#presenceTeardownTimer);
855
1063
  this.#presenceTeardownTimer = null;
856
1064
  }
857
1065
  /**
858
- * Stops the presence watch and forgets its roster. A STATE-DRIVEN teardown (the softphone left `online`
859
- * for good, or lost leadership) passes nothing and says no goodbye; only a deliberate stop passes `bye`.
860
- * 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.
861
1070
  */
862
1071
  #stopPresence(options) {
863
1072
  this.#cancelPresenceTeardown();
@@ -866,6 +1075,7 @@ var SoftphoneImpl = class extends Emitter {
866
1075
  this.#presenceWatch = null;
867
1076
  this.#presenceAgents = [];
868
1077
  this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
1078
+ this.#forgetLine();
869
1079
  }
870
1080
  /**
871
1081
  * A DELIBERATE stop (`stop()` / `destroy()`): say any call is over, then say goodbye, so the backend drops
@@ -875,6 +1085,195 @@ var SoftphoneImpl = class extends Emitter {
875
1085
  this.#presenceWatch?.reportCallState(false);
876
1086
  this.#stopPresence({ bye: true });
877
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
+ }
878
1277
  /**
879
1278
  * Phase A (transfer outcome relay, `docs/specs/transfer-outcome-relay.md`) — `CallImpl.transfer()`'s
880
1279
  * internal seam calls this. No-ops entirely when no Coventia backend is configured (`sources` is
@@ -945,11 +1344,12 @@ var LazyRelay = class {
945
1344
  function defaultZadarmaEngine(config) {
946
1345
  let real = null;
947
1346
  let pending = null;
1347
+ let run = 0;
948
1348
  const incoming = new LazyRelay();
949
1349
  const stateChange = new LazyRelay();
950
1350
  const error = new LazyRelay();
951
1351
  function load() {
952
- pending ??= import('./zadarma-engine-L5DEFXUS.js').then(({ createZadarmaEngine }) => {
1352
+ pending ??= import('./zadarma-engine-TIUE7PZ7.js').then(({ createZadarmaEngine }) => {
953
1353
  const engine = createZadarmaEngine(config);
954
1354
  incoming.attach({ on: (l) => engine.onIncoming(l) });
955
1355
  stateChange.attach({ on: (l) => engine.onStateChange(l) });
@@ -968,13 +1368,20 @@ function defaultZadarmaEngine(config) {
968
1368
  return real?.state ?? "stopped";
969
1369
  },
970
1370
  async start() {
971
- await (await load()).start();
1371
+ const current = run;
1372
+ const engine = await load();
1373
+ if (current !== run) return;
1374
+ await engine.start();
972
1375
  },
973
1376
  async stop() {
1377
+ run += 1;
974
1378
  if (real) await real.stop();
975
1379
  },
976
1380
  async placeCall(destination, options) {
977
- 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);
978
1385
  },
979
1386
  onIncoming: (listener) => incoming.subscribe(listener),
980
1387
  onStateChange: (listener) => stateChange.subscribe(listener),
@@ -984,7 +1391,16 @@ function defaultZadarmaEngine(config) {
984
1391
 
985
1392
  // src/core/create.ts
986
1393
  function createSoftphone(config) {
987
- 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
+ };
988
1404
  }
989
1405
  function resolveEngine(config) {
990
1406
  if (config.provider !== void 0) {
@@ -1009,7 +1425,7 @@ function isCallEngine(value) {
1009
1425
  }
1010
1426
 
1011
1427
  // src/index.ts
1012
- var VERSION = "0.3.0";
1428
+ var VERSION = "0.5.0";
1013
1429
 
1014
1430
  export { CALL_TRANSITIONS, INITIAL_STATES, VERSION, canTransition, createSoftphone, isTerminal };
1015
1431
  //# sourceMappingURL=index.js.map