@coventia/softphone 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{call-FqcvDg1t.d.cts → call-iGvr2ZnO.d.cts} +26 -2
- package/dist/{call-FqcvDg1t.d.ts → call-iGvr2ZnO.d.ts} +26 -2
- package/dist/chunk-3UTUWGVG.js +1891 -0
- package/dist/chunk-3UTUWGVG.js.map +1 -0
- package/dist/chunk-DZRSSZWB.js +39 -0
- package/dist/chunk-DZRSSZWB.js.map +1 -0
- package/dist/element/index.d.ts +25 -1
- package/dist/{engine-CzOCrfY9.d.cts → engine-BKoT_Pj7.d.cts} +10 -1
- package/dist/{engine-CsNRD7B7.d.ts → engine-CeGh5Yai.d.ts} +10 -1
- package/dist/index.cjs +1049 -130
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +179 -13
- package/dist/index.js.map +1 -1
- package/dist/react/index.d.cts +2 -2
- package/dist/react/index.d.ts +2 -2
- package/dist/{softphone-By9sizlV.d.ts → softphone-D0ao5W4z.d.ts} +1 -1
- package/dist/{softphone-JZvJdzU3.d.cts → softphone-DRoS0jJR.d.cts} +1 -1
- package/dist/{sources-QL2LRZVH.js → sources-LGFJG7W2.js} +10 -38
- package/dist/sources-LGFJG7W2.js.map +1 -0
- package/dist/testing/index.d.cts +2 -2
- package/dist/testing/index.d.ts +2 -2
- package/dist/ui/index.d.cts +2 -2
- package/dist/ui/index.d.ts +2 -2
- package/dist/zadarma/index.cjs +865 -116
- package/dist/zadarma/index.cjs.map +1 -1
- package/dist/zadarma/index.d.cts +37 -10
- package/dist/zadarma/index.d.ts +37 -10
- package/dist/zadarma/index.js +2 -1
- package/dist/zadarma-engine-VYIXLBQG.js +7 -0
- package/dist/{zadarma-engine-TIUE7PZ7.js.map → zadarma-engine-VYIXLBQG.js.map} +1 -1
- package/package.json +1 -1
- package/dist/chunk-6RR6G35E.js +0 -1146
- package/dist/chunk-6RR6G35E.js.map +0 -1
- package/dist/sources-QL2LRZVH.js.map +0 -1
- package/dist/zadarma-engine-TIUE7PZ7.js +0 -6
package/dist/zadarma/index.cjs
CHANGED
|
@@ -403,11 +403,47 @@ function createError(code, cause) {
|
|
|
403
403
|
return new CatalogueError(code, cause);
|
|
404
404
|
}
|
|
405
405
|
|
|
406
|
+
// src/sources.ts
|
|
407
|
+
function toDigits(number) {
|
|
408
|
+
return String(number ?? "").replace(/\D/g, "");
|
|
409
|
+
}
|
|
410
|
+
|
|
406
411
|
// src/transports/sip/ua.ts
|
|
407
412
|
var DEFAULT_ICE_CAP_MS = 3e3;
|
|
408
413
|
var REGISTER_EXPIRES = 580;
|
|
409
|
-
var SIGNAL_REFRESH_DEBOUNCE_MS = 3e4;
|
|
410
414
|
var ERROR_DEBOUNCE_MS = 3e4;
|
|
415
|
+
var OFFER_FORGOTTEN_MS = 12e4;
|
|
416
|
+
var DECLINE_GUARD_CEILING_MS = 12e4;
|
|
417
|
+
var OFFER_PUSH_SLACK_MS = 1e4;
|
|
418
|
+
var INVITE_TRANSACTION_MAX_MS = 32e3;
|
|
419
|
+
var ICT_PROCEEDING = 2;
|
|
420
|
+
var ICT_CALLING = 3;
|
|
421
|
+
function inviteTransactionOf(session) {
|
|
422
|
+
const tx = session._request?.transaction;
|
|
423
|
+
return tx && typeof tx.state === "number" && typeof tx.on === "function" ? tx : null;
|
|
424
|
+
}
|
|
425
|
+
function awaitsAnswer(tx) {
|
|
426
|
+
return tx.state === ICT_CALLING || tx.state === ICT_PROCEEDING;
|
|
427
|
+
}
|
|
428
|
+
function deferredCancelOf(session) {
|
|
429
|
+
const flag = session._is_canceled;
|
|
430
|
+
return typeof flag === "boolean" ? flag : null;
|
|
431
|
+
}
|
|
432
|
+
var SUFFIX_MATCH_MIN_DIGITS = 9;
|
|
433
|
+
function sameNumber(a, b) {
|
|
434
|
+
if (!a || !b) return false;
|
|
435
|
+
if (a === b) return true;
|
|
436
|
+
const [shorter, longer] = a.length < b.length ? [a, b] : [b, a];
|
|
437
|
+
return shorter.length >= SUFFIX_MATCH_MIN_DIGITS && longer.endsWith(shorter);
|
|
438
|
+
}
|
|
439
|
+
function isRefusal(e) {
|
|
440
|
+
const status = e?.response?.status_code;
|
|
441
|
+
if (typeof status === "number") return status === 401 || status === 403;
|
|
442
|
+
return e?.cause === "Authentication Error" || e?.cause === "Rejected";
|
|
443
|
+
}
|
|
444
|
+
function debug(message) {
|
|
445
|
+
console.debug(`[softphone] ${message}`);
|
|
446
|
+
}
|
|
411
447
|
var CALL_END_CODES = {
|
|
412
448
|
busy: "call/busy",
|
|
413
449
|
"no-answer": "call/no-answer",
|
|
@@ -443,8 +479,30 @@ var SipCallEngine = class {
|
|
|
443
479
|
#ua = null;
|
|
444
480
|
/** In-flight `#createUA()`. THE concurrency guard — see `#ensureUA`. */
|
|
445
481
|
#uaInFlight = null;
|
|
446
|
-
/**
|
|
482
|
+
/**
|
|
483
|
+
* Bumped by every `#teardownUA`. A UA still being provisioned when its reason to exist went away — the offer
|
|
484
|
+
* ended, the leak guard gave up — is then not built (`#createUA`): built anyway, it would REGISTER for nobody
|
|
485
|
+
* and nothing would ever tear it down.
|
|
486
|
+
*/
|
|
487
|
+
#uaEpoch = 0;
|
|
488
|
+
/**
|
|
489
|
+
* Dials waiting for the current UA to register (`#callWhenRegistered`): dialled by its `registered`, failed by
|
|
490
|
+
* `#teardownUA` — but kept when the UA is only being rebuilt on fresh credentials (`#retryOnFreshCredentials`).
|
|
491
|
+
*/
|
|
447
492
|
#awaitingRegistration = /* @__PURE__ */ new Set();
|
|
493
|
+
/** `placeCall`s in progress, from before their UA exists: no offer's teardown may pull it from under them. */
|
|
494
|
+
#dialing = 0;
|
|
495
|
+
/**
|
|
496
|
+
* Where the current UA's registration stands: `connecting` (its REGISTER goes out once the WSS is up),
|
|
497
|
+
* `registering` (a REGISTER is out, unanswered), `registered` (the WSS is up and no REGISTER of ours is out — a
|
|
498
|
+
* refresh that failed under a live call leaves it here too). What decides how an offer gets a REGISTER sent
|
|
499
|
+
* after it (`#registerAfterOffer`).
|
|
500
|
+
*/
|
|
501
|
+
#registration = "connecting";
|
|
502
|
+
/** An offer arrived while a REGISTER sent BEFORE it was still unanswered: another goes out once it is. */
|
|
503
|
+
#registerAgain = false;
|
|
504
|
+
/** A UA has already been rebuilt on fresh credentials since the last registration that worked: not again. */
|
|
505
|
+
#freshCredentialsTried = false;
|
|
448
506
|
#domain = "";
|
|
449
507
|
#activeCall = null;
|
|
450
508
|
/**
|
|
@@ -463,17 +521,52 @@ var SipCallEngine = class {
|
|
|
463
521
|
#errorListeners = /* @__PURE__ */ new Set();
|
|
464
522
|
#signalUnsub = null;
|
|
465
523
|
#signalConnectionUnsub = null;
|
|
524
|
+
/** The offer waiting for its INVITE, if any (§4.3). */
|
|
525
|
+
#offerWait = null;
|
|
526
|
+
/**
|
|
527
|
+
* The latest offer pushed while a call was in progress. It used to be dropped for good — the caller then waited
|
|
528
|
+
* for the provider to offer it again. Now, when the call ends, it is registered for at once if it is still
|
|
529
|
+
* within its wait (no `offer-ended` for it, inside the leak guard), so it rings as soon as the line is free.
|
|
530
|
+
*/
|
|
531
|
+
#pendingOffer = null;
|
|
532
|
+
/** Armed at the REGISTER's 200 OK while an offer waits (`noInviteAfterRegisterMs`). */
|
|
466
533
|
#noInviteTimer;
|
|
467
|
-
|
|
468
|
-
|
|
534
|
+
/**
|
|
535
|
+
* What the backend has said about the calls offered to this line (`noteOffer`, §5), by `callId` — ended ones
|
|
536
|
+
* included, for a while. Only for telling, at a decline, which call was declined and whether it is still alive.
|
|
537
|
+
*/
|
|
538
|
+
#offers = /* @__PURE__ */ new Map();
|
|
539
|
+
/** Declined calls whose re-offers are being ignored (§4.4). Pruned as they lapse. */
|
|
540
|
+
#declined = [];
|
|
541
|
+
/** CANCELs JsSIP still owes for outbound legs that ended before any ringback (§4.7, `#watchOwedCancel`). */
|
|
542
|
+
#owedCancels = [];
|
|
543
|
+
/** UAs let go of while owing one: un-REGISTERed, still connected — with the cap that stops them regardless. */
|
|
544
|
+
#lingering = /* @__PURE__ */ new Map();
|
|
469
545
|
#errorSentAfter = /* @__PURE__ */ new Map();
|
|
470
546
|
now;
|
|
471
547
|
#resolvedFactory = null;
|
|
548
|
+
/** The loader in flight, shared: the pre-load at `start()` and a first UA racing it fetch the module once. */
|
|
549
|
+
#factoryLoad = null;
|
|
472
550
|
async #resolveFactory() {
|
|
473
551
|
if (this.#resolvedFactory) return this.#resolvedFactory;
|
|
474
|
-
|
|
552
|
+
if (typeof this.factory !== "function") return this.#resolvedFactory = this.factory;
|
|
553
|
+
const loader = this.factory;
|
|
554
|
+
this.#factoryLoad ??= Promise.resolve().then(() => loader()).finally(() => {
|
|
555
|
+
this.#factoryLoad = null;
|
|
556
|
+
});
|
|
557
|
+
this.#resolvedFactory = await this.#factoryLoad;
|
|
475
558
|
return this.#resolvedFactory;
|
|
476
559
|
}
|
|
560
|
+
/**
|
|
561
|
+
* Fetch the SIP stack now, not on the first call. It is loaded lazily (`import('jssip')`, SSR-safe — SPEC §10.2),
|
|
562
|
+
* and that used to happen on the first UA: between the first push after a page load and its REGISTER, measured
|
|
563
|
+
* 33–109 ms on localhost and more on a slow network or a throttled tab. Fire-and-forget: a failure here only
|
|
564
|
+
* leaves the lazy path to try again when a UA is built (incoming-call-reliability.md §4.5).
|
|
565
|
+
*/
|
|
566
|
+
#preloadFactory() {
|
|
567
|
+
this.#resolveFactory().catch(() => {
|
|
568
|
+
});
|
|
569
|
+
}
|
|
477
570
|
get state() {
|
|
478
571
|
return this.#state;
|
|
479
572
|
}
|
|
@@ -538,15 +631,18 @@ var SipCallEngine = class {
|
|
|
538
631
|
if (run !== this.#run) return;
|
|
539
632
|
this.#setState("online");
|
|
540
633
|
} else {
|
|
634
|
+
this.#preloadFactory();
|
|
541
635
|
const signal = this.provider.incomingSignal;
|
|
542
636
|
if (signal) {
|
|
543
637
|
try {
|
|
544
638
|
await signal.connect();
|
|
545
639
|
} catch (cause) {
|
|
546
|
-
if (run
|
|
640
|
+
if (run !== this.#run) return;
|
|
641
|
+
signal.disconnect();
|
|
642
|
+
throw cause;
|
|
547
643
|
}
|
|
548
644
|
if (run !== this.#run) return;
|
|
549
|
-
this.#signalUnsub = signal.onSignal(() =>
|
|
645
|
+
this.#signalUnsub = signal.onSignal((event) => this.#onIncomingSignal(event));
|
|
550
646
|
this.#signalConnectionUnsub = signal.onConnectionChange?.(
|
|
551
647
|
(connected) => this.#setState(connected ? "online" : "offline")
|
|
552
648
|
) ?? null;
|
|
@@ -556,6 +652,7 @@ var SipCallEngine = class {
|
|
|
556
652
|
}
|
|
557
653
|
async stop() {
|
|
558
654
|
this.#run += 1;
|
|
655
|
+
this.#pendingOffer = null;
|
|
559
656
|
this.#clearNoInviteTimer();
|
|
560
657
|
this.#signalUnsub?.();
|
|
561
658
|
this.#signalUnsub = null;
|
|
@@ -566,63 +663,271 @@ var SipCallEngine = class {
|
|
|
566
663
|
this.#setState("stopped");
|
|
567
664
|
}
|
|
568
665
|
async placeCall(destination, _options) {
|
|
569
|
-
|
|
570
|
-
const
|
|
571
|
-
|
|
666
|
+
this.#dialing += 1;
|
|
667
|
+
const run = this.#run;
|
|
668
|
+
try {
|
|
669
|
+
await this.#ensureUA();
|
|
670
|
+
return await this.#callWhenRegistered(this.#buildTarget(destination), run);
|
|
671
|
+
} finally {
|
|
672
|
+
this.#dialing -= 1;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
/**
|
|
676
|
+
* What the backend says about calls offered to this line (incoming-call-reliability.md §5) — kept only to tell,
|
|
677
|
+
* at a decline, WHICH call was declined, and so how long its re-offers last (§4.4).
|
|
678
|
+
*
|
|
679
|
+
* Per `callId`, every message is idempotent, and they come duplicated, with no order at all — not even between
|
|
680
|
+
* the `start` and the `end` of one call, which reach different backend instances and are relayed through Redis,
|
|
681
|
+
* and a stored offer replayed after `hello` can race either. So `ended` is sticky: a `start` after its call's
|
|
682
|
+
* `end` never makes it live again. An `end` also reaches a softphone that is not the line's owner (a `start` only
|
|
683
|
+
* the owner): stored all the same, whatever state this engine is in. `null`: the channel went away, so no `end`
|
|
684
|
+
* can be counted on for the calls still live; a guard following one falls back to its window. What is known to
|
|
685
|
+
* have ended stays known.
|
|
686
|
+
*/
|
|
687
|
+
noteOffer(offer) {
|
|
688
|
+
const now = this.now();
|
|
689
|
+
for (const [callId, known2] of this.#offers) {
|
|
690
|
+
if (now - known2.at >= OFFER_FORGOTTEN_MS) this.#offers.delete(callId);
|
|
691
|
+
}
|
|
692
|
+
if (offer === null) {
|
|
693
|
+
for (const [callId, known2] of this.#offers) if (known2.endedAt === null) this.#offers.delete(callId);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
const known = this.#offers.get(offer.callId);
|
|
697
|
+
this.#offers.delete(offer.callId);
|
|
698
|
+
const from = toDigits(offer.from ?? "") || (known?.from ?? "");
|
|
699
|
+
const fallbackMs = this.#declineFallbackMs();
|
|
700
|
+
if (offer.state === "end") {
|
|
701
|
+
this.#offers.set(offer.callId, { from, at: now, endedAt: known?.endedAt ?? now });
|
|
702
|
+
this.#declined = this.#declined.filter((declined) => declined.callId !== offer.callId);
|
|
703
|
+
if (from) {
|
|
704
|
+
this.#declined = this.#declined.map(
|
|
705
|
+
(declined) => declined.adopts && now >= declined.since && sameNumber(declined.caller, from) ? { ...declined, adopts: false } : declined
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const endedAt = known?.endedAt ?? null;
|
|
711
|
+
this.#offers.set(offer.callId, { from, at: now, endedAt });
|
|
712
|
+
if (!from) return;
|
|
713
|
+
if (endedAt !== null) {
|
|
714
|
+
const isThatCall = (declined) => declined.adopts && now - declined.at < fallbackMs && endedAt >= declined.since && sameNumber(declined.caller, from);
|
|
715
|
+
this.#declined = this.#declined.filter((declined) => !isThatCall(declined));
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
this.#declined = this.#declined.map(
|
|
719
|
+
(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
|
|
720
|
+
);
|
|
572
721
|
}
|
|
573
722
|
// ── incoming (on-demand: push → register → INVITE) ────────────────────────
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
723
|
+
/**
|
|
724
|
+
* The push's own word on what it is (incoming-call-reliability.md §4.1, read by the provider): only an `offer`
|
|
725
|
+
* registers. Before this, every push did — `answered` after each hang-up included — and the UA it left
|
|
726
|
+
* registered is where the next call's INVITE never came (F2).
|
|
727
|
+
*/
|
|
728
|
+
#onIncomingSignal(event) {
|
|
729
|
+
if (event.kind === "offer") void this.#onOffer(event.from);
|
|
730
|
+
else if (event.kind === "offer-ended") this.#onOfferEnded(event.from);
|
|
731
|
+
else debug(`push ignored: ${event.detail}`);
|
|
732
|
+
}
|
|
733
|
+
/** `pushedAt`: when its push came, for an offer kept while a call was in progress (`#pendingOffer`). */
|
|
734
|
+
async #onOffer(from, pushedAt = this.now()) {
|
|
735
|
+
if (this.#isDeclinedCaller(from)) {
|
|
736
|
+
debug("offer ignored: the caller just declined is being offered again");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
if (this.#activeCall) {
|
|
740
|
+
this.#pendingOffer = { from, at: pushedAt };
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
this.#awaitOffer(from, pushedAt);
|
|
744
|
+
const ua = this.#ua;
|
|
745
|
+
if (ua) {
|
|
746
|
+
this.#registerAfterOffer(ua);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
577
749
|
const run = this.#run;
|
|
750
|
+
const epoch = this.#uaEpoch;
|
|
578
751
|
try {
|
|
579
752
|
await this.#ensureUA();
|
|
580
|
-
this.#armNoInviteTeardown();
|
|
581
753
|
} catch {
|
|
582
|
-
if (run === this.#run) this.#teardownUA();
|
|
754
|
+
if (run === this.#run && epoch === this.#uaEpoch) this.#teardownUA();
|
|
583
755
|
}
|
|
584
756
|
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
757
|
+
/**
|
|
758
|
+
* That offer is over — answered elsewhere, declined, cancelled, timed out (§4.1). A UA still waiting for its
|
|
759
|
+
* INVITE goes now instead of when a timer says so; never one carrying a call (`#maybeTeardownUA`). Only when the
|
|
760
|
+
* push names the same caller as the offer waited for: two offers can overlap, and an end that names nobody (a
|
|
761
|
+
* withheld number, a push with no `callerid`) could be anyone's — the timers bound that wait instead.
|
|
762
|
+
*/
|
|
763
|
+
#onOfferEnded(from) {
|
|
764
|
+
const caller = toDigits(from ?? "");
|
|
765
|
+
if (!caller) return;
|
|
766
|
+
if (this.#pendingOffer && sameNumber(caller, toDigits(this.#pendingOffer.from ?? ""))) this.#pendingOffer = null;
|
|
767
|
+
const wait = this.#offerWait;
|
|
768
|
+
if (!wait || !sameNumber(caller, wait.from)) return;
|
|
769
|
+
this.#offerOver();
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Starts waiting for this offer's INVITE: a leak guard from its push (§4.3), and no no-INVITE timer yet.
|
|
773
|
+
* `pushedAt` is now, except for an offer kept through a call (`#pendingOffer`).
|
|
774
|
+
*/
|
|
775
|
+
#awaitOffer(from, pushedAt) {
|
|
776
|
+
this.#endOfferWait();
|
|
777
|
+
const ms = this.provider.quirks?.offerWaitMaxMs;
|
|
778
|
+
const leak = ms ? setTimeout(
|
|
779
|
+
() => {
|
|
780
|
+
if (this.#offerWait !== wait) return;
|
|
781
|
+
debug("offer abandoned: still no INVITE long after its push");
|
|
782
|
+
this.#offerOver();
|
|
783
|
+
},
|
|
784
|
+
Math.max(0, ms - (this.now() - pushedAt))
|
|
785
|
+
) : void 0;
|
|
786
|
+
const wait = { from: toDigits(from ?? ""), at: pushedAt, leak };
|
|
787
|
+
this.#offerWait = wait;
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* The offer kept through the call that just ended, if it is still worth registering for: no `offer-ended` for
|
|
791
|
+
* it, and still inside the leak guard from its push. Taken: it is registered for once, at most.
|
|
792
|
+
*/
|
|
793
|
+
#takePendingOffer() {
|
|
794
|
+
const pending = this.#pendingOffer;
|
|
795
|
+
this.#pendingOffer = null;
|
|
796
|
+
if (!pending || !this.#signalUnsub) return null;
|
|
797
|
+
const maxMs = this.provider.quirks?.offerWaitMaxMs;
|
|
798
|
+
if (maxMs && this.now() - pending.at >= maxMs) return null;
|
|
799
|
+
return pending;
|
|
800
|
+
}
|
|
801
|
+
/** No INVITE is coming for the offer being waited for: its UA goes, unless something else is on it. */
|
|
802
|
+
#offerOver() {
|
|
803
|
+
this.#endOfferWait();
|
|
804
|
+
this.#maybeTeardownUA();
|
|
805
|
+
}
|
|
806
|
+
#endOfferWait() {
|
|
807
|
+
if (this.#offerWait?.leak) clearTimeout(this.#offerWait.leak);
|
|
808
|
+
this.#offerWait = null;
|
|
809
|
+
this.#registerAgain = false;
|
|
588
810
|
this.#clearNoInviteTimer();
|
|
589
|
-
this.#noInviteTimer = setTimeout(() => this.#maybeTeardownUA(), ms);
|
|
590
811
|
}
|
|
591
812
|
/**
|
|
592
|
-
*
|
|
813
|
+
* A REGISTER sent AFTER this offer's push, on the UA that is already there (§4.2). Whether one is needed depends
|
|
814
|
+
* on where that UA's registration stands:
|
|
815
|
+
* - `connecting`: its REGISTER goes out when the WSS is up — after this push already.
|
|
816
|
+
* - `registering`: the REGISTER in flight went out BEFORE this push; another follows its answer.
|
|
817
|
+
* - `registered`: a refresh, now.
|
|
818
|
+
*/
|
|
819
|
+
#registerAfterOffer(ua) {
|
|
820
|
+
if (this.#registration === "registering") this.#registerAgain = true;
|
|
821
|
+
else if (this.#registration === "registered") this.#refreshRegistration(ua);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* A REGISTER refresh whose 200 OK is HEARD — the no-INVITE wait counts from it (§4.3).
|
|
593
825
|
*
|
|
594
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
826
|
+
* JsSIP's `register()` sends one, but announces `registered` only for a UA that was not registered
|
|
827
|
+
* (`Registrator.js`: `if (!this._registered)`), so a refresh's 200 OK would pass in silence.
|
|
828
|
+
* `registrator().onTransportClosed()` clears that flag and sends nothing — it is what JsSIP runs itself when its
|
|
829
|
+
* WSS drops, before registering again — so after it the refresh is announced like a first registration. On the
|
|
830
|
+
* wire nothing changes: the same refresh (same Call-ID, next CSeq) the lab saw answered with the INVITE
|
|
831
|
+
* (Q5). Only called with no REGISTER of ours in flight (`#registration`), so no answer is left orphaned.
|
|
599
832
|
*
|
|
600
|
-
*
|
|
601
|
-
* tight loop against our backend and theirs.
|
|
833
|
+
* A JsSIP without that hook still sends the refresh; its 200 OK just cannot be heard, so the wait starts now.
|
|
602
834
|
*/
|
|
603
|
-
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
if (
|
|
607
|
-
this.#
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
835
|
+
#refreshRegistration(ua) {
|
|
836
|
+
const registrator = ua.registrator?.();
|
|
837
|
+
const heard = typeof registrator?.onTransportClosed === "function";
|
|
838
|
+
if (heard) registrator.onTransportClosed();
|
|
839
|
+
this.#registration = heard ? "registering" : "registered";
|
|
840
|
+
ua.register();
|
|
841
|
+
if (!heard && this.#offerWait) this.#armNoInviteTimer();
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Armed at the REGISTER's 200 OK, never at `ua.start()`: on a slow network the WSS alone outlasted the old 5 s,
|
|
845
|
+
* and the UA was gone before it ever registered (F3). The INVITE follows the 200 OK by ~0.1 s (§2).
|
|
846
|
+
*/
|
|
847
|
+
#armNoInviteTimer() {
|
|
848
|
+
this.#clearNoInviteTimer();
|
|
849
|
+
const ms = this.provider.quirks?.noInviteAfterRegisterMs;
|
|
850
|
+
if (!ms) return;
|
|
851
|
+
this.#noInviteTimer = setTimeout(() => {
|
|
852
|
+
this.#noInviteTimer = void 0;
|
|
853
|
+
this.#offerOver();
|
|
854
|
+
}, ms);
|
|
613
855
|
}
|
|
614
856
|
#clearNoInviteTimer() {
|
|
615
857
|
if (this.#noInviteTimer) clearTimeout(this.#noInviteTimer);
|
|
616
858
|
this.#noInviteTimer = void 0;
|
|
617
859
|
}
|
|
860
|
+
// ── the decline guard (SPEC §3.4, incoming-call-reliability.md §4.4) ───────
|
|
861
|
+
/**
|
|
862
|
+
* Whether an offer — or an INVITE — from this caller is a re-offer of a call just declined. The provider re-offers a
|
|
863
|
+
* declined call until its ring time ends, and nothing in a re-offer tells it from a new call. The guard used to
|
|
864
|
+
* swallow EVERY offer and INVITE for 35 s, whoever called: a new call in that window was lost (CWA-81, F1).
|
|
865
|
+
*
|
|
866
|
+
* Now it holds only for the declined caller, and only while that call lives: until the backend's `end` for it
|
|
867
|
+
* when it announced the call (`noteOffer`) — at most `DECLINE_GUARD_CEILING_MS` — else for the provider's
|
|
868
|
+
* fallback window after the decline. Anyone else always rings; so does a caller this cannot name.
|
|
869
|
+
*/
|
|
870
|
+
#isDeclinedCaller(number) {
|
|
871
|
+
const fallbackMs = this.#declineFallbackMs();
|
|
872
|
+
if (!fallbackMs || this.#declined.length === 0) return false;
|
|
873
|
+
const now = this.now();
|
|
874
|
+
this.#declined = this.#declined.filter((declined) => {
|
|
875
|
+
const offer = declined.callId === null ? void 0 : this.#offers.get(declined.callId);
|
|
876
|
+
if (offer && offer.endedAt === null) return now - declined.at < DECLINE_GUARD_CEILING_MS;
|
|
877
|
+
return now - declined.at < fallbackMs;
|
|
878
|
+
});
|
|
879
|
+
const caller = toDigits(number ?? "");
|
|
880
|
+
return this.#declined.some((declined) => sameNumber(declined.caller, caller));
|
|
881
|
+
}
|
|
882
|
+
/** The decline guard's fallback window — and whether there is a guard at all: only a provider that re-offers. */
|
|
883
|
+
#declineFallbackMs() {
|
|
884
|
+
return this.provider.quirks?.declinedCallerGuardMs ?? 0;
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* The user declined `call`. Remembers the caller as its INVITE named them, and which offer the backend says that
|
|
888
|
+
* is (§4.4). Only what the backend said about THIS call counts: a call from the same caller that ended before
|
|
889
|
+
* this one was pushed (`since`) is another call — taking it for this one lost the callback after a second
|
|
890
|
+
* decline of the same caller.
|
|
891
|
+
* - a live offer from that caller, announced around this call's push or after: the guard follows it until
|
|
892
|
+
* its `end`;
|
|
893
|
+
* - an offer from that caller that ENDED since this call's push (the first leg of a transfer, a ring-group
|
|
894
|
+
* call someone else answered): the fallback window — whatever that caller sends next is not that call, so
|
|
895
|
+
* no later `start` is taken for it;
|
|
896
|
+
* - neither (its `start` lands after the decline): the fallback window, and a `start` from that caller that
|
|
897
|
+
* comes around this call's push is taken as this call's (`noteOffer`).
|
|
898
|
+
*
|
|
899
|
+
* An INVITE that names nobody (a withheld number) guards nobody. It used to borrow the caller of the latest push,
|
|
900
|
+
* which with two offers overlapping is someone else's — and that someone's calls were then ignored.
|
|
901
|
+
*/
|
|
902
|
+
#onDecline(call, since) {
|
|
903
|
+
if (!this.#declineFallbackMs() || call.direction !== "inbound") return;
|
|
904
|
+
const caller = toDigits(call.remoteNumber);
|
|
905
|
+
if (!caller) return;
|
|
906
|
+
const now = this.now();
|
|
907
|
+
let latest = null;
|
|
908
|
+
for (const [callId, offer] of this.#offers) {
|
|
909
|
+
if (!sameNumber(offer.from, caller) || now - offer.at >= OFFER_FORGOTTEN_MS) continue;
|
|
910
|
+
if (offer.endedAt === null && offer.at >= since - OFFER_PUSH_SLACK_MS) latest = { callId, ended: false };
|
|
911
|
+
else if (offer.endedAt !== null && offer.endedAt >= since) latest = { callId, ended: true };
|
|
912
|
+
}
|
|
913
|
+
this.#declined.push(
|
|
914
|
+
latest && !latest.ended ? { caller, callId: latest.callId, adopts: false, at: now, since } : { caller, callId: null, adopts: latest === null, at: now, since }
|
|
915
|
+
);
|
|
916
|
+
}
|
|
618
917
|
#onNewSession(session, originator) {
|
|
619
918
|
if (originator !== "remote") return;
|
|
620
|
-
this.#
|
|
621
|
-
|
|
919
|
+
if (this.#activeCall) {
|
|
920
|
+
session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (this.#isDeclinedCaller(remoteNumberOf(session))) {
|
|
924
|
+
debug("INVITE declined: the caller just declined is being offered again");
|
|
622
925
|
session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
|
|
623
926
|
return;
|
|
624
927
|
}
|
|
625
|
-
const
|
|
928
|
+
const since = this.#offerWait?.at ?? this.now();
|
|
929
|
+
this.#endOfferWait();
|
|
930
|
+
const call = this.#wrapCall(session, since);
|
|
626
931
|
for (const listener of [...this.#incomingListeners]) listener(call);
|
|
627
932
|
}
|
|
628
933
|
// ── UA lifecycle ───────────────────────────────────────────────────────────
|
|
@@ -635,9 +940,9 @@ var SipCallEngine = class {
|
|
|
635
940
|
* torn down. The visible symptom is two `get_webphone_data.php` calls at startup and a phone that
|
|
636
941
|
* never registers.
|
|
637
942
|
*
|
|
638
|
-
* Concurrency is the normal case here, not an edge: the push socket can
|
|
639
|
-
*
|
|
640
|
-
*
|
|
943
|
+
* Concurrency is the normal case here, not an edge: the push socket can offer twice in a heartbeat
|
|
944
|
+
* (a push seen on both sockets of a rebuild, two calls at once), and `placeCall` can land on top of
|
|
945
|
+
* one. So the in-flight promise IS the guard.
|
|
641
946
|
*/
|
|
642
947
|
async #ensureUA() {
|
|
643
948
|
if (this.#ua) return this.#ua;
|
|
@@ -651,6 +956,7 @@ var SipCallEngine = class {
|
|
|
651
956
|
}
|
|
652
957
|
async #createUA() {
|
|
653
958
|
const run = this.#run;
|
|
959
|
+
const epoch = this.#uaEpoch;
|
|
654
960
|
const factory = await this.#resolveFactory();
|
|
655
961
|
let credentials;
|
|
656
962
|
try {
|
|
@@ -661,9 +967,11 @@ var SipCallEngine = class {
|
|
|
661
967
|
throw cause;
|
|
662
968
|
}
|
|
663
969
|
if (run !== this.#run) throw createError("engine/not-started");
|
|
970
|
+
if (epoch !== this.#uaEpoch) throw createError("call/failed", "the UA was let go of before it was built");
|
|
664
971
|
this.#domain = domainOf(credentials);
|
|
665
972
|
const config = this.#uaConfig(credentials, factory);
|
|
666
973
|
const ua = factory.createUA(config);
|
|
974
|
+
let reached = false;
|
|
667
975
|
ua.on("newRTCSession", ({ session, originator }) => {
|
|
668
976
|
if (ua !== this.#ua) {
|
|
669
977
|
if (originator === "remote") session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
|
|
@@ -673,29 +981,82 @@ var SipCallEngine = class {
|
|
|
673
981
|
});
|
|
674
982
|
ua.on("disconnected", ({ error }) => {
|
|
675
983
|
if (ua !== this.#ua) return;
|
|
984
|
+
this.#registration = "connecting";
|
|
676
985
|
if (!error) return;
|
|
986
|
+
if (!reached && this.#retryOnFreshCredentials("unreachable", ua, credentials)) return;
|
|
677
987
|
this.#setState("reconnecting");
|
|
678
988
|
this.#reportError("net/transport-failed", error);
|
|
679
989
|
});
|
|
680
990
|
ua.on("connected", () => {
|
|
681
|
-
if (ua
|
|
991
|
+
if (ua !== this.#ua) return;
|
|
992
|
+
reached = true;
|
|
993
|
+
this.#registration = "registering";
|
|
994
|
+
this.#setState("online");
|
|
995
|
+
});
|
|
996
|
+
ua.on("registered", () => {
|
|
997
|
+
if (ua !== this.#ua) return;
|
|
998
|
+
this.#registration = "registered";
|
|
999
|
+
this.#freshCredentialsTried = false;
|
|
1000
|
+
this.provider.credentialsWorked?.();
|
|
1001
|
+
for (const waiter of [...this.#awaitingRegistration]) waiter.dial(ua);
|
|
1002
|
+
if (this.#registerAgain) {
|
|
1003
|
+
this.#registerAgain = false;
|
|
1004
|
+
this.#refreshRegistration(ua);
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (this.#offerWait) this.#armNoInviteTimer();
|
|
682
1008
|
});
|
|
683
1009
|
ua.on("registrationFailed", (e) => {
|
|
684
1010
|
if (run !== this.#run) return;
|
|
685
1011
|
const detail = e;
|
|
1012
|
+
if (isRefusal(detail) && this.#retryOnFreshCredentials("rejected", ua, credentials)) return;
|
|
686
1013
|
this.#reportError("net/registration-failed", {
|
|
687
1014
|
cause: detail?.cause,
|
|
688
1015
|
status: detail?.response?.status_code,
|
|
689
1016
|
domain: this.#domain,
|
|
690
1017
|
hint: "A bare 401 with no auth challenge usually means the registrar is in the wrong datacenter (SPEC \xA73.1)."
|
|
691
1018
|
});
|
|
692
|
-
if (ua
|
|
693
|
-
|
|
1019
|
+
if (ua !== this.#ua) return;
|
|
1020
|
+
if (this.#carriesCall()) {
|
|
1021
|
+
for (const waiter of [...this.#awaitingRegistration]) waiter.fail();
|
|
1022
|
+
this.#registration = "registered";
|
|
1023
|
+
const wait = this.#offerWait;
|
|
1024
|
+
if (wait) {
|
|
1025
|
+
this.#pendingOffer = { from: wait.from || null, at: wait.at };
|
|
1026
|
+
this.#endOfferWait();
|
|
1027
|
+
}
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
this.#teardownUA();
|
|
694
1031
|
});
|
|
695
1032
|
this.#ua = ua;
|
|
1033
|
+
this.#registration = "connecting";
|
|
696
1034
|
ua.start();
|
|
697
1035
|
return ua;
|
|
698
1036
|
}
|
|
1037
|
+
/**
|
|
1038
|
+
* The credentials behind `ua` did not work. The provider hears it either way, with the credentials in question
|
|
1039
|
+
* (it forgets them unless it has moved on from them already; a provider whose signal shares them renews that
|
|
1040
|
+
* too); then, if it has new ones to give, the current UA is rebuilt on them — once until a registration works,
|
|
1041
|
+
* and never under a live call. Whoever waited on the old UA — an offer, a dial — waits on the new one. Returns
|
|
1042
|
+
* whether it was rebuilt.
|
|
1043
|
+
*
|
|
1044
|
+
* This replaces the engine's old `#refreshSignal` (a close-then-reopen of the push socket after ANY failed
|
|
1045
|
+
* registration): only the provider knows what its credentials and its signal share.
|
|
1046
|
+
*/
|
|
1047
|
+
#retryOnFreshCredentials(reason, ua, credentials) {
|
|
1048
|
+
const fresh = this.provider.credentialsFailed?.(reason, credentials) ?? false;
|
|
1049
|
+
if (!fresh || ua !== this.#ua || this.#freshCredentialsTried) return false;
|
|
1050
|
+
if (this.#carriesCall()) return false;
|
|
1051
|
+
this.#freshCredentialsTried = true;
|
|
1052
|
+
const run = this.#run;
|
|
1053
|
+
this.#teardownUA({ replacing: true });
|
|
1054
|
+
const epoch = this.#uaEpoch;
|
|
1055
|
+
void this.#ensureUA().catch(() => {
|
|
1056
|
+
if (run === this.#run && epoch === this.#uaEpoch) this.#teardownUA();
|
|
1057
|
+
});
|
|
1058
|
+
return true;
|
|
1059
|
+
}
|
|
699
1060
|
#uaConfig(credentials, factory) {
|
|
700
1061
|
return {
|
|
701
1062
|
sockets: [factory.createWebSocket(credentials.wsUrl)],
|
|
@@ -713,21 +1074,133 @@ var SipCallEngine = class {
|
|
|
713
1074
|
user_agent: this.provider.quirks?.userAgent ?? "Coventia Softphone"
|
|
714
1075
|
};
|
|
715
1076
|
}
|
|
716
|
-
|
|
1077
|
+
/**
|
|
1078
|
+
* Lets the current UA go — and one still being provisioned (`#uaEpoch`). `replacing`: a successor is being
|
|
1079
|
+
* built on fresh credentials right away (`#retryOnFreshCredentials`), so the dials and the offer waiting on
|
|
1080
|
+
* this one carry over to it instead of ending here.
|
|
1081
|
+
*/
|
|
1082
|
+
#teardownUA({ replacing = false } = {}) {
|
|
1083
|
+
this.#uaEpoch += 1;
|
|
717
1084
|
this.#uaInFlight = null;
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
this.#
|
|
723
|
-
} catch {
|
|
1085
|
+
this.#registration = "connecting";
|
|
1086
|
+
this.#registerAgain = false;
|
|
1087
|
+
if (!replacing) {
|
|
1088
|
+
for (const waiter of [...this.#awaitingRegistration]) waiter.fail();
|
|
1089
|
+
this.#endOfferWait();
|
|
724
1090
|
}
|
|
1091
|
+
if (!this.#ua) return;
|
|
1092
|
+
const ua = this.#ua;
|
|
725
1093
|
this.#ua = null;
|
|
726
1094
|
this.#activeCall = null;
|
|
727
1095
|
this.#consultCall = null;
|
|
1096
|
+
try {
|
|
1097
|
+
ua.terminateSessions({ status_code: 487 });
|
|
1098
|
+
} catch {
|
|
1099
|
+
}
|
|
1100
|
+
if (this.#owedCancels.some((owed) => owed.ua === ua)) {
|
|
1101
|
+
this.#linger(ua);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
try {
|
|
1105
|
+
ua.stop();
|
|
1106
|
+
} catch {
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
// ── the CANCEL a hang-up still owes (incoming-call-reliability.md §4.7, F6) ─────────────────────────────
|
|
1110
|
+
/**
|
|
1111
|
+
* Watches an outbound leg — a call or a consult leg — from its creation, for the CANCEL JsSIP may end up owing.
|
|
1112
|
+
*
|
|
1113
|
+
* JsSIP's `terminate()` between sending the INVITE and the first provisional response cannot cancel yet (RFC 3261
|
|
1114
|
+
* §9.1): it marks the session canceled, fails it locally with `Canceled`, and sends the CANCEL when a 1xx comes —
|
|
1115
|
+
* 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
|
|
1116
|
+
* the callee's phone rang on for a caller who was gone: a ghost call. Hence: the INVITE went out (`sending`), no
|
|
1117
|
+
* provisional above 100 came (`progress`), and it failed locally `Canceled` → the CANCEL is owed, on this UA.
|
|
1118
|
+
*
|
|
1119
|
+
* Registered BEFORE the leg's own listeners: its `failed` must be on record before the leg's end tears the UA down.
|
|
1120
|
+
*/
|
|
1121
|
+
#watchOwedCancel(session, ua) {
|
|
1122
|
+
let sent = false;
|
|
1123
|
+
let ringing = false;
|
|
1124
|
+
session.on("sending", () => sent = true);
|
|
1125
|
+
session.on("progress", () => ringing = true);
|
|
1126
|
+
session.on("failed", ({ originator, cause }) => {
|
|
1127
|
+
if (!sent || originator !== "local" || cause !== "Canceled") return;
|
|
1128
|
+
const tx = inviteTransactionOf(session);
|
|
1129
|
+
const owes = deferredCancelOf(session) ?? (tx ? tx.state === ICT_CALLING : !ringing);
|
|
1130
|
+
if (!owes || tx && !awaitsAnswer(tx)) return;
|
|
1131
|
+
const owed = { ua, session, tx };
|
|
1132
|
+
this.#owedCancels.push(owed);
|
|
1133
|
+
if (tx) this.#followInvite(owed, tx);
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Follows the INVITE an owed CANCEL is waiting on, until it is over. Not just the first one: Zadarma challenges every
|
|
1138
|
+
* INVITE (407), and JsSIP re-sends it with credentials on a NEW transaction — canceled leg or not — in the same tick
|
|
1139
|
+
* that completes the first. Releasing on that first transaction stopped the UA two seconds later, and the re-sent
|
|
1140
|
+
* INVITE's 1xx landed nowhere: the ghost call again (found driving the real JsSIP). So once one is over, the leg's
|
|
1141
|
+
* current transaction is read again a moment later, and followed while it still waits for an answer.
|
|
1142
|
+
*/
|
|
1143
|
+
#followInvite(owed, tx) {
|
|
1144
|
+
const settle = () => {
|
|
1145
|
+
if (awaitsAnswer(tx)) return;
|
|
1146
|
+
tx.removeListener?.("stateChanged", settle);
|
|
1147
|
+
queueMicrotask(() => {
|
|
1148
|
+
const latest = inviteTransactionOf(owed.session);
|
|
1149
|
+
if (latest && latest !== tx && awaitsAnswer(latest)) {
|
|
1150
|
+
owed.tx = latest;
|
|
1151
|
+
this.#followInvite(owed, latest);
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
this.#owedCancels = this.#owedCancels.filter((other) => other !== owed);
|
|
1155
|
+
this.#releaseIfSettled(owed.ua);
|
|
1156
|
+
});
|
|
1157
|
+
};
|
|
1158
|
+
tx.on("stateChanged", settle);
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Lets `ua` go while it owes a CANCEL: un-REGISTERed at once, as any teardown, but connected until every INVITE it
|
|
1162
|
+
* owes is over — COMPLETED (a final non-2xx: the 487 after the late 1xx's CANCEL), ACCEPTED (a late 2xx, which
|
|
1163
|
+
* JsSIP ACKs and BYEs) or TERMINATED (Timer B, a transport error) — and never longer than Timer B. Detached: the
|
|
1164
|
+
* engine has already moved on (a push or a dial builds a fresh UA), so no timer of its own, no teardown and no
|
|
1165
|
+
* "is a call on it" ever sees this one, and an INVITE reaching it is declined like any let-go UA's.
|
|
1166
|
+
*/
|
|
1167
|
+
#linger(ua) {
|
|
1168
|
+
if (this.#lingering.has(ua)) return;
|
|
1169
|
+
this.#lingering.set(ua, setTimeout(() => this.#release(ua), INVITE_TRANSACTION_MAX_MS));
|
|
1170
|
+
ua.on("registered", () => {
|
|
1171
|
+
if (this.#lingering.has(ua)) this.#unregister(ua);
|
|
1172
|
+
});
|
|
1173
|
+
this.#unregister(ua);
|
|
1174
|
+
this.#releaseIfSettled(ua);
|
|
1175
|
+
}
|
|
1176
|
+
#unregister(ua) {
|
|
1177
|
+
try {
|
|
1178
|
+
ua.unregister();
|
|
1179
|
+
} catch {
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* Once nothing is owed on a lingering UA, it stops — on the next tick: a late 2xx is ACKed and BYEd in the very
|
|
1184
|
+
* tick that settled it, and JsSIP's own `stop()` then keeps the transport ≤ 2 s for that BYE's answer.
|
|
1185
|
+
*/
|
|
1186
|
+
#releaseIfSettled(ua) {
|
|
1187
|
+
if (!this.#lingering.has(ua) || this.#owedCancels.some((owed) => owed.ua === ua)) return;
|
|
1188
|
+
setTimeout(() => this.#release(ua), 0);
|
|
1189
|
+
}
|
|
1190
|
+
#release(ua) {
|
|
1191
|
+
const cap = this.#lingering.get(ua);
|
|
1192
|
+
if (cap === void 0) return;
|
|
1193
|
+
clearTimeout(cap);
|
|
1194
|
+
this.#lingering.delete(ua);
|
|
1195
|
+
this.#owedCancels = this.#owedCancels.filter((owed) => owed.ua !== ua);
|
|
1196
|
+
try {
|
|
1197
|
+
ua.stop();
|
|
1198
|
+
} catch {
|
|
1199
|
+
}
|
|
728
1200
|
}
|
|
729
|
-
/** Place a call once the UA is registered (its 200 OK for REGISTER has arrived). */
|
|
730
1201
|
/**
|
|
1202
|
+
* Place a call once the current UA is registered (its 200 OK for REGISTER has arrived).
|
|
1203
|
+
*
|
|
731
1204
|
* **No `mediaStream`** — JsSIP calls `getUserMedia` itself, which is what opens a real capture.
|
|
732
1205
|
*
|
|
733
1206
|
* That is load-bearing, not incidental. Firefox restricts ICE gathering on a page with no active
|
|
@@ -737,39 +1210,46 @@ var SipCallEngine = class {
|
|
|
737
1210
|
* contains no candidates. The legacy hook passed `mediaConstraints` and nothing else, for what
|
|
738
1211
|
* turns out to be a very good reason.
|
|
739
1212
|
*/
|
|
740
|
-
#callWhenRegistered(
|
|
1213
|
+
#callWhenRegistered(target, run) {
|
|
741
1214
|
const options = { mediaConstraints: buildAudioConstraints(this.#inputDeviceId), pcConfig: PC_CONFIG };
|
|
742
|
-
|
|
743
|
-
const
|
|
1215
|
+
const refusal = () => createError(run === this.#run ? "call/failed" : "engine/not-started");
|
|
1216
|
+
const dial = (registered) => {
|
|
1217
|
+
const session = registered.call(target, options);
|
|
1218
|
+
this.#watchOwedCancel(session, registered);
|
|
1219
|
+
return this.#wrapCall(session);
|
|
1220
|
+
};
|
|
1221
|
+
const ua = this.#ua;
|
|
1222
|
+
if (!ua || run !== this.#run) return Promise.reject(refusal());
|
|
1223
|
+
if (ua.isRegistered()) return Promise.resolve(dial(ua));
|
|
744
1224
|
return new Promise((resolve, reject) => {
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
this.#awaitingRegistration.delete(fail);
|
|
758
|
-
try {
|
|
759
|
-
resolve(ua.call(target, options));
|
|
760
|
-
} catch (cause) {
|
|
761
|
-
reject(cause);
|
|
1225
|
+
const waiter = {
|
|
1226
|
+
dial: (registered) => {
|
|
1227
|
+
this.#awaitingRegistration.delete(waiter);
|
|
1228
|
+
try {
|
|
1229
|
+
resolve(dial(registered));
|
|
1230
|
+
} catch (cause) {
|
|
1231
|
+
reject(cause);
|
|
1232
|
+
}
|
|
1233
|
+
},
|
|
1234
|
+
fail: () => {
|
|
1235
|
+
this.#awaitingRegistration.delete(waiter);
|
|
1236
|
+
reject(refusal());
|
|
762
1237
|
}
|
|
763
|
-
}
|
|
1238
|
+
};
|
|
1239
|
+
this.#awaitingRegistration.add(waiter);
|
|
764
1240
|
});
|
|
765
1241
|
}
|
|
766
|
-
|
|
1242
|
+
/**
|
|
1243
|
+
* `since`, for an inbound call: when its push came (its INVITE, when none was seen) — what `#onDecline` needs to
|
|
1244
|
+
* tell this call from the caller's others.
|
|
1245
|
+
*/
|
|
1246
|
+
#wrapCall(session, since = this.now()) {
|
|
767
1247
|
const call = new SipEngineCall(session, {
|
|
768
1248
|
id: nextCallId(),
|
|
769
1249
|
buildTarget: (t) => this.#buildTarget(t),
|
|
770
1250
|
placeConsult: (t) => this.#placeConsult(t),
|
|
771
1251
|
// The guard is tied to the reject ACTION, not to `ended` — a normal hangup must not arm it.
|
|
772
|
-
onReject: () => this.#
|
|
1252
|
+
onReject: () => this.#onDecline(call, since),
|
|
773
1253
|
iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS,
|
|
774
1254
|
// Inbound answers open their own peer connection, so they need the same ICE servers the
|
|
775
1255
|
// outbound leg got. Missing here, an ANSWERED call is the one that fails — the half nobody
|
|
@@ -786,17 +1266,27 @@ var SipCallEngine = class {
|
|
|
786
1266
|
const code = missed ? "call/missed" : CALL_END_CODES[event.reason];
|
|
787
1267
|
if (code) this.#reportError(code, event.cause);
|
|
788
1268
|
if (this.#activeCall === call) this.#activeCall = null;
|
|
789
|
-
this.#
|
|
1269
|
+
this.#legEnded();
|
|
790
1270
|
}
|
|
791
1271
|
});
|
|
792
1272
|
return call;
|
|
793
1273
|
}
|
|
1274
|
+
/**
|
|
1275
|
+
* A leg ended. The UA goes if nothing is left on it (`#maybeTeardownUA`); and once no leg at all is left, an
|
|
1276
|
+
* offer kept through the call (`#pendingOffer`, §4.3) gets its REGISTER — the line is free now.
|
|
1277
|
+
*/
|
|
1278
|
+
#legEnded() {
|
|
1279
|
+
const pending = this.#activeCall || this.#consultCall ? null : this.#takePendingOffer();
|
|
1280
|
+
this.#maybeTeardownUA();
|
|
1281
|
+
if (pending) void this.#onOffer(pending.from, pending.at);
|
|
1282
|
+
}
|
|
794
1283
|
async #placeConsult(target) {
|
|
795
1284
|
const ua = await this.#ensureUA();
|
|
796
1285
|
const consultSession = ua.call(this.#buildTarget(target), {
|
|
797
1286
|
mediaConstraints: buildAudioConstraints(this.#inputDeviceId),
|
|
798
1287
|
pcConfig: PC_CONFIG
|
|
799
1288
|
});
|
|
1289
|
+
this.#watchOwedCancel(consultSession, ua);
|
|
800
1290
|
const consultCall = new SipEngineCall(consultSession, {
|
|
801
1291
|
id: nextCallId(),
|
|
802
1292
|
buildTarget: (x) => this.#buildTarget(x),
|
|
@@ -807,7 +1297,7 @@ var SipCallEngine = class {
|
|
|
807
1297
|
consultCall.onEvent((event) => {
|
|
808
1298
|
if (event.type === "ended") {
|
|
809
1299
|
if (this.#consultCall === consultCall) this.#consultCall = null;
|
|
810
|
-
this.#
|
|
1300
|
+
this.#legEnded();
|
|
811
1301
|
}
|
|
812
1302
|
});
|
|
813
1303
|
return consultCall;
|
|
@@ -820,19 +1310,29 @@ var SipCallEngine = class {
|
|
|
820
1310
|
* session on it, via `terminateSessions`) the instant the main leg's `ended` fires killed that
|
|
821
1311
|
* consult leg out from under the transfer before the bridge could use it, and the caller was simply
|
|
822
1312
|
* gone. Two real call sites hit this independently (found live, 2026-08-04): the main leg's own
|
|
823
|
-
* `ended` handler, and
|
|
824
|
-
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
1313
|
+
* `ended` handler, and the no-INVITE timer — which then re-armed on EVERY push, including the
|
|
1314
|
+
* `answered` pushes Zadarma sends around other account activity, so it could fire minutes after a
|
|
1315
|
+
* transfer with no `#activeCall` and no idea a consult leg was mid-handoff. Every teardown an offer
|
|
1316
|
+
* asks for (its timers, its `offer-ended`) routes through here rather than checking `#activeCall` alone.
|
|
1317
|
+
*
|
|
1318
|
+
* A dial on its way (`#dialing`) counts too: its UA may not even exist yet, and an offer ending must not
|
|
1319
|
+
* take it. So does an offer still waiting for its INVITE: mid-transfer, with only the consult leg left, an
|
|
1320
|
+
* offer registers on this UA — and the consult leg ending used to tear it down and drop that offer, its call
|
|
1321
|
+
* ringing nowhere until the provider offered it again ~30 s later. The wait's own ending (its INVITE, its
|
|
1322
|
+
* `offer-ended`, the no-INVITE timer, the leak guard) clears `#offerWait` before it calls this, so nothing keeps
|
|
1323
|
+
* the UA past those (incoming-call-reliability.md §4.3).
|
|
827
1324
|
*/
|
|
828
1325
|
#maybeTeardownUA() {
|
|
829
1326
|
if (this.provider.registrationMode !== "on-demand") return;
|
|
830
|
-
if (this.#activeCall || this.#consultCall) return;
|
|
1327
|
+
if (this.#activeCall || this.#consultCall || this.#dialing > 0 || this.#offerWait) return;
|
|
831
1328
|
this.#teardownUA();
|
|
832
1329
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
1330
|
+
/**
|
|
1331
|
+
* A live leg is on the current UA: the call or a consult leg. An outbound call counts from the moment its INVITE
|
|
1332
|
+
* is created — `#callWhenRegistered` wraps it there and then — so no failure in between can end it unseen.
|
|
1333
|
+
*/
|
|
1334
|
+
#carriesCall() {
|
|
1335
|
+
return this.#activeCall !== null || this.#consultCall !== null;
|
|
836
1336
|
}
|
|
837
1337
|
#buildTarget(target) {
|
|
838
1338
|
return target.includes("@") ? target : `sip:${target}@${this.#domain}`;
|
|
@@ -863,6 +1363,8 @@ async function loadJsSipFactory() {
|
|
|
863
1363
|
// src/transports/providers/zadarma.ts
|
|
864
1364
|
var PING = { localMs: 3e4, globalMs: 6e4 };
|
|
865
1365
|
var KEY_REFRESH_BUFFER_MS = 2 * 60 * 60 * 1e3;
|
|
1366
|
+
var KEY_EXPIRY_MARGIN_MS = 6e4;
|
|
1367
|
+
var REKEY_DEBOUNCE_MS = 3e4;
|
|
866
1368
|
var REBUILD_CONNECT_TIMEOUT_MS = 1e4;
|
|
867
1369
|
var REBUILD_RETRY_MS = 3e4;
|
|
868
1370
|
var ZADARMA_QUIRKS = {
|
|
@@ -870,10 +1372,17 @@ var ZADARMA_QUIRKS = {
|
|
|
870
1372
|
// after a valid digest challenge, indistinguishable from a wrong password (§3.1). The old CRM
|
|
871
1373
|
// hook carried this string; the port dropped it, and registration failed for exactly that reason.
|
|
872
1374
|
userAgent: "Zadarma webRTC",
|
|
873
|
-
// Zadarma re-
|
|
1375
|
+
// Zadarma re-offers a declined call until its ring time ends (~30 s by default): the declined caller's offers
|
|
1376
|
+
// are ignored while that call lives — this long after the decline when the backend cannot say (§3.4,
|
|
1377
|
+
// incoming-call-reliability.md §4.4, Q1).
|
|
1378
|
+
declinedCallerGuardMs: 35e3,
|
|
1379
|
+
// The INVITE follows the REGISTER's 200 OK by ~0.1 s. Nothing 10 s after it: no call is coming to this UA.
|
|
1380
|
+
noInviteAfterRegisterMs: 1e4,
|
|
1381
|
+
// A leak guard only: an offer still waiting for its INVITE 2 min after its push is abandoned (§4.3).
|
|
1382
|
+
offerWaitMaxMs: 12e4,
|
|
1383
|
+
// Deprecated names of the two above, same values: this object is exported, and 0.5.1 is a patch. Never read.
|
|
874
1384
|
rejectGuardMs: 35e3,
|
|
875
|
-
|
|
876
|
-
noInviteTeardownMs: 5e3,
|
|
1385
|
+
noInviteTeardownMs: 1e4,
|
|
877
1386
|
// A ceiling on ICE gathering, not a target: JsSIP sends as soon as gathering completes. 3 s leaves
|
|
878
1387
|
// room for a TURN allocation (whose relay candidates arrive last) while still bounding the ~40 s
|
|
879
1388
|
// Chrome can spend against an unreachable server. See `capIceGathering`.
|
|
@@ -883,11 +1392,154 @@ function buildPushUrl(key, sip) {
|
|
|
883
1392
|
const params = new URLSearchParams({ key, integrationType: "site", sipId: sip });
|
|
884
1393
|
return `wss://ws.zadarma.com/webrtc_pushes?${params.toString()}`;
|
|
885
1394
|
}
|
|
886
|
-
|
|
1395
|
+
function readPush(payload) {
|
|
1396
|
+
let data = payload;
|
|
1397
|
+
if (typeof data === "string") {
|
|
1398
|
+
try {
|
|
1399
|
+
data = JSON.parse(data);
|
|
1400
|
+
} catch {
|
|
1401
|
+
return { kind: "other", detail: "unreadable payload" };
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
if (!data || typeof data !== "object") return { kind: "other", detail: "no payload" };
|
|
1405
|
+
const { action, callerid } = data;
|
|
1406
|
+
let from = null;
|
|
1407
|
+
if (typeof callerid === "string" && callerid) from = callerid;
|
|
1408
|
+
else if (typeof callerid === "number") from = String(callerid);
|
|
1409
|
+
if (action === "incoming") return { kind: "offer", from };
|
|
1410
|
+
if (action === "answered") return { kind: "offer-ended", from };
|
|
1411
|
+
return { kind: "other", detail: typeof action === "string" ? `action "${action}"` : "no action" };
|
|
1412
|
+
}
|
|
1413
|
+
function expiryOf(key) {
|
|
1414
|
+
if (!key.keyExpiresAt) return null;
|
|
1415
|
+
const at = new Date(key.keyExpiresAt).getTime();
|
|
1416
|
+
return Number.isFinite(at) ? at : null;
|
|
1417
|
+
}
|
|
1418
|
+
var ZadarmaCredentials = class {
|
|
887
1419
|
constructor(deps) {
|
|
888
1420
|
this.deps = deps;
|
|
889
1421
|
}
|
|
890
1422
|
deps;
|
|
1423
|
+
#key = null;
|
|
1424
|
+
#keyInFlight = null;
|
|
1425
|
+
#sip = null;
|
|
1426
|
+
#sipInFlight = null;
|
|
1427
|
+
/**
|
|
1428
|
+
* Bumped by `forget`, one per half: a fetch begun before it must not put back what was just forgotten — a
|
|
1429
|
+
* refused credential that comes back from a slow request is still refused.
|
|
1430
|
+
*/
|
|
1431
|
+
#keyGeneration = 0;
|
|
1432
|
+
#sipGeneration = 0;
|
|
1433
|
+
/**
|
|
1434
|
+
* The SIP credentials were already refetched once because their WSS could not be reached: if the fresh ones
|
|
1435
|
+
* cannot reach it either, the network is the problem, and asking Zadarma again would not change it. Re-armed by
|
|
1436
|
+
* a registration that works (`worked`), and by forgetting everything (a refusal, a stop).
|
|
1437
|
+
*/
|
|
1438
|
+
#unreachableRefetched = false;
|
|
1439
|
+
/** What is in memory right now — both halves, for one key — or `null`. Never fetches. */
|
|
1440
|
+
current() {
|
|
1441
|
+
const key = this.#key?.value;
|
|
1442
|
+
const sip = this.#sip;
|
|
1443
|
+
return key && sip && sip.forKey === key.key ? { key, pass: sip.pass, domain: sip.domain } : null;
|
|
1444
|
+
}
|
|
1445
|
+
/** Their WSS could not be reached: forget the SIP half — once, until `worked()`. Whether it was forgotten. */
|
|
1446
|
+
forgetUnreachable() {
|
|
1447
|
+
if (this.#unreachableRefetched) return false;
|
|
1448
|
+
this.forget("sip");
|
|
1449
|
+
this.#unreachableRefetched = true;
|
|
1450
|
+
return true;
|
|
1451
|
+
}
|
|
1452
|
+
/** A registration worked with what is in memory. */
|
|
1453
|
+
worked() {
|
|
1454
|
+
this.#unreachableRefetched = false;
|
|
1455
|
+
}
|
|
1456
|
+
/** The cached key while it is alive; otherwise a fresh one (and, in the background, its SIP credentials). */
|
|
1457
|
+
key() {
|
|
1458
|
+
const cached2 = this.#key;
|
|
1459
|
+
if (cached2 && (cached2.expiresAt === null || Date.now() < cached2.expiresAt - KEY_EXPIRY_MARGIN_MS)) {
|
|
1460
|
+
return Promise.resolve(cached2.value);
|
|
1461
|
+
}
|
|
1462
|
+
return this.#fetchKey();
|
|
1463
|
+
}
|
|
1464
|
+
/** A new key now, whatever is cached: the refresh ahead of expiry. The old one serves until this one lands. */
|
|
1465
|
+
renew() {
|
|
1466
|
+
return this.#fetchKey();
|
|
1467
|
+
}
|
|
1468
|
+
/** The key and the SIP credentials it bought, from memory when they are there. */
|
|
1469
|
+
async sip() {
|
|
1470
|
+
const key = await this.key();
|
|
1471
|
+
const { pass, domain } = await this.#sipFor(key);
|
|
1472
|
+
return { key, pass, domain };
|
|
1473
|
+
}
|
|
1474
|
+
/** Synchronous up to the request, so a prefetch and a `provision()` racing it share one exchange. */
|
|
1475
|
+
#sipFor(key) {
|
|
1476
|
+
const cached2 = this.#sip;
|
|
1477
|
+
if (cached2 && cached2.forKey === key.key) return Promise.resolve({ pass: cached2.pass, domain: cached2.domain });
|
|
1478
|
+
const inFlight = this.#sipInFlight;
|
|
1479
|
+
if (inFlight && inFlight.forKey === key.key) return inFlight.promise;
|
|
1480
|
+
const generation = this.#sipGeneration;
|
|
1481
|
+
let request;
|
|
1482
|
+
try {
|
|
1483
|
+
request = this.deps.exchangeWebphoneData(key.key, key.sip);
|
|
1484
|
+
} catch (cause) {
|
|
1485
|
+
request = Promise.reject(cause instanceof Error ? cause : new Error(String(cause)));
|
|
1486
|
+
}
|
|
1487
|
+
const promise = request.then((data) => {
|
|
1488
|
+
if (generation === this.#sipGeneration) this.#sip = { forKey: key.key, pass: data.pass, domain: data.domain };
|
|
1489
|
+
return data;
|
|
1490
|
+
});
|
|
1491
|
+
const entry = { forKey: key.key, promise };
|
|
1492
|
+
this.#sipInFlight = entry;
|
|
1493
|
+
void promise.finally(() => {
|
|
1494
|
+
if (this.#sipInFlight === entry) this.#sipInFlight = null;
|
|
1495
|
+
}).catch(() => {
|
|
1496
|
+
});
|
|
1497
|
+
return promise;
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Drop what stopped working. `all`: the registrar refused the credentials, so the key behind them may be the
|
|
1501
|
+
* stale part too. `sip`: their WSS could not be reached — the key is fine, the datacenter may not be.
|
|
1502
|
+
*/
|
|
1503
|
+
forget(what) {
|
|
1504
|
+
this.#sipGeneration += 1;
|
|
1505
|
+
this.#sip = null;
|
|
1506
|
+
this.#sipInFlight = null;
|
|
1507
|
+
if (what === "sip") return;
|
|
1508
|
+
this.#keyGeneration += 1;
|
|
1509
|
+
this.#key = null;
|
|
1510
|
+
this.#keyInFlight = null;
|
|
1511
|
+
this.#unreachableRefetched = false;
|
|
1512
|
+
}
|
|
1513
|
+
#fetchKey() {
|
|
1514
|
+
if (this.#keyInFlight) return this.#keyInFlight;
|
|
1515
|
+
const generation = this.#keyGeneration;
|
|
1516
|
+
const inFlight = this.deps.fetchKey().then((value) => {
|
|
1517
|
+
if (generation === this.#keyGeneration) {
|
|
1518
|
+
this.#key = { value, expiresAt: expiryOf(value) };
|
|
1519
|
+
void this.#sipFor(value).catch(() => {
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
return value;
|
|
1523
|
+
});
|
|
1524
|
+
this.#keyInFlight = inFlight;
|
|
1525
|
+
void inFlight.finally(() => {
|
|
1526
|
+
if (this.#keyInFlight === inFlight) this.#keyInFlight = null;
|
|
1527
|
+
}).catch(() => {
|
|
1528
|
+
});
|
|
1529
|
+
return inFlight;
|
|
1530
|
+
}
|
|
1531
|
+
};
|
|
1532
|
+
var ZadarmaPushSignal = class {
|
|
1533
|
+
/**
|
|
1534
|
+
* `credentials` is shared with the provider's `provision()`: one key for the push socket and the SIP
|
|
1535
|
+
* credentials, fetched once (§4.5). A signal on its own gets a cache of its own.
|
|
1536
|
+
*/
|
|
1537
|
+
constructor(deps, credentials = new ZadarmaCredentials(deps)) {
|
|
1538
|
+
this.deps = deps;
|
|
1539
|
+
this.credentials = credentials;
|
|
1540
|
+
}
|
|
1541
|
+
deps;
|
|
1542
|
+
credentials;
|
|
891
1543
|
#socket = null;
|
|
892
1544
|
#connected = false;
|
|
893
1545
|
#pingTimers = [];
|
|
@@ -906,6 +1558,14 @@ var ZadarmaPushSignal = class {
|
|
|
906
1558
|
* start again) has reset it: the late socket was built beside the new one, and nothing ever closed it.
|
|
907
1559
|
*/
|
|
908
1560
|
#run = 0;
|
|
1561
|
+
/**
|
|
1562
|
+
* The key is due for its refresh (`#armRefresh`): the next rebuild fetches a new one instead of reusing the
|
|
1563
|
+
* cached one — and keeps trying, on every retry and every hidden-tab tick, until one arrives.
|
|
1564
|
+
*/
|
|
1565
|
+
#renewDue = false;
|
|
1566
|
+
/** A rebuild asked for (`rekey`) while another was in flight: it runs once that one ends. */
|
|
1567
|
+
#rekeyQueued = false;
|
|
1568
|
+
#rekeyAfter = 0;
|
|
909
1569
|
#listeners = /* @__PURE__ */ new Set();
|
|
910
1570
|
#connectionListeners = /* @__PURE__ */ new Set();
|
|
911
1571
|
async connect() {
|
|
@@ -914,9 +1574,25 @@ var ZadarmaPushSignal = class {
|
|
|
914
1574
|
this.#unwatchBackground ??= this.deps.watchBackgroundReconnect?.(() => void this.#reopen());
|
|
915
1575
|
await this.#open();
|
|
916
1576
|
}
|
|
1577
|
+
/**
|
|
1578
|
+
* The registrar refused credentials bought with this socket's key, and the provider has forgotten them: the
|
|
1579
|
+
* socket is rebuilt on a fresh key, make-before-break like every rebuild (`#reopen`).
|
|
1580
|
+
*
|
|
1581
|
+
* Debounced: a registrar refusing every attempt must not become a tight loop against our backend and
|
|
1582
|
+
* Zadarma's. This rebuild used to be the engine's (`#refreshSignal`, a close-then-open); it lives here now
|
|
1583
|
+
* because only the provider knows the push socket and the SIP credentials share one key.
|
|
1584
|
+
*/
|
|
1585
|
+
rekey() {
|
|
1586
|
+
if (this.#closed || !this.#socket) return;
|
|
1587
|
+
const now = Date.now();
|
|
1588
|
+
if (now < this.#rekeyAfter) return;
|
|
1589
|
+
this.#rekeyAfter = now + REKEY_DEBOUNCE_MS;
|
|
1590
|
+
if (this.#rebuilding) this.#rekeyQueued = true;
|
|
1591
|
+
else void this.#reopen();
|
|
1592
|
+
}
|
|
917
1593
|
async #open() {
|
|
918
1594
|
const run = this.#run;
|
|
919
|
-
const { key, sip, keyExpiresAt } = await this.
|
|
1595
|
+
const { key, sip, keyExpiresAt } = await this.credentials.key();
|
|
920
1596
|
if (run !== this.#run) return;
|
|
921
1597
|
const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
|
|
922
1598
|
this.#socket = socket;
|
|
@@ -927,8 +1603,8 @@ var ZadarmaPushSignal = class {
|
|
|
927
1603
|
* Wires one socket's events. `this.#socket` is read when an event FIRES, not when it is wired: a socket that
|
|
928
1604
|
* has been replaced must stop driving connectivity (our own `close()` of it emits `disconnect`, which would
|
|
929
1605
|
* otherwise flip the state to offline right after a perfectly healthy swap). `update` is the exception — it is
|
|
930
|
-
* forwarded from any socket not yet closed, because a push seen twice is harmless (the engine ignores
|
|
931
|
-
*
|
|
1606
|
+
* forwarded from any socket not yet closed, because a push seen twice is harmless (the engine ignores an offer
|
|
1607
|
+
* mid-call, and a second one only sends one more REGISTER) while one seen by neither is a missed call.
|
|
932
1608
|
*
|
|
933
1609
|
* With `hooks`, the socket is a rebuild's candidate: its first `connect` or failure goes to the rebuild instead
|
|
934
1610
|
* of changing the connection state (it is not current yet).
|
|
@@ -940,7 +1616,7 @@ var ZadarmaPushSignal = class {
|
|
|
940
1616
|
this.#setConnected(true);
|
|
941
1617
|
this.#startPings(socket);
|
|
942
1618
|
});
|
|
943
|
-
socket.on("update", () => this.#emit());
|
|
1619
|
+
socket.on("update", (payload) => this.#emit(readPush(payload)));
|
|
944
1620
|
const lost = () => {
|
|
945
1621
|
if (hooks && socket === this.#candidate) return hooks.onFailure();
|
|
946
1622
|
if (socket !== this.#socket) return;
|
|
@@ -979,13 +1655,19 @@ var ZadarmaPushSignal = class {
|
|
|
979
1655
|
* given at construction. A tab left open past the key's 72 h lifetime would reconnect forever with
|
|
980
1656
|
* a dead credential. Absolute, from the backend's `key_expires_at`, so it targets the real expiry
|
|
981
1657
|
* rather than restarting a countdown on every reconnect.
|
|
1658
|
+
*
|
|
1659
|
+
* It is also the refresh of the shared credentials (incoming-call-reliability.md §4.5): the new key replaces
|
|
1660
|
+
* the cached one, and its SIP credentials are fetched with it, in the background — no call waits for either.
|
|
982
1661
|
*/
|
|
983
1662
|
#armRefresh(keyExpiresAt) {
|
|
984
1663
|
clearTimeout(this.#refreshTimer);
|
|
985
1664
|
if (!keyExpiresAt) return;
|
|
986
1665
|
const ms = new Date(keyExpiresAt).getTime() - Date.now() - KEY_REFRESH_BUFFER_MS;
|
|
987
1666
|
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
988
|
-
this.#refreshTimer = setTimeout(() =>
|
|
1667
|
+
this.#refreshTimer = setTimeout(() => {
|
|
1668
|
+
this.#renewDue = true;
|
|
1669
|
+
void this.#reopen();
|
|
1670
|
+
}, ms);
|
|
989
1671
|
}
|
|
990
1672
|
/**
|
|
991
1673
|
* Replaces the push socket with a fresh one — MAKE-BEFORE-BREAK (presence-client-reporting.md §4.1, T8).
|
|
@@ -1001,6 +1683,9 @@ var ZadarmaPushSignal = class {
|
|
|
1001
1683
|
* throws, the socket errors, or it has not connected within `REBUILD_CONNECT_TIMEOUT_MS` — keeps the old socket
|
|
1002
1684
|
* and retries, instead of leaving the tab disconnected. Zadarma does not evict the older of two sockets for one
|
|
1003
1685
|
* sip (multi-tab-leader-election.md §1), so the overlap is safe.
|
|
1686
|
+
*
|
|
1687
|
+
* The key comes from the shared cache — a hidden-tab tick makes no request — unless it is due for its refresh
|
|
1688
|
+
* (`#renewDue`) or has been forgotten after a refused registration (`rekey`).
|
|
1004
1689
|
*/
|
|
1005
1690
|
async #reopen() {
|
|
1006
1691
|
if (this.#closed || this.#rebuilding) return;
|
|
@@ -1014,8 +1699,9 @@ var ZadarmaPushSignal = class {
|
|
|
1014
1699
|
this.#rebuilding = true;
|
|
1015
1700
|
const run = this.#run;
|
|
1016
1701
|
try {
|
|
1017
|
-
const { key, sip, keyExpiresAt } = await this.
|
|
1702
|
+
const { key, sip, keyExpiresAt } = await (this.#renewDue ? this.credentials.renew() : this.credentials.key());
|
|
1018
1703
|
if (run !== this.#run) return;
|
|
1704
|
+
this.#renewDue = false;
|
|
1019
1705
|
const swapped = await new Promise((resolve) => {
|
|
1020
1706
|
const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
|
|
1021
1707
|
this.#candidate = socket;
|
|
@@ -1043,6 +1729,10 @@ var ZadarmaPushSignal = class {
|
|
|
1043
1729
|
} finally {
|
|
1044
1730
|
this.#rebuilding = false;
|
|
1045
1731
|
}
|
|
1732
|
+
if (this.#rekeyQueued) {
|
|
1733
|
+
this.#rekeyQueued = false;
|
|
1734
|
+
void this.#reopen();
|
|
1735
|
+
}
|
|
1046
1736
|
}
|
|
1047
1737
|
/**
|
|
1048
1738
|
* The moment of the swap — synchronous, called from the candidate's own `connect` event so nothing can run
|
|
@@ -1066,9 +1756,17 @@ var ZadarmaPushSignal = class {
|
|
|
1066
1756
|
clearTimeout(this.#retryTimer);
|
|
1067
1757
|
this.#retryTimer = setTimeout(() => void this.#reopen(), REBUILD_RETRY_MS);
|
|
1068
1758
|
}
|
|
1759
|
+
/**
|
|
1760
|
+
* Closes everything — and forgets the credentials: this is how the engine stops, and a phone that is off holds
|
|
1761
|
+
* no SIP password. The next `connect()` fetches them again.
|
|
1762
|
+
*/
|
|
1069
1763
|
disconnect() {
|
|
1070
1764
|
this.#closed = true;
|
|
1071
1765
|
this.#run += 1;
|
|
1766
|
+
this.#renewDue = false;
|
|
1767
|
+
this.#rekeyQueued = false;
|
|
1768
|
+
this.#rekeyAfter = 0;
|
|
1769
|
+
this.credentials.forget("all");
|
|
1072
1770
|
this.#stopPings();
|
|
1073
1771
|
clearTimeout(this.#refreshTimer);
|
|
1074
1772
|
this.#refreshTimer = void 0;
|
|
@@ -1097,16 +1795,11 @@ var ZadarmaPushSignal = class {
|
|
|
1097
1795
|
this.#connected = connected;
|
|
1098
1796
|
for (const listener of [...this.#connectionListeners]) listener(connected);
|
|
1099
1797
|
}
|
|
1100
|
-
#emit() {
|
|
1101
|
-
for (const listener of [...this.#listeners]) listener();
|
|
1798
|
+
#emit(event) {
|
|
1799
|
+
for (const listener of [...this.#listeners]) listener(event);
|
|
1102
1800
|
}
|
|
1103
1801
|
};
|
|
1104
1802
|
var ZadarmaProvider = class {
|
|
1105
|
-
constructor(deps) {
|
|
1106
|
-
this.deps = deps;
|
|
1107
|
-
this.incomingSignal = new ZadarmaPushSignal(deps);
|
|
1108
|
-
}
|
|
1109
|
-
deps;
|
|
1110
1803
|
name = "zadarma";
|
|
1111
1804
|
// Zadarma's PBX ignores a standing registration — it routes INVITEs only to a UA that registered
|
|
1112
1805
|
// in response to a push (SPEC §3.2). Hence on-demand.
|
|
@@ -1114,21 +1807,60 @@ var ZadarmaProvider = class {
|
|
|
1114
1807
|
quirks = ZADARMA_QUIRKS;
|
|
1115
1808
|
incomingSignal;
|
|
1116
1809
|
/**
|
|
1117
|
-
*
|
|
1118
|
-
*
|
|
1119
|
-
|
|
1810
|
+
* One key and one set of SIP credentials for the push socket and every registration (§4.5). Its "unreachable
|
|
1811
|
+
* refetched once" mark lives there too, so a stop — which forgets the credentials — clears it as well.
|
|
1812
|
+
*/
|
|
1813
|
+
#credentials;
|
|
1814
|
+
#signal;
|
|
1815
|
+
constructor(deps) {
|
|
1816
|
+
this.#credentials = new ZadarmaCredentials(deps);
|
|
1817
|
+
this.#signal = new ZadarmaPushSignal(deps, this.#credentials);
|
|
1818
|
+
this.incomingSignal = this.#signal;
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
1821
|
+
* The SIP credentials, from memory (incoming-call-reliability.md §4.5). They were fetched when the push socket
|
|
1822
|
+
* opened — the key from our backend, then `{ pass, domain }` from Zadarma, in the browser for the geo-routing
|
|
1823
|
+
* (SPEC §3.1) — so a push goes straight to the WSS and the REGISTER. Fetched here only when they are not there:
|
|
1824
|
+
* the background fetch failed, or they were forgotten (`credentialsFailed`).
|
|
1120
1825
|
*/
|
|
1121
1826
|
async provision() {
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1827
|
+
return toSipCredentials(await this.#credentials.sip());
|
|
1828
|
+
}
|
|
1829
|
+
/**
|
|
1830
|
+
* `rejected` (401/403): the key may be the stale part, so both are forgotten, and the push socket — which
|
|
1831
|
+
* holds the same key — is rebuilt on a fresh one. `unreachable`: only the SIP credentials, and only once
|
|
1832
|
+
* until a registration works: Zadarma may have moved this browser to another datacenter (SPEC §3.1).
|
|
1833
|
+
*
|
|
1834
|
+
* Only if `credentials` — the ones that failed — are still what is in memory: a UA let go of earlier can report
|
|
1835
|
+
* late, when the cache has already moved on to fresh ones, and those must not be thrown away for it.
|
|
1836
|
+
*/
|
|
1837
|
+
credentialsFailed(reason, credentials) {
|
|
1838
|
+
if (credentials && !this.#holds(credentials)) return true;
|
|
1839
|
+
if (reason === "rejected") {
|
|
1840
|
+
this.#credentials.forget("all");
|
|
1841
|
+
this.#signal.rekey();
|
|
1842
|
+
return true;
|
|
1843
|
+
}
|
|
1844
|
+
return this.#credentials.forgetUnreachable();
|
|
1845
|
+
}
|
|
1846
|
+
credentialsWorked() {
|
|
1847
|
+
this.#credentials.worked();
|
|
1848
|
+
}
|
|
1849
|
+
#holds(credentials) {
|
|
1850
|
+
const current = this.#credentials.current();
|
|
1851
|
+
if (!current) return false;
|
|
1852
|
+
const held = toSipCredentials(current);
|
|
1853
|
+
return held.uri === credentials.uri && held.authUser === credentials.authUser && held.password === credentials.password;
|
|
1130
1854
|
}
|
|
1131
1855
|
};
|
|
1856
|
+
function toSipCredentials({ key, pass, domain }) {
|
|
1857
|
+
return {
|
|
1858
|
+
uri: `sip:${key.sip}@${domain}`,
|
|
1859
|
+
authUser: key.sip,
|
|
1860
|
+
password: pass,
|
|
1861
|
+
wsUrl: `wss://${domain}:4443`
|
|
1862
|
+
};
|
|
1863
|
+
}
|
|
1132
1864
|
|
|
1133
1865
|
// src/core/token-cache.ts
|
|
1134
1866
|
var SKEW_MS = 6e4;
|
|
@@ -1169,6 +1901,23 @@ function invalidateToken(source) {
|
|
|
1169
1901
|
// src/transports/providers/zadarma-browser.ts
|
|
1170
1902
|
var UnauthorizedKeyError = class extends Error {
|
|
1171
1903
|
};
|
|
1904
|
+
var KeyFetchError = class extends Error {
|
|
1905
|
+
constructor(message, status, retryAfterMs) {
|
|
1906
|
+
super(message);
|
|
1907
|
+
this.status = status;
|
|
1908
|
+
this.retryAfterMs = retryAfterMs;
|
|
1909
|
+
}
|
|
1910
|
+
status;
|
|
1911
|
+
retryAfterMs;
|
|
1912
|
+
};
|
|
1913
|
+
function retryAfterMsOf(res) {
|
|
1914
|
+
const value = res.headers?.get?.("retry-after");
|
|
1915
|
+
if (!value) return void 0;
|
|
1916
|
+
const seconds = Number(value);
|
|
1917
|
+
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1e3);
|
|
1918
|
+
const at = Date.parse(value);
|
|
1919
|
+
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : void 0;
|
|
1920
|
+
}
|
|
1172
1921
|
function makeFetchKey(config) {
|
|
1173
1922
|
const attempt = async () => {
|
|
1174
1923
|
let token;
|
|
@@ -1184,7 +1933,7 @@ function makeFetchKey(config) {
|
|
|
1184
1933
|
if (!res.ok) {
|
|
1185
1934
|
const body = await res.json().catch(() => null);
|
|
1186
1935
|
const message = body?.message ?? `webrtc/key HTTP ${res.status}`;
|
|
1187
|
-
throw res.status === 401 ? new UnauthorizedKeyError(message) : new
|
|
1936
|
+
throw res.status === 401 ? new UnauthorizedKeyError(message) : new KeyFetchError(message, res.status, retryAfterMsOf(res));
|
|
1188
1937
|
}
|
|
1189
1938
|
const data = await res.json();
|
|
1190
1939
|
return {
|