@coventia/softphone 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/{call-C6YpT6uR.d.cts → call-iGvr2ZnO.d.cts} +130 -13
  2. package/dist/{call-C6YpT6uR.d.ts → call-iGvr2ZnO.d.ts} +130 -13
  3. package/dist/chunk-3UTUWGVG.js +1891 -0
  4. package/dist/chunk-3UTUWGVG.js.map +1 -0
  5. package/dist/chunk-DZRSSZWB.js +39 -0
  6. package/dist/chunk-DZRSSZWB.js.map +1 -0
  7. package/dist/{chunk-2ZSOGGPO.js → chunk-FRXHEHQA.js} +15 -2
  8. package/dist/chunk-FRXHEHQA.js.map +1 -0
  9. package/dist/{chunk-UHNLEBBO.js → chunk-I3RSC52R.js} +28 -3
  10. package/dist/chunk-I3RSC52R.js.map +1 -0
  11. package/dist/chunk-M76SAXXM.js +13 -0
  12. package/dist/chunk-M76SAXXM.js.map +1 -0
  13. package/dist/{chunk-7R6LOOAI.js → chunk-N4PLEYFJ.js} +4 -2
  14. package/dist/chunk-N4PLEYFJ.js.map +1 -0
  15. package/dist/element/index.d.ts +167 -7
  16. package/dist/element/index.js +138 -43
  17. package/dist/element/index.js.map +1 -1
  18. package/dist/{engine-DPft99Wj.d.ts → engine-BKoT_Pj7.d.cts} +10 -1
  19. package/dist/{engine-CDdaSojx.d.cts → engine-CeGh5Yai.d.ts} +10 -1
  20. package/dist/index.cjs +1596 -145
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +5 -5
  23. package/dist/index.d.ts +5 -5
  24. package/dist/index.js +618 -42
  25. package/dist/index.js.map +1 -1
  26. package/dist/react/index.cjs +26 -0
  27. package/dist/react/index.cjs.map +1 -1
  28. package/dist/react/index.d.cts +24 -5
  29. package/dist/react/index.d.ts +24 -5
  30. package/dist/react/index.js +2 -2
  31. package/dist/{softphone-CY5d2WWY.d.cts → softphone-D0ao5W4z.d.ts} +42 -3
  32. package/dist/{softphone-DXgmTyrX.d.ts → softphone-DRoS0jJR.d.cts} +42 -3
  33. package/dist/{sources-ONYQW733.js → sources-LGFJG7W2.js} +27 -38
  34. package/dist/sources-LGFJG7W2.js.map +1 -0
  35. package/dist/testing/index.cjs +13 -0
  36. package/dist/testing/index.cjs.map +1 -1
  37. package/dist/testing/index.d.cts +2 -2
  38. package/dist/testing/index.d.ts +2 -2
  39. package/dist/testing/index.js +1 -1
  40. package/dist/ui/index.cjs +92 -2
  41. package/dist/ui/index.cjs.map +1 -1
  42. package/dist/ui/index.d.cts +2 -2
  43. package/dist/ui/index.d.ts +2 -2
  44. package/dist/ui/index.js +69 -4
  45. package/dist/ui/index.js.map +1 -1
  46. package/dist/zadarma/index.cjs +950 -104
  47. package/dist/zadarma/index.cjs.map +1 -1
  48. package/dist/zadarma/index.d.cts +40 -14
  49. package/dist/zadarma/index.d.ts +40 -14
  50. package/dist/zadarma/index.js +4 -2
  51. package/dist/zadarma-engine-VYIXLBQG.js +7 -0
  52. package/dist/{zadarma-engine-L5DEFXUS.js.map → zadarma-engine-VYIXLBQG.js.map} +1 -1
  53. package/package.json +1 -1
  54. package/dist/chunk-2ZSOGGPO.js.map +0 -1
  55. package/dist/chunk-7R6LOOAI.js.map +0 -1
  56. package/dist/chunk-EYEDPD7Y.js +0 -1071
  57. package/dist/chunk-EYEDPD7Y.js.map +0 -1
  58. package/dist/chunk-UHNLEBBO.js.map +0 -1
  59. package/dist/sources-ONYQW733.js.map +0 -1
  60. package/dist/zadarma-engine-L5DEFXUS.js +0 -5
@@ -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