@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
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-LGFJG7W2.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
  };
@@ -418,17 +421,70 @@ function startTransferWatch(params) {
418
421
  return stop;
419
422
  }
420
423
 
424
+ // src/core/start-retry.ts
425
+ var BACKOFF_BASE_MS = 1e3;
426
+ var BACKOFF_MAX_MS = 3e4;
427
+ function backoffDelayMs(attempt, random = Math.random) {
428
+ const capped = Math.min(BACKOFF_BASE_MS * 2 ** (Math.max(1, attempt) - 1), BACKOFF_MAX_MS);
429
+ return capped * random();
430
+ }
431
+ function isTransientStatus(status) {
432
+ return status === 0 || status === 408 || status === 425 || status === 429 || status >= 500;
433
+ }
434
+ function isNetworkFailure(error) {
435
+ const name = typeof error.name === "string" ? error.name : "";
436
+ return error instanceof TypeError || name === "AbortError" || name === "TimeoutError" || name === "NetworkError";
437
+ }
438
+ function transientStartFailure(error) {
439
+ let retryAfterMs = 0;
440
+ let viaTokenEndpoint = false;
441
+ let current = error;
442
+ for (let depth = 0; depth < 6 && current !== null && typeof current === "object"; depth += 1) {
443
+ const layer = current;
444
+ if (typeof layer.retryAfterMs === "number" && Number.isFinite(layer.retryAfterMs)) {
445
+ retryAfterMs = Math.max(retryAfterMs, layer.retryAfterMs);
446
+ }
447
+ if (typeof layer.status === "number") return isTransientStatus(layer.status) ? { retryAfterMs } : null;
448
+ if (isNetworkFailure(layer)) return { retryAfterMs };
449
+ if (isSoftphoneError(current)) {
450
+ if (layer.code === "auth/token-rejected") return null;
451
+ if (layer.code === "auth/token-fetch-failed") viaTokenEndpoint = true;
452
+ }
453
+ current = layer.cause;
454
+ }
455
+ return viaTokenEndpoint ? { retryAfterMs } : null;
456
+ }
457
+ function watchWake(wake) {
458
+ if (!isBrowser()) return () => {
459
+ };
460
+ const onOnline = () => wake();
461
+ const onVisibility = () => {
462
+ if (document.visibilityState === "visible") wake();
463
+ };
464
+ window.addEventListener("online", onOnline);
465
+ document.addEventListener("visibilitychange", onVisibility);
466
+ return () => {
467
+ window.removeEventListener("online", onOnline);
468
+ document.removeEventListener("visibilitychange", onVisibility);
469
+ };
470
+ }
471
+
421
472
  // src/core/presence-socket.ts
422
- var RECONNECT_BASE_MS = 1e3;
423
- var RECONNECT_MAX_MS = 3e4;
424
473
  var NOOP_WATCH = { reportCallState: () => {
425
- }, stop: () => {
474
+ }, claimLine: () => false, stop: () => {
426
475
  } };
