@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.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,19 @@ 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
+ );
354
+ } else if (msg.type === "offer" && typeof msg.callId === "string" && msg.callId) {
355
+ const from = typeof msg.from === "string" && msg.from ? msg.from : null;
356
+ if (msg.state === "start") {
357
+ callbacks.onOffer?.({ state: "start", callId: msg.callId, from });
358
+ } else if (msg.state === "end") {
359
+ callbacks.onOffer?.(from ? { state: "end", callId: msg.callId, from } : { state: "end", callId: msg.callId });
360
+ }
338
361
  }
339
362
  };
340
363
  ws.onclose = () => {
@@ -478,7 +501,7 @@ function coventiaSources(config) {
478
501
  outboundNumbers
479
502
  };
480
503
  }
481
- var toContact, toDirection, otherParty, isMissed, toOutcome, toDate, toTransferOutcome, toPresenceState, toPresenceEntry, toWebSocketUrl;
504
+ var toContact, toDirection, otherParty, isMissed, toOutcome, toDate, toTransferOutcome, toPresenceState, toPresenceEntry, toLineLocation, toWebSocketUrl;
482
505
  var init_sources2 = __esm({
483
506
  "src/coventia/sources.ts"() {
484
507
  init_sources();
@@ -527,6 +550,7 @@ var init_sources2 = __esm({
527
550
  name: row.name ?? null,
528
551
  state: toPresenceState(row.state)
529
552
  });
553
+ toLineLocation = (raw) => raw === "desktop" || raw === "browser" ? raw : null;
530
554
  toWebSocketUrl = (httpUrl) => httpUrl.replace(/^http/, "ws").replace(/\/+$/, "");
531
555
  }
532
556
  });
