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