427
476
  function startPresenceSocket(params) {
428
477
  const {
429
478
  source,
430
479
  getIsLeader,
431
480
  onAgentsChanged,
481
+ clientId,
482
+ onLine,
483
+ onClaimResult,
484
+ onOffer,
485
+ onLineUnsupported,
486
+ onConnectFailed,
487
+ onDisconnect,
432
488
  setTimeout: scheduleTimeout = globalThis.setTimeout,
433
489
  clearTimeout: cancelTimeout = globalThis.clearTimeout,
434
490
  random = Math.random
@@ -439,6 +495,8 @@ function startPresenceSocket(params) {
439
495
  let reconnectTimer = null;
440
496
  let agents = /* @__PURE__ */ new Map();
441
497
  let lastReportedOnCall = null;
498
+ let sentOnCall = null;
499
+ let line = null;
442
500
  let connection = null;
443
501
  let byeOnArrival = false;
444
502
  const stop = (options) => {
@@ -455,11 +513,16 @@ function startPresenceSocket(params) {
455
513
  connection?.close();
456
514
  connection = null;
457
515
  };
516
+ const send = (onCall) => {
517
+ if (!connection) return;
518
+ connection.reportCallState(onCall);
519
+ sentOnCall = onCall;
520
+ };
521
+ const mayReport = (onCall) => line !== "elsewhere" || !onCall && sentOnCall === true;
458
522
  const scheduleReconnect = () => {
459
523
  if (stopped) return;
460
524
  attempt += 1;
461
- const capped = Math.min(RECONNECT_BASE_MS * 2 ** (attempt - 1), RECONNECT_MAX_MS);
462
- const delay = capped * random();
525
+ const delay = backoffDelayMs(attempt, random);
463
526
  reconnectTimer = scheduleTimeout(() => void connect(), delay);
464
527
  };
465
528
  const connect = async () => {
@@ -483,10 +546,26 @@ function startPresenceSocket(params) {
483
546
  if (connection === null) return;
484
547
  connection = null;
485
548
  scheduleReconnect();
549
+ onDisconnect?.();
550
+ },
551
+ onLine: (update) => {
552
+ if (stopped) return;
553
+ const was = line;
554
+ line = update.state;
555
+ if (was === "elsewhere" && line === "here" && lastReportedOnCall !== null) send(lastReportedOnCall);
556
+ onLine?.(update);
557
+ },
558
+ onClaimResult: (result) => {
559
+ if (!stopped) onClaimResult?.(result);
560
+ },
561
+ onOffer: (offer) => {
562
+ if (!stopped) onOffer?.(offer);
486
563
  }
487
564
  });
488
565
  } catch {
489
- if (!stopped) scheduleReconnect();
566
+ if (stopped) return;
567
+ scheduleReconnect();
568
+ onConnectFailed?.();
490
569
  return;
491
570
  }
492
571
  if (stopped) {
@@ -496,22 +575,63 @@ function startPresenceSocket(params) {
496
575
  }
497
576
  connection = opened;
498
577
  attempt = 0;
499
- if (lastReportedOnCall !== null) connection.reportCallState(lastReportedOnCall);
578
+ sentOnCall = null;
579
+ line = null;
580
+ if (clientId !== void 0 && opened.hello) opened.hello(clientId);
581
+ else onLineUnsupported?.();
582
+ if (lastReportedOnCall !== null) send(lastReportedOnCall);
500
583
  };
501
584
  void connect();
502
585
  return {
503
586
  reportCallState: (onCall) => {
504
587
  if (onCall === lastReportedOnCall) return;
505
588
  lastReportedOnCall = onCall;
506
- connection?.reportCallState(onCall);
589
+ if (mayReport(onCall)) send(onCall);
590
+ },
591
+ claimLine: () => {
592
+ if (!connection?.claimLine) return false;
593
+ connection.claimLine();
594
+ return true;
507
595
  },
508
596
  stop
509
597
  };
510
598
  }
511
599
 
600
+ // src/core/line.ts
601
+ var CLIENT_ID_KEY = "coventia-softphone:client-id";
602
+ var CLIENT_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/;
603
+ function lineClientId() {
604
+ const stored = readStoredId();
605
+ if (stored) return stored;
606
+ const id = freshClientId();
607
+ try {
608
+ if (isBrowser()) globalThis.sessionStorage?.setItem(CLIENT_ID_KEY, id);
609
+ } catch {
610
+ }
611
+ return id;
612
+ }
613
+ function readStoredId() {
614
+ if (!isBrowser()) return null;
615
+ try {
616
+ const value = globalThis.sessionStorage?.getItem(CLIENT_ID_KEY);
617
+ return value && CLIENT_ID_PATTERN.test(value) ? value : null;
618
+ } catch {
619
+ return null;
620
+ }
621
+ }
622
+ function freshClientId() {
623
+ const uuid = globalThis.crypto?.randomUUID?.();
624
+ if (uuid) return uuid;
625
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
626
+ }
627
+
512
628
  // src/core/softphone-impl.ts
513
629
  var liveInstance = false;
514
630
  var PRESENCE_TEARDOWN_GRACE_MS = 1e4;
631
+ var LINE_ANSWER_TIMEOUT_MS = 4e3;
632
+ var CALL_DECISION_TIMEOUT_MS = LINE_ANSWER_TIMEOUT_MS + 2e3;
633
+ var LINE_CLAIM_TIMEOUT_MS = 1e4;
634
+ var LINE_LAPSE_MS = 1e4;
515
635
  var BrowserAudioDevices = class {
516
636
  inputs = [];
517
637
  outputs = [];
@@ -619,6 +739,8 @@ var SoftphoneImpl = class extends Emitter {
619
739
  #activeCall = null;
620
740
  #destroyed = false;
621
741
  #startedHere = false;
742
+ /** Bumped by every `stop()`, so a `start()` still waiting to lead can tell it was stopped. */
743
+ #run = 0;
622
744
  #isLeader = false;
623
745
  #engine;
624
746
  #tabLock;
@@ -643,6 +765,63 @@ var SoftphoneImpl = class extends Emitter {
643
765
  /** A pending state-driven teardown of the watch — see `PRESENCE_TEARDOWN_GRACE_MS`. */
644
766
  #presenceTeardownTimer = null;
645
767
  #presenceAgents = [];
768
+ // ── The line owner (`docs/specs/line-owner.md` §5) ──────────────────────────────────────────────
769
+ // Heard over the presence watch: the server names ONE softphone per extension that listens, and this one
770
+ // runs its engine only while that is it (or nobody can say). `#engineOn` and `#standby` are the two halves
771
+ // of that decision, kept apart because neither is true while `start()` is still waiting to make it.
772
+ #line = "unknown";
773
+ #lineWhere = null;
774
+ /** How the last attempt to bring the line here went (`lineClaim`), whoever made it. */
775
+ #lineClaim = "idle";
776
+ /** This tab's id in `hello` (`lineClientId`), read once — one id for every reconnect of this softphone. */
777
+ #clientId = null;
778
+ /** What the line wants: the engine running. Set the moment the line decides; the engine follows (`#queueEngine`). */
779
+ #engineOn = false;
780
+ /**
781
+ * What the engine was last TOLD by the queue — started or stopped. Apart from `#engineOn` because the line
782
+ * can flip twice while a start is still running: the stop and start queued meanwhile then find nothing
783
+ * to do, instead of stopping the engine (and the softphone with it) for a moment.
784
+ */
785
+ #engineRunning = false;
786
+ /** Waiting in `standby`: the engine is stopped ON PURPOSE, so its own `stopped` is not the softphone's. */
787
+ #standby = false;
788
+ /**
789
+ * Every engine start and stop the line asks for, one after another: a promotion can follow a demotion
790
+ * within a moment, and an engine started while its own stop is still running can end up neither.
791
+ */
792
+ #engineSteps = Promise.resolve();
793
+ /** Dials in flight (`call()` awaiting `placeCall`): a call about to exist holds the line like one that does. */
794
+ #dialing = 0;
795
+ /**
796
+ * Open from `start()`'s first line until it has decided whether the engine runs — or given up (stopped,
797
+ * destroyed, failed). `call()` waits on it, so it never dials ahead of that decision: not on a phone
798
+ * stopped meanwhile, and not without the line.
799
+ */
800
+ #startDecision = null;
801
+ /**
802
+ * Who decides whether the engine runs. `off`: nobody — not started, a follower, or `stop()` has begun
803
+ * (whatever its teardown sets off, like a call ending, must not start anything). `deciding`: `start()`
804
+ * has leadership and owes the FIRST decision itself, since it must await the engine start — a line
805
+ * message meanwhile only records what it says. `on`: the line decides (`#applyLine`).
806
+ */
807
+ #lineMode = "off";
808
+ /** Ends `start()`'s wait for the server's first word on the line early (`#waitForFirstLine`). */
809
+ #releaseFirstLine = null;
810
+ /** The `takeLine()` in flight — one at a time; a second caller shares it. */
811
+ #claim = null;
812
+ /** Pending: what the server said about the line stops counting (`LINE_LAPSE_MS`). */
813
+ #lineLapseTimer = null;
814
+ // ── The start retry (incoming-call-reliability.md §4.6) ─────────────────────────────────────────────────
815
+ // An engine start that failed for a passing reason (a 5xx, the network, a timeout) is attempted again on its
816
+ // own, with backoff, instead of leaving the phone `offline` until someone presses "Reintentar".
817
+ /** Failed attempts in the current streak; back to 0 when the engine starts, or the softphone stops. */
818
+ #startRetries = 0;
819
+ /** The wait before the next attempt, or `null` when none is pending. */
820
+ #startRetryTimer = null;
821
+ /** No attempt before this (epoch ms): a `Retry-After` the failure carried, which a wake-up must honour too. */
822
+ #startRetryNotBefore = 0;
823
+ /** Unsubscribes the `online` / tab-visible wake-ups, armed only while an attempt is pending. */
824
+ #unwatchWake = null;
646
825
  /**
647
826
  * `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
648
827
  * internal seam, not something an integrator tunes). Tests construct this class directly with a
@@ -660,7 +839,9 @@ var SoftphoneImpl = class extends Emitter {
660
839
  this.sources = config.coventia || config.apiBase ? coventiaBackend(config.coventia ?? {}, cachedTokenSource(config.tokenSource), config.apiBase) : null;
661
840
  this.#resolveContact = config.resolveContact ?? this.sources?.resolveContact;
662
841
  this.#engine.onIncoming((engineCall) => this.#onIncoming(engineCall));
663
- this.#engine.onStateChange((state) => this.#setState(state));
842
+ this.#engine.onStateChange((state) => {
843
+ if (!this.#standby) this.#setState(state);
844
+ });
664
845
  this.#engine.onError?.((error) => this.#onError(error));
665
846
  }
666
847
  get state() {
@@ -678,6 +859,15 @@ var SoftphoneImpl = class extends Emitter {
678
859
  get presence() {
679
860
  return this.#presenceAgents;
680
861
  }
862
+ get line() {
863
+ return this.#line;
864
+ }
865
+ get lineWhere() {
866
+ return this.#lineWhere;
867
+ }
868
+ get lineClaim() {
869
+ return this.#lineClaim;
870
+ }
681
871
  get canPlayAudio() {
682
872
  return true;
683
873
  }
@@ -693,15 +883,34 @@ var SoftphoneImpl = class extends Emitter {
693
883
  }
694
884
  liveInstance = true;
695
885
  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();
886
+ const run = this.#run;
887
+ const decision = this.#openStartDecision();
888
+ try {
889
+ this.#setState("starting");
890
+ await this.#tabLock.acquireLeadership();
891
+ if (this.#destroyed || run !== this.#run) return;
892
+ this.#lineMode = "deciding";
893
+ this.#setLeader(true);
894
+ if (this.#presenceWatch && this.#line === "unknown") await this.#waitForFirstLine();
895
+ if (this.#destroyed || run !== this.#run) return;
896
+ this.#lineMode = "on";
897
+ const engineStart = this.#applyLine();
898
+ decision.end();
899
+ await engineStart;
900
+ } finally {
901
+ decision.end();
902
+ }
701
903
  }
702
904
  async stop() {
703
905
  if (!this.#startedHere) return;
906
+ this.#run += 1;
907
+ this.#cancelStartRetry();
908
+ this.#lineMode = "off";
704
909
  this.#sayGoodbye();
910
+ this.#startDecision?.end();
911
+ this.#standby = false;
912
+ this.#engineOn = false;
913
+ this.#engineRunning = false;
705
914
  this.#tabLock.release();
706
915
  if (this.#isLeader) {
707
916
  await this.#engine.stop();
@@ -723,16 +932,51 @@ var SoftphoneImpl = class extends Emitter {
723
932
  this.#setState("destroyed");
724
933
  this.clear();
725
934
  }
935
+ async takeLine() {
936
+ this.#assertUsable();
937
+ if (this.#line === "here") return;
938
+ if (this.#claim) return this.#claim.promise;
939
+ const watch = this.#presenceWatch;
940
+ if (!watch) {
941
+ this.#setLineClaim("failed");
942
+ throw createError("line/unavailable");
943
+ }
944
+ const claim = this.#openClaim();
945
+ if (!watch.claimLine()) this.#settleClaim(createError("line/unavailable"));
946
+ return claim;
947
+ }
726
948
  async call(destination, options) {
727
949
  this.#assertUsable();
950
+ const run = this.#run;
951
+ if (this.#startRetryTimer !== null) this.#wakeStartRetry();
952
+ if (this.#startDecision) {
953
+ let timer;
954
+ const decided = await Promise.race([
955
+ this.#startDecision.promise.then(() => true),
956
+ new Promise((resolve) => {
957
+ timer = setTimeout(() => resolve(false), CALL_DECISION_TIMEOUT_MS);
958
+ })
959
+ ]);
960
+ clearTimeout(timer);
961
+ if (!decided) throw createError("engine/not-started");
962
+ }
963
+ if (run === this.#run && this.#state === "standby") await this.takeLine();
964
+ this.#assertUsable();
965
+ if (run !== this.#run) throw createError("engine/not-started");
728
966
  if (this.#state !== "online" && this.#state !== "offline" && this.#state !== "starting") {
729
967
  throw new Error(`Cannot place a call while softphone is '${this.#state}'.`);
730
968
  }
731
- if (this.#activeCall) {
969
+ if (this.#activeCall || this.#dialing > 0) {
732
970
  throw new Error("A call is already in progress. Hang up before placing another.");
733
971
  }
734
- const engineCall = await this.#engine.placeCall(destination, options);
735
- return this.#adopt(engineCall);
972
+ this.#dialing += 1;
973
+ try {
974
+ const engineCall = await this.#engine.placeCall(destination, options);
975
+ return this.#adopt(engineCall);
976
+ } finally {
977
+ this.#dialing -= 1;
978
+ this.#syncLine();
979
+ }
736
980
  }
737
981
  // ── internals ───────────────────────────────────────────────────────────────
738
982
  #onIncoming(engineCall) {
@@ -761,6 +1005,7 @@ var SoftphoneImpl = class extends Emitter {
761
1005
  this.#activeCall = null;
762
1006
  this.emit("activeCallChanged" /* ActiveCallChanged */, { call: null });
763
1007
  this.#syncCallState();
1008
+ this.#syncLine();
764
1009
  }
765
1010
  });
