@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.cjs CHANGED
@@ -316,6 +316,16 @@ function coventiaSources(config) {
316
316
  if (ws.readyState !== WS.OPEN) return;
317
317
  ws.send(JSON.stringify({ type: "bye" }));
318
318
  },
319
+ // `line: true` is what makes the server treat this socket as taking part in the line owner;
320
+ // without it (or with an id it rejects) the `hello` is ignored, as an older SDK's would be.
321
+ hello: (clientId) => {
322
+ if (ws.readyState !== WS.OPEN) return;
323
+ ws.send(JSON.stringify({ type: "hello", clientId, line: true }));
324
+ },
325
+ claimLine: () => {
326
+ if (ws.readyState !== WS.OPEN) return;
327
+ ws.send(JSON.stringify({ type: "line-claim" }));
328
+ },
319
329
  close: () => {
320
330
  ws.onclose = null;
321
331
  ws.onerror = null;
@@ -335,6 +345,12 @@ function coventiaSources(config) {
335
345
  callbacks.onSnapshot((msg.agents ?? []).map(toPresenceEntry));
336
346
  } else if (msg.type === "delta" && msg.agent) {
337
347
  callbacks.onDelta(toPresenceEntry(msg.agent));
348
+ } else if (msg.type === "line" && (msg.state === "here" || msg.state === "elsewhere")) {
349
+ callbacks.onLine?.({ state: msg.state, where: msg.state === "here" ? null : toLineLocation(msg.where) });
350
+ } else if (msg.type === "line-claim-result") {
351
+ callbacks.onClaimResult?.(
352
+ msg.ok === true ? { ok: true } : { ok: false, reason: typeof msg.reason === "string" ? msg.reason : "unavailable" }
353
+ );
338
354
  }
339
355
  };
340
356
  ws.onclose = () => {
@@ -478,7 +494,7 @@ function coventiaSources(config) {
478
494
  outboundNumbers
479
495
  };
480
496
  }
481
- var toContact, toDirection, otherParty, isMissed, toOutcome, toDate, toTransferOutcome, toPresenceState, toPresenceEntry, toWebSocketUrl;
497
+ var toContact, toDirection, otherParty, isMissed, toOutcome, toDate, toTransferOutcome, toPresenceState, toPresenceEntry, toLineLocation, toWebSocketUrl;
482
498
  var init_sources2 = __esm({
483
499
  "src/coventia/sources.ts"() {
484
500
  init_sources();
@@ -527,6 +543,7 @@ var init_sources2 = __esm({
527
543
  name: row.name ?? null,
528
544
  state: toPresenceState(row.state)
529
545
  });
546
+ toLineLocation = (raw) => raw === "desktop" || raw === "browser" ? raw : null;
530
547
  toWebSocketUrl = (httpUrl) => httpUrl.replace(/^http/, "ws").replace(/\/+$/, "");
531
548
  }
532
549
  });
