@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.
- package/dist/{call-FqcvDg1t.d.cts → call-iGvr2ZnO.d.cts} +26 -2
- package/dist/{call-FqcvDg1t.d.ts → call-iGvr2ZnO.d.ts} +26 -2
- package/dist/chunk-3UTUWGVG.js +1891 -0
- package/dist/chunk-3UTUWGVG.js.map +1 -0
- package/dist/chunk-DZRSSZWB.js +39 -0
- package/dist/chunk-DZRSSZWB.js.map +1 -0
- package/dist/element/index.d.ts +25 -1
- package/dist/{engine-CzOCrfY9.d.cts → engine-BKoT_Pj7.d.cts} +10 -1
- package/dist/{engine-CsNRD7B7.d.ts → engine-CeGh5Yai.d.ts} +10 -1
- package/dist/index.cjs +1049 -130
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +179 -13
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.cts +2 -2
- package/dist/react/index.d.ts +2 -2
- package/dist/{softphone-By9sizlV.d.ts → softphone-D0ao5W4z.d.ts} +1 -1
- package/dist/{softphone-JZvJdzU3.d.cts → softphone-DRoS0jJR.d.cts} +1 -1
- package/dist/{sources-QL2LRZVH.js → sources-LGFJG7W2.js} +10 -38
- package/dist/sources-LGFJG7W2.js.map +1 -0
- package/dist/testing/index.d.cts +2 -2
- package/dist/testing/index.d.ts +2 -2
- package/dist/ui/index.d.cts +2 -2
- package/dist/ui/index.d.ts +2 -2
- package/dist/zadarma/index.cjs +865 -116
- package/dist/zadarma/index.cjs.map +1 -1
- package/dist/zadarma/index.d.cts +37 -10
- package/dist/zadarma/index.d.ts +37 -10
- package/dist/zadarma/index.js +2 -1
- package/dist/zadarma-engine-VYIXLBQG.js +7 -0
- package/dist/{zadarma-engine-TIUE7PZ7.js.map → zadarma-engine-VYIXLBQG.js.map} +1 -1
- package/package.json +1 -1
- package/dist/chunk-6RR6G35E.js +0 -1146
- package/dist/chunk-6RR6G35E.js.map +0 -1
- package/dist/sources-QL2LRZVH.js.map +0 -1
- 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,
|
|
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
|
-
/**
|
|
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
|
-
|
|
1047
|
-
|
|
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
|
-
|
|
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
|
|
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(() =>
|
|
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
|
-
|
|
1149
|
-
const
|
|
1150
|
-
|
|
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
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
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
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
1177
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
if (
|
|
1186
|
-
this.#
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
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
|
|
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
|
|
1272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
this.#
|
|
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
|
-
|
|
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(
|
|
1795
|
+
#callWhenRegistered(target, run) {
|
|
1320
1796
|
const options = { mediaConstraints: buildAudioConstraints(this.#inputDeviceId), pcConfig: PC_CONFIG };
|
|
1321
|
-
|
|
1322
|
-
const
|
|
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
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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.#
|
|
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
|
|
1403
|
-
*
|
|
1404
|
-
*
|
|
1405
|
-
*
|
|
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
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1520
|
-
*
|
|
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(() =>
|
|
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.
|
|
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
|
-
*
|
|
1707
|
-
*
|
|
1708
|
-
|
|
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
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
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
|
|
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
|
|
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
|
|
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).
|
|
3128
|
-
*
|
|
3129
|
-
*
|
|
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.
|
|
4327
|
+
var VERSION = "0.5.1";
|
|
3409
4328
|
|
|
3410
4329
|
exports.CALL_TRANSITIONS = CALL_TRANSITIONS;
|
|
3411
4330
|
exports.CallEvent = CallEvent;
|