766
1011
  if (this.#resolveContact) {
@@ -790,7 +1035,8 @@ var SoftphoneImpl = class extends Emitter {
790
1035
  /**
791
1036
  * Reports the current on-a-call value to the backend. The watch de-duplicates (it only sends on change) and
792
1037
  * 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).
1038
+ * ceremony — and holds it back while the line is elsewhere, since only the owner's reports count (line-owner
1039
+ * R8). A no-op while there is no presence watch (stopped, not the leader, no backend configured).
794
1040
  */
795
1041
  #syncCallState() {
796
1042
  this.#presenceWatch?.reportCallState(this.#isOnCall());
@@ -811,17 +1057,22 @@ var SoftphoneImpl = class extends Emitter {
811
1057
  * `presence-socket.ts`'s own `getIsLeader` is checked at start and before each reconnect attempt,
812
1058
  * not on a poll tick the way `startTransferWatch`'s is — see that module's own doc comment for why.
813
1059
  * 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
1060
+ * BROAD started/leader gate — whether to be connected AT ALL — is owned entirely here, the same
815
1061
  * split `#onTransfer` already has with `startTransferWatch`.
816
1062
  *
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.
1063
+ * The watch runs for a leader that is `starting`, `online` or in `standby` — not only `online`, as it did
1064
+ * before the line owner (`docs/specs/line-owner.md` §5.4): while starting it is how this softphone learns
1065
+ * whether to listen at all, and in standby how it learns the line came back.
1066
+ *
1067
+ * A leader that is merely `offline` or `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a
1068
+ * blip shorter than that never touches the presence socket at all; a longer outage closes it, which lets
1069
+ * the server hand the line to a softphone that can still take calls. Anything else (stopped, destroyed, or
1070
+ * no longer the leader) tears it down at once: those are not blips, and a follower must never hold a
1071
+ * connection.
821
1072
  */
822
1073
  #syncPresenceWatch() {
823
1074
  const eligible = Boolean(this.sources) && this.#isLeader;
824
- const shouldRun = eligible && this.#state === "online";
1075
+ const shouldRun = eligible && this.#presenceRunsIn(this.#state);
825
1076
  const blip = eligible && (this.#state === "offline" || this.#state === "reconnecting");
826
1077
  if (!blip) this.#cancelPresenceTeardown();
827
1078
  if (blip && this.#presenceWatch !== null) {
@@ -830,13 +1081,25 @@ var SoftphoneImpl = class extends Emitter {
830
1081
  }
831
1082
  if (shouldRun === (this.#presenceWatch !== null)) return;
832
1083
  if (shouldRun) {
1084
+ this.#clientId ??= lineClientId();
833
1085
  this.#presenceWatch = startPresenceSocket({
834
1086
  source: this.sources.presence,
835
1087
  getIsLeader: () => this.#isLeader,
836
1088
  onAgentsChanged: (agents) => {
837
1089
  this.#presenceAgents = agents;
838
1090
  this.emit("presenceChanged" /* PresenceChanged */, { agents });
839
- }
1091
+ },
1092
+ clientId: this.#clientId,
1093
+ onLine: (update) => this.#onLine(update),
1094
+ onClaimResult: (result) => this.#onClaimResult(result),
1095
+ // The calls offered to this line, straight to the engine: after a decline, they say when the declined
1096
+ // call's re-offers stop (incoming-call-reliability.md §4.4). Not public — nothing a host acts on.
1097
+ onOffer: (offer) => this.#engine.noteOffer?.(offer),
1098
+ // A source that cannot take part will never answer, and one that cannot connect cannot answer yet:
1099
+ // `start()` need not wait out its 4 s for either (R7). A later answer still applies.
1100
+ onLineUnsupported: () => this.#releaseFirstLine?.(),
1101
+ onConnectFailed: () => this.#releaseFirstLine?.(),
1102
+ onDisconnect: () => this.#onPresenceDisconnect()
840
1103
  });
841
1104
  this.#syncCallState();
842
1105
  } else {
@@ -851,19 +1114,24 @@ var SoftphoneImpl = class extends Emitter {
851
1114
  if (this.#presenceTeardownTimer !== null) return;
852
1115
  this.#presenceTeardownTimer = setTimeout(() => {
853
1116
  this.#presenceTeardownTimer = null;
854
- if (this.#state === "online" && this.#isLeader) return;
1117
+ if (this.#isLeader && this.#presenceRunsIn(this.#state)) return;
855
1118
  this.#stopPresence();
856
1119
  }, PRESENCE_TEARDOWN_GRACE_MS);
857
1120
  }
1121
+ /** The states in which a leader keeps its presence watch with no clock running (`#syncPresenceWatch`). */
1122
+ #presenceRunsIn(state) {
1123
+ return state === "online" || state === "starting" || state === "standby";
1124
+ }
858
1125
  #cancelPresenceTeardown() {
859
1126
  if (this.#presenceTeardownTimer === null) return;
860
1127
  clearTimeout(this.#presenceTeardownTimer);
861
1128
  this.#presenceTeardownTimer = null;
862
1129
  }
863
1130
  /**
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.
1131
+ * Stops the presence watch and forgets its roster, and the line. A STATE-DRIVEN teardown (an outage
1132
+ * outlasted the grace, the engine stopped, or leadership was lost) passes nothing and says no goodbye;
1133
+ * only a deliberate stop passes `bye`. Any pending grace-period teardown is cancelled — there is nothing
1134
+ * left for it to do.
867
1135
  */
868
1136
  #stopPresence(options) {
869
1137
  this.#cancelPresenceTeardown();
@@ -872,6 +1140,8 @@ var SoftphoneImpl = class extends Emitter {
872
1140
  this.#presenceWatch = null;
873
1141
  this.#presenceAgents = [];
874
1142
  this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
1143
+ this.#forgetLine();
1144
+ this.#engine.noteOffer?.(null);
875
1145
  }
876
1146
  /**
877
1147
  * A DELIBERATE stop (`stop()` / `destroy()`): say any call is over, then say goodbye, so the backend drops
@@ -881,6 +1151,278 @@ var SoftphoneImpl = class extends Emitter {
881
1151
  this.#presenceWatch?.reportCallState(false);
882
1152
  this.#stopPresence({ bye: true });
883
1153
  }
1154
+ // ── the line owner (`docs/specs/line-owner.md` §5) ──────────────────────────────────────────────
1155
+ /**
1156
+ * THE decision: from where the line is and whether a call is in progress, whether the engine runs, and
1157
+ * the state that says so. Synchronous, so the state moves the instant the line does — the waiting screen
1158
+ * goes the moment the line comes here, and a `call()` that took the line dials from `starting` without
1159
+ * waiting out the engine start. Only the engine start or stop is queued (`#queueEngine`). Returns that
1160
+ * queued step, or `null` when nothing had to change.
1161
+ */
1162
+ #applyLine() {
1163
+ if (this.#lineMode !== "on") return null;
1164
+ const run = this.#run;
1165
+ if (this.#line === "elsewhere") {
1166
+ if (this.#standby || this.#activeCall || this.#dialing > 0) return null;
1167
+ this.#standby = true;
1168
+ this.#setState("standby");
1169
+ if (!this.#engineOn) return null;
1170
+ this.#engineOn = false;
1171
+ return this.#queueEngine(run);
1172
+ }
1173
+ if (this.#engineOn) return null;
1174
+ this.#engineOn = true;
1175
+ if (this.#standby) {
1176
+ this.#standby = false;
1177
+ this.#setState("starting");
1178
+ }
1179
+ return this.#queueEngine(run);
1180
+ }
1181
+ /** `#applyLine` from an event — nobody to hand a failure to, and `#engineStartFailed` has reported it. */
1182
+ #syncLine() {
1183
+ void this.#applyLine()?.catch(() => {
1184
+ });
1185
+ }
1186
+ /**
1187
+ * Brings the engine to what the line wants (`#engineOn`) once every step already queued has run — and does
1188
+ * nothing when it is already there: the line can flip twice while a start is still running, and the stop
1189
+ * and start queued meanwhile must not take the engine (and the softphone's state) down and up for nothing.
1190
+ * Nothing at all once `stop()` has run since `run` was read: a stopped softphone's engine is `stop()`'s
1191
+ * alone. Rejects with a failed start's cause; the queue itself never rejects, so one failure blocks nothing.
1192
+ */
1193
+ #queueEngine(run) {
1194
+ const step = this.#engineSteps.then(async () => {
1195
+ if (run !== this.#run || this.#engineOn === this.#engineRunning) return;
1196
+ this.#engineRunning = this.#engineOn;
1197
+ if (this.#engineRunning) await this.#startEngine(run);
1198
+ else await this.#engine.stop();
1199
+ });
1200
+ this.#engineSteps = step.catch(() => {
1201
+ });
1202
+ return step;
1203
+ }
1204
+ /** Resolves when the engine started — or when its failure is being retried on its own (§4.6). */
1205
+ async #startEngine(run) {
1206
+ const reported = this.#lastError;
1207
+ try {
1208
+ await this.#engine.start();
1209
+ if (run === this.#run) this.#startRetries = 0;
1210
+ } catch (cause) {
1211
+ if (run === this.#run && this.#engineStartFailed(cause, reported)) return;
1212
+ throw cause;
1213
+ }
1214
+ }
1215
+ /**
1216
+ * An engine start the line asked for failed. Kept as it was, this softphone would stay the owner on the
1217
+ * server — `starting`, its presence socket renewing the line — with no push socket anywhere: nobody would
1218
+ * listen. So it gives the line up the ordinary way, by closing its presence socket without a goodbye (the
1219
+ * phone was not switched off): the server hands the line on after its grace (R4).
1220
+ *
1221
+ * Then it depends on why (incoming-call-reliability.md §4.6, F5):
1222
+ * - **a passing failure** (a 5xx, the network, a timeout — `transientStartFailure`) is attempted again on its
1223
+ * own, after a backoff, as `reconnecting`, with no error gate. Found live: a voice token answered 500 after
1224
+ * 10 s left a background tab `offline` for good, and every call was lost until someone pressed "Reintentar".
1225
+ * Returns `true`.
1226
+ * - **anything else** (a refused credential, no extension) is reported like any engine failure —
1227
+ * `SoftphoneEvent.Error` and `lastError`, which is the widget's error gate and its "Reintentar" (`stop()`
1228
+ * then `start()`, which asks for the line again) — and nothing retries on its own. Returns `false`.
1229
+ */
1230
+ #engineStartFailed(cause, reportedBefore) {
1231
+ this.#engineOn = false;
1232
+ this.#engineRunning = false;
1233
+ this.#lineMode = "off";
1234
+ const transient = this.#destroyed ? null : transientStartFailure(cause);
1235
+ this.#stopPresence();
1236
+ if (transient) {
1237
+ this.#scheduleStartRetry(transient.retryAfterMs, cause);
1238
+ this.#setState("reconnecting");
1239
+ return true;
1240
+ }
1241
+ if (this.#lastError === reportedBefore) {
1242
+ this.#onError(isSoftphoneError(cause) && isFatal(cause) ? cause : createError("net/transport-failed", cause));
1243
+ }
1244
+ this.#setState("offline");
1245
+ return false;
1246
+ }
1247
+ /**
1248
+ * The next attempt, after the shared backoff (1 s doubling to 30 s, jittered — the presence socket's own) and
1249
+ * never before a `Retry-After` the failure carried. A plain timer: in a hidden tab it can stretch to about a
1250
+ * minute, which is why the browser coming back `online`, or the tab becoming visible, attempts at once
1251
+ * (`watchWake`). One warning per streak, so a console says what is going on without filling up.
1252
+ */
1253
+ #scheduleStartRetry(retryAfterMs, cause) {
1254
+ this.#startRetries += 1;
1255
+ const wait = Math.max(backoffDelayMs(this.#startRetries), retryAfterMs);
1256
+ if (this.#startRetries === 1) {
1257
+ console.warn("[softphone] the phone could not start; it keeps trying on its own", cause);
1258
+ }
1259
+ this.#startRetryNotBefore = Date.now() + retryAfterMs;
1260
+ if (this.#startRetryTimer !== null) clearTimeout(this.#startRetryTimer);
1261
+ const run = this.#run;
1262
+ this.#startRetryTimer = setTimeout(() => void this.#retryStart(run), wait);
1263
+ this.#unwatchWake ??= watchWake(() => this.#wakeStartRetry());
1264
+ }
1265
+ /** The pending attempt, now — unless a `Retry-After` says not yet, in which case it moves up to that moment. */
1266
+ #wakeStartRetry() {
1267
+ if (this.#startRetryTimer === null) return;
1268
+ const run = this.#run;
1269
+ clearTimeout(this.#startRetryTimer);
1270
+ const wait = this.#startRetryNotBefore - Date.now();
1271
+ if (wait > 0) {
1272
+ this.#startRetryTimer = setTimeout(() => void this.#retryStart(run), wait);
1273
+ return;
1274
+ }
1275
+ void this.#retryStart(run);
1276
+ }
1277
+ /** The wait and the wake-ups go; the streak's count stays (the next failure doubles on from it). */
1278
+ #clearStartRetryWait() {
1279
+ if (this.#startRetryTimer !== null) clearTimeout(this.#startRetryTimer);
1280
+ this.#startRetryTimer = null;
1281
+ this.#unwatchWake?.();
1282
+ this.#unwatchWake = null;
1283
+ }
1284
+ #cancelStartRetry() {
1285
+ this.#clearStartRetryWait();
1286
+ this.#startRetries = 0;
1287
+ }
1288
+ /**
1289
+ * One attempt: what "Reintentar" does after its `stop()`, minus the goodbye and the leadership (both kept) —
1290
+ * the presence socket opens again and its `hello` asks for the line, the line decides (`standby` if it went
1291
+ * elsewhere meanwhile, as at any start), and the engine starts. Without the goodbye, an attempt inside the
1292
+ * server's 10 s grace gets the line straight back. Never for a softphone stopped since it was scheduled.
1293
+ */
1294
+ async #retryStart(run) {
1295
+ this.#clearStartRetryWait();
1296
+ if (this.#destroyed || run !== this.#run || !this.#startedHere || !this.#isLeader) return;
1297
+ const decision = this.#openStartDecision();
1298
+ try {
1299
+ this.#lineMode = "deciding";
1300
+ this.#setState("starting");
1301
+ if (this.#presenceWatch && this.#line === "unknown") await this.#waitForFirstLine();
1302
+ if (this.#destroyed || run !== this.#run) return;
1303
+ this.#lineMode = "on";
1304
+ const engineStart = this.#applyLine();
1305
+ if (!engineStart) this.#startRetries = 0;
1306
+ decision.end();
1307
+ await engineStart?.catch(() => {
1308
+ });
1309
+ } finally {
1310
+ decision.end();
1311
+ }
1312
+ }
1313
+ /** `start()`'s decision (`#startDecision`): ended once, by whichever comes first — the decision or its end. */
1314
+ #openStartDecision() {
1315
+ let resolve;
1316
+ const promise = new Promise((r) => {
1317
+ resolve = r;
1318
+ });
1319
+ const decision = {
1320
+ promise,
1321
+ end: () => {
1322
+ resolve();
1323
+ if (this.#startDecision === decision) this.#startDecision = null;
1324
+ }
1325
+ };
1326
+ this.#startDecision = decision;
1327
+ return decision;
1328
+ }
1329
+ /**
1330
+ * `start()`'s wait for the server's first word on the line — released by that word, by a source that
1331
+ * will never say it, by the watch going away (`stop()` among them), or after `LINE_ANSWER_TIMEOUT_MS`.
1332
+ */
1333
+ #waitForFirstLine() {
1334
+ return new Promise((resolve) => {
1335
+ const release = () => {
1336
+ clearTimeout(timer);
1337
+ if (this.#releaseFirstLine === release) this.#releaseFirstLine = null;
1338
+ resolve();
1339
+ };
1340
+ const timer = setTimeout(release, LINE_ANSWER_TIMEOUT_MS);
1341
+ this.#releaseFirstLine = release;
1342
+ });
1343
+ }
1344
+ #onLine(update) {
1345
+ this.#cancelLineLapse();
1346
+ this.#setLine(update.state, update.state === "here" ? null : update.where);
1347
+ if (this.#releaseFirstLine) this.#releaseFirstLine();
1348
+ else this.#syncLine();
1349
+ if (update.state === "here") this.#settleClaim();
1350
+ }
1351
+ #onClaimResult(result) {
1352
+ if (result.ok) {
1353
+ if (this.#line === "here") this.#settleClaim();
1354
+ return;
1355
+ }
1356
+ this.#settleClaim(createError(result.reason === "on_call" ? "line/claim-refused" : "line/unavailable"));
1357
+ }
1358
+ /**
1359
+ * The presence socket dropped; the watch is reconnecting. A claim sent on it will never be answered, so
1360
+ * it fails now rather than on its timeout. What the server said about the line holds for `LINE_LAPSE_MS`
1361
+ * — a reconnect is normally answered well inside that — and then stops counting: `unknown`, which a
1362
+ * waiting softphone answers by listening again.
1363
+ */
1364
+ #onPresenceDisconnect() {
1365
+ this.#engine.noteOffer?.(null);
1366
+ this.#settleClaim(createError("line/unavailable"));
1367
+ if (this.#line === "unknown" || this.#lineLapseTimer !== null) return;
1368
+ this.#lineLapseTimer = setTimeout(() => {
1369
+ this.#lineLapseTimer = null;
1370
+ this.#setLine("unknown", null);
1371
+ this.#syncLine();
1372
+ }, LINE_LAPSE_MS);
1373
+ }
1374
+ #cancelLineLapse() {
1375
+ if (this.#lineLapseTimer === null) return;
1376
+ clearTimeout(this.#lineLapseTimer);
1377
+ this.#lineLapseTimer = null;
1378
+ }
1379
+ /**
1380
+ * The presence watch is gone, so nothing the server said about the line holds any more: it reads
1381
+ * `unknown`, and every wait on it ends. Not followed by `#applyLine` — this runs inside a state change
1382
+ * (`#syncPresenceWatch`) or a stop, and a softphone whose watch is gone is not in `standby` anyway: that
1383
+ * state keeps the watch.
1384
+ */
1385
+ #forgetLine() {
1386
+ this.#cancelLineLapse();
1387
+ this.#releaseFirstLine?.();
1388
+ this.#settleClaim(createError("line/unavailable"));
1389
+ this.#setLine("unknown", null);
1390
+ this.#setLineClaim("idle");
1391
+ }
1392
+ #setLine(line, where) {
1393
+ if (line === "here") this.#setLineClaim("idle");
1394
+ if (line === this.#line && where === this.#lineWhere) return;
1395
+ this.#line = line;
1396
+ this.#lineWhere = where;
1397
+ this.emit("lineChanged" /* LineChanged */, { line, where });
1398
+ }
1399
+ #setLineClaim(claim) {
1400
+ if (claim === this.#lineClaim) return;
1401
+ this.#lineClaim = claim;
1402
+ this.emit("lineClaimChanged" /* LineClaimChanged */, { claim });
1403
+ }
1404
+ #openClaim() {
1405
+ let settle;
1406
+ const promise = new Promise((resolve, reject) => {
1407
+ const timer = setTimeout(() => this.#settleClaim(createError("line/unavailable")), LINE_CLAIM_TIMEOUT_MS);
1408
+ settle = (error) => {
1409
+ clearTimeout(timer);
1410
+ if (error) reject(error);
1411
+ else resolve();
1412
+ };
1413
+ });
1414
+ this.#claim = { promise, settle };
1415
+ this.#setLineClaim("claiming");
1416
+ return promise;
1417
+ }
1418
+ /** Ends the claim in flight, if any — resolved without `error`, rejected with it. */
1419
+ #settleClaim(error) {
1420
+ const claim = this.#claim;
1421
+ if (!claim) return;
1422
+ this.#claim = null;
1423
+ this.#setLineClaim(!error ? "idle" : error.code === "line/claim-refused" ? "refused" : "failed");
1424
+ claim.settle(error);
1425
+ }
884
1426
  /**
885
1427
  * Phase A (transfer outcome relay, `docs/specs/transfer-outcome-relay.md`) — `CallImpl.transfer()`'s
886
1428
  * internal seam calls this. No-ops entirely when no Coventia backend is configured (`sources` is
@@ -929,6 +1471,7 @@ var SoftphoneImpl = class extends Emitter {
929
1471
  };
930
1472
 
931
1473
  // src/core/default-provider.ts
1474
+ var MAX_PENDING_OFFERS = 20;
932
1475
  var LazyRelay = class {
933
1476
  #listeners = /* @__PURE__ */ new Set();
934
1477
  #realOff = /* @__PURE__ */ new Map();
@@ -951,16 +1494,20 @@ var LazyRelay = class {
951
1494
  function defaultZadarmaEngine(config) {
952
1495
  let real = null;
953
1496
  let pending = null;
1497
+ let run = 0;
954
1498
  const incoming = new LazyRelay();
955
1499
  const stateChange = new LazyRelay();
956
1500
  const error = new LazyRelay();
1501
+ let pendingOffers = [];
957
1502
  function load() {
958
- pending ??= import('./zadarma-engine-L5DEFXUS.js').then(({ createZadarmaEngine }) => {
1503
+ pending ??= import('./zadarma-engine-VYIXLBQG.js').then(({ createZadarmaEngine }) => {
959
1504
  const engine = createZadarmaEngine(config);
960
1505
  incoming.attach({ on: (l) => engine.onIncoming(l) });
961
1506
  stateChange.attach({ on: (l) => engine.onStateChange(l) });
962
1507
  error.attach({ on: (l) => engine.onError?.(l) ?? (() => {
963
1508
  }) });
1509
+ for (const offer of pendingOffers) engine.noteOffer?.(offer);
1510
+ pendingOffers = [];
964
1511
  real = engine;
965
1512
  return engine;
966
1513
  }).catch((cause) => {
@@ -974,23 +1521,52 @@ function defaultZadarmaEngine(config) {
974
1521
  return real?.state ?? "stopped";
975
1522
  },
976
1523
  async start() {
977
- await (await load()).start();
1524
+ const current = run;
1525
+ const engine = await load();
1526
+ if (current !== run) return;
1527
+ await engine.start();
978
1528
  },
979
1529
  async stop() {
1530
+ run += 1;
980
1531
  if (real) await real.stop();
981
1532
  },
982
1533
  async placeCall(destination, options) {
983
- return (await load()).placeCall(destination, options);
1534
+ const current = run;
1535
+ const engine = await load();
1536
+ if (current !== run) throw createError("engine/not-started");
1537
+ return engine.placeCall(destination, options);
984
1538
  },
985
1539
  onIncoming: (listener) => incoming.subscribe(listener),
986
1540
  onStateChange: (listener) => stateChange.subscribe(listener),
987
- onError: (listener) => error.subscribe(listener)
1541
+ onError: (listener) => error.subscribe(listener),
1542
+ /*
1543
+ * Relayed like the listeners above, or the decline guard on this — the default — path would never hear the
1544
+ * backend (incoming-call-reliability.md §4.4). Before the engine loads, kept (a few) rather than loading it:
1545
+ * a softphone in `standby` hears offers without ever starting an engine.
1546
+ */
1547
+ noteOffer: (offer) => {
1548
+ if (real) {
1549
+ real.noteOffer?.(offer);
1550
+ return;
1551
+ }
1552
+ pendingOffers.push(offer);
1553
+ if (pendingOffers.length > MAX_PENDING_OFFERS) pendingOffers.shift();
1554
+ }
988
1555
  };
989
1556
  }
990
1557
 
991
1558
  // src/core/create.ts
992
1559
  function createSoftphone(config) {
993
- return new SoftphoneImpl(resolveEngine(config), config);
1560
+ const resolved = withNormalizedBases(config);
1561
+ return new SoftphoneImpl(resolveEngine(resolved), resolved);
1562
+ }
1563
+ function withNormalizedBases(config) {
1564
+ const { apiBase, coventia } = config;
1565
+ return {
1566
+ ...config,
1567
+ ...apiBase ? { apiBase: normalizeApiBase(apiBase) } : {},
1568
+ ...coventia?.baseUrl ? { coventia: { ...coventia, baseUrl: normalizeApiBase(coventia.baseUrl) } } : {}
1569
+ };
994
1570
  }
995
1571
  function resolveEngine(config) {
996
1572
  if (config.provider !== void 0) {
@@ -1015,7 +1591,7 @@ function isCallEngine(value) {
1015
1591
  }
1016
1592
 
1017
1593
  // src/index.ts
1018
- var VERSION = "0.4.0";
1594
+ var VERSION = "0.5.1";
1019
1595
 
1020
1596
  export { CALL_TRANSITIONS, INITIAL_STATES, VERSION, canTransition, createSoftphone, isTerminal };
1021
1597
  //# sourceMappingURL=index.js.map