@coventia/softphone 0.5.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 (37) hide show
  1. package/dist/{call-FqcvDg1t.d.cts → call-iGvr2ZnO.d.cts} +26 -2
  2. package/dist/{call-FqcvDg1t.d.ts → call-iGvr2ZnO.d.ts} +26 -2
  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/element/index.d.ts +25 -1
  8. package/dist/{engine-CzOCrfY9.d.cts → engine-BKoT_Pj7.d.cts} +10 -1
  9. package/dist/{engine-CsNRD7B7.d.ts → engine-CeGh5Yai.d.ts} +10 -1
  10. package/dist/index.cjs +1049 -130
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +5 -5
  13. package/dist/index.d.ts +5 -5
  14. package/dist/index.js +179 -13
  15. package/dist/index.js.map +1 -1
  16. package/dist/react/index.d.cts +2 -2
  17. package/dist/react/index.d.ts +2 -2
  18. package/dist/{softphone-By9sizlV.d.ts → softphone-D0ao5W4z.d.ts} +1 -1
  19. package/dist/{softphone-JZvJdzU3.d.cts → softphone-DRoS0jJR.d.cts} +1 -1
  20. package/dist/{sources-QL2LRZVH.js → sources-LGFJG7W2.js} +10 -38
  21. package/dist/sources-LGFJG7W2.js.map +1 -0
  22. package/dist/testing/index.d.cts +2 -2
  23. package/dist/testing/index.d.ts +2 -2
  24. package/dist/ui/index.d.cts +2 -2
  25. package/dist/ui/index.d.ts +2 -2
  26. package/dist/zadarma/index.cjs +865 -116
  27. package/dist/zadarma/index.cjs.map +1 -1
  28. package/dist/zadarma/index.d.cts +37 -10
  29. package/dist/zadarma/index.d.ts +37 -10
  30. package/dist/zadarma/index.js +2 -1
  31. package/dist/zadarma-engine-VYIXLBQG.js +7 -0
  32. package/dist/{zadarma-engine-TIUE7PZ7.js.map → zadarma-engine-VYIXLBQG.js.map} +1 -1
  33. package/package.json +1 -1
  34. package/dist/chunk-6RR6G35E.js +0 -1146
  35. package/dist/chunk-6RR6G35E.js.map +0 -1
  36. package/dist/sources-QL2LRZVH.js.map +0 -1
  37. package/dist/zadarma-engine-TIUE7PZ7.js +0 -6
package/dist/index.cjs CHANGED
@@ -351,6 +351,13 @@ function coventiaSources(config) {
351
351
  callbacks.onClaimResult?.(
352
352
  msg.ok === true ? { ok: true } : { ok: false, reason: typeof msg.reason === "string" ? msg.reason : "unavailable" }
353
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
+ }
354
361
  }
355
362
  };
356
363
  ws.onclose = () => {
@@ -976,21 +983,53 @@ var init_session = __esm({
976
983
  });
977
984
 
978
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
+ }
979
1011
  function domainOf(credentials) {
980
1012
  const at = credentials.uri.lastIndexOf("@");
981
1013
  return at === -1 ? "" : credentials.uri.slice(at + 1);
982
1014
  }
