@coventia/softphone 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{call-FqcvDg1t.d.cts → call-iGvr2ZnO.d.cts} +26 -2
- package/dist/{call-FqcvDg1t.d.ts → call-iGvr2ZnO.d.ts} +26 -2
- package/dist/chunk-3UTUWGVG.js +1891 -0
- package/dist/chunk-3UTUWGVG.js.map +1 -0
- package/dist/chunk-DZRSSZWB.js +39 -0
- package/dist/chunk-DZRSSZWB.js.map +1 -0
- package/dist/element/index.d.ts +25 -1
- package/dist/{engine-CzOCrfY9.d.cts → engine-BKoT_Pj7.d.cts} +10 -1
- package/dist/{engine-CsNRD7B7.d.ts → engine-CeGh5Yai.d.ts} +10 -1
- package/dist/index.cjs +1049 -130
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +179 -13
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.cts +2 -2
- package/dist/react/index.d.ts +2 -2
- package/dist/{softphone-By9sizlV.d.ts → softphone-D0ao5W4z.d.ts} +1 -1
- package/dist/{softphone-JZvJdzU3.d.cts → softphone-DRoS0jJR.d.cts} +1 -1
- package/dist/{sources-QL2LRZVH.js → sources-LGFJG7W2.js} +10 -38
- package/dist/sources-LGFJG7W2.js.map +1 -0
- package/dist/testing/index.d.cts +2 -2
- package/dist/testing/index.d.ts +2 -2
- package/dist/ui/index.d.cts +2 -2
- package/dist/ui/index.d.ts +2 -2
- package/dist/zadarma/index.cjs +865 -116
- package/dist/zadarma/index.cjs.map +1 -1
- package/dist/zadarma/index.d.cts +37 -10
- package/dist/zadarma/index.d.ts +37 -10
- package/dist/zadarma/index.js +2 -1
- package/dist/zadarma-engine-VYIXLBQG.js +7 -0
- package/dist/{zadarma-engine-TIUE7PZ7.js.map → zadarma-engine-VYIXLBQG.js.map} +1 -1
- package/package.json +1 -1
- package/dist/chunk-6RR6G35E.js +0 -1146
- package/dist/chunk-6RR6G35E.js.map +0 -1
- package/dist/sources-QL2LRZVH.js.map +0 -1
- package/dist/zadarma-engine-TIUE7PZ7.js +0 -6
|
@@ -0,0 +1,1891 @@
|
|
|
1
|
+
import { normalizeApiBase } from './chunk-M76SAXXM.js';
|
|
2
|
+
import { createError, isSoftphoneError } from './chunk-FRXHEHQA.js';
|
|
3
|
+
import { toDigits } from './chunk-DZRSSZWB.js';
|
|
4
|
+
import { cachedTokenSource, invalidateToken } from './chunk-MHOJHG5E.js';
|
|
5
|
+
|
|
6
|
+
// src/transports/sip/media.ts
|
|
7
|
+
function buildAudioConstraints(deviceId) {
|
|
8
|
+
return { audio: deviceId ? { deviceId: { exact: deviceId } } : true, video: false };
|
|
9
|
+
}
|
|
10
|
+
function capIceGathering(session, capMs) {
|
|
11
|
+
let settled = false;
|
|
12
|
+
let timer;
|
|
13
|
+
const settle = (ready) => {
|
|
14
|
+
if (settled) return;
|
|
15
|
+
settled = true;
|
|
16
|
+
if (timer) clearTimeout(timer);
|
|
17
|
+
try {
|
|
18
|
+
ready();
|
|
19
|
+
} catch {
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
session.on("icecandidate", ({ ready }) => {
|
|
23
|
+
if (!timer) timer = setTimeout(() => settle(ready), capMs);
|
|
24
|
+
});
|
|
25
|
+
return () => {
|
|
26
|
+
if (timer) clearTimeout(timer);
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/transports/sip/ice.ts
|
|
31
|
+
var DEFAULT_ICE_SERVERS = [
|
|
32
|
+
{ urls: "stun:stun.l.google.com:19302" }
|
|
33
|
+
];
|
|
34
|
+
var PC_CONFIG = { iceServers: [...DEFAULT_ICE_SERVERS] };
|
|
35
|
+
|
|
36
|
+
// src/transports/sip/session.ts
|
|
37
|
+
function mapEndReason(e) {
|
|
38
|
+
if (e.originator === "local") return "local";
|
|
39
|
+
if (e.originator === "remote") return "remote";
|
|
40
|
+
return "failed";
|
|
41
|
+
}
|
|
42
|
+
function mapFailReason(e) {
|
|
43
|
+
switch (e.cause) {
|
|
44
|
+
// 486 Busy Here, 600 Busy Everywhere. Also what most endpoints send for a MANUAL DECLINE — SIP
|
|
45
|
+
// does not distinguish the two, and neither can we, which is why the copy covers both.
|
|
46
|
+
case "Busy":
|
|
47
|
+
return "busy";
|
|
48
|
+
/*
|
|
49
|
+
`Rejected` is 403 / 603 Decline — an explicit refusal, sent by the endpoints that bother to.
|
|
50
|
+
`Redirected` is 3xx: not literally a rejection, but it ends the same way — not connected, and
|
|
51
|
+
nothing to retry.
|
|
52
|
+
|
|
53
|
+
(Both comments sit ABOVE the group rather than between the labels. A comment between two `case`
|
|
54
|
+
lines makes the first one non-empty to `no-fallthrough`, which then demands a `break` that
|
|
55
|
+
would be unreachable.)
|
|
56
|
+
*/
|
|
57
|
+
case "Rejected":
|
|
58
|
+
case "Redirected":
|
|
59
|
+
return "rejected";
|
|
60
|
+
/*
|
|
61
|
+
`Unavailable` is 480 Temporarily Unavailable / 410 / 408 Request Timeout / 430 — THE ring-out
|
|
62
|
+
case, and the one that was missing. `No Answer` and `Expires` are local: our own no-answer
|
|
63
|
+
timer, and a session that expired.
|
|
64
|
+
*/
|
|
65
|
+
case "Unavailable":
|
|
66
|
+
case "No Answer":
|
|
67
|
+
case "Expires":
|
|
68
|
+
return "no-answer";
|
|
69
|
+
case "Canceled":
|
|
70
|
+
return e.originator === "local" ? "local" : "remote";
|
|
71
|
+
// Everything else is a genuine failure worth naming: Not Found (404/604), Address Incomplete,
|
|
72
|
+
// Incompatible SDP, Authentication Error, WebRTC Error, RTP Timeout, Connection Error…
|
|
73
|
+
default:
|
|
74
|
+
return "failed";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function remoteNumberOf(session) {
|
|
78
|
+
const id = session.remote_identity;
|
|
79
|
+
return id?.uri?.user ?? id?.uri?.toString() ?? "";
|
|
80
|
+
}
|
|
81
|
+
var SipEngineCall = class {
|
|
82
|
+
id;
|
|
83
|
+
direction;
|
|
84
|
+
remoteNumber;
|
|
85
|
+
#session;
|
|
86
|
+
#deps;
|
|
87
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
88
|
+
#ended = false;
|
|
89
|
+
#cancelIceCap = null;
|
|
90
|
+
/**
|
|
91
|
+
* The stream behind a LIVE device swap (`swapInputDevice`), if one has happened. Distinct from
|
|
92
|
+
* JsSIP's own original capture: that one is `_localMediaStreamLocallyGenerated` and JsSIP stops it
|
|
93
|
+
* itself on close (see `#end`'s comment). This one is ours — obtained with our own `getUserMedia`
|
|
94
|
+
* call, attached via `replaceTrack`, and never known to JsSIP at all — so nothing else will ever
|
|
95
|
+
* stop it unless we do, here.
|
|
96
|
+
*/
|
|
97
|
+
#swappedStream = null;
|
|
98
|
+
constructor(session, deps) {
|
|
99
|
+
this.#session = session;
|
|
100
|
+
this.#deps = deps;
|
|
101
|
+
this.id = deps.id;
|
|
102
|
+
this.direction = session.direction === "incoming" ? "inbound" : "outbound";
|
|
103
|
+
this.remoteNumber = remoteNumberOf(session);
|
|
104
|
+
this.#wire();
|
|
105
|
+
}
|
|
106
|
+
/** @internal — the underlying session, for `completeTransfer`'s REFER…Replaces. */
|
|
107
|
+
get session() {
|
|
108
|
+
return this.#session;
|
|
109
|
+
}
|
|
110
|
+
onEvent(listener) {
|
|
111
|
+
this.#listeners.add(listener);
|
|
112
|
+
return () => this.#listeners.delete(listener);
|
|
113
|
+
}
|
|
114
|
+
// ── JsSIP events → raw engine signals ─────────────────────────────────────
|
|
115
|
+
#wire() {
|
|
116
|
+
const s = this.#session;
|
|
117
|
+
if (this.direction === "outbound") {
|
|
118
|
+
s.on("progress", () => this.#fire({ type: "progress", response: "ringing" }));
|
|
119
|
+
}
|
|
120
|
+
const wireRemoteTrack = (pc) => {
|
|
121
|
+
pc.ontrack = ({ streams }) => {
|
|
122
|
+
if (streams[0]) this.#fire({ type: "stream", stream: streams[0] });
|
|
123
|
+
};
|
|
124
|
+
};
|
|
125
|
+
s.on("peerconnection", ({ peerconnection }) => wireRemoteTrack(peerconnection));
|
|
126
|
+
if (s.connection) wireRemoteTrack(s.connection);
|
|
127
|
+
if (this.direction === "outbound" && this.#deps.iceGatheringCapMs) {
|
|
128
|
+
this.#cancelIceCap = capIceGathering(s, this.#deps.iceGatheringCapMs);
|
|
129
|
+
}
|
|
130
|
+
s.on("accepted", () => this.#onAccepted());
|
|
131
|
+
s.on("ended", (e) => this.#end(mapEndReason(e), e.cause));
|
|
132
|
+
s.on("failed", (e) => this.#end(mapFailReason(e), e.cause));
|
|
133
|
+
s.on("hold", ({ originator }) => this.#fire({ type: "held", originator }));
|
|
134
|
+
s.on("unhold", ({ originator }) => this.#fire({ type: "unheld", originator }));
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* The call is live. Nothing to swap: JsSIP opened the real microphone when the session was created,
|
|
138
|
+
* so the far end has had our actual audio from the first packet.
|
|
139
|
+
*
|
|
140
|
+
* There used to be a silent-mic dance here — place the call with a synthetic AudioContext track,
|
|
141
|
+
* swap the real mic in on accept — so a Bluetooth headset stayed in A2DP while the phone rang. It
|
|
142
|
+
* cost more than it bought: with no active capture, Firefox restricts ICE gathering and the offer
|
|
143
|
+
* goes out with no candidates at all, which no STUN or TURN server can fix.
|
|
144
|
+
*/
|
|
145
|
+
#onAccepted() {
|
|
146
|
+
this.#fire({ type: "accepted" });
|
|
147
|
+
}
|
|
148
|
+
// ── Commands ───────────────────────────────────────────────────────────────
|
|
149
|
+
async answer() {
|
|
150
|
+
const deviceId = this.#deps.getInputDeviceId?.() ?? null;
|
|
151
|
+
this.#session.answer({
|
|
152
|
+
mediaConstraints: buildAudioConstraints(deviceId),
|
|
153
|
+
pcConfig: this.#deps.pcConfig ?? PC_CONFIG
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Live-swap the outgoing microphone track — `CallEngine.setInputDevice` on a call that is already
|
|
158
|
+
* connected. Never touches the FIRST call's capture: that one is JsSIP's own `getUserMedia`, opened
|
|
159
|
+
* inside `answer()`/`ua.call()` (see `#onAccepted`'s comment on why that stays that way). This is
|
|
160
|
+
* only for a device chosen AFTER the call is already live.
|
|
161
|
+
*
|
|
162
|
+
* `RTCRtpSender.replaceTrack` rather than a fresh `answer()`/re-INVITE: it needs no SDP
|
|
163
|
+
* renegotiation and the far end never sees an interruption — exactly the mechanism real softphones
|
|
164
|
+
* use for "switch mic mid-call."
|
|
165
|
+
*/
|
|
166
|
+
async swapInputDevice(deviceId) {
|
|
167
|
+
const pc = this.#session.connection;
|
|
168
|
+
if (!pc || this.#ended) return;
|
|
169
|
+
const sender = pc.getSenders().find((s) => s.track?.kind === "audio");
|
|
170
|
+
if (!sender) return;
|
|
171
|
+
let stream;
|
|
172
|
+
try {
|
|
173
|
+
stream = await navigator.mediaDevices.getUserMedia(buildAudioConstraints(deviceId));
|
|
174
|
+
} catch {
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (this.#ended) {
|
|
178
|
+
for (const track2 of stream.getTracks()) track2.stop();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const [track] = stream.getAudioTracks();
|
|
182
|
+
if (!track) return;
|
|
183
|
+
await sender.replaceTrack(track);
|
|
184
|
+
for (const oldTrack of this.#swappedStream?.getTracks() ?? []) oldTrack.stop();
|
|
185
|
+
this.#swappedStream = stream;
|
|
186
|
+
}
|
|
187
|
+
async reject() {
|
|
188
|
+
this.#deps.onReject?.();
|
|
189
|
+
this.#session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
|
|
190
|
+
}
|
|
191
|
+
async hangup() {
|
|
192
|
+
this.#session.terminate();
|
|
193
|
+
}
|
|
194
|
+
mute(muted) {
|
|
195
|
+
if (muted) this.#session.mute({ audio: true });
|
|
196
|
+
else this.#session.unmute({ audio: true });
|
|
197
|
+
}
|
|
198
|
+
async hold() {
|
|
199
|
+
this.#session.hold();
|
|
200
|
+
}
|
|
201
|
+
async unhold() {
|
|
202
|
+
this.#session.unhold();
|
|
203
|
+
}
|
|
204
|
+
sendDigits(digits) {
|
|
205
|
+
this.#session.sendDTMF(digits);
|
|
206
|
+
}
|
|
207
|
+
async transfer(target) {
|
|
208
|
+
this.#session.refer(this.#deps.buildTarget(target));
|
|
209
|
+
}
|
|
210
|
+
async startConsult(target) {
|
|
211
|
+
return this.#deps.placeConsult(target);
|
|
212
|
+
}
|
|
213
|
+
async completeTransfer(consult) {
|
|
214
|
+
const consultSession = consult.session;
|
|
215
|
+
this.#session.refer(consultSession.remote_identity.uri.toString(), { replaces: consultSession });
|
|
216
|
+
}
|
|
217
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
218
|
+
#end(reason, cause) {
|
|
219
|
+
if (this.#ended) return;
|
|
220
|
+
this.#ended = true;
|
|
221
|
+
this.#cancelIceCap?.();
|
|
222
|
+
for (const track of this.#swappedStream?.getTracks() ?? []) track.stop();
|
|
223
|
+
this.#swappedStream = null;
|
|
224
|
+
this.#fire({ type: "ended", reason, cause });
|
|
225
|
+
this.#listeners.clear();
|
|
226
|
+
}
|
|
227
|
+
#fire(event) {
|
|
228
|
+
if (this.#ended && event.type !== "ended") return;
|
|
229
|
+
for (const listener of [...this.#listeners]) listener(event);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// src/transports/sip/ua.ts
|
|
234
|
+
var DEFAULT_ICE_CAP_MS = 3e3;
|
|
235
|
+
var REGISTER_EXPIRES = 580;
|
|
236
|
+
var ERROR_DEBOUNCE_MS = 3e4;
|
|
237
|
+
var OFFER_FORGOTTEN_MS = 12e4;
|
|
238
|
+
var DECLINE_GUARD_CEILING_MS = 12e4;
|
|
239
|
+
var OFFER_PUSH_SLACK_MS = 1e4;
|
|
240
|
+
var INVITE_TRANSACTION_MAX_MS = 32e3;
|
|
241
|
+
var ICT_PROCEEDING = 2;
|
|
242
|
+
var ICT_CALLING = 3;
|
|
243
|
+
function inviteTransactionOf(session) {
|
|
244
|
+
const tx = session._request?.transaction;
|
|
245
|
+
return tx && typeof tx.state === "number" && typeof tx.on === "function" ? tx : null;
|
|
246
|
+
}
|
|
247
|
+
function awaitsAnswer(tx) {
|
|
248
|
+
return tx.state === ICT_CALLING || tx.state === ICT_PROCEEDING;
|
|
249
|
+
}
|
|
250
|
+
function deferredCancelOf(session) {
|
|
251
|
+
const flag = session._is_canceled;
|
|
252
|
+
return typeof flag === "boolean" ? flag : null;
|
|
253
|
+
}
|
|
254
|
+
var SUFFIX_MATCH_MIN_DIGITS = 9;
|
|
255
|
+
function sameNumber(a, b) {
|
|
256
|
+
if (!a || !b) return false;
|
|
257
|
+
if (a === b) return true;
|
|
258
|
+
const [shorter, longer] = a.length < b.length ? [a, b] : [b, a];
|
|
259
|
+
return shorter.length >= SUFFIX_MATCH_MIN_DIGITS && longer.endsWith(shorter);
|
|
260
|
+
}
|
|
261
|
+
function isRefusal(e) {
|
|
262
|
+
const status = e?.response?.status_code;
|
|
263
|
+
if (typeof status === "number") return status === 401 || status === 403;
|
|
264
|
+
return e?.cause === "Authentication Error" || e?.cause === "Rejected";
|
|
265
|
+
}
|
|
266
|
+
function debug(message) {
|
|
267
|
+
console.debug(`[softphone] ${message}`);
|
|
268
|
+
}
|
|
269
|
+
var CALL_END_CODES = {
|
|
270
|
+
busy: "call/busy",
|
|
271
|
+
"no-answer": "call/no-answer",
|
|
272
|
+
rejected: "call/rejected",
|
|
273
|
+
failed: "call/failed"
|
|
274
|
+
};
|
|
275
|
+
function domainOf(credentials) {
|
|
276
|
+
const at = credentials.uri.lastIndexOf("@");
|
|
277
|
+
return at === -1 ? "" : credentials.uri.slice(at + 1);
|
|
278
|
+
}
|
|
279
|
+
var callSeq = 0;
|
|
280
|
+
var nextCallId = () => `sip-${++callSeq}`;
|
|
281
|
+
var SipCallEngine = class {
|
|
282
|
+
constructor(provider, factory, options = {}) {
|
|
283
|
+
this.provider = provider;
|
|
284
|
+
this.factory = factory;
|
|
285
|
+
this.now = options.now ?? Date.now;
|
|
286
|
+
}
|
|
287
|
+
provider;
|
|
288
|
+
factory;
|
|
289
|
+
#state = "stopped";
|
|
290
|
+
/**
|
|
291
|
+
* Which `start()`…`stop()` cycle this is — bumped by `stop()`, which ends one. Everything that awaits
|
|
292
|
+
* and then acts (builds a UA, subscribes to the signal, sets the state) captures it first and gives up
|
|
293
|
+
* if a stop has happened since. Not bumped by `start()`: that would cancel work begun just before it,
|
|
294
|
+
* such as a call placed while the softphone is still starting.
|
|
295
|
+
*
|
|
296
|
+
* Without it a stopped phone came back to life: a provisioning or a signal connect still in flight
|
|
297
|
+
* when `stop()` ran finished afterwards and REGISTERed — and since the PBX routes a call to whichever
|
|
298
|
+
* client registered last, it quietly took calls from the user's other device while its UI said off.
|
|
299
|
+
*/
|
|
300
|
+
#run = 0;
|
|
301
|
+
#ua = null;
|
|
302
|
+
/** In-flight `#createUA()`. THE concurrency guard — see `#ensureUA`. */
|
|
303
|
+
#uaInFlight = null;
|
|
304
|
+
/**
|
|
305
|
+
* Bumped by every `#teardownUA`. A UA still being provisioned when its reason to exist went away — the offer
|
|
306
|
+
* ended, the leak guard gave up — is then not built (`#createUA`): built anyway, it would REGISTER for nobody
|
|
307
|
+
* and nothing would ever tear it down.
|
|
308
|
+
*/
|
|
309
|
+
#uaEpoch = 0;
|
|
310
|
+
/**
|
|
311
|
+
* Dials waiting for the current UA to register (`#callWhenRegistered`): dialled by its `registered`, failed by
|
|
312
|
+
* `#teardownUA` — but kept when the UA is only being rebuilt on fresh credentials (`#retryOnFreshCredentials`).
|
|
313
|
+
*/
|
|
314
|
+
#awaitingRegistration = /* @__PURE__ */ new Set();
|
|
315
|
+
/** `placeCall`s in progress, from before their UA exists: no offer's teardown may pull it from under them. */
|
|
316
|
+
#dialing = 0;
|
|
317
|
+
/**
|
|
318
|
+
* Where the current UA's registration stands: `connecting` (its REGISTER goes out once the WSS is up),
|
|
319
|
+
* `registering` (a REGISTER is out, unanswered), `registered` (the WSS is up and no REGISTER of ours is out — a
|
|
320
|
+
* refresh that failed under a live call leaves it here too). What decides how an offer gets a REGISTER sent
|
|
321
|
+
* after it (`#registerAfterOffer`).
|
|
322
|
+
*/
|
|
323
|
+
#registration = "connecting";
|
|
324
|
+
/** An offer arrived while a REGISTER sent BEFORE it was still unanswered: another goes out once it is. */
|
|
325
|
+
#registerAgain = false;
|
|
326
|
+
/** A UA has already been rebuilt on fresh credentials since the last registration that worked: not again. */
|
|
327
|
+
#freshCredentialsTried = false;
|
|
328
|
+
#domain = "";
|
|
329
|
+
#activeCall = null;
|
|
330
|
+
/**
|
|
331
|
+
* The consult leg from `startConsult` (SPEC §5.6), if one is live on this UA right now.
|
|
332
|
+
*
|
|
333
|
+
* Tracked separately from `#activeCall` so `#maybeTeardownUA` can tell "both legs are actually gone"
|
|
334
|
+
* apart from "the main leg ended, but a consult is still up" — see that method's own comment for the
|
|
335
|
+
* bug this exists to prevent.
|
|
336
|
+
*/
|
|
337
|
+
#consultCall = null;
|
|
338
|
+
/** The chosen microphone, or `null` for the browser default. Read fresh by every new leg's
|
|
339
|
+
* `mediaConstraints`, and pushed live to `#activeCall` by `setInputDevice`. */
|
|
340
|
+
#inputDeviceId = null;
|
|
341
|
+
#incomingListeners = /* @__PURE__ */ new Set();
|
|
342
|
+
#stateListeners = /* @__PURE__ */ new Set();
|
|
343
|
+
#errorListeners = /* @__PURE__ */ new Set();
|
|
344
|
+
#signalUnsub = null;
|
|
345
|
+
#signalConnectionUnsub = null;
|
|
346
|
+
/** The offer waiting for its INVITE, if any (§4.3). */
|
|
347
|
+
#offerWait = null;
|
|
348
|
+
/**
|
|
349
|
+
* The latest offer pushed while a call was in progress. It used to be dropped for good — the caller then waited
|
|
350
|
+
* for the provider to offer it again. Now, when the call ends, it is registered for at once if it is still
|
|
351
|
+
* within its wait (no `offer-ended` for it, inside the leak guard), so it rings as soon as the line is free.
|
|
352
|
+
*/
|
|
353
|
+
#pendingOffer = null;
|
|
354
|
+
/** Armed at the REGISTER's 200 OK while an offer waits (`noInviteAfterRegisterMs`). */
|
|
355
|
+
#noInviteTimer;
|
|
356
|
+
/**
|
|
357
|
+
* What the backend has said about the calls offered to this line (`noteOffer`, §5), by `callId` — ended ones
|
|
358
|
+
* included, for a while. Only for telling, at a decline, which call was declined and whether it is still alive.
|
|
359
|
+
*/
|
|
360
|
+
#offers = /* @__PURE__ */ new Map();
|
|
361
|
+
/** Declined calls whose re-offers are being ignored (§4.4). Pruned as they lapse. */
|
|
362
|
+
#declined = [];
|
|
363
|
+
/** CANCELs JsSIP still owes for outbound legs that ended before any ringback (§4.7, `#watchOwedCancel`). */
|
|
364
|
+
#owedCancels = [];
|
|
365
|
+
/** UAs let go of while owing one: un-REGISTERed, still connected — with the cap that stops them regardless. */
|
|
366
|
+
#lingering = /* @__PURE__ */ new Map();
|
|
367
|
+
#errorSentAfter = /* @__PURE__ */ new Map();
|
|
368
|
+
now;
|
|
369
|
+
#resolvedFactory = null;
|
|
370
|
+
/** The loader in flight, shared: the pre-load at `start()` and a first UA racing it fetch the module once. */
|
|
371
|
+
#factoryLoad = null;
|
|
372
|
+
async #resolveFactory() {
|
|
373
|
+
if (this.#resolvedFactory) return this.#resolvedFactory;
|
|
374
|
+
if (typeof this.factory !== "function") return this.#resolvedFactory = this.factory;
|
|
375
|
+
const loader = this.factory;
|
|
376
|
+
this.#factoryLoad ??= Promise.resolve().then(() => loader()).finally(() => {
|
|
377
|
+
this.#factoryLoad = null;
|
|
378
|
+
});
|
|
379
|
+
this.#resolvedFactory = await this.#factoryLoad;
|
|
380
|
+
return this.#resolvedFactory;
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Fetch the SIP stack now, not on the first call. It is loaded lazily (`import('jssip')`, SSR-safe — SPEC §10.2),
|
|
384
|
+
* and that used to happen on the first UA: between the first push after a page load and its REGISTER, measured
|
|
385
|
+
* 33–109 ms on localhost and more on a slow network or a throttled tab. Fire-and-forget: a failure here only
|
|
386
|
+
* leaves the lazy path to try again when a UA is built (incoming-call-reliability.md §4.5).
|
|
387
|
+
*/
|
|
388
|
+
#preloadFactory() {
|
|
389
|
+
this.#resolveFactory().catch(() => {
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
get state() {
|
|
393
|
+
return this.#state;
|
|
394
|
+
}
|
|
395
|
+
onIncoming(listener) {
|
|
396
|
+
this.#incomingListeners.add(listener);
|
|
397
|
+
return () => this.#incomingListeners.delete(listener);
|
|
398
|
+
}
|
|
399
|
+
onStateChange(listener) {
|
|
400
|
+
this.#stateListeners.add(listener);
|
|
401
|
+
return () => this.#stateListeners.delete(listener);
|
|
402
|
+
}
|
|
403
|
+
onError(listener) {
|
|
404
|
+
this.#errorListeners.add(listener);
|
|
405
|
+
return () => this.#errorListeners.delete(listener);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Switch the microphone: stored for every FUTURE leg's `mediaConstraints`, and — if a call is live
|
|
409
|
+
* right now — pushed to it immediately via `SipEngineCall.swapInputDevice` (SPEC §7 Phase 2, device
|
|
410
|
+
* management). `null` resets to the browser's own default device.
|
|
411
|
+
*/
|
|
412
|
+
async setInputDevice(deviceId) {
|
|
413
|
+
this.#inputDeviceId = deviceId;
|
|
414
|
+
await this.#activeCall?.swapInputDevice(deviceId);
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Report a failure, at most once per code per `ERROR_DEBOUNCE_MS`.
|
|
418
|
+
*
|
|
419
|
+
* A failing registrar retries, and one emission per attempt would flood a host's logging and make
|
|
420
|
+
* whatever the widget shows flicker. Debounced per CODE rather than globally, so a transport
|
|
421
|
+
* failure is never swallowed because a registration failure happened to precede it.
|
|
422
|
+
*/
|
|
423
|
+
#reportError(code, cause) {
|
|
424
|
+
this.#emitError(createError(code, cause));
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Emit, debouncing FATAL codes only.
|
|
428
|
+
*
|
|
429
|
+
* A failing registrar retries, and one emission per attempt would flood a host's logging and make
|
|
430
|
+
* whatever the widget shows flicker — so the same fatal code is reported at most once per window,
|
|
431
|
+
* per CODE, so a transport failure is never swallowed because a registration failure preceded it.
|
|
432
|
+
*
|
|
433
|
+
* Non-fatal codes are never debounced: they describe one call each, and two busy tones in a row are
|
|
434
|
+
* two facts, not a repeated one.
|
|
435
|
+
*/
|
|
436
|
+
#emitError(error) {
|
|
437
|
+
if (error.fatal) {
|
|
438
|
+
const now = this.now();
|
|
439
|
+
if (now < (this.#errorSentAfter.get(error.code) ?? 0)) return;
|
|
440
|
+
this.#errorSentAfter.set(error.code, now + ERROR_DEBOUNCE_MS);
|
|
441
|
+
}
|
|
442
|
+
for (const listener of [...this.#errorListeners]) listener(error);
|
|
443
|
+
}
|
|
444
|
+
async start() {
|
|
445
|
+
const run = this.#run;
|
|
446
|
+
this.#setState("starting");
|
|
447
|
+
if (this.provider.registrationMode === "persistent") {
|
|
448
|
+
try {
|
|
449
|
+
await this.#ensureUA();
|
|
450
|
+
} catch (cause) {
|
|
451
|
+
if (run === this.#run) throw cause;
|
|
452
|
+
}
|
|
453
|
+
if (run !== this.#run) return;
|
|
454
|
+
this.#setState("online");
|
|
455
|
+
} else {
|
|
456
|
+
this.#preloadFactory();
|
|
457
|
+
const signal = this.provider.incomingSignal;
|
|
458
|
+
if (signal) {
|
|
459
|
+
try {
|
|
460
|
+
await signal.connect();
|
|
461
|
+
} catch (cause) {
|
|
462
|
+
if (run !== this.#run) return;
|
|
463
|
+
signal.disconnect();
|
|
464
|
+
throw cause;
|
|
465
|
+
}
|
|
466
|
+
if (run !== this.#run) return;
|
|
467
|
+
this.#signalUnsub = signal.onSignal((event) => this.#onIncomingSignal(event));
|
|
468
|
+
this.#signalConnectionUnsub = signal.onConnectionChange?.(
|
|
469
|
+
(connected) => this.#setState(connected ? "online" : "offline")
|
|
470
|
+
) ?? null;
|
|
471
|
+
}
|
|
472
|
+
this.#setState(signal?.onConnectionChange ? signal.connected ? "online" : "offline" : "online");
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async stop() {
|
|
476
|
+
this.#run += 1;
|
|
477
|
+
this.#pendingOffer = null;
|
|
478
|
+
this.#clearNoInviteTimer();
|
|
479
|
+
this.#signalUnsub?.();
|
|
480
|
+
this.#signalUnsub = null;
|
|
481
|
+
this.#signalConnectionUnsub?.();
|
|
482
|
+
this.#signalConnectionUnsub = null;
|
|
483
|
+
this.provider.incomingSignal?.disconnect();
|
|
484
|
+
this.#teardownUA();
|
|
485
|
+
this.#setState("stopped");
|
|
486
|
+
}
|
|
487
|
+
async placeCall(destination, _options) {
|
|
488
|
+
this.#dialing += 1;
|
|
489
|
+
const run = this.#run;
|
|
490
|
+
try {
|
|
491
|
+
await this.#ensureUA();
|
|
492
|
+
return await this.#callWhenRegistered(this.#buildTarget(destination), run);
|
|
493
|
+
} finally {
|
|
494
|
+
this.#dialing -= 1;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* What the backend says about calls offered to this line (incoming-call-reliability.md §5) — kept only to tell,
|
|
499
|
+
* at a decline, WHICH call was declined, and so how long its re-offers last (§4.4).
|
|
500
|
+
*
|
|
501
|
+
* Per `callId`, every message is idempotent, and they come duplicated, with no order at all — not even between
|
|
502
|
+
* the `start` and the `end` of one call, which reach different backend instances and are relayed through Redis,
|
|
503
|
+
* and a stored offer replayed after `hello` can race either. So `ended` is sticky: a `start` after its call's
|
|
504
|
+
* `end` never makes it live again. An `end` also reaches a softphone that is not the line's owner (a `start` only
|
|
505
|
+
* the owner): stored all the same, whatever state this engine is in. `null`: the channel went away, so no `end`
|
|
506
|
+
* can be counted on for the calls still live; a guard following one falls back to its window. What is known to
|
|
507
|
+
* have ended stays known.
|
|
508
|
+
*/
|
|
509
|
+
noteOffer(offer) {
|
|
510
|
+
const now = this.now();
|
|
511
|
+
for (const [callId, known2] of this.#offers) {
|
|
512
|
+
if (now - known2.at >= OFFER_FORGOTTEN_MS) this.#offers.delete(callId);
|
|
513
|
+
}
|
|
514
|
+
if (offer === null) {
|
|
515
|
+
for (const [callId, known2] of this.#offers) if (known2.endedAt === null) this.#offers.delete(callId);
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const known = this.#offers.get(offer.callId);
|
|
519
|
+
this.#offers.delete(offer.callId);
|
|
520
|
+
const from = toDigits(offer.from ?? "") || (known?.from ?? "");
|
|
521
|
+
const fallbackMs = this.#declineFallbackMs();
|
|
522
|
+
if (offer.state === "end") {
|
|
523
|
+
this.#offers.set(offer.callId, { from, at: now, endedAt: known?.endedAt ?? now });
|
|
524
|
+
this.#declined = this.#declined.filter((declined) => declined.callId !== offer.callId);
|
|
525
|
+
if (from) {
|
|
526
|
+
this.#declined = this.#declined.map(
|
|
527
|
+
(declined) => declined.adopts && now >= declined.since && sameNumber(declined.caller, from) ? { ...declined, adopts: false } : declined
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const endedAt = known?.endedAt ?? null;
|
|
533
|
+
this.#offers.set(offer.callId, { from, at: now, endedAt });
|
|
534
|
+
if (!from) return;
|
|
535
|
+
if (endedAt !== null) {
|
|
536
|
+
const isThatCall = (declined) => declined.adopts && now - declined.at < fallbackMs && endedAt >= declined.since && sameNumber(declined.caller, from);
|
|
537
|
+
this.#declined = this.#declined.filter((declined) => !isThatCall(declined));
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
this.#declined = this.#declined.map(
|
|
541
|
+
(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
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
// ── incoming (on-demand: push → register → INVITE) ────────────────────────
|
|
545
|
+
/**
|
|
546
|
+
* The push's own word on what it is (incoming-call-reliability.md §4.1, read by the provider): only an `offer`
|
|
547
|
+
* registers. Before this, every push did — `answered` after each hang-up included — and the UA it left
|
|
548
|
+
* registered is where the next call's INVITE never came (F2).
|
|
549
|
+
*/
|
|
550
|
+
#onIncomingSignal(event) {
|
|
551
|
+
if (event.kind === "offer") void this.#onOffer(event.from);
|
|
552
|
+
else if (event.kind === "offer-ended") this.#onOfferEnded(event.from);
|
|
553
|
+
else debug(`push ignored: ${event.detail}`);
|
|
554
|
+
}
|
|
555
|
+
/** `pushedAt`: when its push came, for an offer kept while a call was in progress (`#pendingOffer`). */
|
|
556
|
+
async #onOffer(from, pushedAt = this.now()) {
|
|
557
|
+
if (this.#isDeclinedCaller(from)) {
|
|
558
|
+
debug("offer ignored: the caller just declined is being offered again");
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (this.#activeCall) {
|
|
562
|
+
this.#pendingOffer = { from, at: pushedAt };
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
this.#awaitOffer(from, pushedAt);
|
|
566
|
+
const ua = this.#ua;
|
|
567
|
+
if (ua) {
|
|
568
|
+
this.#registerAfterOffer(ua);
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const run = this.#run;
|
|
572
|
+
const epoch = this.#uaEpoch;
|
|
573
|
+
try {
|
|
574
|
+
await this.#ensureUA();
|
|
575
|
+
} catch {
|
|
576
|
+
if (run === this.#run && epoch === this.#uaEpoch) this.#teardownUA();
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* That offer is over — answered elsewhere, declined, cancelled, timed out (§4.1). A UA still waiting for its
|
|
581
|
+
* INVITE goes now instead of when a timer says so; never one carrying a call (`#maybeTeardownUA`). Only when the
|
|
582
|
+
* push names the same caller as the offer waited for: two offers can overlap, and an end that names nobody (a
|
|
583
|
+
* withheld number, a push with no `callerid`) could be anyone's — the timers bound that wait instead.
|
|
584
|
+
*/
|
|
585
|
+
#onOfferEnded(from) {
|
|
586
|
+
const caller = toDigits(from ?? "");
|
|
587
|
+
if (!caller) return;
|
|
588
|
+
if (this.#pendingOffer && sameNumber(caller, toDigits(this.#pendingOffer.from ?? ""))) this.#pendingOffer = null;
|
|
589
|
+
const wait = this.#offerWait;
|
|
590
|
+
if (!wait || !sameNumber(caller, wait.from)) return;
|
|
591
|
+
this.#offerOver();
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Starts waiting for this offer's INVITE: a leak guard from its push (§4.3), and no no-INVITE timer yet.
|
|
595
|
+
* `pushedAt` is now, except for an offer kept through a call (`#pendingOffer`).
|
|
596
|
+
*/
|
|
597
|
+
#awaitOffer(from, pushedAt) {
|
|
598
|
+
this.#endOfferWait();
|
|
599
|
+
const ms = this.provider.quirks?.offerWaitMaxMs;
|
|
600
|
+
const leak = ms ? setTimeout(
|
|
601
|
+
() => {
|
|
602
|
+
if (this.#offerWait !== wait) return;
|
|
603
|
+
debug("offer abandoned: still no INVITE long after its push");
|
|
604
|
+
this.#offerOver();
|
|
605
|
+
},
|
|
606
|
+
Math.max(0, ms - (this.now() - pushedAt))
|
|
607
|
+
) : void 0;
|
|
608
|
+
const wait = { from: toDigits(from ?? ""), at: pushedAt, leak };
|
|
609
|
+
this.#offerWait = wait;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* The offer kept through the call that just ended, if it is still worth registering for: no `offer-ended` for
|
|
613
|
+
* it, and still inside the leak guard from its push. Taken: it is registered for once, at most.
|
|
614
|
+
*/
|
|
615
|
+
#takePendingOffer() {
|
|
616
|
+
const pending = this.#pendingOffer;
|
|
617
|
+
this.#pendingOffer = null;
|
|
618
|
+
if (!pending || !this.#signalUnsub) return null;
|
|
619
|
+
const maxMs = this.provider.quirks?.offerWaitMaxMs;
|
|
620
|
+
if (maxMs && this.now() - pending.at >= maxMs) return null;
|
|
621
|
+
return pending;
|
|
622
|
+
}
|
|
623
|
+
/** No INVITE is coming for the offer being waited for: its UA goes, unless something else is on it. */
|
|
624
|
+
#offerOver() {
|
|
625
|
+
this.#endOfferWait();
|
|
626
|
+
this.#maybeTeardownUA();
|
|
627
|
+
}
|
|
628
|
+
#endOfferWait() {
|
|
629
|
+
if (this.#offerWait?.leak) clearTimeout(this.#offerWait.leak);
|
|
630
|
+
this.#offerWait = null;
|
|
631
|
+
this.#registerAgain = false;
|
|
632
|
+
this.#clearNoInviteTimer();
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* A REGISTER sent AFTER this offer's push, on the UA that is already there (§4.2). Whether one is needed depends
|
|
636
|
+
* on where that UA's registration stands:
|
|
637
|
+
* - `connecting`: its REGISTER goes out when the WSS is up — after this push already.
|
|
638
|
+
* - `registering`: the REGISTER in flight went out BEFORE this push; another follows its answer.
|
|
639
|
+
* - `registered`: a refresh, now.
|
|
640
|
+
*/
|
|
641
|
+
#registerAfterOffer(ua) {
|
|
642
|
+
if (this.#registration === "registering") this.#registerAgain = true;
|
|
643
|
+
else if (this.#registration === "registered") this.#refreshRegistration(ua);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* A REGISTER refresh whose 200 OK is HEARD — the no-INVITE wait counts from it (§4.3).
|
|
647
|
+
*
|
|
648
|
+
* JsSIP's `register()` sends one, but announces `registered` only for a UA that was not registered
|
|
649
|
+
* (`Registrator.js`: `if (!this._registered)`), so a refresh's 200 OK would pass in silence.
|
|
650
|
+
* `registrator().onTransportClosed()` clears that flag and sends nothing — it is what JsSIP runs itself when its
|
|
651
|
+
* WSS drops, before registering again — so after it the refresh is announced like a first registration. On the
|
|
652
|
+
* wire nothing changes: the same refresh (same Call-ID, next CSeq) the lab saw answered with the INVITE
|
|
653
|
+
* (Q5). Only called with no REGISTER of ours in flight (`#registration`), so no answer is left orphaned.
|
|
654
|
+
*
|
|
655
|
+
* A JsSIP without that hook still sends the refresh; its 200 OK just cannot be heard, so the wait starts now.
|
|
656
|
+
*/
|
|
657
|
+
#refreshRegistration(ua) {
|
|
658
|
+
const registrator = ua.registrator?.();
|
|
659
|
+
const heard = typeof registrator?.onTransportClosed === "function";
|
|
660
|
+
if (heard) registrator.onTransportClosed();
|
|
661
|
+
this.#registration = heard ? "registering" : "registered";
|
|
662
|
+
ua.register();
|
|
663
|
+
if (!heard && this.#offerWait) this.#armNoInviteTimer();
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Armed at the REGISTER's 200 OK, never at `ua.start()`: on a slow network the WSS alone outlasted the old 5 s,
|
|
667
|
+
* and the UA was gone before it ever registered (F3). The INVITE follows the 200 OK by ~0.1 s (§2).
|
|
668
|
+
*/
|
|
669
|
+
#armNoInviteTimer() {
|
|
670
|
+
this.#clearNoInviteTimer();
|
|
671
|
+
const ms = this.provider.quirks?.noInviteAfterRegisterMs;
|
|
672
|
+
if (!ms) return;
|
|
673
|
+
this.#noInviteTimer = setTimeout(() => {
|
|
674
|
+
this.#noInviteTimer = void 0;
|
|
675
|
+
this.#offerOver();
|
|
676
|
+
}, ms);
|
|
677
|
+
}
|
|
678
|
+
#clearNoInviteTimer() {
|
|
679
|
+
if (this.#noInviteTimer) clearTimeout(this.#noInviteTimer);
|
|
680
|
+
this.#noInviteTimer = void 0;
|
|
681
|
+
}
|
|
682
|
+
// ── the decline guard (SPEC §3.4, incoming-call-reliability.md §4.4) ───────
|
|
683
|
+
/**
|
|
684
|
+
* Whether an offer — or an INVITE — from this caller is a re-offer of a call just declined. The provider re-offers a
|
|
685
|
+
* declined call until its ring time ends, and nothing in a re-offer tells it from a new call. The guard used to
|
|
686
|
+
* swallow EVERY offer and INVITE for 35 s, whoever called: a new call in that window was lost (CWA-81, F1).
|
|
687
|
+
*
|
|
688
|
+
* Now it holds only for the declined caller, and only while that call lives: until the backend's `end` for it
|
|
689
|
+
* when it announced the call (`noteOffer`) — at most `DECLINE_GUARD_CEILING_MS` — else for the provider's
|
|
690
|
+
* fallback window after the decline. Anyone else always rings; so does a caller this cannot name.
|
|
691
|
+
*/
|
|
692
|
+
#isDeclinedCaller(number) {
|
|
693
|
+
const fallbackMs = this.#declineFallbackMs();
|
|
694
|
+
if (!fallbackMs || this.#declined.length === 0) return false;
|
|
695
|
+
const now = this.now();
|
|
696
|
+
this.#declined = this.#declined.filter((declined) => {
|
|
697
|
+
const offer = declined.callId === null ? void 0 : this.#offers.get(declined.callId);
|
|
698
|
+
if (offer && offer.endedAt === null) return now - declined.at < DECLINE_GUARD_CEILING_MS;
|
|
699
|
+
return now - declined.at < fallbackMs;
|
|
700
|
+
});
|
|
701
|
+
const caller = toDigits(number ?? "");
|
|
702
|
+
return this.#declined.some((declined) => sameNumber(declined.caller, caller));
|
|
703
|
+
}
|
|
704
|
+
/** The decline guard's fallback window — and whether there is a guard at all: only a provider that re-offers. */
|
|
705
|
+
#declineFallbackMs() {
|
|
706
|
+
return this.provider.quirks?.declinedCallerGuardMs ?? 0;
|
|
707
|
+
}
|
|
708
|
+
/**
|
|
709
|
+
* The user declined `call`. Remembers the caller as its INVITE named them, and which offer the backend says that
|
|
710
|
+
* is (§4.4). Only what the backend said about THIS call counts: a call from the same caller that ended before
|
|
711
|
+
* this one was pushed (`since`) is another call — taking it for this one lost the callback after a second
|
|
712
|
+
* decline of the same caller.
|
|
713
|
+
* - a live offer from that caller, announced around this call's push or after: the guard follows it until
|
|
714
|
+
* its `end`;
|
|
715
|
+
* - an offer from that caller that ENDED since this call's push (the first leg of a transfer, a ring-group
|
|
716
|
+
* call someone else answered): the fallback window — whatever that caller sends next is not that call, so
|
|
717
|
+
* no later `start` is taken for it;
|
|
718
|
+
* - neither (its `start` lands after the decline): the fallback window, and a `start` from that caller that
|
|
719
|
+
* comes around this call's push is taken as this call's (`noteOffer`).
|
|
720
|
+
*
|
|
721
|
+
* An INVITE that names nobody (a withheld number) guards nobody. It used to borrow the caller of the latest push,
|
|
722
|
+
* which with two offers overlapping is someone else's — and that someone's calls were then ignored.
|
|
723
|
+
*/
|
|
724
|
+
#onDecline(call, since) {
|
|
725
|
+
if (!this.#declineFallbackMs() || call.direction !== "inbound") return;
|
|
726
|
+
const caller = toDigits(call.remoteNumber);
|
|
727
|
+
if (!caller) return;
|
|
728
|
+
const now = this.now();
|
|
729
|
+
let latest = null;
|
|
730
|
+
for (const [callId, offer] of this.#offers) {
|
|
731
|
+
if (!sameNumber(offer.from, caller) || now - offer.at >= OFFER_FORGOTTEN_MS) continue;
|
|
732
|
+
if (offer.endedAt === null && offer.at >= since - OFFER_PUSH_SLACK_MS) latest = { callId, ended: false };
|
|
733
|
+
else if (offer.endedAt !== null && offer.endedAt >= since) latest = { callId, ended: true };
|
|
734
|
+
}
|
|
735
|
+
this.#declined.push(
|
|
736
|
+
latest && !latest.ended ? { caller, callId: latest.callId, adopts: false, at: now, since } : { caller, callId: null, adopts: latest === null, at: now, since }
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
#onNewSession(session, originator) {
|
|
740
|
+
if (originator !== "remote") return;
|
|
741
|
+
if (this.#activeCall) {
|
|
742
|
+
session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
if (this.#isDeclinedCaller(remoteNumberOf(session))) {
|
|
746
|
+
debug("INVITE declined: the caller just declined is being offered again");
|
|
747
|
+
session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const since = this.#offerWait?.at ?? this.now();
|
|
751
|
+
this.#endOfferWait();
|
|
752
|
+
const call = this.#wrapCall(session, since);
|
|
753
|
+
for (const listener of [...this.#incomingListeners]) listener(call);
|
|
754
|
+
}
|
|
755
|
+
// ── UA lifecycle ───────────────────────────────────────────────────────────
|
|
756
|
+
/**
|
|
757
|
+
* The single UA, created at most once — even under concurrent callers.
|
|
758
|
+
*
|
|
759
|
+
* **The `#ua` check alone is not a guard**, because everything after it awaits: two callers both
|
|
760
|
+
* see `null`, both `provision()`, both build a UA, and both REGISTER the same extension. Zadarma
|
|
761
|
+
* rejects the loser, `registrationFailed` tears down, and the survivor may be the one that was
|
|
762
|
+
* torn down. The visible symptom is two `get_webphone_data.php` calls at startup and a phone that
|
|
763
|
+
* never registers.
|
|
764
|
+
*
|
|
765
|
+
* Concurrency is the normal case here, not an edge: the push socket can offer twice in a heartbeat
|
|
766
|
+
* (a push seen on both sockets of a rebuild, two calls at once), and `placeCall` can land on top of
|
|
767
|
+
* one. So the in-flight promise IS the guard.
|
|
768
|
+
*/
|
|
769
|
+
async #ensureUA() {
|
|
770
|
+
if (this.#ua) return this.#ua;
|
|
771
|
+
if (!this.#uaInFlight) {
|
|
772
|
+
const inFlight = this.#createUA().finally(() => {
|
|
773
|
+
if (this.#uaInFlight === inFlight) this.#uaInFlight = null;
|
|
774
|
+
});
|
|
775
|
+
this.#uaInFlight = inFlight;
|
|
776
|
+
}
|
|
777
|
+
return this.#uaInFlight;
|
|
778
|
+
}
|
|
779
|
+
async #createUA() {
|
|
780
|
+
const run = this.#run;
|
|
781
|
+
const epoch = this.#uaEpoch;
|
|
782
|
+
const factory = await this.#resolveFactory();
|
|
783
|
+
let credentials;
|
|
784
|
+
try {
|
|
785
|
+
credentials = await this.provider.provision();
|
|
786
|
+
} catch (cause) {
|
|
787
|
+
if (isSoftphoneError(cause)) this.#emitError(cause);
|
|
788
|
+
else this.#reportError("provider/provision-failed", cause);
|
|
789
|
+
throw cause;
|
|
790
|
+
}
|
|
791
|
+
if (run !== this.#run) throw createError("engine/not-started");
|
|
792
|
+
if (epoch !== this.#uaEpoch) throw createError("call/failed", "the UA was let go of before it was built");
|
|
793
|
+
this.#domain = domainOf(credentials);
|
|
794
|
+
const config = this.#uaConfig(credentials, factory);
|
|
795
|
+
const ua = factory.createUA(config);
|
|
796
|
+
let reached = false;
|
|
797
|
+
ua.on("newRTCSession", ({ session, originator }) => {
|
|
798
|
+
if (ua !== this.#ua) {
|
|
799
|
+
if (originator === "remote") session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
this.#onNewSession(session, originator);
|
|
803
|
+
});
|
|
804
|
+
ua.on("disconnected", ({ error }) => {
|
|
805
|
+
if (ua !== this.#ua) return;
|
|
806
|
+
this.#registration = "connecting";
|
|
807
|
+
if (!error) return;
|
|
808
|
+
if (!reached && this.#retryOnFreshCredentials("unreachable", ua, credentials)) return;
|
|
809
|
+
this.#setState("reconnecting");
|
|
810
|
+
this.#reportError("net/transport-failed", error);
|
|
811
|
+
});
|
|
812
|
+
ua.on("connected", () => {
|
|
813
|
+
if (ua !== this.#ua) return;
|
|
814
|
+
reached = true;
|
|
815
|
+
this.#registration = "registering";
|
|
816
|
+
this.#setState("online");
|
|
817
|
+
});
|
|
818
|
+
ua.on("registered", () => {
|
|
819
|
+
if (ua !== this.#ua) return;
|
|
820
|
+
this.#registration = "registered";
|
|
821
|
+
this.#freshCredentialsTried = false;
|
|
822
|
+
this.provider.credentialsWorked?.();
|
|
823
|
+
for (const waiter of [...this.#awaitingRegistration]) waiter.dial(ua);
|
|
824
|
+
if (this.#registerAgain) {
|
|
825
|
+
this.#registerAgain = false;
|
|
826
|
+
this.#refreshRegistration(ua);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
if (this.#offerWait) this.#armNoInviteTimer();
|
|
830
|
+
});
|
|
831
|
+
ua.on("registrationFailed", (e) => {
|
|
832
|
+
if (run !== this.#run) return;
|
|
833
|
+
const detail = e;
|
|
834
|
+
if (isRefusal(detail) && this.#retryOnFreshCredentials("rejected", ua, credentials)) return;
|
|
835
|
+
this.#reportError("net/registration-failed", {
|
|
836
|
+
cause: detail?.cause,
|
|
837
|
+
status: detail?.response?.status_code,
|
|
838
|
+
domain: this.#domain,
|
|
839
|
+
hint: "A bare 401 with no auth challenge usually means the registrar is in the wrong datacenter (SPEC \xA73.1)."
|
|
840
|
+
});
|
|
841
|
+
if (ua !== this.#ua) return;
|
|
842
|
+
if (this.#carriesCall()) {
|
|
843
|
+
for (const waiter of [...this.#awaitingRegistration]) waiter.fail();
|
|
844
|
+
this.#registration = "registered";
|
|
845
|
+
const wait = this.#offerWait;
|
|
846
|
+
if (wait) {
|
|
847
|
+
this.#pendingOffer = { from: wait.from || null, at: wait.at };
|
|
848
|
+
this.#endOfferWait();
|
|
849
|
+
}
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
this.#teardownUA();
|
|
853
|
+
});
|
|
854
|
+
this.#ua = ua;
|
|
855
|
+
this.#registration = "connecting";
|
|
856
|
+
ua.start();
|
|
857
|
+
return ua;
|
|
858
|
+
}
|
|
859
|
+
/**
|
|
860
|
+
* The credentials behind `ua` did not work. The provider hears it either way, with the credentials in question
|
|
861
|
+
* (it forgets them unless it has moved on from them already; a provider whose signal shares them renews that
|
|
862
|
+
* too); then, if it has new ones to give, the current UA is rebuilt on them — once until a registration works,
|
|
863
|
+
* and never under a live call. Whoever waited on the old UA — an offer, a dial — waits on the new one. Returns
|
|
864
|
+
* whether it was rebuilt.
|
|
865
|
+
*
|
|
866
|
+
* This replaces the engine's old `#refreshSignal` (a close-then-reopen of the push socket after ANY failed
|
|
867
|
+
* registration): only the provider knows what its credentials and its signal share.
|
|
868
|
+
*/
|
|
869
|
+
#retryOnFreshCredentials(reason, ua, credentials) {
|
|
870
|
+
const fresh = this.provider.credentialsFailed?.(reason, credentials) ?? false;
|
|
871
|
+
if (!fresh || ua !== this.#ua || this.#freshCredentialsTried) return false;
|
|
872
|
+
if (this.#carriesCall()) return false;
|
|
873
|
+
this.#freshCredentialsTried = true;
|
|
874
|
+
const run = this.#run;
|
|
875
|
+
this.#teardownUA({ replacing: true });
|
|
876
|
+
const epoch = this.#uaEpoch;
|
|
877
|
+
void this.#ensureUA().catch(() => {
|
|
878
|
+
if (run === this.#run && epoch === this.#uaEpoch) this.#teardownUA();
|
|
879
|
+
});
|
|
880
|
+
return true;
|
|
881
|
+
}
|
|
882
|
+
#uaConfig(credentials, factory) {
|
|
883
|
+
return {
|
|
884
|
+
sockets: [factory.createWebSocket(credentials.wsUrl)],
|
|
885
|
+
uri: credentials.uri,
|
|
886
|
+
authorization_user: credentials.authUser,
|
|
887
|
+
password: credentials.password,
|
|
888
|
+
register: true,
|
|
889
|
+
register_expires: REGISTER_EXPIRES,
|
|
890
|
+
connection_recovery_min_interval: 2,
|
|
891
|
+
connection_recovery_max_interval: 30,
|
|
892
|
+
// Generic — NOT a provider brand string. If a provider ever requires a specific UA header it
|
|
893
|
+
// belongs in the provider, not hard-coded here.
|
|
894
|
+
// NOT ours to choose: Zadarma refuses to register any client whose User-Agent it does not
|
|
895
|
+
// recognise (see ProviderQuirks.userAgent). The provider decides; the engine just sends it.
|
|
896
|
+
user_agent: this.provider.quirks?.userAgent ?? "Coventia Softphone"
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Lets the current UA go — and one still being provisioned (`#uaEpoch`). `replacing`: a successor is being
|
|
901
|
+
* built on fresh credentials right away (`#retryOnFreshCredentials`), so the dials and the offer waiting on
|
|
902
|
+
* this one carry over to it instead of ending here.
|
|
903
|
+
*/
|
|
904
|
+
#teardownUA({ replacing = false } = {}) {
|
|
905
|
+
this.#uaEpoch += 1;
|
|
906
|
+
this.#uaInFlight = null;
|
|
907
|
+
this.#registration = "connecting";
|
|
908
|
+
this.#registerAgain = false;
|
|
909
|
+
if (!replacing) {
|
|
910
|
+
for (const waiter of [...this.#awaitingRegistration]) waiter.fail();
|
|
911
|
+
this.#endOfferWait();
|
|
912
|
+
}
|
|
913
|
+
if (!this.#ua) return;
|
|
914
|
+
const ua = this.#ua;
|
|
915
|
+
this.#ua = null;
|
|
916
|
+
this.#activeCall = null;
|
|
917
|
+
this.#consultCall = null;
|
|
918
|
+
try {
|
|
919
|
+
ua.terminateSessions({ status_code: 487 });
|
|
920
|
+
} catch {
|
|
921
|
+
}
|
|
922
|
+
if (this.#owedCancels.some((owed) => owed.ua === ua)) {
|
|
923
|
+
this.#linger(ua);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
try {
|
|
927
|
+
ua.stop();
|
|
928
|
+
} catch {
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
// ── the CANCEL a hang-up still owes (incoming-call-reliability.md §4.7, F6) ─────────────────────────────
|
|
932
|
+
/**
|
|
933
|
+
* Watches an outbound leg — a call or a consult leg — from its creation, for the CANCEL JsSIP may end up owing.
|
|
934
|
+
*
|
|
935
|
+
* JsSIP's `terminate()` between sending the INVITE and the first provisional response cannot cancel yet (RFC 3261
|
|
936
|
+
* §9.1): it marks the session canceled, fails it locally with `Canceled`, and sends the CANCEL when a 1xx comes —
|
|
937
|
+
* 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
|
|
938
|
+
* the callee's phone rang on for a caller who was gone: a ghost call. Hence: the INVITE went out (`sending`), no
|
|
939
|
+
* provisional above 100 came (`progress`), and it failed locally `Canceled` → the CANCEL is owed, on this UA.
|
|
940
|
+
*
|
|
941
|
+
* Registered BEFORE the leg's own listeners: its `failed` must be on record before the leg's end tears the UA down.
|
|
942
|
+
*/
|
|
943
|
+
#watchOwedCancel(session, ua) {
|
|
944
|
+
let sent = false;
|
|
945
|
+
let ringing = false;
|
|
946
|
+
session.on("sending", () => sent = true);
|
|
947
|
+
session.on("progress", () => ringing = true);
|
|
948
|
+
session.on("failed", ({ originator, cause }) => {
|
|
949
|
+
if (!sent || originator !== "local" || cause !== "Canceled") return;
|
|
950
|
+
const tx = inviteTransactionOf(session);
|
|
951
|
+
const owes = deferredCancelOf(session) ?? (tx ? tx.state === ICT_CALLING : !ringing);
|
|
952
|
+
if (!owes || tx && !awaitsAnswer(tx)) return;
|
|
953
|
+
const owed = { ua, session, tx };
|
|
954
|
+
this.#owedCancels.push(owed);
|
|
955
|
+
if (tx) this.#followInvite(owed, tx);
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Follows the INVITE an owed CANCEL is waiting on, until it is over. Not just the first one: Zadarma challenges every
|
|
960
|
+
* INVITE (407), and JsSIP re-sends it with credentials on a NEW transaction — canceled leg or not — in the same tick
|
|
961
|
+
* that completes the first. Releasing on that first transaction stopped the UA two seconds later, and the re-sent
|
|
962
|
+
* INVITE's 1xx landed nowhere: the ghost call again (found driving the real JsSIP). So once one is over, the leg's
|
|
963
|
+
* current transaction is read again a moment later, and followed while it still waits for an answer.
|
|
964
|
+
*/
|
|
965
|
+
#followInvite(owed, tx) {
|
|
966
|
+
const settle = () => {
|
|
967
|
+
if (awaitsAnswer(tx)) return;
|
|
968
|
+
tx.removeListener?.("stateChanged", settle);
|
|
969
|
+
queueMicrotask(() => {
|
|
970
|
+
const latest = inviteTransactionOf(owed.session);
|
|
971
|
+
if (latest && latest !== tx && awaitsAnswer(latest)) {
|
|
972
|
+
owed.tx = latest;
|
|
973
|
+
this.#followInvite(owed, latest);
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
this.#owedCancels = this.#owedCancels.filter((other) => other !== owed);
|
|
977
|
+
this.#releaseIfSettled(owed.ua);
|
|
978
|
+
});
|
|
979
|
+
};
|
|
980
|
+
tx.on("stateChanged", settle);
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Lets `ua` go while it owes a CANCEL: un-REGISTERed at once, as any teardown, but connected until every INVITE it
|
|
984
|
+
* owes is over — COMPLETED (a final non-2xx: the 487 after the late 1xx's CANCEL), ACCEPTED (a late 2xx, which
|
|
985
|
+
* JsSIP ACKs and BYEs) or TERMINATED (Timer B, a transport error) — and never longer than Timer B. Detached: the
|
|
986
|
+
* engine has already moved on (a push or a dial builds a fresh UA), so no timer of its own, no teardown and no
|
|
987
|
+
* "is a call on it" ever sees this one, and an INVITE reaching it is declined like any let-go UA's.
|
|
988
|
+
*/
|
|
989
|
+
#linger(ua) {
|
|
990
|
+
if (this.#lingering.has(ua)) return;
|
|
991
|
+
this.#lingering.set(ua, setTimeout(() => this.#release(ua), INVITE_TRANSACTION_MAX_MS));
|
|
992
|
+
ua.on("registered", () => {
|
|
993
|
+
if (this.#lingering.has(ua)) this.#unregister(ua);
|
|
994
|
+
});
|
|
995
|
+
this.#unregister(ua);
|
|
996
|
+
this.#releaseIfSettled(ua);
|
|
997
|
+
}
|
|
998
|
+
#unregister(ua) {
|
|
999
|
+
try {
|
|
1000
|
+
ua.unregister();
|
|
1001
|
+
} catch {
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Once nothing is owed on a lingering UA, it stops — on the next tick: a late 2xx is ACKed and BYEd in the very
|
|
1006
|
+
* tick that settled it, and JsSIP's own `stop()` then keeps the transport ≤ 2 s for that BYE's answer.
|
|
1007
|
+
*/
|
|
1008
|
+
#releaseIfSettled(ua) {
|
|
1009
|
+
if (!this.#lingering.has(ua) || this.#owedCancels.some((owed) => owed.ua === ua)) return;
|
|
1010
|
+
setTimeout(() => this.#release(ua), 0);
|
|
1011
|
+
}
|
|
1012
|
+
#release(ua) {
|
|
1013
|
+
const cap = this.#lingering.get(ua);
|
|
1014
|
+
if (cap === void 0) return;
|
|
1015
|
+
clearTimeout(cap);
|
|
1016
|
+
this.#lingering.delete(ua);
|
|
1017
|
+
this.#owedCancels = this.#owedCancels.filter((owed) => owed.ua !== ua);
|
|
1018
|
+
try {
|
|
1019
|
+
ua.stop();
|
|
1020
|
+
} catch {
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Place a call once the current UA is registered (its 200 OK for REGISTER has arrived).
|
|
1025
|
+
*
|
|
1026
|
+
* **No `mediaStream`** — JsSIP calls `getUserMedia` itself, which is what opens a real capture.
|
|
1027
|
+
*
|
|
1028
|
+
* That is load-bearing, not incidental. Firefox restricts ICE gathering on a page with no active
|
|
1029
|
+
* capture: it discards every address but the default route's ("Ignoring addr from interface other
|
|
1030
|
+
* than …" in about:webrtc) and can end up gathering NOTHING, so the INVITE goes out with
|
|
1031
|
+
* `c=IN IP4 0.0.0.0` and no candidates at all. No STUN or TURN server can rescue an offer that
|
|
1032
|
+
* contains no candidates. The legacy hook passed `mediaConstraints` and nothing else, for what
|
|
1033
|
+
* turns out to be a very good reason.
|
|
1034
|
+
*/
|
|
1035
|
+
#callWhenRegistered(target, run) {
|
|
1036
|
+
const options = { mediaConstraints: buildAudioConstraints(this.#inputDeviceId), pcConfig: PC_CONFIG };
|
|
1037
|
+
const refusal = () => createError(run === this.#run ? "call/failed" : "engine/not-started");
|
|
1038
|
+
const dial = (registered) => {
|
|
1039
|
+
const session = registered.call(target, options);
|
|
1040
|
+
this.#watchOwedCancel(session, registered);
|
|
1041
|
+
return this.#wrapCall(session);
|
|
1042
|
+
};
|
|
1043
|
+
const ua = this.#ua;
|
|
1044
|
+
if (!ua || run !== this.#run) return Promise.reject(refusal());
|
|
1045
|
+
if (ua.isRegistered()) return Promise.resolve(dial(ua));
|
|
1046
|
+
return new Promise((resolve, reject) => {
|
|
1047
|
+
const waiter = {
|
|
1048
|
+
dial: (registered) => {
|
|
1049
|
+
this.#awaitingRegistration.delete(waiter);
|
|
1050
|
+
try {
|
|
1051
|
+
resolve(dial(registered));
|
|
1052
|
+
} catch (cause) {
|
|
1053
|
+
reject(cause);
|
|
1054
|
+
}
|
|
1055
|
+
},
|
|
1056
|
+
fail: () => {
|
|
1057
|
+
this.#awaitingRegistration.delete(waiter);
|
|
1058
|
+
reject(refusal());
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
this.#awaitingRegistration.add(waiter);
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* `since`, for an inbound call: when its push came (its INVITE, when none was seen) — what `#onDecline` needs to
|
|
1066
|
+
* tell this call from the caller's others.
|
|
1067
|
+
*/
|
|
1068
|
+
#wrapCall(session, since = this.now()) {
|
|
1069
|
+
const call = new SipEngineCall(session, {
|
|
1070
|
+
id: nextCallId(),
|
|
1071
|
+
buildTarget: (t) => this.#buildTarget(t),
|
|
1072
|
+
placeConsult: (t) => this.#placeConsult(t),
|
|
1073
|
+
// The guard is tied to the reject ACTION, not to `ended` — a normal hangup must not arm it.
|
|
1074
|
+
onReject: () => this.#onDecline(call, since),
|
|
1075
|
+
iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS,
|
|
1076
|
+
// Inbound answers open their own peer connection, so they need the same ICE servers the
|
|
1077
|
+
// outbound leg got. Missing here, an ANSWERED call is the one that fails — the half nobody
|
|
1078
|
+
// tests until a real person picks up.
|
|
1079
|
+
pcConfig: PC_CONFIG,
|
|
1080
|
+
// Read fresh at answer() time, not captured now — a device chosen while the phone is still
|
|
1081
|
+
// ringing must still apply once it is picked up.
|
|
1082
|
+
getInputDeviceId: () => this.#inputDeviceId
|
|
1083
|
+
});
|
|
1084
|
+
this.#activeCall = call;
|
|
1085
|
+
call.onEvent((event) => {
|
|
1086
|
+
if (event.type === "ended") {
|
|
1087
|
+
const missed = call.direction === "inbound" && event.cause === "Canceled";
|
|
1088
|
+
const code = missed ? "call/missed" : CALL_END_CODES[event.reason];
|
|
1089
|
+
if (code) this.#reportError(code, event.cause);
|
|
1090
|
+
if (this.#activeCall === call) this.#activeCall = null;
|
|
1091
|
+
this.#legEnded();
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
return call;
|
|
1095
|
+
}
|
|
1096
|
+
/**
|
|
1097
|
+
* A leg ended. The UA goes if nothing is left on it (`#maybeTeardownUA`); and once no leg at all is left, an
|
|
1098
|
+
* offer kept through the call (`#pendingOffer`, §4.3) gets its REGISTER — the line is free now.
|
|
1099
|
+
*/
|
|
1100
|
+
#legEnded() {
|
|
1101
|
+
const pending = this.#activeCall || this.#consultCall ? null : this.#takePendingOffer();
|
|
1102
|
+
this.#maybeTeardownUA();
|
|
1103
|
+
if (pending) void this.#onOffer(pending.from, pending.at);
|
|
1104
|
+
}
|
|
1105
|
+
async #placeConsult(target) {
|
|
1106
|
+
const ua = await this.#ensureUA();
|
|
1107
|
+
const consultSession = ua.call(this.#buildTarget(target), {
|
|
1108
|
+
mediaConstraints: buildAudioConstraints(this.#inputDeviceId),
|
|
1109
|
+
pcConfig: PC_CONFIG
|
|
1110
|
+
});
|
|
1111
|
+
this.#watchOwedCancel(consultSession, ua);
|
|
1112
|
+
const consultCall = new SipEngineCall(consultSession, {
|
|
1113
|
+
id: nextCallId(),
|
|
1114
|
+
buildTarget: (x) => this.#buildTarget(x),
|
|
1115
|
+
placeConsult: () => Promise.reject(new Error("cannot consult from a consult leg")),
|
|
1116
|
+
iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS
|
|
1117
|
+
});
|
|
1118
|
+
this.#consultCall = consultCall;
|
|
1119
|
+
consultCall.onEvent((event) => {
|
|
1120
|
+
if (event.type === "ended") {
|
|
1121
|
+
if (this.#consultCall === consultCall) this.#consultCall = null;
|
|
1122
|
+
this.#legEnded();
|
|
1123
|
+
}
|
|
1124
|
+
});
|
|
1125
|
+
return consultCall;
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* On-demand registration (SPEC §3.3): the UA is disposable once NOTHING is left on it — but "the
|
|
1129
|
+
* main leg ended" (or "no INVITE followed this push") is not the same fact as "nothing is left."
|
|
1130
|
+
* `completeTransfer()` (SPEC §5.6) ends the main leg via a REFER…Replaces BYE *while the consult leg
|
|
1131
|
+
* is still what the PBX needs to bridge the caller onto* — tearing the UA down (and with it, EVERY
|
|
1132
|
+
* session on it, via `terminateSessions`) the instant the main leg's `ended` fires killed that
|
|
1133
|
+
* consult leg out from under the transfer before the bridge could use it, and the caller was simply
|
|
1134
|
+
* gone. Two real call sites hit this independently (found live, 2026-08-04): the main leg's own
|
|
1135
|
+
* `ended` handler, and the no-INVITE timer — which then re-armed on EVERY push, including the
|
|
1136
|
+
* `answered` pushes Zadarma sends around other account activity, so it could fire minutes after a
|
|
1137
|
+
* transfer with no `#activeCall` and no idea a consult leg was mid-handoff. Every teardown an offer
|
|
1138
|
+
* asks for (its timers, its `offer-ended`) routes through here rather than checking `#activeCall` alone.
|
|
1139
|
+
*
|
|
1140
|
+
* A dial on its way (`#dialing`) counts too: its UA may not even exist yet, and an offer ending must not
|
|
1141
|
+
* take it. So does an offer still waiting for its INVITE: mid-transfer, with only the consult leg left, an
|
|
1142
|
+
* offer registers on this UA — and the consult leg ending used to tear it down and drop that offer, its call
|
|
1143
|
+
* ringing nowhere until the provider offered it again ~30 s later. The wait's own ending (its INVITE, its
|
|
1144
|
+
* `offer-ended`, the no-INVITE timer, the leak guard) clears `#offerWait` before it calls this, so nothing keeps
|
|
1145
|
+
* the UA past those (incoming-call-reliability.md §4.3).
|
|
1146
|
+
*/
|
|
1147
|
+
#maybeTeardownUA() {
|
|
1148
|
+
if (this.provider.registrationMode !== "on-demand") return;
|
|
1149
|
+
if (this.#activeCall || this.#consultCall || this.#dialing > 0 || this.#offerWait) return;
|
|
1150
|
+
this.#teardownUA();
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* A live leg is on the current UA: the call or a consult leg. An outbound call counts from the moment its INVITE
|
|
1154
|
+
* is created — `#callWhenRegistered` wraps it there and then — so no failure in between can end it unseen.
|
|
1155
|
+
*/
|
|
1156
|
+
#carriesCall() {
|
|
1157
|
+
return this.#activeCall !== null || this.#consultCall !== null;
|
|
1158
|
+
}
|
|
1159
|
+
#buildTarget(target) {
|
|
1160
|
+
return target.includes("@") ? target : `sip:${target}@${this.#domain}`;
|
|
1161
|
+
}
|
|
1162
|
+
#setState(state) {
|
|
1163
|
+
if (state === this.#state) return;
|
|
1164
|
+
this.#state = state;
|
|
1165
|
+
for (const listener of [...this.#stateListeners]) listener(state);
|
|
1166
|
+
}
|
|
1167
|
+
};
|
|
1168
|
+
|
|
1169
|
+
// src/transports/sip/real-factory.ts
|
|
1170
|
+
var cached = null;
|
|
1171
|
+
async function loadJsSipFactory() {
|
|
1172
|
+
if (cached) return cached;
|
|
1173
|
+
const mod = await import('jssip');
|
|
1174
|
+
const JsSIP = mod.default ?? mod;
|
|
1175
|
+
if (!JsSIP?.UA || !JsSIP?.WebSocketInterface) {
|
|
1176
|
+
throw new Error("jssip: unexpected module shape (no UA / WebSocketInterface)");
|
|
1177
|
+
}
|
|
1178
|
+
cached = {
|
|
1179
|
+
createWebSocket: (wsUrl) => new JsSIP.WebSocketInterface(wsUrl),
|
|
1180
|
+
createUA: (config) => new JsSIP.UA(config)
|
|
1181
|
+
};
|
|
1182
|
+
return cached;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// src/transports/providers/zadarma.ts
|
|
1186
|
+
var PING = { localMs: 3e4, globalMs: 6e4 };
|
|
1187
|
+
var KEY_REFRESH_BUFFER_MS = 2 * 60 * 60 * 1e3;
|
|
1188
|
+
var KEY_EXPIRY_MARGIN_MS = 6e4;
|
|
1189
|
+
var REKEY_DEBOUNCE_MS = 3e4;
|
|
1190
|
+
var REBUILD_CONNECT_TIMEOUT_MS = 1e4;
|
|
1191
|
+
var REBUILD_RETRY_MS = 3e4;
|
|
1192
|
+
var ZADARMA_QUIRKS = {
|
|
1193
|
+
// Zadarma's registrar REFUSES to register a WebRTC client with any other User-Agent — a clean 401
|
|
1194
|
+
// after a valid digest challenge, indistinguishable from a wrong password (§3.1). The old CRM
|
|
1195
|
+
// hook carried this string; the port dropped it, and registration failed for exactly that reason.
|
|
1196
|
+
userAgent: "Zadarma webRTC",
|
|
1197
|
+
// Zadarma re-offers a declined call until its ring time ends (~30 s by default): the declined caller's offers
|
|
1198
|
+
// are ignored while that call lives — this long after the decline when the backend cannot say (§3.4,
|
|
1199
|
+
// incoming-call-reliability.md §4.4, Q1).
|
|
1200
|
+
declinedCallerGuardMs: 35e3,
|
|
1201
|
+
// The INVITE follows the REGISTER's 200 OK by ~0.1 s. Nothing 10 s after it: no call is coming to this UA.
|
|
1202
|
+
noInviteAfterRegisterMs: 1e4,
|
|
1203
|
+
// A leak guard only: an offer still waiting for its INVITE 2 min after its push is abandoned (§4.3).
|
|
1204
|
+
offerWaitMaxMs: 12e4,
|
|
1205
|
+
// Deprecated names of the two above, same values: this object is exported, and 0.5.1 is a patch. Never read.
|
|
1206
|
+
rejectGuardMs: 35e3,
|
|
1207
|
+
noInviteTeardownMs: 1e4,
|
|
1208
|
+
// A ceiling on ICE gathering, not a target: JsSIP sends as soon as gathering completes. 3 s leaves
|
|
1209
|
+
// room for a TURN allocation (whose relay candidates arrive last) while still bounding the ~40 s
|
|
1210
|
+
// Chrome can spend against an unreachable server. See `capIceGathering`.
|
|
1211
|
+
iceGatheringCapMs: 3e3
|
|
1212
|
+
};
|
|
1213
|
+
function buildPushUrl(key, sip) {
|
|
1214
|
+
const params = new URLSearchParams({ key, integrationType: "site", sipId: sip });
|
|
1215
|
+
return `wss://ws.zadarma.com/webrtc_pushes?${params.toString()}`;
|
|
1216
|
+
}
|
|
1217
|
+
function readPush(payload) {
|
|
1218
|
+
let data = payload;
|
|
1219
|
+
if (typeof data === "string") {
|
|
1220
|
+
try {
|
|
1221
|
+
data = JSON.parse(data);
|
|
1222
|
+
} catch {
|
|
1223
|
+
return { kind: "other", detail: "unreadable payload" };
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
if (!data || typeof data !== "object") return { kind: "other", detail: "no payload" };
|
|
1227
|
+
const { action, callerid } = data;
|
|
1228
|
+
let from = null;
|
|
1229
|
+
if (typeof callerid === "string" && callerid) from = callerid;
|
|
1230
|
+
else if (typeof callerid === "number") from = String(callerid);
|
|
1231
|
+
if (action === "incoming") return { kind: "offer", from };
|
|
1232
|
+
if (action === "answered") return { kind: "offer-ended", from };
|
|
1233
|
+
return { kind: "other", detail: typeof action === "string" ? `action "${action}"` : "no action" };
|
|
1234
|
+
}
|
|
1235
|
+
function expiryOf(key) {
|
|
1236
|
+
if (!key.keyExpiresAt) return null;
|
|
1237
|
+
const at = new Date(key.keyExpiresAt).getTime();
|
|
1238
|
+
return Number.isFinite(at) ? at : null;
|
|
1239
|
+
}
|
|
1240
|
+
var ZadarmaCredentials = class {
|
|
1241
|
+
constructor(deps) {
|
|
1242
|
+
this.deps = deps;
|
|
1243
|
+
}
|
|
1244
|
+
deps;
|
|
1245
|
+
#key = null;
|
|
1246
|
+
#keyInFlight = null;
|
|
1247
|
+
#sip = null;
|
|
1248
|
+
#sipInFlight = null;
|
|
1249
|
+
/**
|
|
1250
|
+
* Bumped by `forget`, one per half: a fetch begun before it must not put back what was just forgotten — a
|
|
1251
|
+
* refused credential that comes back from a slow request is still refused.
|
|
1252
|
+
*/
|
|
1253
|
+
#keyGeneration = 0;
|
|
1254
|
+
#sipGeneration = 0;
|
|
1255
|
+
/**
|
|
1256
|
+
* The SIP credentials were already refetched once because their WSS could not be reached: if the fresh ones
|
|
1257
|
+
* cannot reach it either, the network is the problem, and asking Zadarma again would not change it. Re-armed by
|
|
1258
|
+
* a registration that works (`worked`), and by forgetting everything (a refusal, a stop).
|
|
1259
|
+
*/
|
|
1260
|
+
#unreachableRefetched = false;
|
|
1261
|
+
/** What is in memory right now — both halves, for one key — or `null`. Never fetches. */
|
|
1262
|
+
current() {
|
|
1263
|
+
const key = this.#key?.value;
|
|
1264
|
+
const sip = this.#sip;
|
|
1265
|
+
return key && sip && sip.forKey === key.key ? { key, pass: sip.pass, domain: sip.domain } : null;
|
|
1266
|
+
}
|
|
1267
|
+
/** Their WSS could not be reached: forget the SIP half — once, until `worked()`. Whether it was forgotten. */
|
|
1268
|
+
forgetUnreachable() {
|
|
1269
|
+
if (this.#unreachableRefetched) return false;
|
|
1270
|
+
this.forget("sip");
|
|
1271
|
+
this.#unreachableRefetched = true;
|
|
1272
|
+
return true;
|
|
1273
|
+
}
|
|
1274
|
+
/** A registration worked with what is in memory. */
|
|
1275
|
+
worked() {
|
|
1276
|
+
this.#unreachableRefetched = false;
|
|
1277
|
+
}
|
|
1278
|
+
/** The cached key while it is alive; otherwise a fresh one (and, in the background, its SIP credentials). */
|
|
1279
|
+
key() {
|
|
1280
|
+
const cached2 = this.#key;
|
|
1281
|
+
if (cached2 && (cached2.expiresAt === null || Date.now() < cached2.expiresAt - KEY_EXPIRY_MARGIN_MS)) {
|
|
1282
|
+
return Promise.resolve(cached2.value);
|
|
1283
|
+
}
|
|
1284
|
+
return this.#fetchKey();
|
|
1285
|
+
}
|
|
1286
|
+
/** A new key now, whatever is cached: the refresh ahead of expiry. The old one serves until this one lands. */
|
|
1287
|
+
renew() {
|
|
1288
|
+
return this.#fetchKey();
|
|
1289
|
+
}
|
|
1290
|
+
/** The key and the SIP credentials it bought, from memory when they are there. */
|
|
1291
|
+
async sip() {
|
|
1292
|
+
const key = await this.key();
|
|
1293
|
+
const { pass, domain } = await this.#sipFor(key);
|
|
1294
|
+
return { key, pass, domain };
|
|
1295
|
+
}
|
|
1296
|
+
/** Synchronous up to the request, so a prefetch and a `provision()` racing it share one exchange. */
|
|
1297
|
+
#sipFor(key) {
|
|
1298
|
+
const cached2 = this.#sip;
|
|
1299
|
+
if (cached2 && cached2.forKey === key.key) return Promise.resolve({ pass: cached2.pass, domain: cached2.domain });
|
|
1300
|
+
const inFlight = this.#sipInFlight;
|
|
1301
|
+
if (inFlight && inFlight.forKey === key.key) return inFlight.promise;
|
|
1302
|
+
const generation = this.#sipGeneration;
|
|
1303
|
+
let request;
|
|
1304
|
+
try {
|
|
1305
|
+
request = this.deps.exchangeWebphoneData(key.key, key.sip);
|
|
1306
|
+
} catch (cause) {
|
|
1307
|
+
request = Promise.reject(cause instanceof Error ? cause : new Error(String(cause)));
|
|
1308
|
+
}
|
|
1309
|
+
const promise = request.then((data) => {
|
|
1310
|
+
if (generation === this.#sipGeneration) this.#sip = { forKey: key.key, pass: data.pass, domain: data.domain };
|
|
1311
|
+
return data;
|
|
1312
|
+
});
|
|
1313
|
+
const entry = { forKey: key.key, promise };
|
|
1314
|
+
this.#sipInFlight = entry;
|
|
1315
|
+
void promise.finally(() => {
|
|
1316
|
+
if (this.#sipInFlight === entry) this.#sipInFlight = null;
|
|
1317
|
+
}).catch(() => {
|
|
1318
|
+
});
|
|
1319
|
+
return promise;
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Drop what stopped working. `all`: the registrar refused the credentials, so the key behind them may be the
|
|
1323
|
+
* stale part too. `sip`: their WSS could not be reached — the key is fine, the datacenter may not be.
|
|
1324
|
+
*/
|
|
1325
|
+
forget(what) {
|
|
1326
|
+
this.#sipGeneration += 1;
|
|
1327
|
+
this.#sip = null;
|
|
1328
|
+
this.#sipInFlight = null;
|
|
1329
|
+
if (what === "sip") return;
|
|
1330
|
+
this.#keyGeneration += 1;
|
|
1331
|
+
this.#key = null;
|
|
1332
|
+
this.#keyInFlight = null;
|
|
1333
|
+
this.#unreachableRefetched = false;
|
|
1334
|
+
}
|
|
1335
|
+
#fetchKey() {
|
|
1336
|
+
if (this.#keyInFlight) return this.#keyInFlight;
|
|
1337
|
+
const generation = this.#keyGeneration;
|
|
1338
|
+
const inFlight = this.deps.fetchKey().then((value) => {
|
|
1339
|
+
if (generation === this.#keyGeneration) {
|
|
1340
|
+
this.#key = { value, expiresAt: expiryOf(value) };
|
|
1341
|
+
void this.#sipFor(value).catch(() => {
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
return value;
|
|
1345
|
+
});
|
|
1346
|
+
this.#keyInFlight = inFlight;
|
|
1347
|
+
void inFlight.finally(() => {
|
|
1348
|
+
if (this.#keyInFlight === inFlight) this.#keyInFlight = null;
|
|
1349
|
+
}).catch(() => {
|
|
1350
|
+
});
|
|
1351
|
+
return inFlight;
|
|
1352
|
+
}
|
|
1353
|
+
};
|
|
1354
|
+
var ZadarmaPushSignal = class {
|
|
1355
|
+
/**
|
|
1356
|
+
* `credentials` is shared with the provider's `provision()`: one key for the push socket and the SIP
|
|
1357
|
+
* credentials, fetched once (§4.5). A signal on its own gets a cache of its own.
|
|
1358
|
+
*/
|
|
1359
|
+
constructor(deps, credentials = new ZadarmaCredentials(deps)) {
|
|
1360
|
+
this.deps = deps;
|
|
1361
|
+
this.credentials = credentials;
|
|
1362
|
+
}
|
|
1363
|
+
deps;
|
|
1364
|
+
credentials;
|
|
1365
|
+
#socket = null;
|
|
1366
|
+
#connected = false;
|
|
1367
|
+
#pingTimers = [];
|
|
1368
|
+
#refreshTimer;
|
|
1369
|
+
/** The replacement socket of a rebuild in flight, not yet current. */
|
|
1370
|
+
#candidate = null;
|
|
1371
|
+
/** Abandons the rebuild in flight (closing its candidate). */
|
|
1372
|
+
#abortRebuild = null;
|
|
1373
|
+
#rebuilding = false;
|
|
1374
|
+
#retryTimer;
|
|
1375
|
+
#unwatchBackground;
|
|
1376
|
+
#closed = false;
|
|
1377
|
+
/**
|
|
1378
|
+
* Bumped by `disconnect()`. An open or a rebuild still awaiting its key when the signal is disconnected
|
|
1379
|
+
* must build nothing afterwards — and `#closed` alone cannot say so once a later `connect()` (stop, then
|
|
1380
|
+
* start again) has reset it: the late socket was built beside the new one, and nothing ever closed it.
|
|
1381
|
+
*/
|
|
1382
|
+
#run = 0;
|
|
1383
|
+
/**
|
|
1384
|
+
* The key is due for its refresh (`#armRefresh`): the next rebuild fetches a new one instead of reusing the
|
|
1385
|
+
* cached one — and keeps trying, on every retry and every hidden-tab tick, until one arrives.
|
|
1386
|
+
*/
|
|
1387
|
+
#renewDue = false;
|
|
1388
|
+
/** A rebuild asked for (`rekey`) while another was in flight: it runs once that one ends. */
|
|
1389
|
+
#rekeyQueued = false;
|
|
1390
|
+
#rekeyAfter = 0;
|
|
1391
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
1392
|
+
#connectionListeners = /* @__PURE__ */ new Set();
|
|
1393
|
+
async connect() {
|
|
1394
|
+
if (this.#socket) return;
|
|
1395
|
+
this.#closed = false;
|
|
1396
|
+
this.#unwatchBackground ??= this.deps.watchBackgroundReconnect?.(() => void this.#reopen());
|
|
1397
|
+
await this.#open();
|
|
1398
|
+
}
|
|
1399
|
+
/**
|
|
1400
|
+
* The registrar refused credentials bought with this socket's key, and the provider has forgotten them: the
|
|
1401
|
+
* socket is rebuilt on a fresh key, make-before-break like every rebuild (`#reopen`).
|
|
1402
|
+
*
|
|
1403
|
+
* Debounced: a registrar refusing every attempt must not become a tight loop against our backend and
|
|
1404
|
+
* Zadarma's. This rebuild used to be the engine's (`#refreshSignal`, a close-then-open); it lives here now
|
|
1405
|
+
* because only the provider knows the push socket and the SIP credentials share one key.
|
|
1406
|
+
*/
|
|
1407
|
+
rekey() {
|
|
1408
|
+
if (this.#closed || !this.#socket) return;
|
|
1409
|
+
const now = Date.now();
|
|
1410
|
+
if (now < this.#rekeyAfter) return;
|
|
1411
|
+
this.#rekeyAfter = now + REKEY_DEBOUNCE_MS;
|
|
1412
|
+
if (this.#rebuilding) this.#rekeyQueued = true;
|
|
1413
|
+
else void this.#reopen();
|
|
1414
|
+
}
|
|
1415
|
+
async #open() {
|
|
1416
|
+
const run = this.#run;
|
|
1417
|
+
const { key, sip, keyExpiresAt } = await this.credentials.key();
|
|
1418
|
+
if (run !== this.#run) return;
|
|
1419
|
+
const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
|
|
1420
|
+
this.#socket = socket;
|
|
1421
|
+
this.#attach(socket);
|
|
1422
|
+
this.#armRefresh(keyExpiresAt);
|
|
1423
|
+
}
|
|
1424
|
+
/**
|
|
1425
|
+
* Wires one socket's events. `this.#socket` is read when an event FIRES, not when it is wired: a socket that
|
|
1426
|
+
* has been replaced must stop driving connectivity (our own `close()` of it emits `disconnect`, which would
|
|
1427
|
+
* otherwise flip the state to offline right after a perfectly healthy swap). `update` is the exception — it is
|
|
1428
|
+
* forwarded from any socket not yet closed, because a push seen twice is harmless (the engine ignores an offer
|
|
1429
|
+
* mid-call, and a second one only sends one more REGISTER) while one seen by neither is a missed call.
|
|
1430
|
+
*
|
|
1431
|
+
* With `hooks`, the socket is a rebuild's candidate: its first `connect` or failure goes to the rebuild instead
|
|
1432
|
+
* of changing the connection state (it is not current yet).
|
|
1433
|
+
*/
|
|
1434
|
+
#attach(socket, hooks) {
|
|
1435
|
+
socket.on("connect", () => {
|
|
1436
|
+
if (hooks && socket === this.#candidate) return hooks.onConnect();
|
|
1437
|
+
if (socket !== this.#socket) return;
|
|
1438
|
+
this.#setConnected(true);
|
|
1439
|
+
this.#startPings(socket);
|
|
1440
|
+
});
|
|
1441
|
+
socket.on("update", (payload) => this.#emit(readPush(payload)));
|
|
1442
|
+
const lost = () => {
|
|
1443
|
+
if (hooks && socket === this.#candidate) return hooks.onFailure();
|
|
1444
|
+
if (socket !== this.#socket) return;
|
|
1445
|
+
this.#stopPings();
|
|
1446
|
+
this.#setConnected(false);
|
|
1447
|
+
};
|
|
1448
|
+
socket.on("disconnect", lost);
|
|
1449
|
+
socket.on("connect_error", lost);
|
|
1450
|
+
}
|
|
1451
|
+
/**
|
|
1452
|
+
* Presence pings — **the reason an idle tab keeps receiving calls**.
|
|
1453
|
+
*
|
|
1454
|
+
* Zadarma's gateway decides whether the extension is online, and it decides from these. Without
|
|
1455
|
+
* them the socket goes quiet, Zadarma marks the extension offline within minutes, and INVITEs stop
|
|
1456
|
+
* being routed — with no error anywhere, because nothing failed. The only visible symptom is that
|
|
1457
|
+
* the phone silently stops ringing until the page is reloaded.
|
|
1458
|
+
*
|
|
1459
|
+
* The names and intervals are Zadarma's, taken from the CRM hook that ran this in production for
|
|
1460
|
+
* years. They are not ours to tune.
|
|
1461
|
+
*/
|
|
1462
|
+
#startPings(socket) {
|
|
1463
|
+
this.#stopPings();
|
|
1464
|
+
this.#pingTimers = [
|
|
1465
|
+
setInterval(() => socket.emit("localPing", {}), PING.localMs),
|
|
1466
|
+
setInterval(() => socket.emit("globalPing", {}), PING.globalMs)
|
|
1467
|
+
];
|
|
1468
|
+
}
|
|
1469
|
+
#stopPings() {
|
|
1470
|
+
for (const t of this.#pingTimers) clearInterval(t);
|
|
1471
|
+
this.#pingTimers = [];
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Rebuild the socket before the WebRTC key expires.
|
|
1475
|
+
*
|
|
1476
|
+
* The key is baked into the socket URL, so socket.io's own reconnection re-presents whatever it was
|
|
1477
|
+
* given at construction. A tab left open past the key's 72 h lifetime would reconnect forever with
|
|
1478
|
+
* a dead credential. Absolute, from the backend's `key_expires_at`, so it targets the real expiry
|
|
1479
|
+
* rather than restarting a countdown on every reconnect.
|
|
1480
|
+
*
|
|
1481
|
+
* It is also the refresh of the shared credentials (incoming-call-reliability.md §4.5): the new key replaces
|
|
1482
|
+
* the cached one, and its SIP credentials are fetched with it, in the background — no call waits for either.
|
|
1483
|
+
*/
|
|
1484
|
+
#armRefresh(keyExpiresAt) {
|
|
1485
|
+
clearTimeout(this.#refreshTimer);
|
|
1486
|
+
if (!keyExpiresAt) return;
|
|
1487
|
+
const ms = new Date(keyExpiresAt).getTime() - Date.now() - KEY_REFRESH_BUFFER_MS;
|
|
1488
|
+
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
1489
|
+
this.#refreshTimer = setTimeout(() => {
|
|
1490
|
+
this.#renewDue = true;
|
|
1491
|
+
void this.#reopen();
|
|
1492
|
+
}, ms);
|
|
1493
|
+
}
|
|
1494
|
+
/**
|
|
1495
|
+
* Replaces the push socket with a fresh one — MAKE-BEFORE-BREAK (presence-client-reporting.md §4.1, T8).
|
|
1496
|
+
*
|
|
1497
|
+
* Why it must happen at all: Zadarma's gateway decides the extension is alive from the raw Engine.IO ping, which
|
|
1498
|
+
* a hidden tab's throttled timers silently stop sending, so while the tab is hidden the socket is rebuilt every
|
|
1499
|
+
* 20 s (`push-socket-reliability.md` §5.3), and once before the key expires. Why it must not leave a gap: the
|
|
1500
|
+
* old close-then-reopen reported `connected = false` for the ~0.5 s in between, which took the softphone
|
|
1501
|
+
* `offline`, tore down the presence socket and left Zadarma with no socket to push an incoming call to.
|
|
1502
|
+
*
|
|
1503
|
+
* Now the OLD socket stays up and stays current until the NEW one has actually connected; only then do we swap,
|
|
1504
|
+
* move the pings and close the old one. A healthy rebuild never changes `connected`. A failed one — the key fetch
|
|
1505
|
+
* throws, the socket errors, or it has not connected within `REBUILD_CONNECT_TIMEOUT_MS` — keeps the old socket
|
|
1506
|
+
* and retries, instead of leaving the tab disconnected. Zadarma does not evict the older of two sockets for one
|
|
1507
|
+
* sip (multi-tab-leader-election.md §1), so the overlap is safe.
|
|
1508
|
+
*
|
|
1509
|
+
* The key comes from the shared cache — a hidden-tab tick makes no request — unless it is due for its refresh
|
|
1510
|
+
* (`#renewDue`) or has been forgotten after a refused registration (`rekey`).
|
|
1511
|
+
*/
|
|
1512
|
+
async #reopen() {
|
|
1513
|
+
if (this.#closed || this.#rebuilding) return;
|
|
1514
|
+
if (!this.#socket) {
|
|
1515
|
+
try {
|
|
1516
|
+
await this.#open();
|
|
1517
|
+
} catch {
|
|
1518
|
+
}
|
|
1519
|
+
return;
|
|
1520
|
+
}
|
|
1521
|
+
this.#rebuilding = true;
|
|
1522
|
+
const run = this.#run;
|
|
1523
|
+
try {
|
|
1524
|
+
const { key, sip, keyExpiresAt } = await (this.#renewDue ? this.credentials.renew() : this.credentials.key());
|
|
1525
|
+
if (run !== this.#run) return;
|
|
1526
|
+
this.#renewDue = false;
|
|
1527
|
+
const swapped = await new Promise((resolve) => {
|
|
1528
|
+
const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
|
|
1529
|
+
this.#candidate = socket;
|
|
1530
|
+
let settled = false;
|
|
1531
|
+
const finish = (ok) => {
|
|
1532
|
+
if (settled) return;
|
|
1533
|
+
settled = true;
|
|
1534
|
+
clearTimeout(timer);
|
|
1535
|
+
this.#abortRebuild = null;
|
|
1536
|
+
if (ok && socket.connected) {
|
|
1537
|
+
this.#swapIn(socket, keyExpiresAt);
|
|
1538
|
+
} else {
|
|
1539
|
+
this.#candidate = null;
|
|
1540
|
+
socket.close();
|
|
1541
|
+
}
|
|
1542
|
+
resolve(ok && socket.connected);
|
|
1543
|
+
};
|
|
1544
|
+
const timer = setTimeout(() => finish(false), REBUILD_CONNECT_TIMEOUT_MS);
|
|
1545
|
+
this.#abortRebuild = () => finish(false);
|
|
1546
|
+
this.#attach(socket, { onConnect: () => finish(true), onFailure: () => finish(false) });
|
|
1547
|
+
});
|
|
1548
|
+
if (!swapped) this.#armRetry();
|
|
1549
|
+
} catch {
|
|
1550
|
+
this.#armRetry();
|
|
1551
|
+
} finally {
|
|
1552
|
+
this.#rebuilding = false;
|
|
1553
|
+
}
|
|
1554
|
+
if (this.#rekeyQueued) {
|
|
1555
|
+
this.#rekeyQueued = false;
|
|
1556
|
+
void this.#reopen();
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* The moment of the swap — synchronous, called from the candidate's own `connect` event so nothing can run
|
|
1561
|
+
* between "the new socket is up" and "it is the current one". Order matters: `#socket` moves first, so the old
|
|
1562
|
+
* socket's `disconnect` (our own `close()` of it, just below) is ignored by `#attach`.
|
|
1563
|
+
*/
|
|
1564
|
+
#swapIn(socket, keyExpiresAt) {
|
|
1565
|
+
const previous = this.#socket;
|
|
1566
|
+
this.#socket = socket;
|
|
1567
|
+
this.#candidate = null;
|
|
1568
|
+
clearTimeout(this.#retryTimer);
|
|
1569
|
+
this.#retryTimer = void 0;
|
|
1570
|
+
this.#stopPings();
|
|
1571
|
+
this.#startPings(socket);
|
|
1572
|
+
this.#setConnected(true);
|
|
1573
|
+
previous?.close();
|
|
1574
|
+
this.#armRefresh(keyExpiresAt);
|
|
1575
|
+
}
|
|
1576
|
+
#armRetry() {
|
|
1577
|
+
if (this.#closed) return;
|
|
1578
|
+
clearTimeout(this.#retryTimer);
|
|
1579
|
+
this.#retryTimer = setTimeout(() => void this.#reopen(), REBUILD_RETRY_MS);
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Closes everything — and forgets the credentials: this is how the engine stops, and a phone that is off holds
|
|
1583
|
+
* no SIP password. The next `connect()` fetches them again.
|
|
1584
|
+
*/
|
|
1585
|
+
disconnect() {
|
|
1586
|
+
this.#closed = true;
|
|
1587
|
+
this.#run += 1;
|
|
1588
|
+
this.#renewDue = false;
|
|
1589
|
+
this.#rekeyQueued = false;
|
|
1590
|
+
this.#rekeyAfter = 0;
|
|
1591
|
+
this.credentials.forget("all");
|
|
1592
|
+
this.#stopPings();
|
|
1593
|
+
clearTimeout(this.#refreshTimer);
|
|
1594
|
+
this.#refreshTimer = void 0;
|
|
1595
|
+
clearTimeout(this.#retryTimer);
|
|
1596
|
+
this.#retryTimer = void 0;
|
|
1597
|
+
this.#abortRebuild?.();
|
|
1598
|
+
this.#unwatchBackground?.();
|
|
1599
|
+
this.#unwatchBackground = void 0;
|
|
1600
|
+
this.#socket?.close();
|
|
1601
|
+
this.#socket = null;
|
|
1602
|
+
this.#setConnected(false);
|
|
1603
|
+
}
|
|
1604
|
+
onSignal(listener) {
|
|
1605
|
+
this.#listeners.add(listener);
|
|
1606
|
+
return () => this.#listeners.delete(listener);
|
|
1607
|
+
}
|
|
1608
|
+
onConnectionChange(listener) {
|
|
1609
|
+
this.#connectionListeners.add(listener);
|
|
1610
|
+
return () => this.#connectionListeners.delete(listener);
|
|
1611
|
+
}
|
|
1612
|
+
get connected() {
|
|
1613
|
+
return this.#connected;
|
|
1614
|
+
}
|
|
1615
|
+
#setConnected(connected) {
|
|
1616
|
+
if (this.#connected === connected) return;
|
|
1617
|
+
this.#connected = connected;
|
|
1618
|
+
for (const listener of [...this.#connectionListeners]) listener(connected);
|
|
1619
|
+
}
|
|
1620
|
+
#emit(event) {
|
|
1621
|
+
for (const listener of [...this.#listeners]) listener(event);
|
|
1622
|
+
}
|
|
1623
|
+
};
|
|
1624
|
+
var ZadarmaProvider = class {
|
|
1625
|
+
name = "zadarma";
|
|
1626
|
+
// Zadarma's PBX ignores a standing registration — it routes INVITEs only to a UA that registered
|
|
1627
|
+
// in response to a push (SPEC §3.2). Hence on-demand.
|
|
1628
|
+
registrationMode = "on-demand";
|
|
1629
|
+
quirks = ZADARMA_QUIRKS;
|
|
1630
|
+
incomingSignal;
|
|
1631
|
+
/**
|
|
1632
|
+
* One key and one set of SIP credentials for the push socket and every registration (§4.5). Its "unreachable
|
|
1633
|
+
* refetched once" mark lives there too, so a stop — which forgets the credentials — clears it as well.
|
|
1634
|
+
*/
|
|
1635
|
+
#credentials;
|
|
1636
|
+
#signal;
|
|
1637
|
+
constructor(deps) {
|
|
1638
|
+
this.#credentials = new ZadarmaCredentials(deps);
|
|
1639
|
+
this.#signal = new ZadarmaPushSignal(deps, this.#credentials);
|
|
1640
|
+
this.incomingSignal = this.#signal;
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* The SIP credentials, from memory (incoming-call-reliability.md §4.5). They were fetched when the push socket
|
|
1644
|
+
* opened — the key from our backend, then `{ pass, domain }` from Zadarma, in the browser for the geo-routing
|
|
1645
|
+
* (SPEC §3.1) — so a push goes straight to the WSS and the REGISTER. Fetched here only when they are not there:
|
|
1646
|
+
* the background fetch failed, or they were forgotten (`credentialsFailed`).
|
|
1647
|
+
*/
|
|
1648
|
+
async provision() {
|
|
1649
|
+
return toSipCredentials(await this.#credentials.sip());
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* `rejected` (401/403): the key may be the stale part, so both are forgotten, and the push socket — which
|
|
1653
|
+
* holds the same key — is rebuilt on a fresh one. `unreachable`: only the SIP credentials, and only once
|
|
1654
|
+
* until a registration works: Zadarma may have moved this browser to another datacenter (SPEC §3.1).
|
|
1655
|
+
*
|
|
1656
|
+
* Only if `credentials` — the ones that failed — are still what is in memory: a UA let go of earlier can report
|
|
1657
|
+
* late, when the cache has already moved on to fresh ones, and those must not be thrown away for it.
|
|
1658
|
+
*/
|
|
1659
|
+
credentialsFailed(reason, credentials) {
|
|
1660
|
+
if (credentials && !this.#holds(credentials)) return true;
|
|
1661
|
+
if (reason === "rejected") {
|
|
1662
|
+
this.#credentials.forget("all");
|
|
1663
|
+
this.#signal.rekey();
|
|
1664
|
+
return true;
|
|
1665
|
+
}
|
|
1666
|
+
return this.#credentials.forgetUnreachable();
|
|
1667
|
+
}
|
|
1668
|
+
credentialsWorked() {
|
|
1669
|
+
this.#credentials.worked();
|
|
1670
|
+
}
|
|
1671
|
+
#holds(credentials) {
|
|
1672
|
+
const current = this.#credentials.current();
|
|
1673
|
+
if (!current) return false;
|
|
1674
|
+
const held = toSipCredentials(current);
|
|
1675
|
+
return held.uri === credentials.uri && held.authUser === credentials.authUser && held.password === credentials.password;
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
function toSipCredentials({ key, pass, domain }) {
|
|
1679
|
+
return {
|
|
1680
|
+
uri: `sip:${key.sip}@${domain}`,
|
|
1681
|
+
authUser: key.sip,
|
|
1682
|
+
password: pass,
|
|
1683
|
+
wsUrl: `wss://${domain}:4443`
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// src/transports/providers/zadarma-browser.ts
|
|
1688
|
+
var UnauthorizedKeyError = class extends Error {
|
|
1689
|
+
};
|
|
1690
|
+
var KeyFetchError = class extends Error {
|
|
1691
|
+
constructor(message, status, retryAfterMs) {
|
|
1692
|
+
super(message);
|
|
1693
|
+
this.status = status;
|
|
1694
|
+
this.retryAfterMs = retryAfterMs;
|
|
1695
|
+
}
|
|
1696
|
+
status;
|
|
1697
|
+
retryAfterMs;
|
|
1698
|
+
};
|
|
1699
|
+
function retryAfterMsOf(res) {
|
|
1700
|
+
const value = res.headers?.get?.("retry-after");
|
|
1701
|
+
if (!value) return void 0;
|
|
1702
|
+
const seconds = Number(value);
|
|
1703
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
|
|
1704
|
+
const at = Date.parse(value);
|
|
1705
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : void 0;
|
|
1706
|
+
}
|
|
1707
|
+
function makeFetchKey(config) {
|
|
1708
|
+
const attempt = async () => {
|
|
1709
|
+
let token;
|
|
1710
|
+
try {
|
|
1711
|
+
({ token } = await config.tokenSource.getToken());
|
|
1712
|
+
} catch (cause) {
|
|
1713
|
+
throw createError("auth/token-fetch-failed", cause);
|
|
1714
|
+
}
|
|
1715
|
+
const qs = config.organizationId ? `?organization_id=${encodeURIComponent(config.organizationId)}` : "";
|
|
1716
|
+
const res = await fetch(`${config.apiBase}/phone/webrtc/key${qs}`, {
|
|
1717
|
+
headers: { Authorization: token }
|
|
1718
|
+
});
|
|
1719
|
+
if (!res.ok) {
|
|
1720
|
+
const body = await res.json().catch(() => null);
|
|
1721
|
+
const message = body?.message ?? `webrtc/key HTTP ${res.status}`;
|
|
1722
|
+
throw res.status === 401 ? new UnauthorizedKeyError(message) : new KeyFetchError(message, res.status, retryAfterMsOf(res));
|
|
1723
|
+
}
|
|
1724
|
+
const data = await res.json();
|
|
1725
|
+
return {
|
|
1726
|
+
key: data.key,
|
|
1727
|
+
sip: data.sip,
|
|
1728
|
+
...data.key_expires_at ? { keyExpiresAt: data.key_expires_at } : {}
|
|
1729
|
+
};
|
|
1730
|
+
};
|
|
1731
|
+
return async () => {
|
|
1732
|
+
try {
|
|
1733
|
+
return await attempt();
|
|
1734
|
+
} catch (error) {
|
|
1735
|
+
if (!(error instanceof UnauthorizedKeyError)) throw error;
|
|
1736
|
+
invalidateToken(config.tokenSource);
|
|
1737
|
+
try {
|
|
1738
|
+
return await attempt();
|
|
1739
|
+
} catch (retryError) {
|
|
1740
|
+
if (retryError instanceof UnauthorizedKeyError) {
|
|
1741
|
+
throw createError("auth/token-rejected", retryError);
|
|
1742
|
+
}
|
|
1743
|
+
throw retryError;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
};
|
|
1747
|
+
}
|
|
1748
|
+
async function exchangeWebphoneData(key, sip, timeoutMs = 1e4) {
|
|
1749
|
+
const params = new URLSearchParams({
|
|
1750
|
+
// Still sent: the endpoint wraps its response either way, and naming the callback keeps the
|
|
1751
|
+
// wrapper predictable rather than relying on their default.
|
|
1752
|
+
jsonpCallback: "cv",
|
|
1753
|
+
integrationType: "site",
|
|
1754
|
+
key,
|
|
1755
|
+
sipId: sip
|
|
1756
|
+
});
|
|
1757
|
+
let res;
|
|
1758
|
+
try {
|
|
1759
|
+
res = await fetch(
|
|
1760
|
+
`https://api.zadarma.com/sys/webrtc/get_webphone_data.php?${params.toString()}`,
|
|
1761
|
+
{ signal: AbortSignal.timeout(timeoutMs) }
|
|
1762
|
+
);
|
|
1763
|
+
} catch (cause) {
|
|
1764
|
+
const timedOut = cause instanceof DOMException && cause.name === "TimeoutError";
|
|
1765
|
+
throw new Error(timedOut ? "get_webphone_data timed out" : "get_webphone_data network error", {
|
|
1766
|
+
cause
|
|
1767
|
+
});
|
|
1768
|
+
}
|
|
1769
|
+
if (!res.ok) throw new Error(`get_webphone_data HTTP ${res.status}`);
|
|
1770
|
+
const data = parseJsonp(await res.text());
|
|
1771
|
+
if (!data || data.error || !data.pass || !data.domain) {
|
|
1772
|
+
throw new Error(data?.error?.content ?? "get_webphone_data: missing SIP credentials");
|
|
1773
|
+
}
|
|
1774
|
+
return { pass: data.pass, domain: data.domain };
|
|
1775
|
+
}
|
|
1776
|
+
function parseJsonp(text) {
|
|
1777
|
+
const open = text.indexOf("(");
|
|
1778
|
+
const close = text.lastIndexOf(")");
|
|
1779
|
+
const body = open !== -1 && close > open ? text.slice(open + 1, close) : text;
|
|
1780
|
+
try {
|
|
1781
|
+
return JSON.parse(body);
|
|
1782
|
+
} catch {
|
|
1783
|
+
return null;
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
function createPushSocket(url) {
|
|
1787
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
1788
|
+
let client = null;
|
|
1789
|
+
let closed = false;
|
|
1790
|
+
void (async () => {
|
|
1791
|
+
const mod = await import('socket.io-client-v2');
|
|
1792
|
+
if (closed) return;
|
|
1793
|
+
const connect = mod.default ?? mod.io;
|
|
1794
|
+
if (!connect) throw new Error("socket.io-client-v2: no connect export");
|
|
1795
|
+
const c = connect(url, {
|
|
1796
|
+
transports: ["websocket"],
|
|
1797
|
+
reconnection: true,
|
|
1798
|
+
// The push socket drops on a schedule under background-tab timer throttling, NOT from network
|
|
1799
|
+
// flakiness — every reconnect observed succeeds on the first attempt (push-socket-reliability.md
|
|
1800
|
+
// §2.3, §5.1). There is nothing to back off from on that first attempt, so waiting the old 10s
|
|
1801
|
+
// before retrying was pure, uncontested exposure — an inbound call landing in that window is
|
|
1802
|
+
// lost, unqueued (Zadarma's gateway speaks EIO3, no offline redelivery). `reconnectionDelayMax`
|
|
1803
|
+
// stays as the ceiling for a genuinely down gateway, where backing off still matters.
|
|
1804
|
+
reconnectionDelay: 300,
|
|
1805
|
+
reconnectionDelayMax: 3e4
|
|
1806
|
+
});
|
|
1807
|
+
client = c;
|
|
1808
|
+
for (const [event, cbs] of handlers) for (const cb of cbs) c.on(event, cb);
|
|
1809
|
+
})();
|
|
1810
|
+
return {
|
|
1811
|
+
on(event, cb) {
|
|
1812
|
+
const list = handlers.get(event) ?? [];
|
|
1813
|
+
list.push(cb);
|
|
1814
|
+
handlers.set(event, list);
|
|
1815
|
+
client?.on(event, cb);
|
|
1816
|
+
},
|
|
1817
|
+
emit(event, payload) {
|
|
1818
|
+
client?.emit(event, payload);
|
|
1819
|
+
},
|
|
1820
|
+
close() {
|
|
1821
|
+
closed = true;
|
|
1822
|
+
client?.close();
|
|
1823
|
+
},
|
|
1824
|
+
get connected() {
|
|
1825
|
+
return client?.connected ?? false;
|
|
1826
|
+
}
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1829
|
+
var BACKGROUND_RECONNECT_MS = 2e4;
|
|
1830
|
+
var HEARTBEAT_WORKER_SOURCE = `
|
|
1831
|
+
let timer;
|
|
1832
|
+
self.onmessage = (e) => {
|
|
1833
|
+
if (e.data.type === 'start') timer = setInterval(() => self.postMessage('tick'), e.data.intervalMs);
|
|
1834
|
+
else if (e.data.type === 'stop') { clearInterval(timer); timer = undefined; }
|
|
1835
|
+
};
|
|
1836
|
+
`;
|
|
1837
|
+
function watchBackgroundReconnect(cb) {
|
|
1838
|
+
if (typeof document === "undefined" || typeof Worker === "undefined") return () => {
|
|
1839
|
+
};
|
|
1840
|
+
let worker = null;
|
|
1841
|
+
let blobUrl = null;
|
|
1842
|
+
const ensureWorker = () => {
|
|
1843
|
+
if (!worker) {
|
|
1844
|
+
blobUrl = URL.createObjectURL(new Blob([HEARTBEAT_WORKER_SOURCE], { type: "application/javascript" }));
|
|
1845
|
+
worker = new Worker(blobUrl);
|
|
1846
|
+
worker.onmessage = (e) => {
|
|
1847
|
+
if (e.data === "tick") cb();
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
return worker;
|
|
1851
|
+
};
|
|
1852
|
+
const onVisibilityChange = () => {
|
|
1853
|
+
if (document.visibilityState === "hidden") {
|
|
1854
|
+
ensureWorker().postMessage({ type: "start", intervalMs: BACKGROUND_RECONNECT_MS });
|
|
1855
|
+
} else {
|
|
1856
|
+
worker?.postMessage({ type: "stop" });
|
|
1857
|
+
}
|
|
1858
|
+
};
|
|
1859
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
1860
|
+
if (document.visibilityState === "hidden") onVisibilityChange();
|
|
1861
|
+
return () => {
|
|
1862
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
1863
|
+
worker?.postMessage({ type: "stop" });
|
|
1864
|
+
worker?.terminate();
|
|
1865
|
+
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
function makeZadarmaBrowserDeps(config) {
|
|
1869
|
+
return {
|
|
1870
|
+
fetchKey: makeFetchKey(config),
|
|
1871
|
+
exchangeWebphoneData: (key, sip) => exchangeWebphoneData(key, sip),
|
|
1872
|
+
createPushSocket,
|
|
1873
|
+
watchBackgroundReconnect
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
// src/transports/zadarma-engine.ts
|
|
1878
|
+
function createZadarmaEngine(config) {
|
|
1879
|
+
const provider = new ZadarmaProvider(
|
|
1880
|
+
makeZadarmaBrowserDeps({
|
|
1881
|
+
...config,
|
|
1882
|
+
apiBase: normalizeApiBase(config.apiBase),
|
|
1883
|
+
tokenSource: cachedTokenSource(config.tokenSource)
|
|
1884
|
+
})
|
|
1885
|
+
);
|
|
1886
|
+
return new SipCallEngine(provider, loadJsSipFactory);
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
export { ZADARMA_QUIRKS, createZadarmaEngine };
|
|
1890
|
+
//# sourceMappingURL=chunk-3UTUWGVG.js.map
|
|
1891
|
+
//# sourceMappingURL=chunk-3UTUWGVG.js.map
|