@@ -679,6 +696,19 @@ var init_errors = __esm({
679
696
  message: "The provider does not support this operation.",
680
697
  solution: "Guard the UI on the operation before offering it."
681
698
  },
699
+ // ── Line owner (docs/specs/line-owner.md §5) ────────────────────────────────
700
+ // What `takeLine()` rejects with — and `call()` from `standby`, which takes the line first. Never
701
+ // emitted on `SoftphoneEvent.Error`: the promise IS the answer, and nothing about the phone is broken.
702
+ "line/claim-refused": {
703
+ fatal: false,
704
+ message: "The line was not moved here: the user is on a call on the softphone that has it.",
705
+ solution: "Nothing to fix. The line never moves during a call; take it again once that call has ended. Incoming calls keep ringing where they ring now."
706
+ },
707
+ "line/unavailable": {
708
+ fatal: false,
709
+ message: "The line could not be taken: nothing decided where it is.",
710
+ solution: "The presence connection is down, the backend predates the line owner, or it did not answer in time. Retry in a moment; incoming calls keep ringing where they ring now."
711
+ },
682
712
  // ── Provider ────────────────────────────────────────────────────────────────
683
713
  "provider/provision-failed": {
684
714
  fatal: true,
@@ -978,9 +1008,22 @@ var init_ua = __esm({
978
1008
  provider;
979
1009
  factory;
980
1010
  #state = "stopped";
1011
+ /**
1012
+ * Which `start()`…`stop()` cycle this is — bumped by `stop()`, which ends one. Everything that awaits
1013
+ * and then acts (builds a UA, subscribes to the signal, sets the state) captures it first and gives up
1014
+ * if a stop has happened since. Not bumped by `start()`: that would cancel work begun just before it,
1015
+ * such as a call placed while the softphone is still starting.
1016
+ *
1017
+ * Without it a stopped phone came back to life: a provisioning or a signal connect still in flight
1018
+ * when `stop()` ran finished afterwards and REGISTERed — and since the PBX routes a call to whichever
1019
+ * client registered last, it quietly took calls from the user's other device while its UI said off.
1020
+ */
1021
+ #run = 0;
981
1022
  #ua = null;
982
1023
  /** In-flight `#createUA()`. THE concurrency guard — see `#ensureUA`. */
983
1024
  #uaInFlight = null;
1025
+ /** Calls waiting for the current UA to register (`#callWhenRegistered`), failed by `#teardownUA`. */
1026
+ #awaitingRegistration = /* @__PURE__ */ new Set();
984
1027
  #domain = "";
985
1028
  #activeCall = null;
986
1029
  /**
@@ -1063,14 +1106,25 @@ var init_ua = __esm({
1063
1106
  for (const listener of [...this.#errorListeners]) listener(error);
1064
1107
  }
1065
1108
  async start() {
1109
+ const run = this.#run;
1066
1110
  this.#setState("starting");
1067
1111
  if (this.provider.registrationMode === "persistent") {
1068
- await this.#ensureUA();
1112
+ try {
1113
+ await this.#ensureUA();
1114
+ } catch (cause) {
1115
+ if (run === this.#run) throw cause;
1116
+ }
1117
+ if (run !== this.#run) return;
1069
1118
  this.#setState("online");
1070
1119
  } else {
1071
1120
  const signal = this.provider.incomingSignal;
1072
1121
  if (signal) {
1073
- await signal.connect();
1122
+ try {
1123
+ await signal.connect();
1124
+ } catch (cause) {
1125
+ if (run === this.#run) throw cause;
1126
+ }
1127
+ if (run !== this.#run) return;
1074
1128
  this.#signalUnsub = signal.onSignal(() => void this.#onIncomingSignal());
1075
1129
  this.#signalConnectionUnsub = signal.onConnectionChange?.(
1076
1130
  (connected) => this.#setState(connected ? "online" : "offline")
@@ -1080,6 +1134,7 @@ var init_ua = __esm({
1080
1134
  }
1081
1135
  }
1082
1136
  async stop() {
1137
+ this.#run += 1;
1083
1138
  this.#clearNoInviteTimer();
1084
1139
  this.#signalUnsub?.();
1085
1140
  this.#signalUnsub = null;
@@ -1098,11 +1153,12 @@ var init_ua = __esm({
1098
1153
  async #onIncomingSignal() {
1099
1154
  if (this.now() < this.#rejectGuardUntil) return;
1100
1155
  if (this.#activeCall) return;
1156
+ const run = this.#run;
1101
1157
  try {
1102
1158
  await this.#ensureUA();
1103
1159
  this.#armNoInviteTeardown();
1104
1160
  } catch {
1105
- this.#teardownUA();
1161
+ if (run === this.#run) this.#teardownUA();
1106
1162
  }
1107
1163
  }
1108
1164
  #armNoInviteTeardown() {
@@ -1164,12 +1220,16 @@ var init_ua = __esm({
1164
1220
  */
1165
1221
  async #ensureUA() {
1166
1222
  if (this.#ua) return this.#ua;
1167
- this.#uaInFlight ??= this.#createUA().finally(() => {
1168
- this.#uaInFlight = null;
1169
- });
1223
+ if (!this.#uaInFlight) {
1224
+ const inFlight = this.#createUA().finally(() => {
1225
+ if (this.#uaInFlight === inFlight) this.#uaInFlight = null;
1226
+ });
1227
+ this.#uaInFlight = inFlight;
1228
+ }
1170
1229
  return this.#uaInFlight;
1171
1230
  }
1172
1231
  async #createUA() {
1232
+ const run = this.#run;
1173
1233
  const factory = await this.#resolveFactory();
1174
1234
  let credentials;
1175
1235
  try {
@@ -1179,17 +1239,28 @@ var init_ua = __esm({
1179
1239
  else this.#reportError("provider/provision-failed", cause);
1180
1240
  throw cause;
1181
1241
  }
1242
+ if (run !== this.#run) throw createError("engine/not-started");
1182
1243
  this.#domain = domainOf(credentials);
1183
1244
  const config = this.#uaConfig(credentials, factory);
1184
1245
  const ua = factory.createUA(config);
1185
- ua.on("newRTCSession", ({ session, originator }) => this.#onNewSession(session, originator));
1246
+ ua.on("newRTCSession", ({ session, originator }) => {
1247
+ if (ua !== this.#ua) {
1248
+ if (originator === "remote") session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
1249
+ return;
1250
+ }
1251
+ this.#onNewSession(session, originator);
1252
+ });
1186
1253
  ua.on("disconnected", ({ error }) => {
1254
+ if (ua !== this.#ua) return;
1187
1255
  if (!error) return;
1188
1256
  this.#setState("reconnecting");
1189
1257
  this.#reportError("net/transport-failed", error);
1190
1258
  });
1191
- ua.on("connected", () => this.#setState("online"));
1259
+ ua.on("connected", () => {
1260
+ if (ua === this.#ua) this.#setState("online");
1261
+ });
1192
1262
  ua.on("registrationFailed", (e) => {
1263
+ if (run !== this.#run) return;
1193
1264
  const detail = e;
1194
1265
  this.#reportError("net/registration-failed", {
1195
1266
  cause: detail?.cause,
@@ -1197,7 +1268,7 @@ var init_ua = __esm({
1197
1268
  domain: this.#domain,
1198
1269
  hint: "A bare 401 with no auth challenge usually means the registrar is in the wrong datacenter (SPEC \xA73.1)."
1199
1270
  });
1200
- this.#teardownUA();
1271
+ if (ua === this.#ua) this.#teardownUA();
1201
1272
  void this.#refreshSignal();
1202
1273
  });
1203
1274
  this.#ua = ua;
@@ -1224,6 +1295,7 @@ var init_ua = __esm({
1224
1295
  #teardownUA() {
1225
1296
  this.#uaInFlight = null;
1226
1297
  if (!this.#ua) return;
1298
+ for (const fail of [...this.#awaitingRegistration]) fail();
1227
1299
  try {
1228
1300
  this.#ua.terminateSessions({ status_code: 487 });
1229
1301
  this.#ua.stop();
@@ -1247,8 +1319,27 @@ var init_ua = __esm({
1247
1319
  #callWhenRegistered(ua, target) {
1248
1320
  const options = { mediaConstraints: buildAudioConstraints(this.#inputDeviceId), pcConfig: PC_CONFIG };
1249
1321
  if (ua.isRegistered()) return Promise.resolve(ua.call(target, options));
1250
- return new Promise((resolve) => {
1251
- ua.on("registered", () => resolve(ua.call(target, options)));
1322
+ const run = this.#run;
1323
+ return new Promise((resolve, reject) => {
1324
+ let settled = false;
1325
+ const fail = () => {
1326
+ if (settled) return;
1327
+ settled = true;
1328
+ this.#awaitingRegistration.delete(fail);
1329
+ reject(createError(run === this.#run ? "call/failed" : "engine/not-started"));
1330
+ };
1331
+ this.#awaitingRegistration.add(fail);
1332
+ ua.on("registered", () => {
1333
+ if (settled) return;
1334
+ if (ua !== this.#ua) return fail();
1335
+ settled = true;
1336
+ this.#awaitingRegistration.delete(fail);
1337
+ try {
1338
+ resolve(ua.call(target, options));
1339
+ } catch (cause) {
1340
+ reject(cause);
1341
+ }
1342
+ });
1252
1343
  });
1253
1344
  }
1254
1345
  #wrapCall(session) {
@@ -1398,6 +1489,12 @@ var init_zadarma = __esm({
1398
1489
  #retryTimer;
1399
1490
  #unwatchBackground;
1400
1491
  #closed = false;
1492
+ /**
1493
+ * Bumped by `disconnect()`. An open or a rebuild still awaiting its key when the signal is disconnected
1494
+ * must build nothing afterwards — and `#closed` alone cannot say so once a later `connect()` (stop, then
1495
+ * start again) has reset it: the late socket was built beside the new one, and nothing ever closed it.
1496
+ */
1497
+ #run = 0;
1401
1498
  #listeners = /* @__PURE__ */ new Set();
1402
1499
  #connectionListeners = /* @__PURE__ */ new Set();
1403
1500
  async connect() {
@@ -1407,8 +1504,9 @@ var init_zadarma = __esm({
1407
1504
  await this.#open();
1408
1505
  }
1409
1506
  async #open() {
1507
+ const run = this.#run;
1410
1508
  const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
1411
- if (this.#closed) return;
1509
+ if (run !== this.#run) return;
1412
1510
  const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1413
1511
  this.#socket = socket;
1414
1512
  this.#attach(socket);
@@ -1503,9 +1601,10 @@ var init_zadarma = __esm({
1503
1601
  return;
1504
1602
  }
1505
1603
  this.#rebuilding = true;
1604
+ const run = this.#run;
1506
1605
  try {
1507
1606
  const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
1508
- if (this.#closed) return;
1607
+ if (run !== this.#run) return;
1509
1608
  const swapped = await new Promise((resolve) => {
1510
1609
  const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1511
1610
  this.#candidate = socket;
@@ -1558,6 +1657,7 @@ var init_zadarma = __esm({
1558
1657
  }
1559
1658
  disconnect() {
1560
1659
  this.#closed = true;
1660
+ this.#run += 1;
1561
1661
  this.#stopPings();
1562
1662
  clearTimeout(this.#refreshTimer);
1563
1663
  this.#refreshTimer = void 0;
@@ -1801,6 +1901,20 @@ var init_zadarma_browser = __esm({
1801
1901
  }
1802
1902
  });
1803
1903
 
1904
+ // src/core/api-base.ts
1905
+ function normalizeApiBase(url) {
1906
+ const trimmed = url.replace(/\/+$/, "");
1907
+ if (!trimmed.endsWith("/api/v1")) return trimmed;
1908
+ console.warn(
1909
+ "[softphone] apiBase should be Coventia's domain only, without /api/v1. Ignoring the /api/v1 suffix."
1910
+ );
1911
+ return trimmed.slice(0, -"/api/v1".length).replace(/\/+$/, "");
1912
+ }
1913
+ var init_api_base = __esm({
1914
+ "src/core/api-base.ts"() {
1915
+ }
1916
+ });
1917
+
1804
1918
  // src/transports/zadarma-engine.ts
1805
1919
  var zadarma_engine_exports = {};
1806
1920
  __export(zadarma_engine_exports, {
@@ -1808,7 +1922,11 @@ __export(zadarma_engine_exports, {
1808
1922
  });
1809
1923
  function createZadarmaEngine(config) {
1810
1924
  const provider = new ZadarmaProvider(
1811
- makeZadarmaBrowserDeps({ ...config, tokenSource: cachedTokenSource(config.tokenSource) })
1925
+ makeZadarmaBrowserDeps({
1926
+ ...config,
1927
+ apiBase: normalizeApiBase(config.apiBase),
1928
+ tokenSource: cachedTokenSource(config.tokenSource)
1929
+ })
1812
1930
  );
1813
1931
  return new SipCallEngine(provider, loadJsSipFactory);
1814
1932
  }
@@ -1819,6 +1937,7 @@ var init_zadarma_engine = __esm({
1819
1937
  init_zadarma();
1820
1938
  init_zadarma_browser();
1821
1939
  init_token_cache();
1940
+ init_api_base();
1822
1941
  }
1823
1942
  });
1824
1943
 
@@ -1882,6 +2001,8 @@ var SoftphoneEvent = /* @__PURE__ */ ((SoftphoneEvent2) => {
1882
2001
  SoftphoneEvent2["LeaderChanged"] = "leaderChanged";
1883
2002
  SoftphoneEvent2["TransferOutcome"] = "transferOutcome";
1884
2003
  SoftphoneEvent2["PresenceChanged"] = "presenceChanged";
2004
+ SoftphoneEvent2["LineChanged"] = "lineChanged";
2005
+ SoftphoneEvent2["LineClaimChanged"] = "lineClaimChanged";
1885
2006
  return SoftphoneEvent2;
1886
2007
  })(SoftphoneEvent || {});
1887
2008
  var CallEvent = /* @__PURE__ */ ((CallEvent2) => {
@@ -2171,13 +2292,15 @@ function isWebLocksSupported() {
2171
2292
  return isBrowser() && typeof navigator.locks?.request === "function";
2172
2293
  }
2173
2294
  function webLocksTabLock(lockName = LOCK_NAME) {
2174
- const controller = new AbortController();
2295
+ let controller;
2175
2296
  let releaseHold;
2176
2297
  return {
2177
2298
  acquireLeadership() {
2178
2299
  if (!isWebLocksSupported()) return Promise.resolve();
2300
+ const { signal } = controller = new AbortController();
2179
2301
  return new Promise((resolveAcquired) => {
2180
- navigator.locks.request(lockName, { signal: controller.signal }, () => {
2302
+ navigator.locks.request(lockName, { signal }, () => {
2303
+ if (signal.aborted) return Promise.resolve();
2181
2304
  resolveAcquired();
2182
2305
  return new Promise((resolveHold) => {
2183
2306
  releaseHold = resolveHold;
@@ -2192,7 +2315,7 @@ function webLocksTabLock(lockName = LOCK_NAME) {
2192
2315
  releaseHold = void 0;
2193
2316
  resolve();
2194
2317
  } else {
2195
- controller.abort();
2318
+ controller?.abort();
2196
2319
  }
2197
2320
  }
2198
2321
  };
@@ -2277,13 +2400,19 @@ function startTransferWatch(params) {
2277
2400
  var RECONNECT_BASE_MS = 1e3;
2278
2401
  var RECONNECT_MAX_MS = 3e4;
2279
2402
  var NOOP_WATCH = { reportCallState: () => {
2280
- }, stop: () => {
2403
+ }, claimLine: () => false, stop: () => {
2281
2404
  } };
2282
2405
  function startPresenceSocket(params) {
2283
2406
  const {
2284
2407
  source,
2285
2408
  getIsLeader,
2286
2409
  onAgentsChanged,
2410
+ clientId,
2411
+ onLine,
2412
+ onClaimResult,
2413
+ onLineUnsupported,
2414
+ onConnectFailed,
2415
+ onDisconnect,
2287
2416
  setTimeout: scheduleTimeout = globalThis.setTimeout,
2288
2417
  clearTimeout: cancelTimeout = globalThis.clearTimeout,
2289
2418
  random = Math.random
@@ -2294,6 +2423,8 @@ function startPresenceSocket(params) {
2294
2423
  let reconnectTimer = null;
2295
2424
  let agents = /* @__PURE__ */ new Map();
2296
2425
  let lastReportedOnCall = null;
2426
+ let sentOnCall = null;
2427
+ let line = null;
2297
2428
  let connection = null;
2298
2429
  let byeOnArrival = false;
2299
2430
  const stop = (options) => {
@@ -2310,6 +2441,12 @@ function startPresenceSocket(params) {
2310
2441
  connection?.close();
2311
2442
  connection = null;
2312
2443
  };
2444
+ const send = (onCall) => {
2445
+ if (!connection) return;
2446
+ connection.reportCallState(onCall);
2447
+ sentOnCall = onCall;
2448
+ };
2449
+ const mayReport = (onCall) => line !== "elsewhere" || !onCall && sentOnCall === true;
2313
2450
  const scheduleReconnect = () => {
2314
2451
  if (stopped) return;
2315
2452
  attempt += 1;
@@ -2338,10 +2475,23 @@ function startPresenceSocket(params) {
2338
2475
  if (connection === null) return;
2339
2476
  connection = null;
2340
2477
  scheduleReconnect();
2478
+ onDisconnect?.();
2479
+ },
2480
+ onLine: (update) => {
2481
+ if (stopped) return;
2482
+ const was = line;
2483
+ line = update.state;
2484
+ if (was === "elsewhere" && line === "here" && lastReportedOnCall !== null) send(lastReportedOnCall);
2485
+ onLine?.(update);
2486
+ },
2487
+ onClaimResult: (result) => {
2488
+ if (!stopped) onClaimResult?.(result);
2341
2489
  }
2342
2490
  });
2343
2491
  } catch {
2344
- if (!stopped) scheduleReconnect();
2492
+ if (stopped) return;
2493
+ scheduleReconnect();
2494
+ onConnectFailed?.();
2345
2495
  return;
2346
2496
  }
2347
2497
  if (stopped) {
@@ -2351,23 +2501,64 @@ function startPresenceSocket(params) {
2351
2501
  }
2352
2502
  connection = opened;
2353
2503
  attempt = 0;
2354
- if (lastReportedOnCall !== null) connection.reportCallState(lastReportedOnCall);
2504
+ sentOnCall = null;
2505
+ line = null;
2506
+ if (clientId !== void 0 && opened.hello) opened.hello(clientId);
2507
+ else onLineUnsupported?.();
2508
+ if (lastReportedOnCall !== null) send(lastReportedOnCall);
2355
2509
  };
2356
2510
  void connect();
2357
2511
  return {
2358
2512
  reportCallState: (onCall) => {
2359
2513
  if (onCall === lastReportedOnCall) return;
2360
2514
  lastReportedOnCall = onCall;
2361
- connection?.reportCallState(onCall);
2515
+ if (mayReport(onCall)) send(onCall);
2516
+ },
2517
+ claimLine: () => {
2518
+ if (!connection?.claimLine) return false;
2519
+ connection.claimLine();
2520
+ return true;
2362
2521
  },
2363
2522
  stop
2364
2523
  };
2365
2524
  }
2366
2525
 
2526
+ // src/core/line.ts
2527
+ var CLIENT_ID_KEY = "coventia-softphone:client-id";
2528
+ var CLIENT_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/;
2529
+ function lineClientId() {
2530
+ const stored = readStoredId();
2531
+ if (stored) return stored;
2532
+ const id = freshClientId();
2533
+ try {
2534
+ if (isBrowser()) globalThis.sessionStorage?.setItem(CLIENT_ID_KEY, id);
2535
+ } catch {
2536
+ }
2537
+ return id;
2538
+ }
2539
+ function readStoredId() {
2540
+ if (!isBrowser()) return null;
2541
+ try {
2542
+ const value = globalThis.sessionStorage?.getItem(CLIENT_ID_KEY);
2543
+ return value && CLIENT_ID_PATTERN.test(value) ? value : null;
2544
+ } catch {
2545
+ return null;
2546
+ }
2547
+ }
2548
+ function freshClientId() {
2549
+ const uuid = globalThis.crypto?.randomUUID?.();
2550
+ if (uuid) return uuid;
2551
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
2552
+ }
2553
+
2367
2554
  // src/core/softphone-impl.ts
2368
2555
  init_errors();
2369
2556
  var liveInstance = false;
2370
2557
  var PRESENCE_TEARDOWN_GRACE_MS = 1e4;
2558
+ var LINE_ANSWER_TIMEOUT_MS = 4e3;
2559
+ var CALL_DECISION_TIMEOUT_MS = LINE_ANSWER_TIMEOUT_MS + 2e3;
2560
+ var LINE_CLAIM_TIMEOUT_MS = 1e4;
2561
+ var LINE_LAPSE_MS = 1e4;
2371
2562
  var BrowserAudioDevices = class {
2372
2563
  inputs = [];
2373
2564
  outputs = [];
@@ -2475,6 +2666,8 @@ var SoftphoneImpl = class extends Emitter {
2475
2666
  #activeCall = null;
2476
2667
  #destroyed = false;
2477
2668
  #startedHere = false;
2669
+ /** Bumped by every `stop()`, so a `start()` still waiting to lead can tell it was stopped. */
2670
+ #run = 0;
2478
2671
  #isLeader = false;
2479
2672
  #engine;
2480
2673
  #tabLock;
@@ -2499,6 +2692,52 @@ var SoftphoneImpl = class extends Emitter {
2499
2692
  /** A pending state-driven teardown of the watch — see `PRESENCE_TEARDOWN_GRACE_MS`. */
2500
2693
  #presenceTeardownTimer = null;
2501
2694
  #presenceAgents = [];
2695
+ // ── The line owner (`docs/specs/line-owner.md` §5) ──────────────────────────────────────────────
2696
+ // Heard over the presence watch: the server names ONE softphone per extension that listens, and this one
2697
+ // runs its engine only while that is it (or nobody can say). `#engineOn` and `#standby` are the two halves
2698
+ // of that decision, kept apart because neither is true while `start()` is still waiting to make it.
2699
+ #line = "unknown";
2700
+ #lineWhere = null;
2701
+ /** How the last attempt to bring the line here went (`lineClaim`), whoever made it. */
2702
+ #lineClaim = "idle";
2703
+ /** This tab's id in `hello` (`lineClientId`), read once — one id for every reconnect of this softphone. */
2704
+ #clientId = null;
2705
+ /** What the line wants: the engine running. Set the moment the line decides; the engine follows (`#queueEngine`). */
2706
+ #engineOn = false;
2707
+ /**
2708
+ * What the engine was last TOLD by the queue — started or stopped. Apart from `#engineOn` because the line
2709
+ * can flip twice while a start is still running: the stop and start queued meanwhile then find nothing
2710
+ * to do, instead of stopping the engine (and the softphone with it) for a moment.
2711
+ */
2712
+ #engineRunning = false;
2713
+ /** Waiting in `standby`: the engine is stopped ON PURPOSE, so its own `stopped` is not the softphone's. */
2714
+ #standby = false;
2715
+ /**
2716
+ * Every engine start and stop the line asks for, one after another: a promotion can follow a demotion
2717
+ * within a moment, and an engine started while its own stop is still running can end up neither.
2718
+ */
2719
+ #engineSteps = Promise.resolve();
2720
+ /** Dials in flight (`call()` awaiting `placeCall`): a call about to exist holds the line like one that does. */
2721
+ #dialing = 0;
2722
+ /**
2723
+ * Open from `start()`'s first line until it has decided whether the engine runs — or given up (stopped,
2724
+ * destroyed, failed). `call()` waits on it, so it never dials ahead of that decision: not on a phone
2725
+ * stopped meanwhile, and not without the line.
2726
+ */
2727
+ #startDecision = null;
2728
+ /**
2729
+ * Who decides whether the engine runs. `off`: nobody — not started, a follower, or `stop()` has begun
2730
+ * (whatever its teardown sets off, like a call ending, must not start anything). `deciding`: `start()`
2731
+ * has leadership and owes the FIRST decision itself, since it must await the engine start — a line
2732
+ * message meanwhile only records what it says. `on`: the line decides (`#applyLine`).
2733
+ */
2734
+ #lineMode = "off";
2735
+ /** Ends `start()`'s wait for the server's first word on the line early (`#waitForFirstLine`). */
2736
+ #releaseFirstLine = null;
2737
+ /** The `takeLine()` in flight — one at a time; a second caller shares it. */
2738
+ #claim = null;
2739
+ /** Pending: what the server said about the line stops counting (`LINE_LAPSE_MS`). */
2740
+ #lineLapseTimer = null;
2502
2741
  /**
2503
2742
  * `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
2504
2743
  * internal seam, not something an integrator tunes). Tests construct this class directly with a
@@ -2516,7 +2755,9 @@ var SoftphoneImpl = class extends Emitter {
2516
2755
  this.sources = config.coventia || config.apiBase ? coventiaBackend(config.coventia ?? {}, cachedTokenSource(config.tokenSource), config.apiBase) : null;
2517
2756
  this.#resolveContact = config.resolveContact ?? this.sources?.resolveContact;
2518
2757
  this.#engine.onIncoming((engineCall) => this.#onIncoming(engineCall));
2519
- this.#engine.onStateChange((state) => this.#setState(state));
2758
+ this.#engine.onStateChange((state) => {
2759
+ if (!this.#standby) this.#setState(state);
2760
+ });
2520
2761
  this.#engine.onError?.((error) => this.#onError(error));
2521
2762
  }
2522
2763
  get state() {
@@ -2534,6 +2775,15 @@ var SoftphoneImpl = class extends Emitter {
2534
2775
  get presence() {
2535
2776
  return this.#presenceAgents;
2536
2777
  }
2778
+ get line() {
2779
+ return this.#line;
2780
+ }
2781
+ get lineWhere() {
2782
+ return this.#lineWhere;
2783
+ }
2784
+ get lineClaim() {
2785
+ return this.#lineClaim;
2786
+ }
2537
2787
  get canPlayAudio() {
2538
2788
  return true;
2539
2789
  }
@@ -2549,15 +2799,33 @@ var SoftphoneImpl = class extends Emitter {
2549
2799
  }
2550
2800
  liveInstance = true;
2551
2801
  this.#startedHere = true;
2552
- this.#setState("starting");
2553
- await this.#tabLock.acquireLeadership();
2554
- if (this.#destroyed) return;
2555
- this.#setLeader(true);
2556
- await this.#engine.start();
2802
+ const run = this.#run;
2803
+ const decision = this.#openStartDecision();
2804
+ try {
2805
+ this.#setState("starting");
2806
+ await this.#tabLock.acquireLeadership();
2807
+ if (this.#destroyed || run !== this.#run) return;
2808
+ this.#lineMode = "deciding";
2809
+ this.#setLeader(true);
2810
+ if (this.#presenceWatch && this.#line === "unknown") await this.#waitForFirstLine();
2811
+ if (this.#destroyed || run !== this.#run) return;
2812
+ this.#lineMode = "on";
2813
+ const engineStart = this.#applyLine();
2814
+ decision.end();
2815
+ await engineStart;
2816
+ } finally {
2817
+ decision.end();
2818
+ }
2557
2819
  }
2558
2820
  async stop() {
2559
2821
  if (!this.#startedHere) return;
2822
+ this.#run += 1;
2823
+ this.#lineMode = "off";
2560
2824
  this.#sayGoodbye();
2825
+ this.#startDecision?.end();
2826
+ this.#standby = false;
2827
+ this.#engineOn = false;
2828
+ this.#engineRunning = false;
2561
2829
  this.#tabLock.release();
2562
2830
  if (this.#isLeader) {
2563
2831
  await this.#engine.stop();
@@ -2579,16 +2847,50 @@ var SoftphoneImpl = class extends Emitter {
2579
2847
  this.#setState("destroyed");
2580
2848
  this.clear();
2581
2849
  }
2850
+ async takeLine() {
2851
+ this.#assertUsable();
2852
+ if (this.#line === "here") return;
2853
+ if (this.#claim) return this.#claim.promise;
2854
+ const watch = this.#presenceWatch;
2855
+ if (!watch) {
2856
+ this.#setLineClaim("failed");
2857
+ throw createError("line/unavailable");
2858
+ }
2859
+ const claim = this.#openClaim();
2860
+ if (!watch.claimLine()) this.#settleClaim(createError("line/unavailable"));
2861
+ return claim;
2862
+ }
2582
2863
  async call(destination, options) {
2583
2864
  this.#assertUsable();
2865
+ const run = this.#run;
2866
+ if (this.#startDecision) {
2867
+ let timer;
2868
+ const decided = await Promise.race([
2869
+ this.#startDecision.promise.then(() => true),
2870
+ new Promise((resolve) => {
2871
+ timer = setTimeout(() => resolve(false), CALL_DECISION_TIMEOUT_MS);
2872
+ })
2873
+ ]);
2874
+ clearTimeout(timer);
2875
+ if (!decided) throw createError("engine/not-started");
2876
+ }
2877
+ if (run === this.#run && this.#state === "standby") await this.takeLine();
2878
+ this.#assertUsable();
2879
+ if (run !== this.#run) throw createError("engine/not-started");
2584
2880
  if (this.#state !== "online" && this.#state !== "offline" && this.#state !== "starting") {
2585
2881
  throw new Error(`Cannot place a call while softphone is '${this.#state}'.`);
2586
2882
  }
2587
- if (this.#activeCall) {
2883
+ if (this.#activeCall || this.#dialing > 0) {
2588
2884
  throw new Error("A call is already in progress. Hang up before placing another.");
2589
2885
  }
2590
- const engineCall = await this.#engine.placeCall(destination, options);
2591
- return this.#adopt(engineCall);
2886
+ this.#dialing += 1;
2887
+ try {
2888
+ const engineCall = await this.#engine.placeCall(destination, options);
2889
+ return this.#adopt(engineCall);
2890
+ } finally {
2891
+ this.#dialing -= 1;
2892
+ this.#syncLine();
2893
+ }
2592
2894
  }
2593
2895
  // ── internals ───────────────────────────────────────────────────────────────
2594
2896
  #onIncoming(engineCall) {
@@ -2617,6 +2919,7 @@ var SoftphoneImpl = class extends Emitter {
2617
2919
  this.#activeCall = null;
2618
2920
  this.emit("activeCallChanged" /* ActiveCallChanged */, { call: null });
2619
2921
  this.#syncCallState();
2922
+ this.#syncLine();
2620
2923
  }
2621
2924
  });
2622
2925
  if (this.#resolveContact) {
@@ -2646,7 +2949,8 @@ var SoftphoneImpl = class extends Emitter {
2646
2949
  /**
2647
2950
  * Reports the current on-a-call value to the backend. The watch de-duplicates (it only sends on change) and
2648
2951
  * remembers the latest value to replay after a reconnect, so this can be called on every transition without
2649
- * ceremony. A no-op while there is no presence watch (not online, not the leader, no backend configured).
2952
+ * ceremony — and holds it back while the line is elsewhere, since only the owner's reports count (line-owner
2953
+ * R8). A no-op while there is no presence watch (stopped, not the leader, no backend configured).
2650
2954
  */
2651
2955
  #syncCallState() {
2652
2956
  this.#presenceWatch?.reportCallState(this.#isOnCall());
@@ -2667,17 +2971,22 @@ var SoftphoneImpl = class extends Emitter {
2667
2971
  * `presence-socket.ts`'s own `getIsLeader` is checked at start and before each reconnect attempt,
2668
2972
  * not on a poll tick the way `startTransferWatch`'s is — see that module's own doc comment for why.
2669
2973
  * This still passes `getIsLeader: () => this.#isLeader` for that narrower, defensive recheck; the
2670
- * BROAD online/leader gate — whether to be connected AT ALL — is owned entirely here, the same
2974
+ * BROAD started/leader gate — whether to be connected AT ALL — is owned entirely here, the same
2671
2975
  * split `#onTransfer` already has with `startTransferWatch`.
2672
2976
  *
2673
- * One exception to "tear down the moment `online` is lost": a leader that is merely `offline` or
2674
- * `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a blip shorter than that never touches
2675
- * the presence socket at all. Anything else (stopped, starting, destroyed, or no longer the leader) still
2676
- * tears it down at once: those are not blips, and a follower must never hold a connection.
2977
+ * The watch runs for a leader that is `starting`, `online` or in `standby` — not only `online`, as it did
2978
+ * before the line owner (`docs/specs/line-owner.md` §5.4): while starting it is how this softphone learns
2979
+ * whether to listen at all, and in standby how it learns the line came back.
2980
+ *
2981
+ * A leader that is merely `offline` or `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a
2982
+ * blip shorter than that never touches the presence socket at all; a longer outage closes it, which lets
2983
+ * the server hand the line to a softphone that can still take calls. Anything else (stopped, destroyed, or
2984
+ * no longer the leader) tears it down at once: those are not blips, and a follower must never hold a
2985
+ * connection.
2677
2986
  */
2678
2987
  #syncPresenceWatch() {
2679
2988
  const eligible = Boolean(this.sources) && this.#isLeader;
2680
- const shouldRun = eligible && this.#state === "online";
2989
+ const shouldRun = eligible && this.#presenceRunsIn(this.#state);
2681
2990
  const blip = eligible && (this.#state === "offline" || this.#state === "reconnecting");
2682
2991
  if (!blip) this.#cancelPresenceTeardown();
2683
2992
  if (blip && this.#presenceWatch !== null) {
@@ -2686,13 +2995,22 @@ var SoftphoneImpl = class extends Emitter {
2686
2995
  }
2687
2996
  if (shouldRun === (this.#presenceWatch !== null)) return;
2688
2997
  if (shouldRun) {
2998
+ this.#clientId ??= lineClientId();
2689
2999
  this.#presenceWatch = startPresenceSocket({
2690
3000
  source: this.sources.presence,
2691
3001
  getIsLeader: () => this.#isLeader,
2692
3002
  onAgentsChanged: (agents) => {
2693
3003
  this.#presenceAgents = agents;
2694
3004
  this.emit("presenceChanged" /* PresenceChanged */, { agents });
2695
- }
3005
+ },
3006
+ clientId: this.#clientId,
3007
+ onLine: (update) => this.#onLine(update),
3008
+ onClaimResult: (result) => this.#onClaimResult(result),
3009
+ // A source that cannot take part will never answer, and one that cannot connect cannot answer yet:
3010
+ // `start()` need not wait out its 4 s for either (R7). A later answer still applies.
3011
+ onLineUnsupported: () => this.#releaseFirstLine?.(),
3012
+ onConnectFailed: () => this.#releaseFirstLine?.(),
3013
+ onDisconnect: () => this.#onPresenceDisconnect()
2696
3014
  });
2697
3015
  this.#syncCallState();
2698
3016
  } else {
@@ -2707,19 +3025,24 @@ var SoftphoneImpl = class extends Emitter {
2707
3025
  if (this.#presenceTeardownTimer !== null) return;
2708
3026
  this.#presenceTeardownTimer = setTimeout(() => {
2709
3027
  this.#presenceTeardownTimer = null;
2710
- if (this.#state === "online" && this.#isLeader) return;
3028
+ if (this.#isLeader && this.#presenceRunsIn(this.#state)) return;
2711
3029
  this.#stopPresence();
2712
3030
  }, PRESENCE_TEARDOWN_GRACE_MS);
2713
3031
  }
3032
+ /** The states in which a leader keeps its presence watch with no clock running (`#syncPresenceWatch`). */
3033
+ #presenceRunsIn(state) {
3034
+ return state === "online" || state === "starting" || state === "standby";
3035
+ }
2714
3036
  #cancelPresenceTeardown() {
2715
3037
  if (this.#presenceTeardownTimer === null) return;
2716
3038
  clearTimeout(this.#presenceTeardownTimer);
2717
3039
  this.#presenceTeardownTimer = null;
2718
3040
  }
2719
3041
  /**
2720
- * Stops the presence watch and forgets its roster. A STATE-DRIVEN teardown (the softphone left `online`
2721
- * for good, or lost leadership) passes nothing and says no goodbye; only a deliberate stop passes `bye`.
2722
- * Any pending grace-period teardown is cancelled — there is nothing left for it to do.
3042
+ * Stops the presence watch and forgets its roster, and the line. A STATE-DRIVEN teardown (an outage
3043
+ * outlasted the grace, the engine stopped, or leadership was lost) passes nothing and says no goodbye;
3044
+ * only a deliberate stop passes `bye`. Any pending grace-period teardown is cancelled — there is nothing
3045
+ * left for it to do.
2723
3046
  */
2724
3047
  #stopPresence(options) {
2725
3048
  this.#cancelPresenceTeardown();
@@ -2728,6 +3051,7 @@ var SoftphoneImpl = class extends Emitter {
2728
3051
  this.#presenceWatch = null;
2729
3052
  this.#presenceAgents = [];
2730
3053
  this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
3054
+ this.#forgetLine();
2731
3055
  }
2732
3056
  /**
2733
3057
  * A DELIBERATE stop (`stop()` / `destroy()`): say any call is over, then say goodbye, so the backend drops
@@ -2737,6 +3061,195 @@ var SoftphoneImpl = class extends Emitter {
2737
3061
  this.#presenceWatch?.reportCallState(false);
2738
3062
  this.#stopPresence({ bye: true });
2739
3063
  }
3064
+ // ── the line owner (`docs/specs/line-owner.md` §5) ──────────────────────────────────────────────
3065
+ /**
3066
+ * THE decision: from where the line is and whether a call is in progress, whether the engine runs, and
3067
+ * the state that says so. Synchronous, so the state moves the instant the line does — the waiting screen
3068
+ * goes the moment the line comes here, and a `call()` that took the line dials from `starting` without
3069
+ * waiting out the engine start. Only the engine start or stop is queued (`#queueEngine`). Returns that
3070
+ * queued step, or `null` when nothing had to change.
3071
+ */
3072
+ #applyLine() {
3073
+ if (this.#lineMode !== "on") return null;
3074
+ const run = this.#run;
3075
+ if (this.#line === "elsewhere") {
3076
+ if (this.#standby || this.#activeCall || this.#dialing > 0) return null;
3077
+ this.#standby = true;
3078
+ this.#setState("standby");
3079
+ if (!this.#engineOn) return null;
3080
+ this.#engineOn = false;
3081
+ return this.#queueEngine(run);
3082
+ }
3083
+ if (this.#engineOn) return null;
3084
+ this.#engineOn = true;
3085
+ if (this.#standby) {
3086
+ this.#standby = false;
3087
+ this.#setState("starting");
3088
+ }
3089
+ return this.#queueEngine(run);
3090
+ }
3091
+ /** `#applyLine` from an event — nobody to hand a failure to, and `#engineStartFailed` has reported it. */
3092
+ #syncLine() {
3093
+ void this.#applyLine()?.catch(() => {
3094
+ });
3095
+ }
3096
+ /**
3097
+ * Brings the engine to what the line wants (`#engineOn`) once every step already queued has run — and does
3098
+ * nothing when it is already there: the line can flip twice while a start is still running, and the stop
3099
+ * and start queued meanwhile must not take the engine (and the softphone's state) down and up for nothing.
3100
+ * Nothing at all once `stop()` has run since `run` was read: a stopped softphone's engine is `stop()`'s
3101
+ * alone. Rejects with a failed start's cause; the queue itself never rejects, so one failure blocks nothing.
3102
+ */
3103
+ #queueEngine(run) {
3104
+ const step = this.#engineSteps.then(async () => {
3105
+ if (run !== this.#run || this.#engineOn === this.#engineRunning) return;
3106
+ this.#engineRunning = this.#engineOn;
3107
+ if (this.#engineRunning) await this.#startEngine(run);
3108
+ else await this.#engine.stop();
3109
+ });
3110
+ this.#engineSteps = step.catch(() => {
3111
+ });
3112
+ return step;
3113
+ }
3114
+ async #startEngine(run) {
3115
+ const reported = this.#lastError;
3116
+ try {
3117
+ await this.#engine.start();
3118
+ } catch (cause) {
3119
+ if (run === this.#run) this.#engineStartFailed(cause, reported);
3120
+ throw cause;
3121
+ }
3122
+ }
3123
+ /**
3124
+ * An engine start the line asked for failed. Kept as it was, this softphone would stay the owner on the
3125
+ * server — `starting`, its presence socket renewing the line — with no push socket anywhere: nobody would
3126
+ * listen. So it gives the line up the ordinary way, by closing its presence socket without a goodbye (the
3127
+ * phone was not switched off): the server hands the line on after its grace (R4). The failure is reported
3128
+ * like any engine failure — `SoftphoneEvent.Error` and `lastError`, which is the widget's error gate and
3129
+ * its "Reintentar" (`stop()` then `start()`, which asks for the line again). Nothing retries on its own.
3130
+ */
3131
+ #engineStartFailed(cause, reportedBefore) {
3132
+ this.#engineOn = false;
3133
+ this.#engineRunning = false;
3134
+ this.#lineMode = "off";
3135
+ if (this.#lastError === reportedBefore) {
3136
+ this.#onError(isSoftphoneError(cause) && isFatal(cause) ? cause : createError("net/transport-failed", cause));
3137
+ }
3138
+ this.#stopPresence();
3139
+ this.#setState("offline");
3140
+ }
3141
+ /** `start()`'s decision (`#startDecision`): ended once, by whichever comes first — the decision or its end. */
3142
+ #openStartDecision() {
3143
+ let resolve;
3144
+ const promise = new Promise((r) => {
3145
+ resolve = r;
3146
+ });
3147
+ const decision = {
3148
+ promise,
3149
+ end: () => {
3150
+ resolve();
3151
+ if (this.#startDecision === decision) this.#startDecision = null;
3152
+ }
3153
+ };
3154
+ this.#startDecision = decision;
3155
+ return decision;
3156
+ }
3157
+ /**
3158
+ * `start()`'s wait for the server's first word on the line — released by that word, by a source that
3159
+ * will never say it, by the watch going away (`stop()` among them), or after `LINE_ANSWER_TIMEOUT_MS`.
3160
+ */
3161
+ #waitForFirstLine() {
3162
+ return new Promise((resolve) => {
3163
+ const release = () => {
3164
+ clearTimeout(timer);
3165
+ if (this.#releaseFirstLine === release) this.#releaseFirstLine = null;
3166
+ resolve();
3167
+ };
3168
+ const timer = setTimeout(release, LINE_ANSWER_TIMEOUT_MS);
3169
+ this.#releaseFirstLine = release;
3170
+ });
3171
+ }
3172
+ #onLine(update) {
3173
+ this.#cancelLineLapse();
3174
+ this.#setLine(update.state, update.state === "here" ? null : update.where);
3175
+ if (this.#releaseFirstLine) this.#releaseFirstLine();
3176
+ else this.#syncLine();
3177
+ if (update.state === "here") this.#settleClaim();
3178
+ }
3179
+ #onClaimResult(result) {
3180
+ if (result.ok) {
3181
+ if (this.#line === "here") this.#settleClaim();
3182
+ return;
3183
+ }
3184
+ this.#settleClaim(createError(result.reason === "on_call" ? "line/claim-refused" : "line/unavailable"));
3185
+ }
3186
+ /**
3187
+ * The presence socket dropped; the watch is reconnecting. A claim sent on it will never be answered, so
3188
+ * it fails now rather than on its timeout. What the server said about the line holds for `LINE_LAPSE_MS`
3189
+ * — a reconnect is normally answered well inside that — and then stops counting: `unknown`, which a
3190
+ * waiting softphone answers by listening again.
3191
+ */
3192
+ #onPresenceDisconnect() {
3193
+ this.#settleClaim(createError("line/unavailable"));
3194
+ if (this.#line === "unknown" || this.#lineLapseTimer !== null) return;
3195
+ this.#lineLapseTimer = setTimeout(() => {
3196
+ this.#lineLapseTimer = null;
3197
+ this.#setLine("unknown", null);
3198
+ this.#syncLine();
3199
+ }, LINE_LAPSE_MS);
3200
+ }
3201
+ #cancelLineLapse() {
3202
+ if (this.#lineLapseTimer === null) return;
3203
+ clearTimeout(this.#lineLapseTimer);
3204
+ this.#lineLapseTimer = null;
3205
+ }
3206
+ /**
3207
+ * The presence watch is gone, so nothing the server said about the line holds any more: it reads
3208
+ * `unknown`, and every wait on it ends. Not followed by `#applyLine` — this runs inside a state change
3209
+ * (`#syncPresenceWatch`) or a stop, and a softphone whose watch is gone is not in `standby` anyway: that
3210
+ * state keeps the watch.
3211
+ */
3212
+ #forgetLine() {
3213
+ this.#cancelLineLapse();
3214
+ this.#releaseFirstLine?.();
3215
+ this.#settleClaim(createError("line/unavailable"));
3216
+ this.#setLine("unknown", null);
3217
+ this.#setLineClaim("idle");
3218
+ }
3219
+ #setLine(line, where) {
3220
+ if (line === "here") this.#setLineClaim("idle");
3221
+ if (line === this.#line && where === this.#lineWhere) return;
3222
+ this.#line = line;
3223
+ this.#lineWhere = where;
3224
+ this.emit("lineChanged" /* LineChanged */, { line, where });
3225
+ }
3226
+ #setLineClaim(claim) {
3227
+ if (claim === this.#lineClaim) return;
3228
+ this.#lineClaim = claim;
3229
+ this.emit("lineClaimChanged" /* LineClaimChanged */, { claim });
3230
+ }
3231
+ #openClaim() {
3232
+ let settle;
3233
+ const promise = new Promise((resolve, reject) => {
3234
+ const timer = setTimeout(() => this.#settleClaim(createError("line/unavailable")), LINE_CLAIM_TIMEOUT_MS);
3235
+ settle = (error) => {
3236
+ clearTimeout(timer);
3237
+ if (error) reject(error);
3238
+ else resolve();
3239
+ };
3240
+ });
3241
+ this.#claim = { promise, settle };
3242
+ this.#setLineClaim("claiming");
3243
+ return promise;
3244
+ }
3245
+ /** Ends the claim in flight, if any — resolved without `error`, rejected with it. */
3246
+ #settleClaim(error) {
3247
+ const claim = this.#claim;
3248
+ if (!claim) return;
3249
+ this.#claim = null;
3250
+ this.#setLineClaim(!error ? "idle" : error.code === "line/claim-refused" ? "refused" : "failed");
3251
+ claim.settle(error);
3252
+ }
2740
3253
  /**
2741
3254
  * Phase A (transfer outcome relay, `docs/specs/transfer-outcome-relay.md`) — `CallImpl.transfer()`'s
2742
3255
  * internal seam calls this. No-ops entirely when no Coventia backend is configured (`sources` is
@@ -2785,6 +3298,7 @@ var SoftphoneImpl = class extends Emitter {
2785
3298
  };
2786
3299
 
2787
3300
  // src/core/default-provider.ts
3301
+ init_errors();
2788
3302
  var LazyRelay = class {
2789
3303
  #listeners = /* @__PURE__ */ new Set();
2790
3304
  #realOff = /* @__PURE__ */ new Map();
@@ -2807,6 +3321,7 @@ var LazyRelay = class {
2807
3321
  function defaultZadarmaEngine(config) {
2808
3322
  let real = null;
2809
3323
  let pending = null;
3324
+ let run = 0;
2810
3325
  const incoming = new LazyRelay();
2811
3326
  const stateChange = new LazyRelay();
2812
3327
  const error = new LazyRelay();
@@ -2830,13 +3345,20 @@ function defaultZadarmaEngine(config) {
2830
3345
  return real?.state ?? "stopped";
2831
3346
  },
2832
3347
  async start() {
2833
- await (await load()).start();
3348
+ const current = run;
3349
+ const engine = await load();
3350
+ if (current !== run) return;
3351
+ await engine.start();
2834
3352
  },
2835
3353
  async stop() {
3354
+ run += 1;
2836
3355
  if (real) await real.stop();
2837
3356
  },
2838
3357
  async placeCall(destination, options) {
2839
- return (await load()).placeCall(destination, options);
3358
+ const current = run;
3359
+ const engine = await load();
3360
+ if (current !== run) throw createError("engine/not-started");
3361
+ return engine.placeCall(destination, options);
2840
3362
  },
2841
3363
  onIncoming: (listener) => incoming.subscribe(listener),
2842
3364
  onStateChange: (listener) => stateChange.subscribe(listener),
@@ -2845,8 +3367,18 @@ function defaultZadarmaEngine(config) {
2845
3367
  }
2846
3368
 
2847
3369
  // src/core/create.ts
3370
+ init_api_base();
2848
3371
  function createSoftphone(config) {
2849
- return new SoftphoneImpl(resolveEngine(config), config);
3372
+ const resolved = withNormalizedBases(config);
3373
+ return new SoftphoneImpl(resolveEngine(resolved), resolved);
3374
+ }
3375
+ function withNormalizedBases(config) {
3376
+ const { apiBase, coventia } = config;
3377
+ return {
3378
+ ...config,
3379
+ ...apiBase ? { apiBase: normalizeApiBase(apiBase) } : {},
3380
+ ...coventia?.baseUrl ? { coventia: { ...coventia, baseUrl: normalizeApiBase(coventia.baseUrl) } } : {}
3381
+ };
2850
3382
  }
2851
3383
  function resolveEngine(config) {
2852
3384
  if (config.provider !== void 0) {
@@ -2873,7 +3405,7 @@ function isCallEngine(value) {
2873
3405
  // src/index.ts
2874
3406
  init_errors();
2875
3407
  init_outbound_numbers();
2876
- var VERSION = "0.4.0";
3408
+ var VERSION = "0.5.0";
2877
3409
 
2878
3410
  exports.CALL_TRANSITIONS = CALL_TRANSITIONS;
2879
3411
  exports.CallEvent = CallEvent;