983
- 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;
984
1016
  var init_ua = __esm({
985
1017
  "src/transports/sip/ua.ts"() {
986
1018
  init_session();
987
1019
  init_media();
988
1020
  init_ice();
989
1021
  init_errors();
1022
+ init_sources();
990
1023
  DEFAULT_ICE_CAP_MS = 3e3;
991
1024
  REGISTER_EXPIRES = 580;
992
- SIGNAL_REFRESH_DEBOUNCE_MS = 3e4;
993
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;
994
1033
  CALL_END_CODES = {
995
1034
  busy: "call/busy",
996
1035
  "no-answer": "call/no-answer",
@@ -1022,8 +1061,30 @@ var init_ua = __esm({
1022
1061
  #ua = null;
1023
1062
  /** In-flight `#createUA()`. THE concurrency guard — see `#ensureUA`. */
1024
1063
  #uaInFlight = null;
1025
- /** Calls waiting for the current UA to register (`#callWhenRegistered`), failed by `#teardownUA`. */
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
+ */
1026
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;
1027
1088
  #domain = "";
1028
1089
  #activeCall = null;
1029
1090
  /**
@@ -1042,17 +1103,52 @@ var init_ua = __esm({
1042
1103
  #errorListeners = /* @__PURE__ */ new Set();
1043
1104
  #signalUnsub = null;
1044
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`). */
1045
1115
  #noInviteTimer;
1046
- #rejectGuardUntil = 0;
1047
- #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();
1048
1127
  #errorSentAfter = /* @__PURE__ */ new Map();
1049
1128
  now;
1050
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;
1051
1132
  async #resolveFactory() {
1052
1133
  if (this.#resolvedFactory) return this.#resolvedFactory;
1053
- 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;
1054
1140
  return this.#resolvedFactory;
1055
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
+ }
1056
1152
  get state() {
1057
1153
  return this.#state;
1058
1154
  }
@@ -1117,15 +1213,18 @@ var init_ua = __esm({
1117
1213
  if (run !== this.#run) return;
1118
1214
  this.#setState("online");
1119
1215
  } else {
1216
+ this.#preloadFactory();
1120
1217
  const signal = this.provider.incomingSignal;
1121
1218
  if (signal) {
1122
1219
  try {
1123
1220
  await signal.connect();
1124
1221
  } catch (cause) {
1125
- if (run === this.#run) throw cause;
1222
+ if (run !== this.#run) return;
1223
+ signal.disconnect();
1224
+ throw cause;
1126
1225
  }
1127
1226
  if (run !== this.#run) return;
1128
- this.#signalUnsub = signal.onSignal(() => void this.#onIncomingSignal());
1227
+ this.#signalUnsub = signal.onSignal((event) => this.#onIncomingSignal(event));
1129
1228
  this.#signalConnectionUnsub = signal.onConnectionChange?.(
1130
1229
  (connected) => this.#setState(connected ? "online" : "offline")
1131
1230
  ) ?? null;
@@ -1135,6 +1234,7 @@ var init_ua = __esm({
1135
1234
  }
1136
1235
  async stop() {
1137
1236
  this.#run += 1;
1237
+ this.#pendingOffer = null;
1138
1238
  this.#clearNoInviteTimer();
1139
1239
  this.#signalUnsub?.();
1140
1240
  this.#signalUnsub = null;
@@ -1145,63 +1245,271 @@ var init_ua = __esm({
1145
1245
  this.#setState("stopped");
1146
1246
  }
1147
1247
  async placeCall(destination, _options) {
1148
- const ua = await this.#ensureUA();
1149
- const session = await this.#callWhenRegistered(ua, this.#buildTarget(destination));
1150
- 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
+ );
1151
1303
  }
1152
1304
  // ── incoming (on-demand: push → register → INVITE) ────────────────────────
1153
- async #onIncomingSignal() {
1154
- if (this.now() < this.#rejectGuardUntil) return;
1155
- 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
+ }
1156
1331
  const run = this.#run;
1332
+ const epoch = this.#uaEpoch;
1157
1333
  try {
1158
1334
  await this.#ensureUA();
1159
- this.#armNoInviteTeardown();
1160
1335
  } catch {
1161
- if (run === this.#run) this.#teardownUA();
1336
+ if (run === this.#run && epoch === this.#uaEpoch) this.#teardownUA();
1162
1337
  }
1163
1338
  }
1164
- #armNoInviteTeardown() {
1165
- const ms = this.provider.quirks?.noInviteTeardownMs;
1166
- 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;
1167
1392
  this.#clearNoInviteTimer();
1168
- this.#noInviteTimer = setTimeout(() => this.#maybeTeardownUA(), ms);
1169
1393
  }
1170
1394
  /**
1171
- * 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).
1172
1407
  *
1173
- * Registration fails when the credentials behind it have gone stale — and for Zadarma those come
1174
- * from the same WebRTC key the push socket is holding. Tearing down the UA alone leaves that stale
1175
- * key in place, so every subsequent attempt fails the same way and the phone is dead until the page
1176
- * is reloaded. Reconnecting re-fetches the key. The legacy hook did exactly this from both of its
1177
- * `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.
1178
1414
  *
1179
- * Debounced, which the legacy was not: a provider failing every attempt would otherwise become a
1180
- * 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.
1181
1416
  */
1182
- async #refreshSignal() {
1183
- const signal = this.provider.incomingSignal;
1184
- if (!signal) return;
1185
- if (this.now() < this.#signalRefreshAfter) return;
1186
- this.#signalRefreshAfter = this.now() + SIGNAL_REFRESH_DEBOUNCE_MS;
1187
- try {
1188
- signal.disconnect();
1189
- await signal.connect();
1190
- } catch {
1191
- }
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);
1192
1437
  }
1193
1438
  #clearNoInviteTimer() {
1194
1439
  if (this.#noInviteTimer) clearTimeout(this.#noInviteTimer);
1195
1440
  this.#noInviteTimer = void 0;
1196
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
+ }
1197
1499
  #onNewSession(session, originator) {
1198
1500
  if (originator !== "remote") return;
1199
- this.#clearNoInviteTimer();
1200
- if (this.#activeCall || this.now() < this.#rejectGuardUntil) {
1501
+ if (this.#activeCall) {
1201
1502
  session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
1202
1503
  return;
1203
1504
  }
1204
- 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);
1205
1513
  for (const listener of [...this.#incomingListeners]) listener(call);
1206
1514
  }
1207
1515
  // ── UA lifecycle ───────────────────────────────────────────────────────────
@@ -1214,9 +1522,9 @@ var init_ua = __esm({
1214
1522
  * torn down. The visible symptom is two `get_webphone_data.php` calls at startup and a phone that
1215
1523
  * never registers.
1216
1524
  *
1217
- * Concurrency is the normal case here, not an edge: the push socket can signal twice in a
1218
- * heartbeat (SPEC §3.3 — a push is not an INVITE; it fires for transfers, retries and hangups),
1219
- * 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.
1220
1528
  */
1221
1529
  async #ensureUA() {
1222
1530
  if (this.#ua) return this.#ua;
@@ -1230,6 +1538,7 @@ var init_ua = __esm({
1230
1538
  }
1231
1539
  async #createUA() {
1232
1540
  const run = this.#run;
1541
+ const epoch = this.#uaEpoch;
1233
1542
  const factory = await this.#resolveFactory();
1234
1543
  let credentials;
1235
1544
  try {
@@ -1240,9 +1549,11 @@ var init_ua = __esm({
1240
1549
  throw cause;
1241
1550
  }
1242
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");
1243
1553
  this.#domain = domainOf(credentials);
1244
1554
  const config = this.#uaConfig(credentials, factory);
1245
1555
  const ua = factory.createUA(config);
1556
+ let reached = false;
1246
1557
  ua.on("newRTCSession", ({ session, originator }) => {
1247
1558
  if (ua !== this.#ua) {
1248
1559
  if (originator === "remote") session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
@@ -1252,29 +1563,82 @@ var init_ua = __esm({
1252
1563
  });
1253
1564
  ua.on("disconnected", ({ error }) => {
1254
1565
  if (ua !== this.#ua) return;
1566
+ this.#registration = "connecting";
1255
1567
  if (!error) return;
1568
+ if (!reached && this.#retryOnFreshCredentials("unreachable", ua, credentials)) return;
1256
1569
  this.#setState("reconnecting");
1257
1570
  this.#reportError("net/transport-failed", error);
1258
1571
  });
1259
1572
  ua.on("connected", () => {
1260
- if (ua === this.#ua) this.#setState("online");
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();
1261
1590
  });
1262
1591
  ua.on("registrationFailed", (e) => {
1263
1592
  if (run !== this.#run) return;
1264
1593
  const detail = e;
1594
+ if (isRefusal(detail) && this.#retryOnFreshCredentials("rejected", ua, credentials)) return;
1265
1595
  this.#reportError("net/registration-failed", {
1266
1596
  cause: detail?.cause,
1267
1597
  status: detail?.response?.status_code,
1268
1598
  domain: this.#domain,
1269
1599
  hint: "A bare 401 with no auth challenge usually means the registrar is in the wrong datacenter (SPEC \xA73.1)."
1270
1600
  });
1271
- if (ua === this.#ua) this.#teardownUA();
1272
- void this.#refreshSignal();
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
+ }
1612
+ this.#teardownUA();
1273
1613
  });
1274
1614
  this.#ua = ua;
1615
+ this.#registration = "connecting";
1275
1616
  ua.start();
1276
1617
  return ua;
1277
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
+ }
1278
1642
  #uaConfig(credentials, factory) {
1279
1643
  return {
1280
1644
  sockets: [factory.createWebSocket(credentials.wsUrl)],
@@ -1292,21 +1656,133 @@ var init_ua = __esm({
1292
1656
  user_agent: this.provider.quirks?.userAgent ?? "Coventia Softphone"
1293
1657
  };
1294
1658
  }
1295
- #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;
1296
1666
  this.#uaInFlight = null;
1297
- if (!this.#ua) return;
1298
- for (const fail of [...this.#awaitingRegistration]) fail();
1299
- try {
1300
- this.#ua.terminateSessions({ status_code: 487 });
1301
- this.#ua.stop();
1302
- } catch {
1667
+ this.#registration = "connecting";
1668
+ this.#registerAgain = false;
1669
+ if (!replacing) {
1670
+ for (const waiter of [...this.#awaitingRegistration]) waiter.fail();
1671
+ this.#endOfferWait();
1303
1672
  }
1673
+ if (!this.#ua) return;
1674
+ const ua = this.#ua;
1304
1675
  this.#ua = null;
1305
1676
  this.#activeCall = null;
1306
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
+ }
1307
1690
  }
1308
- /** Place a call once the UA is registered (its 200 OK for REGISTER has arrived). */
1691
+ // ── the CANCEL a hang-up still owes (incoming-call-reliability.md §4.7, F6) ─────────────────────────────
1309
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);
1741
+ }
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
+ *
1310
1786
  * **No `mediaStream`** — JsSIP calls `getUserMedia` itself, which is what opens a real capture.
1311
1787
  *
1312
1788
  * That is load-bearing, not incidental. Firefox restricts ICE gathering on a page with no active
@@ -1316,39 +1792,46 @@ var init_ua = __esm({
1316
1792
  * contains no candidates. The legacy hook passed `mediaConstraints` and nothing else, for what
1317
1793
  * turns out to be a very good reason.
1318
1794
  */
1319
- #callWhenRegistered(ua, target) {
1795
+ #callWhenRegistered(target, run) {
1320
1796
  const options = { mediaConstraints: buildAudioConstraints(this.#inputDeviceId), pcConfig: PC_CONFIG };
1321
- if (ua.isRegistered()) return Promise.resolve(ua.call(target, options));
1322
- const run = this.#run;
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));
1323
1806
  return new Promise((resolve, reject) => {
1324
- let settled = false;
1325
- const fail = () => {
1326
- if (settled) return;
1327
- settled = true;
1328
- this.#awaitingRegistration.delete(fail);
1329
- reject(createError(run === this.#run ? "call/failed" : "engine/not-started"));
1330
- };
1331
- this.#awaitingRegistration.add(fail);
1332
- ua.on("registered", () => {
1333
- if (settled) return;
1334
- if (ua !== this.#ua) return fail();
1335
- settled = true;
1336
- this.#awaitingRegistration.delete(fail);
1337
- try {
1338
- resolve(ua.call(target, options));
1339
- } catch (cause) {
1340
- reject(cause);
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());
1341
1819
  }
1342
- });
1820
+ };
1821
+ this.#awaitingRegistration.add(waiter);
1343
1822
  });
1344
1823
  }
1345
- #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()) {
1346
1829
  const call = new SipEngineCall(session, {
1347
1830
  id: nextCallId(),
1348
1831
  buildTarget: (t) => this.#buildTarget(t),
1349
1832
  placeConsult: (t) => this.#placeConsult(t),
1350
1833
  // The guard is tied to the reject ACTION, not to `ended` — a normal hangup must not arm it.
1351
- onReject: () => this.#startRejectGuard(),
1834
+ onReject: () => this.#onDecline(call, since),
1352
1835
  iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS,
1353
1836
  // Inbound answers open their own peer connection, so they need the same ICE servers the
1354
1837
  // outbound leg got. Missing here, an ANSWERED call is the one that fails — the half nobody
@@ -1365,17 +1848,27 @@ var init_ua = __esm({
1365
1848
  const code = missed ? "call/missed" : CALL_END_CODES[event.reason];
1366
1849
  if (code) this.#reportError(code, event.cause);
1367
1850
  if (this.#activeCall === call) this.#activeCall = null;
1368
- this.#maybeTeardownUA();
1851
+ this.#legEnded();
1369
1852
  }
1370
1853
  });
1371
1854
  return call;
1372
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
+ }
1373
1865
  async #placeConsult(target) {
1374
1866
  const ua = await this.#ensureUA();
1375
1867
  const consultSession = ua.call(this.#buildTarget(target), {
1376
1868
  mediaConstraints: buildAudioConstraints(this.#inputDeviceId),
1377
1869
  pcConfig: PC_CONFIG
1378
1870
  });
1871
+ this.#watchOwedCancel(consultSession, ua);
1379
1872
  const consultCall = new SipEngineCall(consultSession, {
1380
1873
  id: nextCallId(),
1381
1874
  buildTarget: (x) => this.#buildTarget(x),
@@ -1386,7 +1879,7 @@ var init_ua = __esm({
1386
1879
  consultCall.onEvent((event) => {
1387
1880
  if (event.type === "ended") {
1388
1881
  if (this.#consultCall === consultCall) this.#consultCall = null;
1389
- this.#maybeTeardownUA();
1882
+ this.#legEnded();
1390
1883
  }
1391
1884
  });
1392
1885
  return consultCall;
@@ -1399,19 +1892,29 @@ var init_ua = __esm({
1399
1892
  * session on it, via `terminateSessions`) the instant the main leg's `ended` fires killed that
1400
1893
  * consult leg out from under the transfer before the bridge could use it, and the caller was simply
1401
1894
  * gone. Two real call sites hit this independently (found live, 2026-08-04): the main leg's own
1402
- * `ended` handler, and `#armNoInviteTeardown`'s 5s timer — the latter re-arms on EVERY push,
1403
- * including unrelated "answered" pushes Zadarma sends around other account activity, so it can fire
1404
- * minutes after a transfer with no `#activeCall` and no idea a consult leg is mid-handoff. Both now
1405
- * 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).
1406
1906
  */
1407
1907
  #maybeTeardownUA() {
1408
1908
  if (this.provider.registrationMode !== "on-demand") return;
1409
- if (this.#activeCall || this.#consultCall) return;
1909
+ if (this.#activeCall || this.#consultCall || this.#dialing > 0 || this.#offerWait) return;
1410
1910
  this.#teardownUA();
1411
1911
  }
1412
- #startRejectGuard() {
1413
- const ms = this.provider.quirks?.rejectGuardMs;
1414
- 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;
1415
1918
  }
1416
1919
  #buildTarget(target) {
1417
1920
  return target.includes("@") ? target : `sip:${target}@${this.#domain}`;
@@ -1451,11 +1954,44 @@ function buildPushUrl(key, sip) {
1451
1954
  const params = new URLSearchParams({ key, integrationType: "site", sipId: sip });
1452
1955
  return `wss://ws.zadarma.com/webrtc_pushes?${params.toString()}`;
1453
1956
  }
1454
- 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;
1455
1989
  var init_zadarma = __esm({
1456
1990
  "src/transports/providers/zadarma.ts"() {
1457
1991
  PING = { localMs: 3e4, globalMs: 6e4 };
1458
1992
  KEY_REFRESH_BUFFER_MS = 2 * 60 * 60 * 1e3;
1993
+ KEY_EXPIRY_MARGIN_MS = 6e4;
1994
+ REKEY_DEBOUNCE_MS = 3e4;
1459
1995
  REBUILD_CONNECT_TIMEOUT_MS = 1e4;
1460
1996
  REBUILD_RETRY_MS = 3e4;
1461
1997
  ZADARMA_QUIRKS = {
@@ -1463,20 +1999,147 @@ var init_zadarma = __esm({
1463
1999
  // after a valid digest challenge, indistinguishable from a wrong password (§3.1). The old CRM
1464
2000
  // hook carried this string; the port dropped it, and registration failed for exactly that reason.
1465
2001
  userAgent: "Zadarma webRTC",
1466
- // 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.
1467
2011
  rejectGuardMs: 35e3,
1468
- // A push is not an INVITE (§3.3). If none arrives, tear the fresh UA down.
1469
- noInviteTeardownMs: 5e3,
2012
+ noInviteTeardownMs: 1e4,
1470
2013
  // A ceiling on ICE gathering, not a target: JsSIP sends as soon as gathering completes. 3 s leaves
1471
2014
  // room for a TURN allocation (whose relay candidates arrive last) while still bounding the ~40 s
1472
2015
  // Chrome can spend against an unreachable server. See `capIceGathering`.
1473
2016
  iceGatheringCapMs: 3e3
1474
2017
  };
1475
- ZadarmaPushSignal = class {
2018
+ ZadarmaCredentials = class {
1476
2019
  constructor(deps) {
1477
2020
  this.deps = deps;
1478
2021
  }
1479
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;
1480
2143
  #socket = null;
1481
2144
  #connected = false;
1482
2145
  #pingTimers = [];
@@ -1495,6 +2158,14 @@ var init_zadarma = __esm({
1495
2158
  * start again) has reset it: the late socket was built beside the new one, and nothing ever closed it.
1496
2159
  */
1497
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;
1498
2169
  #listeners = /* @__PURE__ */ new Set();
1499
2170
  #connectionListeners = /* @__PURE__ */ new Set();
1500
2171
  async connect() {
@@ -1503,9 +2174,25 @@ var init_zadarma = __esm({
1503
2174
  this.#unwatchBackground ??= this.deps.watchBackgroundReconnect?.(() => void this.#reopen());
1504
2175
  await this.#open();
1505
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
+ }
1506
2193
  async #open() {
1507
2194
  const run = this.#run;
1508
- const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
2195
+ const { key, sip, keyExpiresAt } = await this.credentials.key();
1509
2196
  if (run !== this.#run) return;
1510
2197
  const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1511
2198
  this.#socket = socket;
@@ -1516,8 +2203,8 @@ var init_zadarma = __esm({
1516
2203
  * Wires one socket's events. `this.#socket` is read when an event FIRES, not when it is wired: a socket that
1517
2204
  * has been replaced must stop driving connectivity (our own `close()` of it emits `disconnect`, which would
1518
2205
  * otherwise flip the state to offline right after a perfectly healthy swap). `update` is the exception — it is
1519
- * forwarded from any socket not yet closed, because a push seen twice is harmless (the engine ignores a
1520
- * 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.
1521
2208
  *
1522
2209
  * With `hooks`, the socket is a rebuild's candidate: its first `connect` or failure goes to the rebuild instead
1523
2210
  * of changing the connection state (it is not current yet).
@@ -1529,7 +2216,7 @@ var init_zadarma = __esm({
1529
2216
  this.#setConnected(true);
1530
2217
  this.#startPings(socket);
1531
2218
  });
1532
- socket.on("update", () => this.#emit());
2219
+ socket.on("update", (payload) => this.#emit(readPush(payload)));
1533
2220
  const lost = () => {
1534
2221
  if (hooks && socket === this.#candidate) return hooks.onFailure();
1535
2222
  if (socket !== this.#socket) return;
@@ -1568,13 +2255,19 @@ var init_zadarma = __esm({
1568
2255
  * given at construction. A tab left open past the key's 72 h lifetime would reconnect forever with
1569
2256
  * a dead credential. Absolute, from the backend's `key_expires_at`, so it targets the real expiry
1570
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.
1571
2261
  */
1572
2262
  #armRefresh(keyExpiresAt) {
1573
2263
  clearTimeout(this.#refreshTimer);
1574
2264
  if (!keyExpiresAt) return;
1575
2265
  const ms = new Date(keyExpiresAt).getTime() - Date.now() - KEY_REFRESH_BUFFER_MS;
1576
2266
  if (!Number.isFinite(ms) || ms <= 0) return;
1577
- this.#refreshTimer = setTimeout(() => void this.#reopen(), ms);
2267
+ this.#refreshTimer = setTimeout(() => {
2268
+ this.#renewDue = true;
2269
+ void this.#reopen();
2270
+ }, ms);
1578
2271
  }
1579
2272
  /**
1580
2273
  * Replaces the push socket with a fresh one — MAKE-BEFORE-BREAK (presence-client-reporting.md §4.1, T8).
@@ -1590,6 +2283,9 @@ var init_zadarma = __esm({
1590
2283
  * throws, the socket errors, or it has not connected within `REBUILD_CONNECT_TIMEOUT_MS` — keeps the old socket
1591
2284
  * and retries, instead of leaving the tab disconnected. Zadarma does not evict the older of two sockets for one
1592
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`).
1593
2289
  */
1594
2290
  async #reopen() {
1595
2291
  if (this.#closed || this.#rebuilding) return;
@@ -1603,8 +2299,9 @@ var init_zadarma = __esm({
1603
2299
  this.#rebuilding = true;
1604
2300
  const run = this.#run;
1605
2301
  try {
1606
- const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
2302
+ const { key, sip, keyExpiresAt } = await (this.#renewDue ? this.credentials.renew() : this.credentials.key());
1607
2303
  if (run !== this.#run) return;
2304
+ this.#renewDue = false;
1608
2305
  const swapped = await new Promise((resolve) => {
1609
2306
  const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1610
2307
  this.#candidate = socket;
@@ -1632,6 +2329,10 @@ var init_zadarma = __esm({
1632
2329
  } finally {
1633
2330
  this.#rebuilding = false;
1634
2331
  }
2332
+ if (this.#rekeyQueued) {
2333
+ this.#rekeyQueued = false;
2334
+ void this.#reopen();
2335
+ }
1635
2336
  }
1636
2337
  /**
1637
2338
  * The moment of the swap — synchronous, called from the candidate's own `connect` event so nothing can run
@@ -1655,9 +2356,17 @@ var init_zadarma = __esm({
1655
2356
  clearTimeout(this.#retryTimer);
1656
2357
  this.#retryTimer = setTimeout(() => void this.#reopen(), REBUILD_RETRY_MS);
1657
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
+ */
1658
2363
  disconnect() {
1659
2364
  this.#closed = true;
1660
2365
  this.#run += 1;
2366
+ this.#renewDue = false;
2367
+ this.#rekeyQueued = false;
2368
+ this.#rekeyAfter = 0;
2369
+ this.credentials.forget("all");
1661
2370
  this.#stopPings();
1662
2371
  clearTimeout(this.#refreshTimer);
1663
2372
  this.#refreshTimer = void 0;
@@ -1686,16 +2395,11 @@ var init_zadarma = __esm({
1686
2395
  this.#connected = connected;
1687
2396
  for (const listener of [...this.#connectionListeners]) listener(connected);
1688
2397
  }
1689
- #emit() {
1690
- for (const listener of [...this.#listeners]) listener();
2398
+ #emit(event) {
2399
+ for (const listener of [...this.#listeners]) listener(event);
1691
2400
  }
1692
2401
  };
1693
2402
  ZadarmaProvider = class {
1694
- constructor(deps) {
1695
- this.deps = deps;
1696
- this.incomingSignal = new ZadarmaPushSignal(deps);
1697
- }
1698
- deps;
1699
2403
  name = "zadarma";
1700
2404
  // Zadarma's PBX ignores a standing registration — it routes INVITEs only to a UA that registered
1701
2405
  // in response to a push (SPEC §3.2). Hence on-demand.
@@ -1703,25 +2407,64 @@ var init_zadarma = __esm({
1703
2407
  quirks = ZADARMA_QUIRKS;
1704
2408
  incomingSignal;
1705
2409
  /**
1706
- * Per-registration credentialing: fetch a scoped key, then exchange it in the browser for
1707
- * ephemeral SIP creds. The key fetch is cheap and backend-cached; doing it here (rather than
1708
- * 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`).
1709
2425
  */
1710
2426
  async provision() {
1711
- const { key, sip } = await this.deps.fetchKey();
1712
- const { pass, domain } = await this.deps.exchangeWebphoneData(key, sip);
1713
- return {
1714
- uri: `sip:${sip}@${domain}`,
1715
- authUser: sip,
1716
- password: pass,
1717
- wsUrl: `wss://${domain}:4443`
1718
- };
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;
1719
2454
  }
1720
2455
  };
1721
2456
  }
1722
2457
  });
1723
2458
 
1724
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
+ }
1725
2468
  function makeFetchKey(config) {
1726
2469
  const attempt = async () => {
1727
2470
  let token;
@@ -1737,7 +2480,7 @@ function makeFetchKey(config) {
1737
2480
  if (!res.ok) {
1738
2481
  const body = await res.json().catch(() => null);
1739
2482
  const message = body?.message ?? `webrtc/key HTTP ${res.status}`;
1740
- 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));
1741
2484
  }
1742
2485
  const data = await res.json();
1743
2486
  return {
@@ -1883,13 +2626,22 @@ function makeZadarmaBrowserDeps(config) {
1883
2626
  watchBackgroundReconnect
1884
2627
  };
1885
2628
  }
1886
- var UnauthorizedKeyError, BACKGROUND_RECONNECT_MS, HEARTBEAT_WORKER_SOURCE;
2629
+ var UnauthorizedKeyError, KeyFetchError, BACKGROUND_RECONNECT_MS, HEARTBEAT_WORKER_SOURCE;
1887
2630
  var init_zadarma_browser = __esm({
1888
2631
  "src/transports/providers/zadarma-browser.ts"() {
1889
2632
  init_token_cache();
1890
2633
  init_errors();
1891
2634
  UnauthorizedKeyError = class extends Error {
1892
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
+ };
1893
2645
  BACKGROUND_RECONNECT_MS = 2e4;
1894
2646
  HEARTBEAT_WORKER_SOURCE = `
1895
2647
  let timer;
@@ -2396,9 +3148,56 @@ function startTransferWatch(params) {
2396
3148
  return stop;
2397
3149
  }
2398
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
+
2399
3200
  // src/core/presence-socket.ts
2400
- var RECONNECT_BASE_MS = 1e3;
2401
- var RECONNECT_MAX_MS = 3e4;
2402
3201
  var NOOP_WATCH = { reportCallState: () => {
2403
3202
  }, claimLine: () => false, stop: () => {
2404
3203
  } };
@@ -2410,6 +3209,7 @@ function startPresenceSocket(params) {
2410
3209
  clientId,
2411
3210
  onLine,
2412
3211
  onClaimResult,
3212
+ onOffer,
2413
3213
  onLineUnsupported,
2414
3214
  onConnectFailed,
2415
3215
  onDisconnect,
@@ -2450,8 +3250,7 @@ function startPresenceSocket(params) {
2450
3250
  const scheduleReconnect = () => {
2451
3251
  if (stopped) return;
2452
3252
  attempt += 1;
2453
- const capped = Math.min(RECONNECT_BASE_MS * 2 ** (attempt - 1), RECONNECT_MAX_MS);
2454
- const delay = capped * random();
3253
+ const delay = backoffDelayMs(attempt, random);
2455
3254
  reconnectTimer = scheduleTimeout(() => void connect(), delay);
2456
3255
  };
2457
3256
  const connect = async () => {
@@ -2486,6 +3285,9 @@ function startPresenceSocket(params) {
2486
3285
  },
2487
3286
  onClaimResult: (result) => {
2488
3287
  if (!stopped) onClaimResult?.(result);
3288
+ },
3289
+ onOffer: (offer) => {
3290
+ if (!stopped) onOffer?.(offer);
2489
3291
  }
2490
3292
  });
2491
3293
  } catch {
@@ -2738,6 +3540,17 @@ var SoftphoneImpl = class extends Emitter {
2738
3540
  #claim = null;
2739
3541
  /** Pending: what the server said about the line stops counting (`LINE_LAPSE_MS`). */
2740
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;
2741
3554
  /**
2742
3555
  * `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
2743
3556
  * internal seam, not something an integrator tunes). Tests construct this class directly with a
@@ -2820,6 +3633,7 @@ var SoftphoneImpl = class extends Emitter {
2820
3633
  async stop() {
2821
3634
  if (!this.#startedHere) return;
2822
3635
  this.#run += 1;
3636
+ this.#cancelStartRetry();
2823
3637
  this.#lineMode = "off";
2824
3638
  this.#sayGoodbye();
2825
3639
  this.#startDecision?.end();
@@ -2863,6 +3677,7 @@ var SoftphoneImpl = class extends Emitter {
2863
3677
  async call(destination, options) {
2864
3678
  this.#assertUsable();
2865
3679
  const run = this.#run;
3680
+ if (this.#startRetryTimer !== null) this.#wakeStartRetry();
2866
3681
  if (this.#startDecision) {
2867
3682
  let timer;
2868
3683
  const decided = await Promise.race([
@@ -3006,6 +3821,9 @@ var SoftphoneImpl = class extends Emitter {
3006
3821
  clientId: this.#clientId,
3007
3822
  onLine: (update) => this.#onLine(update),
3008
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),
3009
3827
  // A source that cannot take part will never answer, and one that cannot connect cannot answer yet:
3010
3828
  // `start()` need not wait out its 4 s for either (R7). A later answer still applies.
3011
3829
  onLineUnsupported: () => this.#releaseFirstLine?.(),
@@ -3052,6 +3870,7 @@ var SoftphoneImpl = class extends Emitter {
3052
3870
  this.#presenceAgents = [];
3053
3871
  this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
3054
3872
  this.#forgetLine();
3873
+ this.#engine.noteOffer?.(null);
3055
3874
  }
3056
3875
  /**
3057
3876
  * A DELIBERATE stop (`stop()` / `destroy()`): say any call is over, then say goodbye, so the backend drops
@@ -3111,12 +3930,14 @@ var SoftphoneImpl = class extends Emitter {
3111
3930
  });
3112
3931
  return step;
3113
3932
  }
3933
+ /** Resolves when the engine started — or when its failure is being retried on its own (§4.6). */
3114
3934
  async #startEngine(run) {
3115
3935
  const reported = this.#lastError;
3116
3936
  try {
3117
3937
  await this.#engine.start();
3938
+ if (run === this.#run) this.#startRetries = 0;
3118
3939
  } catch (cause) {
3119
- if (run === this.#run) this.#engineStartFailed(cause, reported);
3940
+ if (run === this.#run && this.#engineStartFailed(cause, reported)) return;
3120
3941
  throw cause;
3121
3942
  }
3122
3943
  }
@@ -3124,19 +3945,99 @@ var SoftphoneImpl = class extends Emitter {
3124
3945
  * An engine start the line asked for failed. Kept as it was, this softphone would stay the owner on the
3125
3946
  * server — `starting`, its presence socket renewing the line — with no push socket anywhere: nobody would
3126
3947
  * listen. So it gives the line up the ordinary way, by closing its presence socket without a goodbye (the
3127
- * phone was not switched off): the server hands the line on after its grace (R4). The failure is reported
3128
- * like any engine failure — `SoftphoneEvent.Error` and `lastError`, which is the widget's error gate and
3129
- * its "Reintentar" (`stop()` then `start()`, which asks for the line again). Nothing retries on its own.
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`.
3130
3958
  */
3131
3959
  #engineStartFailed(cause, reportedBefore) {
3132
3960
  this.#engineOn = false;
3133
3961
  this.#engineRunning = false;
3134
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
+ }
3135
3970
  if (this.#lastError === reportedBefore) {
3136
3971
  this.#onError(isSoftphoneError(cause) && isFatal(cause) ? cause : createError("net/transport-failed", cause));
3137
3972
  }
3138
- this.#stopPresence();
3139
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
+ }
3140
4041
  }
3141
4042
  /** `start()`'s decision (`#startDecision`): ended once, by whichever comes first — the decision or its end. */
3142
4043
  #openStartDecision() {
@@ -3190,6 +4091,7 @@ var SoftphoneImpl = class extends Emitter {
3190
4091
  * waiting softphone answers by listening again.
3191
4092
  */
3192
4093
  #onPresenceDisconnect() {
4094
+ this.#engine.noteOffer?.(null);
3193
4095
  this.#settleClaim(createError("line/unavailable"));
3194
4096
  if (this.#line === "unknown" || this.#lineLapseTimer !== null) return;
3195
4097
  this.#lineLapseTimer = setTimeout(() => {
@@ -3299,6 +4201,7 @@ var SoftphoneImpl = class extends Emitter {
3299
4201
 
3300
4202
  // src/core/default-provider.ts
3301
4203
  init_errors();
4204
+ var MAX_PENDING_OFFERS = 20;
3302
4205
  var LazyRelay = class {
3303
4206
  #listeners = /* @__PURE__ */ new Set();
3304
4207
  #realOff = /* @__PURE__ */ new Map();
@@ -3325,6 +4228,7 @@ function defaultZadarmaEngine(config) {
3325
4228
  const incoming = new LazyRelay();
3326
4229
  const stateChange = new LazyRelay();
3327
4230
  const error = new LazyRelay();
4231
+ let pendingOffers = [];
3328
4232
  function load() {
3329
4233
  pending ??= Promise.resolve().then(() => (init_zadarma_engine(), zadarma_engine_exports)).then(({ createZadarmaEngine: createZadarmaEngine2 }) => {
3330
4234
  const engine = createZadarmaEngine2(config);
@@ -3332,6 +4236,8 @@ function defaultZadarmaEngine(config) {
3332
4236
  stateChange.attach({ on: (l) => engine.onStateChange(l) });
3333
4237
  error.attach({ on: (l) => engine.onError?.(l) ?? (() => {
3334
4238
  }) });
4239
+ for (const offer of pendingOffers) engine.noteOffer?.(offer);
4240
+ pendingOffers = [];
3335
4241
  real = engine;
3336
4242
  return engine;
3337
4243
  }).catch((cause) => {
@@ -3362,7 +4268,20 @@ function defaultZadarmaEngine(config) {
3362
4268
  },
3363
4269
  onIncoming: (listener) => incoming.subscribe(listener),
3364
4270
  onStateChange: (listener) => stateChange.subscribe(listener),
3365
- 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
+ }
3366
4285
  };
3367
4286
  }
3368
4287
 
@@ -3405,7 +4324,7 @@ function isCallEngine(value) {
3405
4324
  // src/index.ts
3406
4325
  init_errors();
3407
4326
  init_outbound_numbers();
3408
- var VERSION = "0.5.0";
4327
+ var VERSION = "0.5.1";
3409
4328
 
3410
4329
  exports.CALL_TRANSITIONS = CALL_TRANSITIONS;
3411
4330
  exports.CallEvent = CallEvent;