@@ -679,6 +703,19 @@ var init_errors = __esm({
679
703
  message: "The provider does not support this operation.",
680
704
  solution: "Guard the UI on the operation before offering it."
681
705
  },
706
+ // ── Line owner (docs/specs/line-owner.md §5) ────────────────────────────────
707
+ // What `takeLine()` rejects with — and `call()` from `standby`, which takes the line first. Never
708
+ // emitted on `SoftphoneEvent.Error`: the promise IS the answer, and nothing about the phone is broken.
709
+ "line/claim-refused": {
710
+ fatal: false,
711
+ message: "The line was not moved here: the user is on a call on the softphone that has it.",
712
+ 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."
713
+ },
714
+ "line/unavailable": {
715
+ fatal: false,
716
+ message: "The line could not be taken: nothing decided where it is.",
717
+ 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."
718
+ },
682
719
  // ── Provider ────────────────────────────────────────────────────────────────
683
720
  "provider/provision-failed": {
684
721
  fatal: true,
@@ -946,21 +983,53 @@ var init_session = __esm({
946
983
  });
947
984
 
948
985
  // src/transports/sip/ua.ts
986
+ function inviteTransactionOf(session) {
987
+ const tx = session._request?.transaction;
988
+ return tx && typeof tx.state === "number" && typeof tx.on === "function" ? tx : null;
989
+ }
990
+ function awaitsAnswer(tx) {
991
+ return tx.state === ICT_CALLING || tx.state === ICT_PROCEEDING;
992
+ }
993
+ function deferredCancelOf(session) {
994
+ const flag = session._is_canceled;
995
+ return typeof flag === "boolean" ? flag : null;
996
+ }
997
+ function sameNumber(a, b) {
998
+ if (!a || !b) return false;
999
+ if (a === b) return true;
1000
+ const [shorter, longer] = a.length < b.length ? [a, b] : [b, a];
1001
+ return shorter.length >= SUFFIX_MATCH_MIN_DIGITS && longer.endsWith(shorter);
1002
+ }
1003
+ function isRefusal(e) {
1004
+ const status = e?.response?.status_code;
1005
+ if (typeof status === "number") return status === 401 || status === 403;
1006
+ return e?.cause === "Authentication Error" || e?.cause === "Rejected";
1007
+ }
1008
+ function debug(message) {
1009
+ console.debug(`[softphone] ${message}`);
1010
+ }
949
1011
  function domainOf(credentials) {
950
1012
  const at = credentials.uri.lastIndexOf("@");
951
1013
  return at === -1 ? "" : credentials.uri.slice(at + 1);
952
1014
  }
953
- var DEFAULT_ICE_CAP_MS, REGISTER_EXPIRES, SIGNAL_REFRESH_DEBOUNCE_MS, ERROR_DEBOUNCE_MS, CALL_END_CODES, callSeq, nextCallId, SipCallEngine;
1015
+ var DEFAULT_ICE_CAP_MS, REGISTER_EXPIRES, ERROR_DEBOUNCE_MS, OFFER_FORGOTTEN_MS, DECLINE_GUARD_CEILING_MS, OFFER_PUSH_SLACK_MS, INVITE_TRANSACTION_MAX_MS, ICT_PROCEEDING, ICT_CALLING, SUFFIX_MATCH_MIN_DIGITS, CALL_END_CODES, callSeq, nextCallId, SipCallEngine;
954
1016
  var init_ua = __esm({
955
1017
  "src/transports/sip/ua.ts"() {
956
1018
  init_session();
957
1019
  init_media();
958
1020
  init_ice();
959
1021
  init_errors();
1022
+ init_sources();
960
1023
  DEFAULT_ICE_CAP_MS = 3e3;
961
1024
  REGISTER_EXPIRES = 580;
962
- SIGNAL_REFRESH_DEBOUNCE_MS = 3e4;
963
1025
  ERROR_DEBOUNCE_MS = 3e4;
1026
+ OFFER_FORGOTTEN_MS = 12e4;
1027
+ DECLINE_GUARD_CEILING_MS = 12e4;
1028
+ OFFER_PUSH_SLACK_MS = 1e4;
1029
+ INVITE_TRANSACTION_MAX_MS = 32e3;
1030
+ ICT_PROCEEDING = 2;
1031
+ ICT_CALLING = 3;
1032
+ SUFFIX_MATCH_MIN_DIGITS = 9;
964
1033
  CALL_END_CODES = {
965
1034
  busy: "call/busy",
966
1035
  "no-answer": "call/no-answer",
@@ -978,9 +1047,44 @@ var init_ua = __esm({
978
1047
  provider;
979
1048
  factory;
980
1049
  #state = "stopped";
1050
+ /**
1051
+ * Which `start()`…`stop()` cycle this is — bumped by `stop()`, which ends one. Everything that awaits
1052
+ * and then acts (builds a UA, subscribes to the signal, sets the state) captures it first and gives up
1053
+ * if a stop has happened since. Not bumped by `start()`: that would cancel work begun just before it,
1054
+ * such as a call placed while the softphone is still starting.
1055
+ *
1056
+ * Without it a stopped phone came back to life: a provisioning or a signal connect still in flight
1057
+ * when `stop()` ran finished afterwards and REGISTERed — and since the PBX routes a call to whichever
1058
+ * client registered last, it quietly took calls from the user's other device while its UI said off.
1059
+ */
1060
+ #run = 0;
981
1061
  #ua = null;
982
1062
  /** In-flight `#createUA()`. THE concurrency guard — see `#ensureUA`. */
983
1063
  #uaInFlight = null;
1064
+ /**
1065
+ * Bumped by every `#teardownUA`. A UA still being provisioned when its reason to exist went away — the offer
1066
+ * ended, the leak guard gave up — is then not built (`#createUA`): built anyway, it would REGISTER for nobody
1067
+ * and nothing would ever tear it down.
1068
+ */
1069
+ #uaEpoch = 0;
1070
+ /**
1071
+ * Dials waiting for the current UA to register (`#callWhenRegistered`): dialled by its `registered`, failed by
1072
+ * `#teardownUA` — but kept when the UA is only being rebuilt on fresh credentials (`#retryOnFreshCredentials`).
1073
+ */
1074
+ #awaitingRegistration = /* @__PURE__ */ new Set();
1075
+ /** `placeCall`s in progress, from before their UA exists: no offer's teardown may pull it from under them. */
1076
+ #dialing = 0;
1077
+ /**
1078
+ * Where the current UA's registration stands: `connecting` (its REGISTER goes out once the WSS is up),
1079
+ * `registering` (a REGISTER is out, unanswered), `registered` (the WSS is up and no REGISTER of ours is out — a
1080
+ * refresh that failed under a live call leaves it here too). What decides how an offer gets a REGISTER sent
1081
+ * after it (`#registerAfterOffer`).
1082
+ */
1083
+ #registration = "connecting";
1084
+ /** An offer arrived while a REGISTER sent BEFORE it was still unanswered: another goes out once it is. */
1085
+ #registerAgain = false;
1086
+ /** A UA has already been rebuilt on fresh credentials since the last registration that worked: not again. */
1087
+ #freshCredentialsTried = false;
984
1088
  #domain = "";
985
1089
  #activeCall = null;
986
1090
  /**
@@ -999,17 +1103,52 @@ var init_ua = __esm({
999
1103
  #errorListeners = /* @__PURE__ */ new Set();
1000
1104
  #signalUnsub = null;
1001
1105
  #signalConnectionUnsub = null;
1106
+ /** The offer waiting for its INVITE, if any (§4.3). */
1107
+ #offerWait = null;
1108
+ /**
1109
+ * The latest offer pushed while a call was in progress. It used to be dropped for good — the caller then waited
1110
+ * for the provider to offer it again. Now, when the call ends, it is registered for at once if it is still
1111
+ * within its wait (no `offer-ended` for it, inside the leak guard), so it rings as soon as the line is free.
1112
+ */
1113
+ #pendingOffer = null;
1114
+ /** Armed at the REGISTER's 200 OK while an offer waits (`noInviteAfterRegisterMs`). */
1002
1115
  #noInviteTimer;
1003
- #rejectGuardUntil = 0;
1004
- #signalRefreshAfter = 0;
1116
+ /**
1117
+ * What the backend has said about the calls offered to this line (`noteOffer`, §5), by `callId` — ended ones
1118
+ * included, for a while. Only for telling, at a decline, which call was declined and whether it is still alive.
1119
+ */
1120
+ #offers = /* @__PURE__ */ new Map();
1121
+ /** Declined calls whose re-offers are being ignored (§4.4). Pruned as they lapse. */
1122
+ #declined = [];
1123
+ /** CANCELs JsSIP still owes for outbound legs that ended before any ringback (§4.7, `#watchOwedCancel`). */
1124
+ #owedCancels = [];
1125
+ /** UAs let go of while owing one: un-REGISTERed, still connected — with the cap that stops them regardless. */
1126
+ #lingering = /* @__PURE__ */ new Map();
1005
1127
  #errorSentAfter = /* @__PURE__ */ new Map();
1006
1128
  now;
1007
1129
  #resolvedFactory = null;
1130
+ /** The loader in flight, shared: the pre-load at `start()` and a first UA racing it fetch the module once. */
1131
+ #factoryLoad = null;
1008
1132
  async #resolveFactory() {
1009
1133
  if (this.#resolvedFactory) return this.#resolvedFactory;
1010
- this.#resolvedFactory = typeof this.factory === "function" ? await this.factory() : this.factory;
1134
+ if (typeof this.factory !== "function") return this.#resolvedFactory = this.factory;
1135
+ const loader = this.factory;
1136
+ this.#factoryLoad ??= Promise.resolve().then(() => loader()).finally(() => {
1137
+ this.#factoryLoad = null;
1138
+ });
1139
+ this.#resolvedFactory = await this.#factoryLoad;
1011
1140
  return this.#resolvedFactory;
1012
1141
  }
1142
+ /**
1143
+ * Fetch the SIP stack now, not on the first call. It is loaded lazily (`import('jssip')`, SSR-safe — SPEC §10.2),
1144
+ * and that used to happen on the first UA: between the first push after a page load and its REGISTER, measured
1145
+ * 33–109 ms on localhost and more on a slow network or a throttled tab. Fire-and-forget: a failure here only
1146
+ * leaves the lazy path to try again when a UA is built (incoming-call-reliability.md §4.5).
1147
+ */
1148
+ #preloadFactory() {
1149
+ this.#resolveFactory().catch(() => {
1150
+ });
1151
+ }
1013
1152
  get state() {
1014
1153
  return this.#state;
1015
1154
  }
@@ -1063,15 +1202,29 @@ var init_ua = __esm({
1063
1202
  for (const listener of [...this.#errorListeners]) listener(error);
1064
1203
  }
1065
1204
  async start() {
1205
+ const run = this.#run;
1066
1206
  this.#setState("starting");
1067
1207
  if (this.provider.registrationMode === "persistent") {
1068
- await this.#ensureUA();
1208
+ try {
1209
+ await this.#ensureUA();
1210
+ } catch (cause) {
1211
+ if (run === this.#run) throw cause;
1212
+ }
1213
+ if (run !== this.#run) return;
1069
1214
  this.#setState("online");
1070
1215
  } else {
1216
+ this.#preloadFactory();
1071
1217
  const signal = this.provider.incomingSignal;
1072
1218
  if (signal) {
1073
- await signal.connect();
1074
- this.#signalUnsub = signal.onSignal(() => void this.#onIncomingSignal());
1219
+ try {
1220
+ await signal.connect();
1221
+ } catch (cause) {
1222
+ if (run !== this.#run) return;
1223
+ signal.disconnect();
1224
+ throw cause;
1225
+ }
1226
+ if (run !== this.#run) return;
1227
+ this.#signalUnsub = signal.onSignal((event) => this.#onIncomingSignal(event));
1075
1228
  this.#signalConnectionUnsub = signal.onConnectionChange?.(
1076
1229
  (connected) => this.#setState(connected ? "online" : "offline")
1077
1230
  ) ?? null;
@@ -1080,6 +1233,8 @@ var init_ua = __esm({
1080
1233
  }
1081
1234
  }
1082
1235
  async stop() {
1236
+ this.#run += 1;
1237
+ this.#pendingOffer = null;
1083
1238
  this.#clearNoInviteTimer();
1084
1239
  this.#signalUnsub?.();
1085
1240
  this.#signalUnsub = null;
@@ -1090,62 +1245,271 @@ var init_ua = __esm({
1090
1245
  this.#setState("stopped");
1091
1246
  }
1092
1247
  async placeCall(destination, _options) {
1093
- const ua = await this.#ensureUA();
1094
- const session = await this.#callWhenRegistered(ua, this.#buildTarget(destination));
1095
- return this.#wrapCall(session);
1248
+ this.#dialing += 1;
1249
+ const run = this.#run;
1250
+ try {
1251
+ await this.#ensureUA();
1252
+ return await this.#callWhenRegistered(this.#buildTarget(destination), run);
1253
+ } finally {
1254
+ this.#dialing -= 1;
1255
+ }
1256
+ }
1257
+ /**
1258
+ * What the backend says about calls offered to this line (incoming-call-reliability.md §5) — kept only to tell,
1259
+ * at a decline, WHICH call was declined, and so how long its re-offers last (§4.4).
1260
+ *
1261
+ * Per `callId`, every message is idempotent, and they come duplicated, with no order at all — not even between
1262
+ * the `start` and the `end` of one call, which reach different backend instances and are relayed through Redis,
1263
+ * and a stored offer replayed after `hello` can race either. So `ended` is sticky: a `start` after its call's
1264
+ * `end` never makes it live again. An `end` also reaches a softphone that is not the line's owner (a `start` only
1265
+ * the owner): stored all the same, whatever state this engine is in. `null`: the channel went away, so no `end`
1266
+ * can be counted on for the calls still live; a guard following one falls back to its window. What is known to
1267
+ * have ended stays known.
1268
+ */
1269
+ noteOffer(offer) {
1270
+ const now = this.now();
1271
+ for (const [callId, known2] of this.#offers) {
1272
+ if (now - known2.at >= OFFER_FORGOTTEN_MS) this.#offers.delete(callId);
1273
+ }
1274
+ if (offer === null) {
1275
+ for (const [callId, known2] of this.#offers) if (known2.endedAt === null) this.#offers.delete(callId);
1276
+ return;
1277
+ }
1278
+ const known = this.#offers.get(offer.callId);
1279
+ this.#offers.delete(offer.callId);
1280
+ const from = toDigits(offer.from ?? "") || (known?.from ?? "");
1281
+ const fallbackMs = this.#declineFallbackMs();
1282
+ if (offer.state === "end") {
1283
+ this.#offers.set(offer.callId, { from, at: now, endedAt: known?.endedAt ?? now });
1284
+ this.#declined = this.#declined.filter((declined) => declined.callId !== offer.callId);
1285
+ if (from) {
1286
+ this.#declined = this.#declined.map(
1287
+ (declined) => declined.adopts && now >= declined.since && sameNumber(declined.caller, from) ? { ...declined, adopts: false } : declined
1288
+ );
1289
+ }
1290
+ return;
1291
+ }
1292
+ const endedAt = known?.endedAt ?? null;
1293
+ this.#offers.set(offer.callId, { from, at: now, endedAt });
1294
+ if (!from) return;
1295
+ if (endedAt !== null) {
1296
+ const isThatCall = (declined) => declined.adopts && now - declined.at < fallbackMs && endedAt >= declined.since && sameNumber(declined.caller, from);
1297
+ this.#declined = this.#declined.filter((declined) => !isThatCall(declined));
1298
+ return;
1299
+ }
1300
+ this.#declined = this.#declined.map(
1301
+ (declined) => declined.adopts && now - declined.at < fallbackMs && now <= declined.since + OFFER_PUSH_SLACK_MS && sameNumber(declined.caller, from) ? { ...declined, callId: offer.callId, adopts: false } : declined
1302
+ );
1096
1303
  }
1097
1304
  // ── incoming (on-demand: push → register → INVITE) ────────────────────────
1098
- async #onIncomingSignal() {
1099
- if (this.now() < this.#rejectGuardUntil) return;
1100
- if (this.#activeCall) return;
1305
+ /**
1306
+ * The push's own word on what it is (incoming-call-reliability.md §4.1, read by the provider): only an `offer`
1307
+ * registers. Before this, every push did — `answered` after each hang-up included — and the UA it left
1308
+ * registered is where the next call's INVITE never came (F2).
1309
+ */
1310
+ #onIncomingSignal(event) {
1311
+ if (event.kind === "offer") void this.#onOffer(event.from);
1312
+ else if (event.kind === "offer-ended") this.#onOfferEnded(event.from);
1313
+ else debug(`push ignored: ${event.detail}`);
1314
+ }
1315
+ /** `pushedAt`: when its push came, for an offer kept while a call was in progress (`#pendingOffer`). */
1316
+ async #onOffer(from, pushedAt = this.now()) {
1317
+ if (this.#isDeclinedCaller(from)) {
1318
+ debug("offer ignored: the caller just declined is being offered again");
1319
+ return;
1320
+ }
1321
+ if (this.#activeCall) {
1322
+ this.#pendingOffer = { from, at: pushedAt };
1323
+ return;
1324
+ }
1325
+ this.#awaitOffer(from, pushedAt);
1326
+ const ua = this.#ua;
1327
+ if (ua) {
1328
+ this.#registerAfterOffer(ua);
1329
+ return;
1330
+ }
1331
+ const run = this.#run;
1332
+ const epoch = this.#uaEpoch;
1101
1333
  try {
1102
1334
  await this.#ensureUA();
1103
- this.#armNoInviteTeardown();
1104
1335
  } catch {
1105
- this.#teardownUA();
1336
+ if (run === this.#run && epoch === this.#uaEpoch) this.#teardownUA();
1106
1337
  }
1107
1338
  }
1108
- #armNoInviteTeardown() {
1109
- const ms = this.provider.quirks?.noInviteTeardownMs;
1110
- if (!ms) return;
1339
+ /**
1340
+ * That offer is over — answered elsewhere, declined, cancelled, timed out (§4.1). A UA still waiting for its
1341
+ * INVITE goes now instead of when a timer says so; never one carrying a call (`#maybeTeardownUA`). Only when the
1342
+ * push names the same caller as the offer waited for: two offers can overlap, and an end that names nobody (a
1343
+ * withheld number, a push with no `callerid`) could be anyone's — the timers bound that wait instead.
1344
+ */
1345
+ #onOfferEnded(from) {
1346
+ const caller = toDigits(from ?? "");
1347
+ if (!caller) return;
1348
+ if (this.#pendingOffer && sameNumber(caller, toDigits(this.#pendingOffer.from ?? ""))) this.#pendingOffer = null;
1349
+ const wait = this.#offerWait;
1350
+ if (!wait || !sameNumber(caller, wait.from)) return;
1351
+ this.#offerOver();
1352
+ }
1353
+ /**
1354
+ * Starts waiting for this offer's INVITE: a leak guard from its push (§4.3), and no no-INVITE timer yet.
1355
+ * `pushedAt` is now, except for an offer kept through a call (`#pendingOffer`).
1356
+ */
1357
+ #awaitOffer(from, pushedAt) {
1358
+ this.#endOfferWait();
1359
+ const ms = this.provider.quirks?.offerWaitMaxMs;
1360
+ const leak = ms ? setTimeout(
1361
+ () => {
1362
+ if (this.#offerWait !== wait) return;
1363
+ debug("offer abandoned: still no INVITE long after its push");
1364
+ this.#offerOver();
1365
+ },
1366
+ Math.max(0, ms - (this.now() - pushedAt))
1367
+ ) : void 0;
1368
+ const wait = { from: toDigits(from ?? ""), at: pushedAt, leak };
1369
+ this.#offerWait = wait;
1370
+ }
1371
+ /**
1372
+ * The offer kept through the call that just ended, if it is still worth registering for: no `offer-ended` for
1373
+ * it, and still inside the leak guard from its push. Taken: it is registered for once, at most.
1374
+ */
1375
+ #takePendingOffer() {
1376
+ const pending = this.#pendingOffer;
1377
+ this.#pendingOffer = null;
1378
+ if (!pending || !this.#signalUnsub) return null;
1379
+ const maxMs = this.provider.quirks?.offerWaitMaxMs;
1380
+ if (maxMs && this.now() - pending.at >= maxMs) return null;
1381
+ return pending;
1382
+ }
1383
+ /** No INVITE is coming for the offer being waited for: its UA goes, unless something else is on it. */
1384
+ #offerOver() {
1385
+ this.#endOfferWait();
1386
+ this.#maybeTeardownUA();
1387
+ }
1388
+ #endOfferWait() {
1389
+ if (this.#offerWait?.leak) clearTimeout(this.#offerWait.leak);
1390
+ this.#offerWait = null;
1391
+ this.#registerAgain = false;
1111
1392
  this.#clearNoInviteTimer();
1112
- this.#noInviteTimer = setTimeout(() => this.#maybeTeardownUA(), ms);
1113
1393
  }
1114
1394
  /**
1115
- * Rebuild the incoming-signal channel with fresh credentials, after a registration failure.
1395
+ * A REGISTER sent AFTER this offer's push, on the UA that is already there (§4.2). Whether one is needed depends
1396
+ * on where that UA's registration stands:
1397
+ * - `connecting`: its REGISTER goes out when the WSS is up — after this push already.
1398
+ * - `registering`: the REGISTER in flight went out BEFORE this push; another follows its answer.
1399
+ * - `registered`: a refresh, now.
1400
+ */
1401
+ #registerAfterOffer(ua) {
1402
+ if (this.#registration === "registering") this.#registerAgain = true;
1403
+ else if (this.#registration === "registered") this.#refreshRegistration(ua);
1404
+ }
1405
+ /**
1406
+ * A REGISTER refresh whose 200 OK is HEARD — the no-INVITE wait counts from it (§4.3).
1116
1407
  *
1117
- * Registration fails when the credentials behind it have gone stale — and for Zadarma those come
1118
- * from the same WebRTC key the push socket is holding. Tearing down the UA alone leaves that stale
1119
- * key in place, so every subsequent attempt fails the same way and the phone is dead until the page
1120
- * is reloaded. Reconnecting re-fetches the key. The legacy hook did exactly this from both of its
1121
- * `registrationFailed` handlers.
1408
+ * JsSIP's `register()` sends one, but announces `registered` only for a UA that was not registered
1409
+ * (`Registrator.js`: `if (!this._registered)`), so a refresh's 200 OK would pass in silence.
1410
+ * `registrator().onTransportClosed()` clears that flag and sends nothing — it is what JsSIP runs itself when its
1411
+ * WSS drops, before registering again — so after it the refresh is announced like a first registration. On the
1412
+ * wire nothing changes: the same refresh (same Call-ID, next CSeq) the lab saw answered with the INVITE
1413
+ * (Q5). Only called with no REGISTER of ours in flight (`#registration`), so no answer is left orphaned.
1122
1414
  *
1123
- * Debounced, which the legacy was not: a provider failing every attempt would otherwise become a
1124
- * tight loop against our backend and theirs.
1415
+ * A JsSIP without that hook still sends the refresh; its 200 OK just cannot be heard, so the wait starts now.
1125
1416
  */
1126
- async #refreshSignal() {
1127
- const signal = this.provider.incomingSignal;
1128
- if (!signal) return;
1129
- if (this.now() < this.#signalRefreshAfter) return;
1130
- this.#signalRefreshAfter = this.now() + SIGNAL_REFRESH_DEBOUNCE_MS;
1131
- try {
1132
- signal.disconnect();
1133
- await signal.connect();
1134
- } catch {
1135
- }
1417
+ #refreshRegistration(ua) {
1418
+ const registrator = ua.registrator?.();
1419
+ const heard = typeof registrator?.onTransportClosed === "function";
1420
+ if (heard) registrator.onTransportClosed();
1421
+ this.#registration = heard ? "registering" : "registered";
1422
+ ua.register();
1423
+ if (!heard && this.#offerWait) this.#armNoInviteTimer();
1424
+ }
1425
+ /**
1426
+ * Armed at the REGISTER's 200 OK, never at `ua.start()`: on a slow network the WSS alone outlasted the old 5 s,
1427
+ * and the UA was gone before it ever registered (F3). The INVITE follows the 200 OK by ~0.1 s (§2).
1428
+ */
1429
+ #armNoInviteTimer() {
1430
+ this.#clearNoInviteTimer();
1431
+ const ms = this.provider.quirks?.noInviteAfterRegisterMs;
1432
+ if (!ms) return;
1433
+ this.#noInviteTimer = setTimeout(() => {
1434
+ this.#noInviteTimer = void 0;
1435
+ this.#offerOver();
1436
+ }, ms);
1136
1437
  }
1137
1438
  #clearNoInviteTimer() {
1138
1439
  if (this.#noInviteTimer) clearTimeout(this.#noInviteTimer);
1139
1440
  this.#noInviteTimer = void 0;
1140
1441
  }
1442
+ // ── the decline guard (SPEC §3.4, incoming-call-reliability.md §4.4) ───────
1443
+ /**
1444
+ * Whether an offer — or an INVITE — from this caller is a re-offer of a call just declined. The provider re-offers a
1445
+ * declined call until its ring time ends, and nothing in a re-offer tells it from a new call. The guard used to
1446
+ * swallow EVERY offer and INVITE for 35 s, whoever called: a new call in that window was lost (CWA-81, F1).
1447
+ *
1448
+ * Now it holds only for the declined caller, and only while that call lives: until the backend's `end` for it
1449
+ * when it announced the call (`noteOffer`) — at most `DECLINE_GUARD_CEILING_MS` — else for the provider's
1450
+ * fallback window after the decline. Anyone else always rings; so does a caller this cannot name.
1451
+ */
1452
+ #isDeclinedCaller(number) {
1453
+ const fallbackMs = this.#declineFallbackMs();
1454
+ if (!fallbackMs || this.#declined.length === 0) return false;
1455
+ const now = this.now();
1456
+ this.#declined = this.#declined.filter((declined) => {
1457
+ const offer = declined.callId === null ? void 0 : this.#offers.get(declined.callId);
1458
+ if (offer && offer.endedAt === null) return now - declined.at < DECLINE_GUARD_CEILING_MS;
1459
+ return now - declined.at < fallbackMs;
1460
+ });
1461
+ const caller = toDigits(number ?? "");
1462
+ return this.#declined.some((declined) => sameNumber(declined.caller, caller));
1463
+ }
1464
+ /** The decline guard's fallback window — and whether there is a guard at all: only a provider that re-offers. */
1465
+ #declineFallbackMs() {
1466
+ return this.provider.quirks?.declinedCallerGuardMs ?? 0;
1467
+ }
1468
+ /**
1469
+ * The user declined `call`. Remembers the caller as its INVITE named them, and which offer the backend says that
1470
+ * is (§4.4). Only what the backend said about THIS call counts: a call from the same caller that ended before
1471
+ * this one was pushed (`since`) is another call — taking it for this one lost the callback after a second
1472
+ * decline of the same caller.
1473
+ * - a live offer from that caller, announced around this call's push or after: the guard follows it until
1474
+ * its `end`;
1475
+ * - an offer from that caller that ENDED since this call's push (the first leg of a transfer, a ring-group
1476
+ * call someone else answered): the fallback window — whatever that caller sends next is not that call, so
1477
+ * no later `start` is taken for it;
1478
+ * - neither (its `start` lands after the decline): the fallback window, and a `start` from that caller that
1479
+ * comes around this call's push is taken as this call's (`noteOffer`).
1480
+ *
1481
+ * An INVITE that names nobody (a withheld number) guards nobody. It used to borrow the caller of the latest push,
1482
+ * which with two offers overlapping is someone else's — and that someone's calls were then ignored.
1483
+ */
1484
+ #onDecline(call, since) {
1485
+ if (!this.#declineFallbackMs() || call.direction !== "inbound") return;
1486
+ const caller = toDigits(call.remoteNumber);
1487
+ if (!caller) return;
1488
+ const now = this.now();
1489
+ let latest = null;
1490
+ for (const [callId, offer] of this.#offers) {
1491
+ if (!sameNumber(offer.from, caller) || now - offer.at >= OFFER_FORGOTTEN_MS) continue;
1492
+ if (offer.endedAt === null && offer.at >= since - OFFER_PUSH_SLACK_MS) latest = { callId, ended: false };
1493
+ else if (offer.endedAt !== null && offer.endedAt >= since) latest = { callId, ended: true };
1494
+ }
1495
+ this.#declined.push(
1496
+ latest && !latest.ended ? { caller, callId: latest.callId, adopts: false, at: now, since } : { caller, callId: null, adopts: latest === null, at: now, since }
1497
+ );
1498
+ }
1141
1499
  #onNewSession(session, originator) {
1142
1500
  if (originator !== "remote") return;
1143
- this.#clearNoInviteTimer();
1144
- if (this.#activeCall || this.now() < this.#rejectGuardUntil) {
1501
+ if (this.#activeCall) {
1145
1502
  session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
1146
1503
  return;
1147
1504
  }
1148
- const call = this.#wrapCall(session);
1505
+ if (this.#isDeclinedCaller(remoteNumberOf(session))) {
1506
+ debug("INVITE declined: the caller just declined is being offered again");
1507
+ session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
1508
+ return;
1509
+ }
1510
+ const since = this.#offerWait?.at ?? this.now();
1511
+ this.#endOfferWait();
1512
+ const call = this.#wrapCall(session, since);
1149
1513
  for (const listener of [...this.#incomingListeners]) listener(call);
1150
1514
  }
1151
1515
  // ── UA lifecycle ───────────────────────────────────────────────────────────
@@ -1158,18 +1522,23 @@ var init_ua = __esm({
1158
1522
  * torn down. The visible symptom is two `get_webphone_data.php` calls at startup and a phone that
1159
1523
  * never registers.
1160
1524
  *
1161
- * Concurrency is the normal case here, not an edge: the push socket can signal twice in a
1162
- * heartbeat (SPEC §3.3 — a push is not an INVITE; it fires for transfers, retries and hangups),
1163
- * and `placeCall` can land on top of one. So the in-flight promise IS the guard.
1525
+ * Concurrency is the normal case here, not an edge: the push socket can offer twice in a heartbeat
1526
+ * (a push seen on both sockets of a rebuild, two calls at once), and `placeCall` can land on top of
1527
+ * one. So the in-flight promise IS the guard.
1164
1528
  */
1165
1529
  async #ensureUA() {
1166
1530
  if (this.#ua) return this.#ua;
1167
- this.#uaInFlight ??= this.#createUA().finally(() => {
1168
- this.#uaInFlight = null;
1169
- });
1531
+ if (!this.#uaInFlight) {
1532
+ const inFlight = this.#createUA().finally(() => {
1533
+ if (this.#uaInFlight === inFlight) this.#uaInFlight = null;
1534
+ });
1535
+ this.#uaInFlight = inFlight;
1536
+ }
1170
1537
  return this.#uaInFlight;
1171
1538
  }
1172
1539
  async #createUA() {
1540
+ const run = this.#run;
1541
+ const epoch = this.#uaEpoch;
1173
1542
  const factory = await this.#resolveFactory();
1174
1543
  let credentials;
1175
1544
  try {
@@ -1179,31 +1548,97 @@ var init_ua = __esm({
1179
1548
  else this.#reportError("provider/provision-failed", cause);
1180
1549
  throw cause;
1181
1550
  }
1551
+ if (run !== this.#run) throw createError("engine/not-started");
1552
+ if (epoch !== this.#uaEpoch) throw createError("call/failed", "the UA was let go of before it was built");
1182
1553
  this.#domain = domainOf(credentials);
1183
1554
  const config = this.#uaConfig(credentials, factory);
1184
1555
  const ua = factory.createUA(config);
1185
- ua.on("newRTCSession", ({ session, originator }) => this.#onNewSession(session, originator));
1556
+ let reached = false;
1557
+ ua.on("newRTCSession", ({ session, originator }) => {
1558
+ if (ua !== this.#ua) {
1559
+ if (originator === "remote") session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
1560
+ return;
1561
+ }
1562
+ this.#onNewSession(session, originator);
1563
+ });
1186
1564
  ua.on("disconnected", ({ error }) => {
1565
+ if (ua !== this.#ua) return;
1566
+ this.#registration = "connecting";
1187
1567
  if (!error) return;
1568
+ if (!reached && this.#retryOnFreshCredentials("unreachable", ua, credentials)) return;
1188
1569
  this.#setState("reconnecting");
1189
1570
  this.#reportError("net/transport-failed", error);
1190
1571
  });
1191
- ua.on("connected", () => this.#setState("online"));
1572
+ ua.on("connected", () => {
1573
+ if (ua !== this.#ua) return;
1574
+ reached = true;
1575
+ this.#registration = "registering";
1576
+ this.#setState("online");
1577
+ });
1578
+ ua.on("registered", () => {
1579
+ if (ua !== this.#ua) return;
1580
+ this.#registration = "registered";
1581
+ this.#freshCredentialsTried = false;
1582
+ this.provider.credentialsWorked?.();
1583
+ for (const waiter of [...this.#awaitingRegistration]) waiter.dial(ua);
1584
+ if (this.#registerAgain) {
1585
+ this.#registerAgain = false;
1586
+ this.#refreshRegistration(ua);
1587
+ return;
1588
+ }
1589
+ if (this.#offerWait) this.#armNoInviteTimer();
1590
+ });
1192
1591
  ua.on("registrationFailed", (e) => {
1592
+ if (run !== this.#run) return;
1193
1593
  const detail = e;
1594
+ if (isRefusal(detail) && this.#retryOnFreshCredentials("rejected", ua, credentials)) return;
1194
1595
  this.#reportError("net/registration-failed", {
1195
1596
  cause: detail?.cause,
1196
1597
  status: detail?.response?.status_code,
1197
1598
  domain: this.#domain,
1198
1599
  hint: "A bare 401 with no auth challenge usually means the registrar is in the wrong datacenter (SPEC \xA73.1)."
1199
1600
  });
1601
+ if (ua !== this.#ua) return;
1602
+ if (this.#carriesCall()) {
1603
+ for (const waiter of [...this.#awaitingRegistration]) waiter.fail();
1604
+ this.#registration = "registered";
1605
+ const wait = this.#offerWait;
1606
+ if (wait) {
1607
+ this.#pendingOffer = { from: wait.from || null, at: wait.at };
1608
+ this.#endOfferWait();
1609
+ }
1610
+ return;
1611
+ }
1200
1612
  this.#teardownUA();
1201
- void this.#refreshSignal();
1202
1613
  });
1203
1614
  this.#ua = ua;
1615
+ this.#registration = "connecting";
1204
1616
  ua.start();
1205
1617
  return ua;
1206
1618
  }
1619
+ /**
1620
+ * The credentials behind `ua` did not work. The provider hears it either way, with the credentials in question
1621
+ * (it forgets them unless it has moved on from them already; a provider whose signal shares them renews that
1622
+ * too); then, if it has new ones to give, the current UA is rebuilt on them — once until a registration works,
1623
+ * and never under a live call. Whoever waited on the old UA — an offer, a dial — waits on the new one. Returns
1624
+ * whether it was rebuilt.
1625
+ *
1626
+ * This replaces the engine's old `#refreshSignal` (a close-then-reopen of the push socket after ANY failed
1627
+ * registration): only the provider knows what its credentials and its signal share.
1628
+ */
1629
+ #retryOnFreshCredentials(reason, ua, credentials) {
1630
+ const fresh = this.provider.credentialsFailed?.(reason, credentials) ?? false;
1631
+ if (!fresh || ua !== this.#ua || this.#freshCredentialsTried) return false;
1632
+ if (this.#carriesCall()) return false;
1633
+ this.#freshCredentialsTried = true;
1634
+ const run = this.#run;
1635
+ this.#teardownUA({ replacing: true });
1636
+ const epoch = this.#uaEpoch;
1637
+ void this.#ensureUA().catch(() => {
1638
+ if (run === this.#run && epoch === this.#uaEpoch) this.#teardownUA();
1639
+ });
1640
+ return true;
1641
+ }
1207
1642
  #uaConfig(credentials, factory) {
1208
1643
  return {
1209
1644
  sockets: [factory.createWebSocket(credentials.wsUrl)],
@@ -1221,20 +1656,133 @@ var init_ua = __esm({
1221
1656
  user_agent: this.provider.quirks?.userAgent ?? "Coventia Softphone"
1222
1657
  };
1223
1658
  }
1224
- #teardownUA() {
1659
+ /**
1660
+ * Lets the current UA go — and one still being provisioned (`#uaEpoch`). `replacing`: a successor is being
1661
+ * built on fresh credentials right away (`#retryOnFreshCredentials`), so the dials and the offer waiting on
1662
+ * this one carry over to it instead of ending here.
1663
+ */
1664
+ #teardownUA({ replacing = false } = {}) {
1665
+ this.#uaEpoch += 1;
1225
1666
  this.#uaInFlight = null;
1226
- if (!this.#ua) return;
1227
- try {
1228
- this.#ua.terminateSessions({ status_code: 487 });
1229
- this.#ua.stop();
1230
- } catch {
1667
+ this.#registration = "connecting";
1668
+ this.#registerAgain = false;
1669
+ if (!replacing) {
1670
+ for (const waiter of [...this.#awaitingRegistration]) waiter.fail();
1671
+ this.#endOfferWait();
1231
1672
  }
1673
+ if (!this.#ua) return;
1674
+ const ua = this.#ua;
1232
1675
  this.#ua = null;
1233
1676
  this.#activeCall = null;
1234
1677
  this.#consultCall = null;
1678
+ try {
1679
+ ua.terminateSessions({ status_code: 487 });
1680
+ } catch {
1681
+ }
1682
+ if (this.#owedCancels.some((owed) => owed.ua === ua)) {
1683
+ this.#linger(ua);
1684
+ return;
1685
+ }
1686
+ try {
1687
+ ua.stop();
1688
+ } catch {
1689
+ }
1690
+ }
1691
+ // ── the CANCEL a hang-up still owes (incoming-call-reliability.md §4.7, F6) ─────────────────────────────
1692
+ /**
1693
+ * Watches an outbound leg — a call or a consult leg — from its creation, for the CANCEL JsSIP may end up owing.
1694
+ *
1695
+ * JsSIP's `terminate()` between sending the INVITE and the first provisional response cannot cancel yet (RFC 3261
1696
+ * §9.1): it marks the session canceled, fails it locally with `Canceled`, and sends the CANCEL when a 1xx comes —
1697
+ * or ACKs and BYEs a late 2xx. Live, that 1xx came 17 s late, the engine had torn the UA down on the call's end, and
1698
+ * the callee's phone rang on for a caller who was gone: a ghost call. Hence: the INVITE went out (`sending`), no
1699
+ * provisional above 100 came (`progress`), and it failed locally `Canceled` → the CANCEL is owed, on this UA.
1700
+ *
1701
+ * Registered BEFORE the leg's own listeners: its `failed` must be on record before the leg's end tears the UA down.
1702
+ */
1703
+ #watchOwedCancel(session, ua) {
1704
+ let sent = false;
1705
+ let ringing = false;
1706
+ session.on("sending", () => sent = true);
1707
+ session.on("progress", () => ringing = true);
1708
+ session.on("failed", ({ originator, cause }) => {
1709
+ if (!sent || originator !== "local" || cause !== "Canceled") return;
1710
+ const tx = inviteTransactionOf(session);
1711
+ const owes = deferredCancelOf(session) ?? (tx ? tx.state === ICT_CALLING : !ringing);
1712
+ if (!owes || tx && !awaitsAnswer(tx)) return;
1713
+ const owed = { ua, session, tx };
1714
+ this.#owedCancels.push(owed);
1715
+ if (tx) this.#followInvite(owed, tx);
1716
+ });
1717
+ }
1718
+ /**
1719
+ * Follows the INVITE an owed CANCEL is waiting on, until it is over. Not just the first one: Zadarma challenges every
1720
+ * INVITE (407), and JsSIP re-sends it with credentials on a NEW transaction — canceled leg or not — in the same tick
1721
+ * that completes the first. Releasing on that first transaction stopped the UA two seconds later, and the re-sent
1722
+ * INVITE's 1xx landed nowhere: the ghost call again (found driving the real JsSIP). So once one is over, the leg's
1723
+ * current transaction is read again a moment later, and followed while it still waits for an answer.
1724
+ */
1725
+ #followInvite(owed, tx) {
1726
+ const settle = () => {
1727
+ if (awaitsAnswer(tx)) return;
1728
+ tx.removeListener?.("stateChanged", settle);
1729
+ queueMicrotask(() => {
1730
+ const latest = inviteTransactionOf(owed.session);
1731
+ if (latest && latest !== tx && awaitsAnswer(latest)) {
1732
+ owed.tx = latest;
1733
+ this.#followInvite(owed, latest);
1734
+ return;
1735
+ }
1736
+ this.#owedCancels = this.#owedCancels.filter((other) => other !== owed);
1737
+ this.#releaseIfSettled(owed.ua);
1738
+ });
1739
+ };
1740
+ tx.on("stateChanged", settle);
1235
1741
  }
1236
- /** Place a call once the UA is registered (its 200 OK for REGISTER has arrived). */
1237
1742
  /**
1743
+ * Lets `ua` go while it owes a CANCEL: un-REGISTERed at once, as any teardown, but connected until every INVITE it
1744
+ * owes is over — COMPLETED (a final non-2xx: the 487 after the late 1xx's CANCEL), ACCEPTED (a late 2xx, which
1745
+ * JsSIP ACKs and BYEs) or TERMINATED (Timer B, a transport error) — and never longer than Timer B. Detached: the
1746
+ * engine has already moved on (a push or a dial builds a fresh UA), so no timer of its own, no teardown and no
1747
+ * "is a call on it" ever sees this one, and an INVITE reaching it is declined like any let-go UA's.
1748
+ */
1749
+ #linger(ua) {
1750
+ if (this.#lingering.has(ua)) return;
1751
+ this.#lingering.set(ua, setTimeout(() => this.#release(ua), INVITE_TRANSACTION_MAX_MS));
1752
+ ua.on("registered", () => {
1753
+ if (this.#lingering.has(ua)) this.#unregister(ua);
1754
+ });
1755
+ this.#unregister(ua);
1756
+ this.#releaseIfSettled(ua);
1757
+ }
1758
+ #unregister(ua) {
1759
+ try {
1760
+ ua.unregister();
1761
+ } catch {
1762
+ }
1763
+ }
1764
+ /**
1765
+ * Once nothing is owed on a lingering UA, it stops — on the next tick: a late 2xx is ACKed and BYEd in the very
1766
+ * tick that settled it, and JsSIP's own `stop()` then keeps the transport ≤ 2 s for that BYE's answer.
1767
+ */
1768
+ #releaseIfSettled(ua) {
1769
+ if (!this.#lingering.has(ua) || this.#owedCancels.some((owed) => owed.ua === ua)) return;
1770
+ setTimeout(() => this.#release(ua), 0);
1771
+ }
1772
+ #release(ua) {
1773
+ const cap = this.#lingering.get(ua);
1774
+ if (cap === void 0) return;
1775
+ clearTimeout(cap);
1776
+ this.#lingering.delete(ua);
1777
+ this.#owedCancels = this.#owedCancels.filter((owed) => owed.ua !== ua);
1778
+ try {
1779
+ ua.stop();
1780
+ } catch {
1781
+ }
1782
+ }
1783
+ /**
1784
+ * Place a call once the current UA is registered (its 200 OK for REGISTER has arrived).
1785
+ *
1238
1786
  * **No `mediaStream`** — JsSIP calls `getUserMedia` itself, which is what opens a real capture.
1239
1787
  *
1240
1788
  * That is load-bearing, not incidental. Firefox restricts ICE gathering on a page with no active
@@ -1244,20 +1792,46 @@ var init_ua = __esm({
1244
1792
  * contains no candidates. The legacy hook passed `mediaConstraints` and nothing else, for what
1245
1793
  * turns out to be a very good reason.
1246
1794
  */
1247
- #callWhenRegistered(ua, target) {
1795
+ #callWhenRegistered(target, run) {
1248
1796
  const options = { mediaConstraints: buildAudioConstraints(this.#inputDeviceId), pcConfig: PC_CONFIG };
1249
- if (ua.isRegistered()) return Promise.resolve(ua.call(target, options));
1250
- return new Promise((resolve) => {
1251
- ua.on("registered", () => resolve(ua.call(target, options)));
1797
+ const refusal = () => createError(run === this.#run ? "call/failed" : "engine/not-started");
1798
+ const dial = (registered) => {
1799
+ const session = registered.call(target, options);
1800
+ this.#watchOwedCancel(session, registered);
1801
+ return this.#wrapCall(session);
1802
+ };
1803
+ const ua = this.#ua;
1804
+ if (!ua || run !== this.#run) return Promise.reject(refusal());
1805
+ if (ua.isRegistered()) return Promise.resolve(dial(ua));
1806
+ return new Promise((resolve, reject) => {
1807
+ const waiter = {
1808
+ dial: (registered) => {
1809
+ this.#awaitingRegistration.delete(waiter);
1810
+ try {
1811
+ resolve(dial(registered));
1812
+ } catch (cause) {
1813
+ reject(cause);
1814
+ }
1815
+ },
1816
+ fail: () => {
1817
+ this.#awaitingRegistration.delete(waiter);
1818
+ reject(refusal());
1819
+ }
1820
+ };
1821
+ this.#awaitingRegistration.add(waiter);
1252
1822
  });
1253
1823
  }
1254
- #wrapCall(session) {
1824
+ /**
1825
+ * `since`, for an inbound call: when its push came (its INVITE, when none was seen) — what `#onDecline` needs to
1826
+ * tell this call from the caller's others.
1827
+ */
1828
+ #wrapCall(session, since = this.now()) {
1255
1829
  const call = new SipEngineCall(session, {
1256
1830
  id: nextCallId(),
1257
1831
  buildTarget: (t) => this.#buildTarget(t),
1258
1832
  placeConsult: (t) => this.#placeConsult(t),
1259
1833
  // The guard is tied to the reject ACTION, not to `ended` — a normal hangup must not arm it.
1260
- onReject: () => this.#startRejectGuard(),
1834
+ onReject: () => this.#onDecline(call, since),
1261
1835
  iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS,
1262
1836
  // Inbound answers open their own peer connection, so they need the same ICE servers the
1263
1837
  // outbound leg got. Missing here, an ANSWERED call is the one that fails — the half nobody
@@ -1274,17 +1848,27 @@ var init_ua = __esm({
1274
1848
  const code = missed ? "call/missed" : CALL_END_CODES[event.reason];
1275
1849
  if (code) this.#reportError(code, event.cause);
1276
1850
  if (this.#activeCall === call) this.#activeCall = null;
1277
- this.#maybeTeardownUA();
1851
+ this.#legEnded();
1278
1852
  }
1279
1853
  });
1280
1854
  return call;
1281
1855
  }
1856
+ /**
1857
+ * A leg ended. The UA goes if nothing is left on it (`#maybeTeardownUA`); and once no leg at all is left, an
1858
+ * offer kept through the call (`#pendingOffer`, §4.3) gets its REGISTER — the line is free now.
1859
+ */
1860
+ #legEnded() {
1861
+ const pending = this.#activeCall || this.#consultCall ? null : this.#takePendingOffer();
1862
+ this.#maybeTeardownUA();
1863
+ if (pending) void this.#onOffer(pending.from, pending.at);
1864
+ }
1282
1865
  async #placeConsult(target) {
1283
1866
  const ua = await this.#ensureUA();
1284
1867
  const consultSession = ua.call(this.#buildTarget(target), {
1285
1868
  mediaConstraints: buildAudioConstraints(this.#inputDeviceId),
1286
1869
  pcConfig: PC_CONFIG
1287
1870
  });
1871
+ this.#watchOwedCancel(consultSession, ua);
1288
1872
  const consultCall = new SipEngineCall(consultSession, {
1289
1873
  id: nextCallId(),
1290
1874
  buildTarget: (x) => this.#buildTarget(x),
@@ -1295,7 +1879,7 @@ var init_ua = __esm({
1295
1879
  consultCall.onEvent((event) => {
1296
1880
  if (event.type === "ended") {
1297
1881
  if (this.#consultCall === consultCall) this.#consultCall = null;
1298
- this.#maybeTeardownUA();
1882
+ this.#legEnded();
1299
1883
  }
1300
1884
  });
1301
1885
  return consultCall;
@@ -1308,19 +1892,29 @@ var init_ua = __esm({
1308
1892
  * session on it, via `terminateSessions`) the instant the main leg's `ended` fires killed that
1309
1893
  * consult leg out from under the transfer before the bridge could use it, and the caller was simply
1310
1894
  * gone. Two real call sites hit this independently (found live, 2026-08-04): the main leg's own
1311
- * `ended` handler, and `#armNoInviteTeardown`'s 5s timer — the latter re-arms on EVERY push,
1312
- * including unrelated "answered" pushes Zadarma sends around other account activity, so it can fire
1313
- * minutes after a transfer with no `#activeCall` and no idea a consult leg is mid-handoff. Both now
1314
- * route through here rather than checking `#activeCall` alone.
1895
+ * `ended` handler, and the no-INVITE timer — which then re-armed on EVERY push, including the
1896
+ * `answered` pushes Zadarma sends around other account activity, so it could fire minutes after a
1897
+ * transfer with no `#activeCall` and no idea a consult leg was mid-handoff. Every teardown an offer
1898
+ * asks for (its timers, its `offer-ended`) routes through here rather than checking `#activeCall` alone.
1899
+ *
1900
+ * A dial on its way (`#dialing`) counts too: its UA may not even exist yet, and an offer ending must not
1901
+ * take it. So does an offer still waiting for its INVITE: mid-transfer, with only the consult leg left, an
1902
+ * offer registers on this UA — and the consult leg ending used to tear it down and drop that offer, its call
1903
+ * ringing nowhere until the provider offered it again ~30 s later. The wait's own ending (its INVITE, its
1904
+ * `offer-ended`, the no-INVITE timer, the leak guard) clears `#offerWait` before it calls this, so nothing keeps
1905
+ * the UA past those (incoming-call-reliability.md §4.3).
1315
1906
  */
1316
1907
  #maybeTeardownUA() {
1317
1908
  if (this.provider.registrationMode !== "on-demand") return;
1318
- if (this.#activeCall || this.#consultCall) return;
1909
+ if (this.#activeCall || this.#consultCall || this.#dialing > 0 || this.#offerWait) return;
1319
1910
  this.#teardownUA();
1320
1911
  }
1321
- #startRejectGuard() {
1322
- const ms = this.provider.quirks?.rejectGuardMs;
1323
- if (ms) this.#rejectGuardUntil = this.now() + ms;
1912
+ /**
1913
+ * A live leg is on the current UA: the call or a consult leg. An outbound call counts from the moment its INVITE
1914
+ * is created — `#callWhenRegistered` wraps it there and then — so no failure in between can end it unseen.
1915
+ */
1916
+ #carriesCall() {
1917
+ return this.#activeCall !== null || this.#consultCall !== null;
1324
1918
  }
1325
1919
  #buildTarget(target) {
1326
1920
  return target.includes("@") ? target : `sip:${target}@${this.#domain}`;
@@ -1360,11 +1954,44 @@ function buildPushUrl(key, sip) {
1360
1954
  const params = new URLSearchParams({ key, integrationType: "site", sipId: sip });
1361
1955
  return `wss://ws.zadarma.com/webrtc_pushes?${params.toString()}`;
1362
1956
  }
1363
- var PING, KEY_REFRESH_BUFFER_MS, REBUILD_CONNECT_TIMEOUT_MS, REBUILD_RETRY_MS, ZADARMA_QUIRKS, ZadarmaPushSignal, ZadarmaProvider;
1957
+ function readPush(payload) {
1958
+ let data = payload;
1959
+ if (typeof data === "string") {
1960
+ try {
1961
+ data = JSON.parse(data);
1962
+ } catch {
1963
+ return { kind: "other", detail: "unreadable payload" };
1964
+ }
1965
+ }
1966
+ if (!data || typeof data !== "object") return { kind: "other", detail: "no payload" };
1967
+ const { action, callerid } = data;
1968
+ let from = null;
1969
+ if (typeof callerid === "string" && callerid) from = callerid;
1970
+ else if (typeof callerid === "number") from = String(callerid);
1971
+ if (action === "incoming") return { kind: "offer", from };
1972
+ if (action === "answered") return { kind: "offer-ended", from };
1973
+ return { kind: "other", detail: typeof action === "string" ? `action "${action}"` : "no action" };
1974
+ }
1975
+ function expiryOf(key) {
1976
+ if (!key.keyExpiresAt) return null;
1977
+ const at = new Date(key.keyExpiresAt).getTime();
1978
+ return Number.isFinite(at) ? at : null;
1979
+ }
1980
+ function toSipCredentials({ key, pass, domain }) {
1981
+ return {
1982
+ uri: `sip:${key.sip}@${domain}`,
1983
+ authUser: key.sip,
1984
+ password: pass,
1985
+ wsUrl: `wss://${domain}:4443`
1986
+ };
1987
+ }
1988
+ var PING, KEY_REFRESH_BUFFER_MS, KEY_EXPIRY_MARGIN_MS, REKEY_DEBOUNCE_MS, REBUILD_CONNECT_TIMEOUT_MS, REBUILD_RETRY_MS, ZADARMA_QUIRKS, ZadarmaCredentials, ZadarmaPushSignal, ZadarmaProvider;
1364
1989
  var init_zadarma = __esm({
1365
1990
  "src/transports/providers/zadarma.ts"() {
1366
1991
  PING = { localMs: 3e4, globalMs: 6e4 };
1367
1992
  KEY_REFRESH_BUFFER_MS = 2 * 60 * 60 * 1e3;
1993
+ KEY_EXPIRY_MARGIN_MS = 6e4;
1994
+ REKEY_DEBOUNCE_MS = 3e4;
1368
1995
  REBUILD_CONNECT_TIMEOUT_MS = 1e4;
1369
1996
  REBUILD_RETRY_MS = 3e4;
1370
1997
  ZADARMA_QUIRKS = {
@@ -1372,20 +1999,147 @@ var init_zadarma = __esm({
1372
1999
  // after a valid digest challenge, indistinguishable from a wrong password (§3.1). The old CRM
1373
2000
  // hook carried this string; the port dropped it, and registration failed for exactly that reason.
1374
2001
  userAgent: "Zadarma webRTC",
1375
- // Zadarma re-rings a declined call for ~30 s (return_timeout). Swallow those (§3.4).
2002
+ // Zadarma re-offers a declined call until its ring time ends (~30 s by default): the declined caller's offers
2003
+ // are ignored while that call lives — this long after the decline when the backend cannot say (§3.4,
2004
+ // incoming-call-reliability.md §4.4, Q1).
2005
+ declinedCallerGuardMs: 35e3,
2006
+ // The INVITE follows the REGISTER's 200 OK by ~0.1 s. Nothing 10 s after it: no call is coming to this UA.
2007
+ noInviteAfterRegisterMs: 1e4,
2008
+ // A leak guard only: an offer still waiting for its INVITE 2 min after its push is abandoned (§4.3).
2009
+ offerWaitMaxMs: 12e4,
2010
+ // Deprecated names of the two above, same values: this object is exported, and 0.5.1 is a patch. Never read.
1376
2011
  rejectGuardMs: 35e3,
1377
- // A push is not an INVITE (§3.3). If none arrives, tear the fresh UA down.
1378
- noInviteTeardownMs: 5e3,
2012
+ noInviteTeardownMs: 1e4,
1379
2013
  // A ceiling on ICE gathering, not a target: JsSIP sends as soon as gathering completes. 3 s leaves
1380
2014
  // room for a TURN allocation (whose relay candidates arrive last) while still bounding the ~40 s
1381
2015
  // Chrome can spend against an unreachable server. See `capIceGathering`.
1382
2016
  iceGatheringCapMs: 3e3
1383
2017
  };
1384
- ZadarmaPushSignal = class {
2018
+ ZadarmaCredentials = class {
1385
2019
  constructor(deps) {
1386
2020
  this.deps = deps;
1387
2021
  }
1388
2022
  deps;
2023
+ #key = null;
2024
+ #keyInFlight = null;
2025
+ #sip = null;
2026
+ #sipInFlight = null;
2027
+ /**
2028
+ * Bumped by `forget`, one per half: a fetch begun before it must not put back what was just forgotten — a
2029
+ * refused credential that comes back from a slow request is still refused.
2030
+ */
2031
+ #keyGeneration = 0;
2032
+ #sipGeneration = 0;
2033
+ /**
2034
+ * The SIP credentials were already refetched once because their WSS could not be reached: if the fresh ones
2035
+ * cannot reach it either, the network is the problem, and asking Zadarma again would not change it. Re-armed by
2036
+ * a registration that works (`worked`), and by forgetting everything (a refusal, a stop).
2037
+ */
2038
+ #unreachableRefetched = false;
2039
+ /** What is in memory right now — both halves, for one key — or `null`. Never fetches. */
2040
+ current() {
2041
+ const key = this.#key?.value;
2042
+ const sip = this.#sip;
2043
+ return key && sip && sip.forKey === key.key ? { key, pass: sip.pass, domain: sip.domain } : null;
2044
+ }
2045
+ /** Their WSS could not be reached: forget the SIP half — once, until `worked()`. Whether it was forgotten. */
2046
+ forgetUnreachable() {
2047
+ if (this.#unreachableRefetched) return false;
2048
+ this.forget("sip");
2049
+ this.#unreachableRefetched = true;
2050
+ return true;
2051
+ }
2052
+ /** A registration worked with what is in memory. */
2053
+ worked() {
2054
+ this.#unreachableRefetched = false;
2055
+ }
2056
+ /** The cached key while it is alive; otherwise a fresh one (and, in the background, its SIP credentials). */
2057
+ key() {
2058
+ const cached2 = this.#key;
2059
+ if (cached2 && (cached2.expiresAt === null || Date.now() < cached2.expiresAt - KEY_EXPIRY_MARGIN_MS)) {
2060
+ return Promise.resolve(cached2.value);
2061
+ }
2062
+ return this.#fetchKey();
2063
+ }
2064
+ /** A new key now, whatever is cached: the refresh ahead of expiry. The old one serves until this one lands. */
2065
+ renew() {
2066
+ return this.#fetchKey();
2067
+ }
2068
+ /** The key and the SIP credentials it bought, from memory when they are there. */
2069
+ async sip() {
2070
+ const key = await this.key();
2071
+ const { pass, domain } = await this.#sipFor(key);
2072
+ return { key, pass, domain };
2073
+ }
2074
+ /** Synchronous up to the request, so a prefetch and a `provision()` racing it share one exchange. */
2075
+ #sipFor(key) {
2076
+ const cached2 = this.#sip;
2077
+ if (cached2 && cached2.forKey === key.key) return Promise.resolve({ pass: cached2.pass, domain: cached2.domain });
2078
+ const inFlight = this.#sipInFlight;
2079
+ if (inFlight && inFlight.forKey === key.key) return inFlight.promise;
2080
+ const generation = this.#sipGeneration;
2081
+ let request;
2082
+ try {
2083
+ request = this.deps.exchangeWebphoneData(key.key, key.sip);
2084
+ } catch (cause) {
2085
+ request = Promise.reject(cause instanceof Error ? cause : new Error(String(cause)));
2086
+ }
2087
+ const promise = request.then((data) => {
2088
+ if (generation === this.#sipGeneration) this.#sip = { forKey: key.key, pass: data.pass, domain: data.domain };
2089
+ return data;
2090
+ });
2091
+ const entry = { forKey: key.key, promise };
2092
+ this.#sipInFlight = entry;
2093
+ void promise.finally(() => {
2094
+ if (this.#sipInFlight === entry) this.#sipInFlight = null;
2095
+ }).catch(() => {
2096
+ });
2097
+ return promise;
2098
+ }
2099
+ /**
2100
+ * Drop what stopped working. `all`: the registrar refused the credentials, so the key behind them may be the
2101
+ * stale part too. `sip`: their WSS could not be reached — the key is fine, the datacenter may not be.
2102
+ */
2103
+ forget(what) {
2104
+ this.#sipGeneration += 1;
2105
+ this.#sip = null;
2106
+ this.#sipInFlight = null;
2107
+ if (what === "sip") return;
2108
+ this.#keyGeneration += 1;
2109
+ this.#key = null;
2110
+ this.#keyInFlight = null;
2111
+ this.#unreachableRefetched = false;
2112
+ }
2113
+ #fetchKey() {
2114
+ if (this.#keyInFlight) return this.#keyInFlight;
2115
+ const generation = this.#keyGeneration;
2116
+ const inFlight = this.deps.fetchKey().then((value) => {
2117
+ if (generation === this.#keyGeneration) {
2118
+ this.#key = { value, expiresAt: expiryOf(value) };
2119
+ void this.#sipFor(value).catch(() => {
2120
+ });
2121
+ }
2122
+ return value;
2123
+ });
2124
+ this.#keyInFlight = inFlight;
2125
+ void inFlight.finally(() => {
2126
+ if (this.#keyInFlight === inFlight) this.#keyInFlight = null;
2127
+ }).catch(() => {
2128
+ });
2129
+ return inFlight;
2130
+ }
2131
+ };
2132
+ ZadarmaPushSignal = class {
2133
+ /**
2134
+ * `credentials` is shared with the provider's `provision()`: one key for the push socket and the SIP
2135
+ * credentials, fetched once (§4.5). A signal on its own gets a cache of its own.
2136
+ */
2137
+ constructor(deps, credentials = new ZadarmaCredentials(deps)) {
2138
+ this.deps = deps;
2139
+ this.credentials = credentials;
2140
+ }
2141
+ deps;
2142
+ credentials;
1389
2143
  #socket = null;
1390
2144
  #connected = false;
1391
2145
  #pingTimers = [];
@@ -1398,6 +2152,20 @@ var init_zadarma = __esm({
1398
2152
  #retryTimer;
1399
2153
  #unwatchBackground;
1400
2154
  #closed = false;
2155
+ /**
2156
+ * Bumped by `disconnect()`. An open or a rebuild still awaiting its key when the signal is disconnected
2157
+ * must build nothing afterwards — and `#closed` alone cannot say so once a later `connect()` (stop, then
2158
+ * start again) has reset it: the late socket was built beside the new one, and nothing ever closed it.
2159
+ */
2160
+ #run = 0;
2161
+ /**
2162
+ * The key is due for its refresh (`#armRefresh`): the next rebuild fetches a new one instead of reusing the
2163
+ * cached one — and keeps trying, on every retry and every hidden-tab tick, until one arrives.
2164
+ */
2165
+ #renewDue = false;
2166
+ /** A rebuild asked for (`rekey`) while another was in flight: it runs once that one ends. */
2167
+ #rekeyQueued = false;
2168
+ #rekeyAfter = 0;
1401
2169
  #listeners = /* @__PURE__ */ new Set();
1402
2170
  #connectionListeners = /* @__PURE__ */ new Set();
1403
2171
  async connect() {
@@ -1406,9 +2174,26 @@ var init_zadarma = __esm({
1406
2174
  this.#unwatchBackground ??= this.deps.watchBackgroundReconnect?.(() => void this.#reopen());
1407
2175
  await this.#open();
1408
2176
  }
2177
+ /**
2178
+ * The registrar refused credentials bought with this socket's key, and the provider has forgotten them: the
2179
+ * socket is rebuilt on a fresh key, make-before-break like every rebuild (`#reopen`).
2180
+ *
2181
+ * Debounced: a registrar refusing every attempt must not become a tight loop against our backend and
2182
+ * Zadarma's. This rebuild used to be the engine's (`#refreshSignal`, a close-then-open); it lives here now
2183
+ * because only the provider knows the push socket and the SIP credentials share one key.
2184
+ */
2185
+ rekey() {
2186
+ if (this.#closed || !this.#socket) return;
2187
+ const now = Date.now();
2188
+ if (now < this.#rekeyAfter) return;
2189
+ this.#rekeyAfter = now + REKEY_DEBOUNCE_MS;
2190
+ if (this.#rebuilding) this.#rekeyQueued = true;
2191
+ else void this.#reopen();
2192
+ }
1409
2193
  async #open() {
1410
- const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
1411
- if (this.#closed) return;
2194
+ const run = this.#run;
2195
+ const { key, sip, keyExpiresAt } = await this.credentials.key();
2196
+ if (run !== this.#run) return;
1412
2197
  const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1413
2198
  this.#socket = socket;
1414
2199
  this.#attach(socket);
@@ -1418,8 +2203,8 @@ var init_zadarma = __esm({
1418
2203
  * Wires one socket's events. `this.#socket` is read when an event FIRES, not when it is wired: a socket that
1419
2204
  * has been replaced must stop driving connectivity (our own `close()` of it emits `disconnect`, which would
1420
2205
  * otherwise flip the state to offline right after a perfectly healthy swap). `update` is the exception — it is
1421
- * forwarded from any socket not yet closed, because a push seen twice is harmless (the engine ignores a
1422
- * repeated signal mid-call and reuses a UA that already exists) while one seen by neither is a missed call.
2206
+ * forwarded from any socket not yet closed, because a push seen twice is harmless (the engine ignores an offer
2207
+ * mid-call, and a second one only sends one more REGISTER) while one seen by neither is a missed call.
1423
2208
  *
1424
2209
  * With `hooks`, the socket is a rebuild's candidate: its first `connect` or failure goes to the rebuild instead
1425
2210
  * of changing the connection state (it is not current yet).
@@ -1431,7 +2216,7 @@ var init_zadarma = __esm({
1431
2216
  this.#setConnected(true);
1432
2217
  this.#startPings(socket);
1433
2218
  });
1434
- socket.on("update", () => this.#emit());
2219
+ socket.on("update", (payload) => this.#emit(readPush(payload)));
1435
2220
  const lost = () => {
1436
2221
  if (hooks && socket === this.#candidate) return hooks.onFailure();
1437
2222
  if (socket !== this.#socket) return;
@@ -1470,13 +2255,19 @@ var init_zadarma = __esm({
1470
2255
  * given at construction. A tab left open past the key's 72 h lifetime would reconnect forever with
1471
2256
  * a dead credential. Absolute, from the backend's `key_expires_at`, so it targets the real expiry
1472
2257
  * rather than restarting a countdown on every reconnect.
2258
+ *
2259
+ * It is also the refresh of the shared credentials (incoming-call-reliability.md §4.5): the new key replaces
2260
+ * the cached one, and its SIP credentials are fetched with it, in the background — no call waits for either.
1473
2261
  */
1474
2262
  #armRefresh(keyExpiresAt) {
1475
2263
  clearTimeout(this.#refreshTimer);
1476
2264
  if (!keyExpiresAt) return;
1477
2265
  const ms = new Date(keyExpiresAt).getTime() - Date.now() - KEY_REFRESH_BUFFER_MS;
1478
2266
  if (!Number.isFinite(ms) || ms <= 0) return;
1479
- this.#refreshTimer = setTimeout(() => void this.#reopen(), ms);
2267
+ this.#refreshTimer = setTimeout(() => {
2268
+ this.#renewDue = true;
2269
+ void this.#reopen();
2270
+ }, ms);
1480
2271
  }
1481
2272
  /**
1482
2273
  * Replaces the push socket with a fresh one — MAKE-BEFORE-BREAK (presence-client-reporting.md §4.1, T8).
@@ -1492,6 +2283,9 @@ var init_zadarma = __esm({
1492
2283
  * throws, the socket errors, or it has not connected within `REBUILD_CONNECT_TIMEOUT_MS` — keeps the old socket
1493
2284
  * and retries, instead of leaving the tab disconnected. Zadarma does not evict the older of two sockets for one
1494
2285
  * sip (multi-tab-leader-election.md §1), so the overlap is safe.
2286
+ *
2287
+ * The key comes from the shared cache — a hidden-tab tick makes no request — unless it is due for its refresh
2288
+ * (`#renewDue`) or has been forgotten after a refused registration (`rekey`).
1495
2289
  */
1496
2290
  async #reopen() {
1497
2291
  if (this.#closed || this.#rebuilding) return;
@@ -1503,9 +2297,11 @@ var init_zadarma = __esm({
1503
2297
  return;
1504
2298
  }
1505
2299
  this.#rebuilding = true;
2300
+ const run = this.#run;
1506
2301
  try {
1507
- const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
1508
- if (this.#closed) return;
2302
+ const { key, sip, keyExpiresAt } = await (this.#renewDue ? this.credentials.renew() : this.credentials.key());
2303
+ if (run !== this.#run) return;
2304
+ this.#renewDue = false;
1509
2305
  const swapped = await new Promise((resolve) => {
1510
2306
  const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1511
2307
  this.#candidate = socket;
@@ -1533,6 +2329,10 @@ var init_zadarma = __esm({
1533
2329
  } finally {
1534
2330
  this.#rebuilding = false;
1535
2331
  }
2332
+ if (this.#rekeyQueued) {
2333
+ this.#rekeyQueued = false;
2334
+ void this.#reopen();
2335
+ }
1536
2336
  }
1537
2337
  /**
1538
2338
  * The moment of the swap — synchronous, called from the candidate's own `connect` event so nothing can run
@@ -1556,8 +2356,17 @@ var init_zadarma = __esm({
1556
2356
  clearTimeout(this.#retryTimer);
1557
2357
  this.#retryTimer = setTimeout(() => void this.#reopen(), REBUILD_RETRY_MS);
1558
2358
  }
2359
+ /**
2360
+ * Closes everything — and forgets the credentials: this is how the engine stops, and a phone that is off holds
2361
+ * no SIP password. The next `connect()` fetches them again.
2362
+ */
1559
2363
  disconnect() {
1560
2364
  this.#closed = true;
2365
+ this.#run += 1;
2366
+ this.#renewDue = false;
2367
+ this.#rekeyQueued = false;
2368
+ this.#rekeyAfter = 0;
2369
+ this.credentials.forget("all");
1561
2370
  this.#stopPings();
1562
2371
  clearTimeout(this.#refreshTimer);
1563
2372
  this.#refreshTimer = void 0;
@@ -1586,16 +2395,11 @@ var init_zadarma = __esm({
1586
2395
  this.#connected = connected;
1587
2396
  for (const listener of [...this.#connectionListeners]) listener(connected);
1588
2397
  }
1589
- #emit() {
1590
- for (const listener of [...this.#listeners]) listener();
2398
+ #emit(event) {
2399
+ for (const listener of [...this.#listeners]) listener(event);
1591
2400
  }
1592
2401
  };
1593
2402
  ZadarmaProvider = class {
1594
- constructor(deps) {
1595
- this.deps = deps;
1596
- this.incomingSignal = new ZadarmaPushSignal(deps);
1597
- }
1598
- deps;
1599
2403
  name = "zadarma";
1600
2404
  // Zadarma's PBX ignores a standing registration — it routes INVITEs only to a UA that registered
1601
2405
  // in response to a push (SPEC §3.2). Hence on-demand.
@@ -1603,25 +2407,64 @@ var init_zadarma = __esm({
1603
2407
  quirks = ZADARMA_QUIRKS;
1604
2408
  incomingSignal;
1605
2409
  /**
1606
- * Per-registration credentialing: fetch a scoped key, then exchange it in the browser for
1607
- * ephemeral SIP creds. The key fetch is cheap and backend-cached; doing it here (rather than
1608
- * sharing state with the push signal) keeps the two paths independent.
2410
+ * One key and one set of SIP credentials for the push socket and every registration (§4.5). Its "unreachable
2411
+ * refetched once" mark lives there too, so a stop — which forgets the credentials — clears it as well.
2412
+ */
2413
+ #credentials;
2414
+ #signal;
2415
+ constructor(deps) {
2416
+ this.#credentials = new ZadarmaCredentials(deps);
2417
+ this.#signal = new ZadarmaPushSignal(deps, this.#credentials);
2418
+ this.incomingSignal = this.#signal;
2419
+ }
2420
+ /**
2421
+ * The SIP credentials, from memory (incoming-call-reliability.md §4.5). They were fetched when the push socket
2422
+ * opened — the key from our backend, then `{ pass, domain }` from Zadarma, in the browser for the geo-routing
2423
+ * (SPEC §3.1) — so a push goes straight to the WSS and the REGISTER. Fetched here only when they are not there:
2424
+ * the background fetch failed, or they were forgotten (`credentialsFailed`).
1609
2425
  */
1610
2426
  async provision() {
1611
- const { key, sip } = await this.deps.fetchKey();
1612
- const { pass, domain } = await this.deps.exchangeWebphoneData(key, sip);
1613
- return {
1614
- uri: `sip:${sip}@${domain}`,
1615
- authUser: sip,
1616
- password: pass,
1617
- wsUrl: `wss://${domain}:4443`
1618
- };
2427
+ return toSipCredentials(await this.#credentials.sip());
2428
+ }
2429
+ /**
2430
+ * `rejected` (401/403): the key may be the stale part, so both are forgotten, and the push socket — which
2431
+ * holds the same key — is rebuilt on a fresh one. `unreachable`: only the SIP credentials, and only once
2432
+ * until a registration works: Zadarma may have moved this browser to another datacenter (SPEC §3.1).
2433
+ *
2434
+ * Only if `credentials` — the ones that failed — are still what is in memory: a UA let go of earlier can report
2435
+ * late, when the cache has already moved on to fresh ones, and those must not be thrown away for it.
2436
+ */
2437
+ credentialsFailed(reason, credentials) {
2438
+ if (credentials && !this.#holds(credentials)) return true;
2439
+ if (reason === "rejected") {
2440
+ this.#credentials.forget("all");
2441
+ this.#signal.rekey();
2442
+ return true;
2443
+ }
2444
+ return this.#credentials.forgetUnreachable();
2445
+ }
2446
+ credentialsWorked() {
2447
+ this.#credentials.worked();
2448
+ }
2449
+ #holds(credentials) {
2450
+ const current = this.#credentials.current();
2451
+ if (!current) return false;
2452
+ const held = toSipCredentials(current);
2453
+ return held.uri === credentials.uri && held.authUser === credentials.authUser && held.password === credentials.password;
1619
2454
  }
1620
2455
  };
1621
2456
  }
1622
2457
  });
1623
2458
 
1624
2459
  // src/transports/providers/zadarma-browser.ts
2460
+ function retryAfterMsOf(res) {
2461
+ const value = res.headers?.get?.("retry-after");
2462
+ if (!value) return void 0;
2463
+ const seconds = Number(value);
2464
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
2465
+ const at = Date.parse(value);
2466
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : void 0;
2467
+ }
1625
2468
  function makeFetchKey(config) {
1626
2469
  const attempt = async () => {
1627
2470
  let token;
@@ -1637,7 +2480,7 @@ function makeFetchKey(config) {
1637
2480
  if (!res.ok) {
1638
2481
  const body = await res.json().catch(() => null);
1639
2482
  const message = body?.message ?? `webrtc/key HTTP ${res.status}`;
1640
- throw res.status === 401 ? new UnauthorizedKeyError(message) : new Error(message);
2483
+ throw res.status === 401 ? new UnauthorizedKeyError(message) : new KeyFetchError(message, res.status, retryAfterMsOf(res));
1641
2484
  }
1642
2485
  const data = await res.json();
1643
2486
  return {
@@ -1783,13 +2626,22 @@ function makeZadarmaBrowserDeps(config) {
1783
2626
  watchBackgroundReconnect
1784
2627
  };
1785
2628
  }
1786
- var UnauthorizedKeyError, BACKGROUND_RECONNECT_MS, HEARTBEAT_WORKER_SOURCE;
2629
+ var UnauthorizedKeyError, KeyFetchError, BACKGROUND_RECONNECT_MS, HEARTBEAT_WORKER_SOURCE;
1787
2630
  var init_zadarma_browser = __esm({
1788
2631
  "src/transports/providers/zadarma-browser.ts"() {
1789
2632
  init_token_cache();
1790
2633
  init_errors();
1791
2634
  UnauthorizedKeyError = class extends Error {
1792
2635
  };
2636
+ KeyFetchError = class extends Error {
2637
+ constructor(message, status, retryAfterMs) {
2638
+ super(message);
2639
+ this.status = status;
2640
+ this.retryAfterMs = retryAfterMs;
2641
+ }
2642
+ status;
2643
+ retryAfterMs;
2644
+ };
1793
2645
  BACKGROUND_RECONNECT_MS = 2e4;
1794
2646
  HEARTBEAT_WORKER_SOURCE = `
1795
2647
  let timer;
@@ -1801,6 +2653,20 @@ var init_zadarma_browser = __esm({
1801
2653
  }
1802
2654
  });
1803
2655
 
2656
+ // src/core/api-base.ts
2657
+ function normalizeApiBase(url) {
2658
+ const trimmed = url.replace(/\/+$/, "");
2659
+ if (!trimmed.endsWith("/api/v1")) return trimmed;
2660
+ console.warn(
2661
+ "[softphone] apiBase should be Coventia's domain only, without /api/v1. Ignoring the /api/v1 suffix."
2662
+ );
2663
+ return trimmed.slice(0, -"/api/v1".length).replace(/\/+$/, "");
2664
+ }
2665
+ var init_api_base = __esm({
2666
+ "src/core/api-base.ts"() {
2667
+ }
2668
+ });
2669
+
1804
2670
  // src/transports/zadarma-engine.ts
1805
2671
  var zadarma_engine_exports = {};
1806
2672
  __export(zadarma_engine_exports, {
@@ -1808,7 +2674,11 @@ __export(zadarma_engine_exports, {
1808
2674
  });
1809
2675
  function createZadarmaEngine(config) {
1810
2676
  const provider = new ZadarmaProvider(
1811
- makeZadarmaBrowserDeps({ ...config, tokenSource: cachedTokenSource(config.tokenSource) })
2677
+ makeZadarmaBrowserDeps({
2678
+ ...config,
2679
+ apiBase: normalizeApiBase(config.apiBase),
2680
+ tokenSource: cachedTokenSource(config.tokenSource)
2681
+ })
1812
2682
  );
1813
2683
  return new SipCallEngine(provider, loadJsSipFactory);
1814
2684
  }
@@ -1819,6 +2689,7 @@ var init_zadarma_engine = __esm({
1819
2689
  init_zadarma();
1820
2690
  init_zadarma_browser();
1821
2691
  init_token_cache();
2692
+ init_api_base();
1822
2693
  }
1823
2694
  });
1824
2695
 
@@ -1882,6 +2753,8 @@ var SoftphoneEvent = /* @__PURE__ */ ((SoftphoneEvent2) => {
1882
2753
  SoftphoneEvent2["LeaderChanged"] = "leaderChanged";
1883
2754
  SoftphoneEvent2["TransferOutcome"] = "transferOutcome";
1884
2755
  SoftphoneEvent2["PresenceChanged"] = "presenceChanged";
2756
+ SoftphoneEvent2["LineChanged"] = "lineChanged";
2757
+ SoftphoneEvent2["LineClaimChanged"] = "lineClaimChanged";
1885
2758
  return SoftphoneEvent2;
1886
2759
  })(SoftphoneEvent || {});
1887
2760
  var CallEvent = /* @__PURE__ */ ((CallEvent2) => {
@@ -2171,13 +3044,15 @@ function isWebLocksSupported() {
2171
3044
  return isBrowser() && typeof navigator.locks?.request === "function";
2172
3045
  }
2173
3046
  function webLocksTabLock(lockName = LOCK_NAME) {
2174
- const controller = new AbortController();
3047
+ let controller;
2175
3048
  let releaseHold;
2176
3049
  return {
2177
3050
  acquireLeadership() {
2178
3051
  if (!isWebLocksSupported()) return Promise.resolve();
3052
+ const { signal } = controller = new AbortController();
2179
3053
  return new Promise((resolveAcquired) => {
2180
- navigator.locks.request(lockName, { signal: controller.signal }, () => {
3054
+ navigator.locks.request(lockName, { signal }, () => {
3055
+ if (signal.aborted) return Promise.resolve();
2181
3056
  resolveAcquired();
2182
3057
  return new Promise((resolveHold) => {
2183
3058
  releaseHold = resolveHold;
@@ -2192,7 +3067,7 @@ function webLocksTabLock(lockName = LOCK_NAME) {
2192
3067
  releaseHold = void 0;
2193
3068
  resolve();
2194
3069
  } else {
2195
- controller.abort();
3070
+ controller?.abort();
2196
3071
  }
2197
3072
  }
2198
3073
  };
@@ -2273,17 +3148,71 @@ function startTransferWatch(params) {
2273
3148
  return stop;
2274
3149
  }
2275
3150
 
3151
+ // src/core/start-retry.ts
3152
+ init_errors();
3153
+ var BACKOFF_BASE_MS = 1e3;
3154
+ var BACKOFF_MAX_MS = 3e4;
3155
+ function backoffDelayMs(attempt, random = Math.random) {
3156
+ const capped = Math.min(BACKOFF_BASE_MS * 2 ** (Math.max(1, attempt) - 1), BACKOFF_MAX_MS);
3157
+ return capped * random();
3158
+ }
3159
+ function isTransientStatus(status) {
3160
+ return status === 0 || status === 408 || status === 425 || status === 429 || status >= 500;
3161
+ }
3162
+ function isNetworkFailure(error) {
3163
+ const name = typeof error.name === "string" ? error.name : "";
3164
+ return error instanceof TypeError || name === "AbortError" || name === "TimeoutError" || name === "NetworkError";
3165
+ }
3166
+ function transientStartFailure(error) {
3167
+ let retryAfterMs = 0;
3168
+ let viaTokenEndpoint = false;
3169
+ let current = error;
3170
+ for (let depth = 0; depth < 6 && current !== null && typeof current === "object"; depth += 1) {
3171
+ const layer = current;
3172
+ if (typeof layer.retryAfterMs === "number" && Number.isFinite(layer.retryAfterMs)) {
3173
+ retryAfterMs = Math.max(retryAfterMs, layer.retryAfterMs);
3174
+ }
3175
+ if (typeof layer.status === "number") return isTransientStatus(layer.status) ? { retryAfterMs } : null;
3176
+ if (isNetworkFailure(layer)) return { retryAfterMs };
3177
+ if (isSoftphoneError(current)) {
3178
+ if (layer.code === "auth/token-rejected") return null;
3179
+ if (layer.code === "auth/token-fetch-failed") viaTokenEndpoint = true;
3180
+ }
3181
+ current = layer.cause;
3182
+ }
3183
+ return viaTokenEndpoint ? { retryAfterMs } : null;
3184
+ }
3185
+ function watchWake(wake) {
3186
+ if (!isBrowser()) return () => {
3187
+ };
3188
+ const onOnline = () => wake();
3189
+ const onVisibility = () => {
3190
+ if (document.visibilityState === "visible") wake();
3191
+ };
3192
+ window.addEventListener("online", onOnline);
3193
+ document.addEventListener("visibilitychange", onVisibility);
3194
+ return () => {
3195
+ window.removeEventListener("online", onOnline);
3196
+ document.removeEventListener("visibilitychange", onVisibility);
3197
+ };
3198
+ }
3199
+
2276
3200
  // src/core/presence-socket.ts
2277
- var RECONNECT_BASE_MS = 1e3;
2278
- var RECONNECT_MAX_MS = 3e4;
2279
3201
  var NOOP_WATCH = { reportCallState: () => {
2280
- }, stop: () => {
3202
+ }, claimLine: () => false, stop: () => {
2281
3203
  } };
2282
3204
  function startPresenceSocket(params) {
2283
3205
  const {
2284
3206
  source,
2285
3207
  getIsLeader,
2286
3208
  onAgentsChanged,
3209
+ clientId,
3210
+ onLine,
3211
+ onClaimResult,
3212
+ onOffer,
3213
+ onLineUnsupported,
3214
+ onConnectFailed,
3215
+ onDisconnect,
2287
3216
  setTimeout: scheduleTimeout = globalThis.setTimeout,
2288
3217
  clearTimeout: cancelTimeout = globalThis.clearTimeout,
2289
3218
  random = Math.random
@@ -2294,6 +3223,8 @@ function startPresenceSocket(params) {
2294
3223
  let reconnectTimer = null;
2295
3224
  let agents = /* @__PURE__ */ new Map();
2296
3225
  let lastReportedOnCall = null;
3226
+ let sentOnCall = null;
3227
+ let line = null;
2297
3228
  let connection = null;
2298
3229
  let byeOnArrival = false;
2299
3230
  const stop = (options) => {
@@ -2310,11 +3241,16 @@ function startPresenceSocket(params) {
2310
3241
  connection?.close();
2311
3242
  connection = null;
2312
3243
  };
3244
+ const send = (onCall) => {
3245
+ if (!connection) return;
3246
+ connection.reportCallState(onCall);
3247
+ sentOnCall = onCall;
3248
+ };
3249
+ const mayReport = (onCall) => line !== "elsewhere" || !onCall && sentOnCall === true;
2313
3250
  const scheduleReconnect = () => {
2314
3251
  if (stopped) return;
2315
3252
  attempt += 1;
2316
- const capped = Math.min(RECONNECT_BASE_MS * 2 ** (attempt - 1), RECONNECT_MAX_MS);
2317
- const delay = capped * random();
3253
+ const delay = backoffDelayMs(attempt, random);
2318
3254
  reconnectTimer = scheduleTimeout(() => void connect(), delay);
2319
3255
  };
2320
3256
  const connect = async () => {
@@ -2338,10 +3274,26 @@ function startPresenceSocket(params) {
2338
3274
  if (connection === null) return;
2339
3275
  connection = null;
2340
3276
  scheduleReconnect();
3277
+ onDisconnect?.();
3278
+ },
3279
+ onLine: (update) => {
3280
+ if (stopped) return;
3281
+ const was = line;
3282
+ line = update.state;
3283
+ if (was === "elsewhere" && line === "here" && lastReportedOnCall !== null) send(lastReportedOnCall);
3284
+ onLine?.(update);
3285
+ },
3286
+ onClaimResult: (result) => {
3287
+ if (!stopped) onClaimResult?.(result);
3288
+ },
3289
+ onOffer: (offer) => {
3290
+ if (!stopped) onOffer?.(offer);
2341
3291
  }
2342
3292
  });
2343
3293
  } catch {
2344
- if (!stopped) scheduleReconnect();
3294
+ if (stopped) return;
3295
+ scheduleReconnect();
3296
+ onConnectFailed?.();
2345
3297
  return;
2346
3298
  }
2347
3299
  if (stopped) {
@@ -2351,23 +3303,64 @@ function startPresenceSocket(params) {
2351
3303
  }
2352
3304
  connection = opened;
2353
3305
  attempt = 0;
2354
- if (lastReportedOnCall !== null) connection.reportCallState(lastReportedOnCall);
3306
+ sentOnCall = null;
3307
+ line = null;
3308
+ if (clientId !== void 0 && opened.hello) opened.hello(clientId);
3309
+ else onLineUnsupported?.();
3310
+ if (lastReportedOnCall !== null) send(lastReportedOnCall);
2355
3311
  };
2356
3312
  void connect();
2357
3313
  return {
2358
3314
  reportCallState: (onCall) => {
2359
3315
  if (onCall === lastReportedOnCall) return;
2360
3316
  lastReportedOnCall = onCall;
2361
- connection?.reportCallState(onCall);
3317
+ if (mayReport(onCall)) send(onCall);
3318
+ },
3319
+ claimLine: () => {
3320
+ if (!connection?.claimLine) return false;
3321
+ connection.claimLine();
3322
+ return true;
2362
3323
  },
2363
3324
  stop
2364
3325
  };
2365
3326
  }
2366
3327
 
3328
+ // src/core/line.ts
3329
+ var CLIENT_ID_KEY = "coventia-softphone:client-id";
3330
+ var CLIENT_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/;
3331
+ function lineClientId() {
3332
+ const stored = readStoredId();
3333
+ if (stored) return stored;
3334
+ const id = freshClientId();
3335
+ try {
3336
+ if (isBrowser()) globalThis.sessionStorage?.setItem(CLIENT_ID_KEY, id);
3337
+ } catch {
3338
+ }
3339
+ return id;
3340
+ }
3341
+ function readStoredId() {
3342
+ if (!isBrowser()) return null;
3343
+ try {
3344
+ const value = globalThis.sessionStorage?.getItem(CLIENT_ID_KEY);
3345
+ return value && CLIENT_ID_PATTERN.test(value) ? value : null;
3346
+ } catch {
3347
+ return null;
3348
+ }
3349
+ }
3350
+ function freshClientId() {
3351
+ const uuid = globalThis.crypto?.randomUUID?.();
3352
+ if (uuid) return uuid;
3353
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
3354
+ }
3355
+
2367
3356
  // src/core/softphone-impl.ts
2368
3357
  init_errors();
2369
3358
  var liveInstance = false;
2370
3359
  var PRESENCE_TEARDOWN_GRACE_MS = 1e4;
3360
+ var LINE_ANSWER_TIMEOUT_MS = 4e3;
3361
+ var CALL_DECISION_TIMEOUT_MS = LINE_ANSWER_TIMEOUT_MS + 2e3;
3362
+ var LINE_CLAIM_TIMEOUT_MS = 1e4;
3363
+ var LINE_LAPSE_MS = 1e4;
2371
3364
  var BrowserAudioDevices = class {
2372
3365
  inputs = [];
2373
3366
  outputs = [];
@@ -2475,6 +3468,8 @@ var SoftphoneImpl = class extends Emitter {
2475
3468
  #activeCall = null;
2476
3469
  #destroyed = false;
2477
3470
  #startedHere = false;
3471
+ /** Bumped by every `stop()`, so a `start()` still waiting to lead can tell it was stopped. */
3472
+ #run = 0;
2478
3473
  #isLeader = false;
2479
3474
  #engine;
2480
3475
  #tabLock;
@@ -2499,6 +3494,63 @@ var SoftphoneImpl = class extends Emitter {
2499
3494
  /** A pending state-driven teardown of the watch — see `PRESENCE_TEARDOWN_GRACE_MS`. */
2500
3495
  #presenceTeardownTimer = null;
2501
3496
  #presenceAgents = [];
3497
+ // ── The line owner (`docs/specs/line-owner.md` §5) ──────────────────────────────────────────────
3498
+ // Heard over the presence watch: the server names ONE softphone per extension that listens, and this one
3499
+ // runs its engine only while that is it (or nobody can say). `#engineOn` and `#standby` are the two halves
3500
+ // of that decision, kept apart because neither is true while `start()` is still waiting to make it.
3501
+ #line = "unknown";
3502
+ #lineWhere = null;
3503
+ /** How the last attempt to bring the line here went (`lineClaim`), whoever made it. */
3504
+ #lineClaim = "idle";
3505
+ /** This tab's id in `hello` (`lineClientId`), read once — one id for every reconnect of this softphone. */
3506
+ #clientId = null;
3507
+ /** What the line wants: the engine running. Set the moment the line decides; the engine follows (`#queueEngine`). */
3508
+ #engineOn = false;
3509
+ /**
3510
+ * What the engine was last TOLD by the queue — started or stopped. Apart from `#engineOn` because the line
3511
+ * can flip twice while a start is still running: the stop and start queued meanwhile then find nothing
3512
+ * to do, instead of stopping the engine (and the softphone with it) for a moment.
3513
+ */
3514
+ #engineRunning = false;
3515
+ /** Waiting in `standby`: the engine is stopped ON PURPOSE, so its own `stopped` is not the softphone's. */
3516
+ #standby = false;
3517
+ /**
3518
+ * Every engine start and stop the line asks for, one after another: a promotion can follow a demotion
3519
+ * within a moment, and an engine started while its own stop is still running can end up neither.
3520
+ */
3521
+ #engineSteps = Promise.resolve();
3522
+ /** Dials in flight (`call()` awaiting `placeCall`): a call about to exist holds the line like one that does. */
3523
+ #dialing = 0;
3524
+ /**
3525
+ * Open from `start()`'s first line until it has decided whether the engine runs — or given up (stopped,
3526
+ * destroyed, failed). `call()` waits on it, so it never dials ahead of that decision: not on a phone
3527
+ * stopped meanwhile, and not without the line.
3528
+ */
3529
+ #startDecision = null;
3530
+ /**
3531
+ * Who decides whether the engine runs. `off`: nobody — not started, a follower, or `stop()` has begun
3532
+ * (whatever its teardown sets off, like a call ending, must not start anything). `deciding`: `start()`
3533
+ * has leadership and owes the FIRST decision itself, since it must await the engine start — a line
3534
+ * message meanwhile only records what it says. `on`: the line decides (`#applyLine`).
3535
+ */
3536
+ #lineMode = "off";
3537
+ /** Ends `start()`'s wait for the server's first word on the line early (`#waitForFirstLine`). */
3538
+ #releaseFirstLine = null;
3539
+ /** The `takeLine()` in flight — one at a time; a second caller shares it. */
3540
+ #claim = null;
3541
+ /** Pending: what the server said about the line stops counting (`LINE_LAPSE_MS`). */
3542
+ #lineLapseTimer = null;
3543
+ // ── The start retry (incoming-call-reliability.md §4.6) ─────────────────────────────────────────────────
3544
+ // An engine start that failed for a passing reason (a 5xx, the network, a timeout) is attempted again on its
3545
+ // own, with backoff, instead of leaving the phone `offline` until someone presses "Reintentar".
3546
+ /** Failed attempts in the current streak; back to 0 when the engine starts, or the softphone stops. */
3547
+ #startRetries = 0;
3548
+ /** The wait before the next attempt, or `null` when none is pending. */
3549
+ #startRetryTimer = null;
3550
+ /** No attempt before this (epoch ms): a `Retry-After` the failure carried, which a wake-up must honour too. */
3551
+ #startRetryNotBefore = 0;
3552
+ /** Unsubscribes the `online` / tab-visible wake-ups, armed only while an attempt is pending. */
3553
+ #unwatchWake = null;
2502
3554
  /**
2503
3555
  * `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
2504
3556
  * internal seam, not something an integrator tunes). Tests construct this class directly with a
@@ -2516,7 +3568,9 @@ var SoftphoneImpl = class extends Emitter {
2516
3568
  this.sources = config.coventia || config.apiBase ? coventiaBackend(config.coventia ?? {}, cachedTokenSource(config.tokenSource), config.apiBase) : null;
2517
3569
  this.#resolveContact = config.resolveContact ?? this.sources?.resolveContact;
2518
3570
  this.#engine.onIncoming((engineCall) => this.#onIncoming(engineCall));
2519
- this.#engine.onStateChange((state) => this.#setState(state));
3571
+ this.#engine.onStateChange((state) => {
3572
+ if (!this.#standby) this.#setState(state);
3573
+ });
2520
3574
  this.#engine.onError?.((error) => this.#onError(error));
2521
3575
  }
2522
3576
  get state() {
@@ -2534,6 +3588,15 @@ var SoftphoneImpl = class extends Emitter {
2534
3588
  get presence() {
2535
3589
  return this.#presenceAgents;
2536
3590
  }
3591
+ get line() {
3592
+ return this.#line;
3593
+ }
3594
+ get lineWhere() {
3595
+ return this.#lineWhere;
3596
+ }
3597
+ get lineClaim() {
3598
+ return this.#lineClaim;
3599
+ }
2537
3600
  get canPlayAudio() {
2538
3601
  return true;
2539
3602
  }
@@ -2549,15 +3612,34 @@ var SoftphoneImpl = class extends Emitter {
2549
3612
  }
2550
3613
  liveInstance = true;
2551
3614
  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();
3615
+ const run = this.#run;
3616
+ const decision = this.#openStartDecision();
3617
+ try {
3618
+ this.#setState("starting");
3619
+ await this.#tabLock.acquireLeadership();
3620
+ if (this.#destroyed || run !== this.#run) return;
3621
+ this.#lineMode = "deciding";
3622
+ this.#setLeader(true);
3623
+ if (this.#presenceWatch && this.#line === "unknown") await this.#waitForFirstLine();
3624
+ if (this.#destroyed || run !== this.#run) return;
3625
+ this.#lineMode = "on";
3626
+ const engineStart = this.#applyLine();
3627
+ decision.end();
3628
+ await engineStart;
3629
+ } finally {
3630
+ decision.end();
3631
+ }
2557
3632
  }
2558
3633
  async stop() {
2559
3634
  if (!this.#startedHere) return;
3635
+ this.#run += 1;
3636
+ this.#cancelStartRetry();
3637
+ this.#lineMode = "off";
2560
3638
  this.#sayGoodbye();
3639
+ this.#startDecision?.end();
3640
+ this.#standby = false;
3641
+ this.#engineOn = false;
3642
+ this.#engineRunning = false;
2561
3643
  this.#tabLock.release();
2562
3644
  if (this.#isLeader) {
2563
3645
  await this.#engine.stop();
@@ -2579,16 +3661,51 @@ var SoftphoneImpl = class extends Emitter {
2579
3661
  this.#setState("destroyed");
2580
3662
  this.clear();
2581
3663
  }
3664
+ async takeLine() {
3665
+ this.#assertUsable();
3666
+ if (this.#line === "here") return;
3667
+ if (this.#claim) return this.#claim.promise;
3668
+ const watch = this.#presenceWatch;
3669
+ if (!watch) {
3670
+ this.#setLineClaim("failed");
3671
+ throw createError("line/unavailable");
3672
+ }
3673
+ const claim = this.#openClaim();
3674
+ if (!watch.claimLine()) this.#settleClaim(createError("line/unavailable"));
3675
+ return claim;
3676
+ }
2582
3677
  async call(destination, options) {
2583
3678
  this.#assertUsable();
3679
+ const run = this.#run;
3680
+ if (this.#startRetryTimer !== null) this.#wakeStartRetry();
3681
+ if (this.#startDecision) {
3682
+ let timer;
3683
+ const decided = await Promise.race([
3684
+ this.#startDecision.promise.then(() => true),
3685
+ new Promise((resolve) => {
3686
+ timer = setTimeout(() => resolve(false), CALL_DECISION_TIMEOUT_MS);
3687
+ })
3688
+ ]);
3689
+ clearTimeout(timer);
3690
+ if (!decided) throw createError("engine/not-started");
3691
+ }
3692
+ if (run === this.#run && this.#state === "standby") await this.takeLine();
3693
+ this.#assertUsable();
3694
+ if (run !== this.#run) throw createError("engine/not-started");
2584
3695
  if (this.#state !== "online" && this.#state !== "offline" && this.#state !== "starting") {
2585
3696
  throw new Error(`Cannot place a call while softphone is '${this.#state}'.`);
2586
3697
  }
2587
- if (this.#activeCall) {
3698
+ if (this.#activeCall || this.#dialing > 0) {
2588
3699
  throw new Error("A call is already in progress. Hang up before placing another.");
2589
3700
  }
2590
- const engineCall = await this.#engine.placeCall(destination, options);
2591
- return this.#adopt(engineCall);
3701
+ this.#dialing += 1;
3702
+ try {
3703
+ const engineCall = await this.#engine.placeCall(destination, options);
3704
+ return this.#adopt(engineCall);
3705
+ } finally {
3706
+ this.#dialing -= 1;
3707
+ this.#syncLine();
3708
+ }
2592
3709
  }
2593
3710
  // ── internals ───────────────────────────────────────────────────────────────
2594
3711
  #onIncoming(engineCall) {
@@ -2617,6 +3734,7 @@ var SoftphoneImpl = class extends Emitter {
2617
3734
  this.#activeCall = null;
2618
3735
  this.emit("activeCallChanged" /* ActiveCallChanged */, { call: null });
2619
3736
  this.#syncCallState();
3737
+ this.#syncLine();
2620
3738
  }
2621
3739
  });
2622
3740
  if (this.#resolveContact) {
@@ -2646,7 +3764,8 @@ var SoftphoneImpl = class extends Emitter {
2646
3764
  /**
2647
3765
  * Reports the current on-a-call value to the backend. The watch de-duplicates (it only sends on change) and
2648
3766
  * 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).
3767
+ * ceremony — and holds it back while the line is elsewhere, since only the owner's reports count (line-owner
3768
+ * R8). A no-op while there is no presence watch (stopped, not the leader, no backend configured).
2650
3769
  */
2651
3770
  #syncCallState() {
2652
3771
  this.#presenceWatch?.reportCallState(this.#isOnCall());
@@ -2667,17 +3786,22 @@ var SoftphoneImpl = class extends Emitter {
2667
3786
  * `presence-socket.ts`'s own `getIsLeader` is checked at start and before each reconnect attempt,
2668
3787
  * not on a poll tick the way `startTransferWatch`'s is — see that module's own doc comment for why.
2669
3788
  * 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
3789
+ * BROAD started/leader gate — whether to be connected AT ALL — is owned entirely here, the same
2671
3790
  * split `#onTransfer` already has with `startTransferWatch`.
2672
3791
  *
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.
3792
+ * The watch runs for a leader that is `starting`, `online` or in `standby` — not only `online`, as it did
3793
+ * before the line owner (`docs/specs/line-owner.md` §5.4): while starting it is how this softphone learns
3794
+ * whether to listen at all, and in standby how it learns the line came back.
3795
+ *
3796
+ * A leader that is merely `offline` or `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a
3797
+ * blip shorter than that never touches the presence socket at all; a longer outage closes it, which lets
3798
+ * the server hand the line to a softphone that can still take calls. Anything else (stopped, destroyed, or
3799
+ * no longer the leader) tears it down at once: those are not blips, and a follower must never hold a
3800
+ * connection.
2677
3801
  */
2678
3802
  #syncPresenceWatch() {
2679
3803
  const eligible = Boolean(this.sources) && this.#isLeader;
2680
- const shouldRun = eligible && this.#state === "online";
3804
+ const shouldRun = eligible && this.#presenceRunsIn(this.#state);
2681
3805
  const blip = eligible && (this.#state === "offline" || this.#state === "reconnecting");
2682
3806
  if (!blip) this.#cancelPresenceTeardown();
2683
3807
  if (blip && this.#presenceWatch !== null) {
@@ -2686,13 +3810,25 @@ var SoftphoneImpl = class extends Emitter {
2686
3810
  }
2687
3811
  if (shouldRun === (this.#presenceWatch !== null)) return;
2688
3812
  if (shouldRun) {
3813
+ this.#clientId ??= lineClientId();
2689
3814
  this.#presenceWatch = startPresenceSocket({
2690
3815
  source: this.sources.presence,
2691
3816
  getIsLeader: () => this.#isLeader,
2692
3817
  onAgentsChanged: (agents) => {
2693
3818
  this.#presenceAgents = agents;
2694
3819
  this.emit("presenceChanged" /* PresenceChanged */, { agents });
2695
- }
3820
+ },
3821
+ clientId: this.#clientId,
3822
+ onLine: (update) => this.#onLine(update),
3823
+ onClaimResult: (result) => this.#onClaimResult(result),
3824
+ // The calls offered to this line, straight to the engine: after a decline, they say when the declined
3825
+ // call's re-offers stop (incoming-call-reliability.md §4.4). Not public — nothing a host acts on.
3826
+ onOffer: (offer) => this.#engine.noteOffer?.(offer),
3827
+ // A source that cannot take part will never answer, and one that cannot connect cannot answer yet:
3828
+ // `start()` need not wait out its 4 s for either (R7). A later answer still applies.
3829
+ onLineUnsupported: () => this.#releaseFirstLine?.(),
3830
+ onConnectFailed: () => this.#releaseFirstLine?.(),
3831
+ onDisconnect: () => this.#onPresenceDisconnect()
2696
3832
  });
2697
3833
  this.#syncCallState();
2698
3834
  } else {
@@ -2707,19 +3843,24 @@ var SoftphoneImpl = class extends Emitter {
2707
3843
  if (this.#presenceTeardownTimer !== null) return;
2708
3844
  this.#presenceTeardownTimer = setTimeout(() => {
2709
3845
  this.#presenceTeardownTimer = null;
2710
- if (this.#state === "online" && this.#isLeader) return;
3846
+ if (this.#isLeader && this.#presenceRunsIn(this.#state)) return;
2711
3847
  this.#stopPresence();
2712
3848
  }, PRESENCE_TEARDOWN_GRACE_MS);
2713
3849
  }
3850
+ /** The states in which a leader keeps its presence watch with no clock running (`#syncPresenceWatch`). */
3851
+ #presenceRunsIn(state) {
3852
+ return state === "online" || state === "starting" || state === "standby";
3853
+ }
2714
3854
  #cancelPresenceTeardown() {
2715
3855
  if (this.#presenceTeardownTimer === null) return;
2716
3856
  clearTimeout(this.#presenceTeardownTimer);
2717
3857
  this.#presenceTeardownTimer = null;
2718
3858
  }
2719
3859
  /**
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.
3860
+ * Stops the presence watch and forgets its roster, and the line. A STATE-DRIVEN teardown (an outage
3861
+ * outlasted the grace, the engine stopped, or leadership was lost) passes nothing and says no goodbye;
3862
+ * only a deliberate stop passes `bye`. Any pending grace-period teardown is cancelled — there is nothing
3863
+ * left for it to do.
2723
3864
  */
2724
3865
  #stopPresence(options) {
2725
3866
  this.#cancelPresenceTeardown();
@@ -2728,6 +3869,8 @@ var SoftphoneImpl = class extends Emitter {
2728
3869
  this.#presenceWatch = null;
2729
3870
  this.#presenceAgents = [];
2730
3871
  this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
3872
+ this.#forgetLine();
3873
+ this.#engine.noteOffer?.(null);
2731
3874
  }
2732
3875
  /**
2733
3876
  * A DELIBERATE stop (`stop()` / `destroy()`): say any call is over, then say goodbye, so the backend drops
@@ -2737,6 +3880,278 @@ var SoftphoneImpl = class extends Emitter {
2737
3880
  this.#presenceWatch?.reportCallState(false);
2738
3881
  this.#stopPresence({ bye: true });
2739
3882
  }
3883
+ // ── the line owner (`docs/specs/line-owner.md` §5) ──────────────────────────────────────────────
3884
+ /**
3885
+ * THE decision: from where the line is and whether a call is in progress, whether the engine runs, and
3886
+ * the state that says so. Synchronous, so the state moves the instant the line does — the waiting screen
3887
+ * goes the moment the line comes here, and a `call()` that took the line dials from `starting` without
3888
+ * waiting out the engine start. Only the engine start or stop is queued (`#queueEngine`). Returns that
3889
+ * queued step, or `null` when nothing had to change.
3890
+ */
3891
+ #applyLine() {
3892
+ if (this.#lineMode !== "on") return null;
3893
+ const run = this.#run;
3894
+ if (this.#line === "elsewhere") {
3895
+ if (this.#standby || this.#activeCall || this.#dialing > 0) return null;
3896
+ this.#standby = true;
3897
+ this.#setState("standby");
3898
+ if (!this.#engineOn) return null;
3899
+ this.#engineOn = false;
3900
+ return this.#queueEngine(run);
3901
+ }
3902
+ if (this.#engineOn) return null;
3903
+ this.#engineOn = true;
3904
+ if (this.#standby) {
3905
+ this.#standby = false;
3906
+ this.#setState("starting");
3907
+ }
3908
+ return this.#queueEngine(run);
3909
+ }
3910
+ /** `#applyLine` from an event — nobody to hand a failure to, and `#engineStartFailed` has reported it. */
3911
+ #syncLine() {
3912
+ void this.#applyLine()?.catch(() => {
3913
+ });
3914
+ }
3915
+ /**
3916
+ * Brings the engine to what the line wants (`#engineOn`) once every step already queued has run — and does
3917
+ * nothing when it is already there: the line can flip twice while a start is still running, and the stop
3918
+ * and start queued meanwhile must not take the engine (and the softphone's state) down and up for nothing.
3919
+ * Nothing at all once `stop()` has run since `run` was read: a stopped softphone's engine is `stop()`'s
3920
+ * alone. Rejects with a failed start's cause; the queue itself never rejects, so one failure blocks nothing.
3921
+ */
3922
+ #queueEngine(run) {
3923
+ const step = this.#engineSteps.then(async () => {
3924
+ if (run !== this.#run || this.#engineOn === this.#engineRunning) return;
3925
+ this.#engineRunning = this.#engineOn;
3926
+ if (this.#engineRunning) await this.#startEngine(run);
3927
+ else await this.#engine.stop();
3928
+ });
3929
+ this.#engineSteps = step.catch(() => {
3930
+ });
3931
+ return step;
3932
+ }
3933
+ /** Resolves when the engine started — or when its failure is being retried on its own (§4.6). */
3934
+ async #startEngine(run) {
3935
+ const reported = this.#lastError;
3936
+ try {
3937
+ await this.#engine.start();
3938
+ if (run === this.#run) this.#startRetries = 0;
3939
+ } catch (cause) {
3940
+ if (run === this.#run && this.#engineStartFailed(cause, reported)) return;
3941
+ throw cause;
3942
+ }
3943
+ }
3944
+ /**
3945
+ * An engine start the line asked for failed. Kept as it was, this softphone would stay the owner on the
3946
+ * server — `starting`, its presence socket renewing the line — with no push socket anywhere: nobody would
3947
+ * listen. So it gives the line up the ordinary way, by closing its presence socket without a goodbye (the
3948
+ * phone was not switched off): the server hands the line on after its grace (R4).
3949
+ *
3950
+ * Then it depends on why (incoming-call-reliability.md §4.6, F5):
3951
+ * - **a passing failure** (a 5xx, the network, a timeout — `transientStartFailure`) is attempted again on its
3952
+ * own, after a backoff, as `reconnecting`, with no error gate. Found live: a voice token answered 500 after
3953
+ * 10 s left a background tab `offline` for good, and every call was lost until someone pressed "Reintentar".
3954
+ * Returns `true`.
3955
+ * - **anything else** (a refused credential, no extension) is reported like any engine failure —
3956
+ * `SoftphoneEvent.Error` and `lastError`, which is the widget's error gate and its "Reintentar" (`stop()`
3957
+ * then `start()`, which asks for the line again) — and nothing retries on its own. Returns `false`.
3958
+ */
3959
+ #engineStartFailed(cause, reportedBefore) {
3960
+ this.#engineOn = false;
3961
+ this.#engineRunning = false;
3962
+ this.#lineMode = "off";
3963
+ const transient = this.#destroyed ? null : transientStartFailure(cause);
3964
+ this.#stopPresence();
3965
+ if (transient) {
3966
+ this.#scheduleStartRetry(transient.retryAfterMs, cause);
3967
+ this.#setState("reconnecting");
3968
+ return true;
3969
+ }
3970
+ if (this.#lastError === reportedBefore) {
3971
+ this.#onError(isSoftphoneError(cause) && isFatal(cause) ? cause : createError("net/transport-failed", cause));
3972
+ }
3973
+ this.#setState("offline");
3974
+ return false;
3975
+ }
3976
+ /**
3977
+ * The next attempt, after the shared backoff (1 s doubling to 30 s, jittered — the presence socket's own) and
3978
+ * never before a `Retry-After` the failure carried. A plain timer: in a hidden tab it can stretch to about a
3979
+ * minute, which is why the browser coming back `online`, or the tab becoming visible, attempts at once
3980
+ * (`watchWake`). One warning per streak, so a console says what is going on without filling up.
3981
+ */
3982
+ #scheduleStartRetry(retryAfterMs, cause) {
3983
+ this.#startRetries += 1;
3984
+ const wait = Math.max(backoffDelayMs(this.#startRetries), retryAfterMs);
3985
+ if (this.#startRetries === 1) {
3986
+ console.warn("[softphone] the phone could not start; it keeps trying on its own", cause);
3987
+ }
3988
+ this.#startRetryNotBefore = Date.now() + retryAfterMs;
3989
+ if (this.#startRetryTimer !== null) clearTimeout(this.#startRetryTimer);
3990
+ const run = this.#run;
3991
+ this.#startRetryTimer = setTimeout(() => void this.#retryStart(run), wait);
3992
+ this.#unwatchWake ??= watchWake(() => this.#wakeStartRetry());
3993
+ }
3994
+ /** The pending attempt, now — unless a `Retry-After` says not yet, in which case it moves up to that moment. */
3995
+ #wakeStartRetry() {
3996
+ if (this.#startRetryTimer === null) return;
3997
+ const run = this.#run;
3998
+ clearTimeout(this.#startRetryTimer);
3999
+ const wait = this.#startRetryNotBefore - Date.now();
4000
+ if (wait > 0) {
4001
+ this.#startRetryTimer = setTimeout(() => void this.#retryStart(run), wait);
4002
+ return;
4003
+ }
4004
+ void this.#retryStart(run);
4005
+ }
4006
+ /** The wait and the wake-ups go; the streak's count stays (the next failure doubles on from it). */
4007
+ #clearStartRetryWait() {
4008
+ if (this.#startRetryTimer !== null) clearTimeout(this.#startRetryTimer);
4009
+ this.#startRetryTimer = null;
4010
+ this.#unwatchWake?.();
4011
+ this.#unwatchWake = null;
4012
+ }
4013
+ #cancelStartRetry() {
4014
+ this.#clearStartRetryWait();
4015
+ this.#startRetries = 0;
4016
+ }
4017
+ /**
4018
+ * One attempt: what "Reintentar" does after its `stop()`, minus the goodbye and the leadership (both kept) —
4019
+ * the presence socket opens again and its `hello` asks for the line, the line decides (`standby` if it went
4020
+ * elsewhere meanwhile, as at any start), and the engine starts. Without the goodbye, an attempt inside the
4021
+ * server's 10 s grace gets the line straight back. Never for a softphone stopped since it was scheduled.
4022
+ */
4023
+ async #retryStart(run) {
4024
+ this.#clearStartRetryWait();
4025
+ if (this.#destroyed || run !== this.#run || !this.#startedHere || !this.#isLeader) return;
4026
+ const decision = this.#openStartDecision();
4027
+ try {
4028
+ this.#lineMode = "deciding";
4029
+ this.#setState("starting");
4030
+ if (this.#presenceWatch && this.#line === "unknown") await this.#waitForFirstLine();
4031
+ if (this.#destroyed || run !== this.#run) return;
4032
+ this.#lineMode = "on";
4033
+ const engineStart = this.#applyLine();
4034
+ if (!engineStart) this.#startRetries = 0;
4035
+ decision.end();
4036
+ await engineStart?.catch(() => {
4037
+ });
4038
+ } finally {
4039
+ decision.end();
4040
+ }
4041
+ }
4042
+ /** `start()`'s decision (`#startDecision`): ended once, by whichever comes first — the decision or its end. */
4043
+ #openStartDecision() {
4044
+ let resolve;
4045
+ const promise = new Promise((r) => {
4046
+ resolve = r;
4047
+ });
4048
+ const decision = {
4049
+ promise,
4050
+ end: () => {
4051
+ resolve();
4052
+ if (this.#startDecision === decision) this.#startDecision = null;
4053
+ }
4054
+ };
4055
+ this.#startDecision = decision;
4056
+ return decision;
4057
+ }
4058
+ /**
4059
+ * `start()`'s wait for the server's first word on the line — released by that word, by a source that
4060
+ * will never say it, by the watch going away (`stop()` among them), or after `LINE_ANSWER_TIMEOUT_MS`.
4061
+ */
4062
+ #waitForFirstLine() {
4063
+ return new Promise((resolve) => {
4064
+ const release = () => {
4065
+ clearTimeout(timer);
4066
+ if (this.#releaseFirstLine === release) this.#releaseFirstLine = null;
4067
+ resolve();
4068
+ };
4069
+ const timer = setTimeout(release, LINE_ANSWER_TIMEOUT_MS);
4070
+ this.#releaseFirstLine = release;
4071
+ });
4072
+ }
4073
+ #onLine(update) {
4074
+ this.#cancelLineLapse();
4075
+ this.#setLine(update.state, update.state === "here" ? null : update.where);
4076
+ if (this.#releaseFirstLine) this.#releaseFirstLine();
4077
+ else this.#syncLine();
4078
+ if (update.state === "here") this.#settleClaim();
4079
+ }
4080
+ #onClaimResult(result) {
4081
+ if (result.ok) {
4082
+ if (this.#line === "here") this.#settleClaim();
4083
+ return;
4084
+ }
4085
+ this.#settleClaim(createError(result.reason === "on_call" ? "line/claim-refused" : "line/unavailable"));
4086
+ }
4087
+ /**
4088
+ * The presence socket dropped; the watch is reconnecting. A claim sent on it will never be answered, so
4089
+ * it fails now rather than on its timeout. What the server said about the line holds for `LINE_LAPSE_MS`
4090
+ * — a reconnect is normally answered well inside that — and then stops counting: `unknown`, which a
4091
+ * waiting softphone answers by listening again.
4092
+ */
4093
+ #onPresenceDisconnect() {
4094
+ this.#engine.noteOffer?.(null);
4095
+ this.#settleClaim(createError("line/unavailable"));
4096
+ if (this.#line === "unknown" || this.#lineLapseTimer !== null) return;
4097
+ this.#lineLapseTimer = setTimeout(() => {
4098
+ this.#lineLapseTimer = null;
4099
+ this.#setLine("unknown", null);
4100
+ this.#syncLine();
4101
+ }, LINE_LAPSE_MS);
4102
+ }
4103
+ #cancelLineLapse() {
4104
+ if (this.#lineLapseTimer === null) return;
4105
+ clearTimeout(this.#lineLapseTimer);
4106
+ this.#lineLapseTimer = null;
4107
+ }
4108
+ /**
4109
+ * The presence watch is gone, so nothing the server said about the line holds any more: it reads
4110
+ * `unknown`, and every wait on it ends. Not followed by `#applyLine` — this runs inside a state change
4111
+ * (`#syncPresenceWatch`) or a stop, and a softphone whose watch is gone is not in `standby` anyway: that
4112
+ * state keeps the watch.
4113
+ */
4114
+ #forgetLine() {
4115
+ this.#cancelLineLapse();
4116
+ this.#releaseFirstLine?.();
4117
+ this.#settleClaim(createError("line/unavailable"));
4118
+ this.#setLine("unknown", null);
4119
+ this.#setLineClaim("idle");
4120
+ }
4121
+ #setLine(line, where) {
4122
+ if (line === "here") this.#setLineClaim("idle");
4123
+ if (line === this.#line && where === this.#lineWhere) return;
4124
+ this.#line = line;
4125
+ this.#lineWhere = where;
4126
+ this.emit("lineChanged" /* LineChanged */, { line, where });
4127
+ }
4128
+ #setLineClaim(claim) {
4129
+ if (claim === this.#lineClaim) return;
4130
+ this.#lineClaim = claim;
4131
+ this.emit("lineClaimChanged" /* LineClaimChanged */, { claim });
4132
+ }
4133
+ #openClaim() {
4134
+ let settle;
4135
+ const promise = new Promise((resolve, reject) => {
4136
+ const timer = setTimeout(() => this.#settleClaim(createError("line/unavailable")), LINE_CLAIM_TIMEOUT_MS);
4137
+ settle = (error) => {
4138
+ clearTimeout(timer);
4139
+ if (error) reject(error);
4140
+ else resolve();
4141
+ };
4142
+ });
4143
+ this.#claim = { promise, settle };
4144
+ this.#setLineClaim("claiming");
4145
+ return promise;
4146
+ }
4147
+ /** Ends the claim in flight, if any — resolved without `error`, rejected with it. */
4148
+ #settleClaim(error) {
4149
+ const claim = this.#claim;
4150
+ if (!claim) return;
4151
+ this.#claim = null;
4152
+ this.#setLineClaim(!error ? "idle" : error.code === "line/claim-refused" ? "refused" : "failed");
4153
+ claim.settle(error);
4154
+ }
2740
4155
  /**
2741
4156
  * Phase A (transfer outcome relay, `docs/specs/transfer-outcome-relay.md`) — `CallImpl.transfer()`'s
2742
4157
  * internal seam calls this. No-ops entirely when no Coventia backend is configured (`sources` is
@@ -2785,6 +4200,8 @@ var SoftphoneImpl = class extends Emitter {
2785
4200
  };
2786
4201
 
2787
4202
  // src/core/default-provider.ts
4203
+ init_errors();
4204
+ var MAX_PENDING_OFFERS = 20;
2788
4205
  var LazyRelay = class {
2789
4206
  #listeners = /* @__PURE__ */ new Set();
2790
4207
  #realOff = /* @__PURE__ */ new Map();
@@ -2807,9 +4224,11 @@ var LazyRelay = class {
2807
4224
  function defaultZadarmaEngine(config) {
2808
4225
  let real = null;
2809
4226
  let pending = null;
4227
+ let run = 0;
2810
4228
  const incoming = new LazyRelay();
2811
4229
  const stateChange = new LazyRelay();
2812
4230
  const error = new LazyRelay();
4231
+ let pendingOffers = [];
2813
4232
  function load() {
2814
4233
  pending ??= Promise.resolve().then(() => (init_zadarma_engine(), zadarma_engine_exports)).then(({ createZadarmaEngine: createZadarmaEngine2 }) => {
2815
4234
  const engine = createZadarmaEngine2(config);
@@ -2817,6 +4236,8 @@ function defaultZadarmaEngine(config) {
2817
4236
  stateChange.attach({ on: (l) => engine.onStateChange(l) });
2818
4237
  error.attach({ on: (l) => engine.onError?.(l) ?? (() => {
2819
4238
  }) });
4239
+ for (const offer of pendingOffers) engine.noteOffer?.(offer);
4240
+ pendingOffers = [];
2820
4241
  real = engine;
2821
4242
  return engine;
2822
4243
  }).catch((cause) => {
@@ -2830,23 +4251,53 @@ function defaultZadarmaEngine(config) {
2830
4251
  return real?.state ?? "stopped";
2831
4252
  },
2832
4253
  async start() {
2833
- await (await load()).start();
4254
+ const current = run;
4255
+ const engine = await load();
4256
+ if (current !== run) return;
4257
+ await engine.start();
2834
4258
  },
2835
4259
  async stop() {
4260
+ run += 1;
2836
4261
  if (real) await real.stop();
2837
4262
  },
2838
4263
  async placeCall(destination, options) {
2839
- return (await load()).placeCall(destination, options);
4264
+ const current = run;
4265
+ const engine = await load();
4266
+ if (current !== run) throw createError("engine/not-started");
4267
+ return engine.placeCall(destination, options);
2840
4268
  },
2841
4269
  onIncoming: (listener) => incoming.subscribe(listener),
2842
4270
  onStateChange: (listener) => stateChange.subscribe(listener),
2843
- onError: (listener) => error.subscribe(listener)
4271
+ onError: (listener) => error.subscribe(listener),
4272
+ /*
4273
+ * Relayed like the listeners above, or the decline guard on this — the default — path would never hear the
4274
+ * backend (incoming-call-reliability.md §4.4). Before the engine loads, kept (a few) rather than loading it:
4275
+ * a softphone in `standby` hears offers without ever starting an engine.
4276
+ */
4277
+ noteOffer: (offer) => {
4278
+ if (real) {
4279
+ real.noteOffer?.(offer);
4280
+ return;
4281
+ }
4282
+ pendingOffers.push(offer);
4283
+ if (pendingOffers.length > MAX_PENDING_OFFERS) pendingOffers.shift();
4284
+ }
2844
4285
  };
2845
4286
  }
2846
4287
 
2847
4288
  // src/core/create.ts
4289
+ init_api_base();
2848
4290
  function createSoftphone(config) {
2849
- return new SoftphoneImpl(resolveEngine(config), config);
4291
+ const resolved = withNormalizedBases(config);
4292
+ return new SoftphoneImpl(resolveEngine(resolved), resolved);
4293
+ }
4294
+ function withNormalizedBases(config) {
4295
+ const { apiBase, coventia } = config;
4296
+ return {
4297
+ ...config,
4298
+ ...apiBase ? { apiBase: normalizeApiBase(apiBase) } : {},
4299
+ ...coventia?.baseUrl ? { coventia: { ...coventia, baseUrl: normalizeApiBase(coventia.baseUrl) } } : {}
4300
+ };
2850
4301
  }
2851
4302
  function resolveEngine(config) {
2852
4303
  if (config.provider !== void 0) {
@@ -2873,7 +4324,7 @@ function isCallEngine(value) {
2873
4324
  // src/index.ts
2874
4325
  init_errors();
2875
4326
  init_outbound_numbers();
2876
- var VERSION = "0.4.0";
4327
+ var VERSION = "0.5.1";
2877
4328
 
2878
4329
  exports.CALL_TRANSITIONS = CALL_TRANSITIONS;
2879
4330
  exports.CallEvent = CallEvent;