@coventia/softphone 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{call-C6YpT6uR.d.cts → call-iGvr2ZnO.d.cts} +130 -13
- package/dist/{call-C6YpT6uR.d.ts → call-iGvr2ZnO.d.ts} +130 -13
- package/dist/chunk-3UTUWGVG.js +1891 -0
- package/dist/chunk-3UTUWGVG.js.map +1 -0
- package/dist/chunk-DZRSSZWB.js +39 -0
- package/dist/chunk-DZRSSZWB.js.map +1 -0
- package/dist/{chunk-2ZSOGGPO.js → chunk-FRXHEHQA.js} +15 -2
- package/dist/chunk-FRXHEHQA.js.map +1 -0
- package/dist/{chunk-UHNLEBBO.js → chunk-I3RSC52R.js} +28 -3
- package/dist/chunk-I3RSC52R.js.map +1 -0
- package/dist/chunk-M76SAXXM.js +13 -0
- package/dist/chunk-M76SAXXM.js.map +1 -0
- package/dist/{chunk-7R6LOOAI.js → chunk-N4PLEYFJ.js} +4 -2
- package/dist/chunk-N4PLEYFJ.js.map +1 -0
- package/dist/element/index.d.ts +167 -7
- package/dist/element/index.js +138 -43
- package/dist/element/index.js.map +1 -1
- package/dist/{engine-DPft99Wj.d.ts → engine-BKoT_Pj7.d.cts} +10 -1
- package/dist/{engine-CDdaSojx.d.cts → engine-CeGh5Yai.d.ts} +10 -1
- package/dist/index.cjs +1596 -145
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -5
- package/dist/index.d.ts +5 -5
- package/dist/index.js +618 -42
- package/dist/index.js.map +1 -1
- package/dist/react/index.cjs +26 -0
- package/dist/react/index.cjs.map +1 -1
- package/dist/react/index.d.cts +24 -5
- package/dist/react/index.d.ts +24 -5
- package/dist/react/index.js +2 -2
- package/dist/{softphone-CY5d2WWY.d.cts → softphone-D0ao5W4z.d.ts} +42 -3
- package/dist/{softphone-DXgmTyrX.d.ts → softphone-DRoS0jJR.d.cts} +42 -3
- package/dist/{sources-ONYQW733.js → sources-LGFJG7W2.js} +27 -38
- package/dist/sources-LGFJG7W2.js.map +1 -0
- package/dist/testing/index.cjs +13 -0
- package/dist/testing/index.cjs.map +1 -1
- package/dist/testing/index.d.cts +2 -2
- package/dist/testing/index.d.ts +2 -2
- package/dist/testing/index.js +1 -1
- package/dist/ui/index.cjs +92 -2
- package/dist/ui/index.cjs.map +1 -1
- package/dist/ui/index.d.cts +2 -2
- package/dist/ui/index.d.ts +2 -2
- package/dist/ui/index.js +69 -4
- package/dist/ui/index.js.map +1 -1
- package/dist/zadarma/index.cjs +950 -104
- package/dist/zadarma/index.cjs.map +1 -1
- package/dist/zadarma/index.d.cts +40 -14
- package/dist/zadarma/index.d.ts +40 -14
- package/dist/zadarma/index.js +4 -2
- package/dist/zadarma-engine-VYIXLBQG.js +7 -0
- package/dist/{zadarma-engine-L5DEFXUS.js.map → zadarma-engine-VYIXLBQG.js.map} +1 -1
- package/package.json +1 -1
- package/dist/chunk-2ZSOGGPO.js.map +0 -1
- package/dist/chunk-7R6LOOAI.js.map +0 -1
- package/dist/chunk-EYEDPD7Y.js +0 -1071
- package/dist/chunk-EYEDPD7Y.js.map +0 -1
- package/dist/chunk-UHNLEBBO.js.map +0 -1
- package/dist/sources-ONYQW733.js.map +0 -1
- package/dist/zadarma-engine-L5DEFXUS.js +0 -5
package/dist/index.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { isBrowser } from './chunk-KAXEMJ7E.js';
|
|
2
2
|
export { isBrowser, isWebRtcSupported } from './chunk-KAXEMJ7E.js';
|
|
3
|
-
export { CallEvent, SoftphoneEvent } from './chunk-
|
|
3
|
+
export { CallEvent, SoftphoneEvent } from './chunk-N4PLEYFJ.js';
|
|
4
4
|
export { isOutboundNumberRefused } from './chunk-5E5OEXS2.js';
|
|
5
|
-
import {
|
|
6
|
-
|
|
5
|
+
import { normalizeApiBase } from './chunk-M76SAXXM.js';
|
|
6
|
+
import { createError, isSoftphoneError, isFatal } from './chunk-FRXHEHQA.js';
|
|
7
|
+
export { ERROR_CATALOGUE, isFatal } from './chunk-FRXHEHQA.js';
|
|
7
8
|
import { cachedTokenSource } from './chunk-MHOJHG5E.js';
|
|
8
9
|
|
|
9
10
|
// src/core/emitter.ts
|
|
@@ -285,7 +286,7 @@ function coventiaBackend(config, tokenSource, fallbackBaseUrl) {
|
|
|
285
286
|
}
|
|
286
287
|
let pending = null;
|
|
287
288
|
const load = () => {
|
|
288
|
-
pending ??= import('./sources-
|
|
289
|
+
pending ??= import('./sources-LGFJG7W2.js').then(
|
|
289
290
|
(m) => m.coventiaSources({ ...config, baseUrl, tokenSource })
|
|
290
291
|
);
|
|
291
292
|
return pending;
|
|
@@ -316,13 +317,15 @@ function isWebLocksSupported() {
|
|
|
316
317
|
return isBrowser() && typeof navigator.locks?.request === "function";
|
|
317
318
|
}
|
|
318
319
|
function webLocksTabLock(lockName = LOCK_NAME) {
|
|
319
|
-
|
|
320
|
+
let controller;
|
|
320
321
|
let releaseHold;
|
|
321
322
|
return {
|
|
322
323
|
acquireLeadership() {
|
|
323
324
|
if (!isWebLocksSupported()) return Promise.resolve();
|
|
325
|
+
const { signal } = controller = new AbortController();
|
|
324
326
|
return new Promise((resolveAcquired) => {
|
|
325
|
-
navigator.locks.request(lockName, { signal
|
|
327
|
+
navigator.locks.request(lockName, { signal }, () => {
|
|
328
|
+
if (signal.aborted) return Promise.resolve();
|
|
326
329
|
resolveAcquired();
|
|
327
330
|
return new Promise((resolveHold) => {
|
|
328
331
|
releaseHold = resolveHold;
|
|
@@ -337,7 +340,7 @@ function webLocksTabLock(lockName = LOCK_NAME) {
|
|
|
337
340
|
releaseHold = void 0;
|
|
338
341
|
resolve();
|
|
339
342
|
} else {
|
|
340
|
-
controller
|
|
343
|
+
controller?.abort();
|
|
341
344
|
}
|
|
342
345
|
}
|
|
343
346
|
};
|
|
@@ -418,17 +421,70 @@ function startTransferWatch(params) {
|
|
|
418
421
|
return stop;
|
|
419
422
|
}
|
|
420
423
|
|
|
424
|
+
// src/core/start-retry.ts
|
|
425
|
+
var BACKOFF_BASE_MS = 1e3;
|
|
426
|
+
var BACKOFF_MAX_MS = 3e4;
|
|
427
|
+
function backoffDelayMs(attempt, random = Math.random) {
|
|
428
|
+
const capped = Math.min(BACKOFF_BASE_MS * 2 ** (Math.max(1, attempt) - 1), BACKOFF_MAX_MS);
|
|
429
|
+
return capped * random();
|
|
430
|
+
}
|
|
431
|
+
function isTransientStatus(status) {
|
|
432
|
+
return status === 0 || status === 408 || status === 425 || status === 429 || status >= 500;
|
|
433
|
+
}
|
|
434
|
+
function isNetworkFailure(error) {
|
|
435
|
+
const name = typeof error.name === "string" ? error.name : "";
|
|
436
|
+
return error instanceof TypeError || name === "AbortError" || name === "TimeoutError" || name === "NetworkError";
|
|
437
|
+
}
|
|
438
|
+
function transientStartFailure(error) {
|
|
439
|
+
let retryAfterMs = 0;
|
|
440
|
+
let viaTokenEndpoint = false;
|
|
441
|
+
let current = error;
|
|
442
|
+
for (let depth = 0; depth < 6 && current !== null && typeof current === "object"; depth += 1) {
|
|
443
|
+
const layer = current;
|
|
444
|
+
if (typeof layer.retryAfterMs === "number" && Number.isFinite(layer.retryAfterMs)) {
|
|
445
|
+
retryAfterMs = Math.max(retryAfterMs, layer.retryAfterMs);
|
|
446
|
+
}
|
|
447
|
+
if (typeof layer.status === "number") return isTransientStatus(layer.status) ? { retryAfterMs } : null;
|
|
448
|
+
if (isNetworkFailure(layer)) return { retryAfterMs };
|
|
449
|
+
if (isSoftphoneError(current)) {
|
|
450
|
+
if (layer.code === "auth/token-rejected") return null;
|
|
451
|
+
if (layer.code === "auth/token-fetch-failed") viaTokenEndpoint = true;
|
|
452
|
+
}
|
|
453
|
+
current = layer.cause;
|
|
454
|
+
}
|
|
455
|
+
return viaTokenEndpoint ? { retryAfterMs } : null;
|
|
456
|
+
}
|
|
457
|
+
function watchWake(wake) {
|
|
458
|
+
if (!isBrowser()) return () => {
|
|
459
|
+
};
|
|
460
|
+
const onOnline = () => wake();
|
|
461
|
+
const onVisibility = () => {
|
|
462
|
+
if (document.visibilityState === "visible") wake();
|
|
463
|
+
};
|
|
464
|
+
window.addEventListener("online", onOnline);
|
|
465
|
+
document.addEventListener("visibilitychange", onVisibility);
|
|
466
|
+
return () => {
|
|
467
|
+
window.removeEventListener("online", onOnline);
|
|
468
|
+
document.removeEventListener("visibilitychange", onVisibility);
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
421
472
|
// src/core/presence-socket.ts
|
|
422
|
-
var RECONNECT_BASE_MS = 1e3;
|
|
423
|
-
var RECONNECT_MAX_MS = 3e4;
|
|
424
473
|
var NOOP_WATCH = { reportCallState: () => {
|
|
425
|
-
}, stop: () => {
|
|
474
|
+
}, claimLine: () => false, stop: () => {
|
|
426
475
|
} };
|
|
427
476
|
function startPresenceSocket(params) {
|
|
428
477
|
const {
|
|
429
478
|
source,
|
|
430
479
|
getIsLeader,
|
|
431
480
|
onAgentsChanged,
|
|
481
|
+
clientId,
|
|
482
|
+
onLine,
|
|
483
|
+
onClaimResult,
|
|
484
|
+
onOffer,
|
|
485
|
+
onLineUnsupported,
|
|
486
|
+
onConnectFailed,
|
|
487
|
+
onDisconnect,
|
|
432
488
|
setTimeout: scheduleTimeout = globalThis.setTimeout,
|
|
433
489
|
clearTimeout: cancelTimeout = globalThis.clearTimeout,
|
|
434
490
|
random = Math.random
|
|
@@ -439,6 +495,8 @@ function startPresenceSocket(params) {
|
|
|
439
495
|
let reconnectTimer = null;
|
|
440
496
|
let agents = /* @__PURE__ */ new Map();
|
|
441
497
|
let lastReportedOnCall = null;
|
|
498
|
+
let sentOnCall = null;
|
|
499
|
+
let line = null;
|
|
442
500
|
let connection = null;
|
|
443
501
|
let byeOnArrival = false;
|
|
444
502
|
const stop = (options) => {
|
|
@@ -455,11 +513,16 @@ function startPresenceSocket(params) {
|
|
|
455
513
|
connection?.close();
|
|
456
514
|
connection = null;
|
|
457
515
|
};
|
|
516
|
+
const send = (onCall) => {
|
|
517
|
+
if (!connection) return;
|
|
518
|
+
connection.reportCallState(onCall);
|
|
519
|
+
sentOnCall = onCall;
|
|
520
|
+
};
|
|
521
|
+
const mayReport = (onCall) => line !== "elsewhere" || !onCall && sentOnCall === true;
|
|
458
522
|
const scheduleReconnect = () => {
|
|
459
523
|
if (stopped) return;
|
|
460
524
|
attempt += 1;
|
|
461
|
-
const
|
|
462
|
-
const delay = capped * random();
|
|
525
|
+
const delay = backoffDelayMs(attempt, random);
|
|
463
526
|
reconnectTimer = scheduleTimeout(() => void connect(), delay);
|
|
464
527
|
};
|
|
465
528
|
const connect = async () => {
|
|
@@ -483,10 +546,26 @@ function startPresenceSocket(params) {
|
|
|
483
546
|
if (connection === null) return;
|
|
484
547
|
connection = null;
|
|
485
548
|
scheduleReconnect();
|
|
549
|
+
onDisconnect?.();
|
|
550
|
+
},
|
|
551
|
+
onLine: (update) => {
|
|
552
|
+
if (stopped) return;
|
|
553
|
+
const was = line;
|
|
554
|
+
line = update.state;
|
|
555
|
+
if (was === "elsewhere" && line === "here" && lastReportedOnCall !== null) send(lastReportedOnCall);
|
|
556
|
+
onLine?.(update);
|
|
557
|
+
},
|
|
558
|
+
onClaimResult: (result) => {
|
|
559
|
+
if (!stopped) onClaimResult?.(result);
|
|
560
|
+
},
|
|
561
|
+
onOffer: (offer) => {
|
|
562
|
+
if (!stopped) onOffer?.(offer);
|
|
486
563
|
}
|
|
487
564
|
});
|
|
488
565
|
} catch {
|
|
489
|
-
if (
|
|
566
|
+
if (stopped) return;
|
|
567
|
+
scheduleReconnect();
|
|
568
|
+
onConnectFailed?.();
|
|
490
569
|
return;
|
|
491
570
|
}
|
|
492
571
|
if (stopped) {
|
|
@@ -496,22 +575,63 @@ function startPresenceSocket(params) {
|
|
|
496
575
|
}
|
|
497
576
|
connection = opened;
|
|
498
577
|
attempt = 0;
|
|
499
|
-
|
|
578
|
+
sentOnCall = null;
|
|
579
|
+
line = null;
|
|
580
|
+
if (clientId !== void 0 && opened.hello) opened.hello(clientId);
|
|
581
|
+
else onLineUnsupported?.();
|
|
582
|
+
if (lastReportedOnCall !== null) send(lastReportedOnCall);
|
|
500
583
|
};
|
|
501
584
|
void connect();
|
|
502
585
|
return {
|
|
503
586
|
reportCallState: (onCall) => {
|
|
504
587
|
if (onCall === lastReportedOnCall) return;
|
|
505
588
|
lastReportedOnCall = onCall;
|
|
506
|
-
|
|
589
|
+
if (mayReport(onCall)) send(onCall);
|
|
590
|
+
},
|
|
591
|
+
claimLine: () => {
|
|
592
|
+
if (!connection?.claimLine) return false;
|
|
593
|
+
connection.claimLine();
|
|
594
|
+
return true;
|
|
507
595
|
},
|
|
508
596
|
stop
|
|
509
597
|
};
|
|
510
598
|
}
|
|
511
599
|
|
|
600
|
+
// src/core/line.ts
|
|
601
|
+
var CLIENT_ID_KEY = "coventia-softphone:client-id";
|
|
602
|
+
var CLIENT_ID_PATTERN = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
603
|
+
function lineClientId() {
|
|
604
|
+
const stored = readStoredId();
|
|
605
|
+
if (stored) return stored;
|
|
606
|
+
const id = freshClientId();
|
|
607
|
+
try {
|
|
608
|
+
if (isBrowser()) globalThis.sessionStorage?.setItem(CLIENT_ID_KEY, id);
|
|
609
|
+
} catch {
|
|
610
|
+
}
|
|
611
|
+
return id;
|
|
612
|
+
}
|
|
613
|
+
function readStoredId() {
|
|
614
|
+
if (!isBrowser()) return null;
|
|
615
|
+
try {
|
|
616
|
+
const value = globalThis.sessionStorage?.getItem(CLIENT_ID_KEY);
|
|
617
|
+
return value && CLIENT_ID_PATTERN.test(value) ? value : null;
|
|
618
|
+
} catch {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function freshClientId() {
|
|
623
|
+
const uuid = globalThis.crypto?.randomUUID?.();
|
|
624
|
+
if (uuid) return uuid;
|
|
625
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
|
|
626
|
+
}
|
|
627
|
+
|
|
512
628
|
// src/core/softphone-impl.ts
|
|
513
629
|
var liveInstance = false;
|
|
514
630
|
var PRESENCE_TEARDOWN_GRACE_MS = 1e4;
|
|
631
|
+
var LINE_ANSWER_TIMEOUT_MS = 4e3;
|
|
632
|
+
var CALL_DECISION_TIMEOUT_MS = LINE_ANSWER_TIMEOUT_MS + 2e3;
|
|
633
|
+
var LINE_CLAIM_TIMEOUT_MS = 1e4;
|
|
634
|
+
var LINE_LAPSE_MS = 1e4;
|
|
515
635
|
var BrowserAudioDevices = class {
|
|
516
636
|
inputs = [];
|
|
517
637
|
outputs = [];
|
|
@@ -619,6 +739,8 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
619
739
|
#activeCall = null;
|
|
620
740
|
#destroyed = false;
|
|
621
741
|
#startedHere = false;
|
|
742
|
+
/** Bumped by every `stop()`, so a `start()` still waiting to lead can tell it was stopped. */
|
|
743
|
+
#run = 0;
|
|
622
744
|
#isLeader = false;
|
|
623
745
|
#engine;
|
|
624
746
|
#tabLock;
|
|
@@ -643,6 +765,63 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
643
765
|
/** A pending state-driven teardown of the watch — see `PRESENCE_TEARDOWN_GRACE_MS`. */
|
|
644
766
|
#presenceTeardownTimer = null;
|
|
645
767
|
#presenceAgents = [];
|
|
768
|
+
// ── The line owner (`docs/specs/line-owner.md` §5) ──────────────────────────────────────────────
|
|
769
|
+
// Heard over the presence watch: the server names ONE softphone per extension that listens, and this one
|
|
770
|
+
// runs its engine only while that is it (or nobody can say). `#engineOn` and `#standby` are the two halves
|
|
771
|
+
// of that decision, kept apart because neither is true while `start()` is still waiting to make it.
|
|
772
|
+
#line = "unknown";
|
|
773
|
+
#lineWhere = null;
|
|
774
|
+
/** How the last attempt to bring the line here went (`lineClaim`), whoever made it. */
|
|
775
|
+
#lineClaim = "idle";
|
|
776
|
+
/** This tab's id in `hello` (`lineClientId`), read once — one id for every reconnect of this softphone. */
|
|
777
|
+
#clientId = null;
|
|
778
|
+
/** What the line wants: the engine running. Set the moment the line decides; the engine follows (`#queueEngine`). */
|
|
779
|
+
#engineOn = false;
|
|
780
|
+
/**
|
|
781
|
+
* What the engine was last TOLD by the queue — started or stopped. Apart from `#engineOn` because the line
|
|
782
|
+
* can flip twice while a start is still running: the stop and start queued meanwhile then find nothing
|
|
783
|
+
* to do, instead of stopping the engine (and the softphone with it) for a moment.
|
|
784
|
+
*/
|
|
785
|
+
#engineRunning = false;
|
|
786
|
+
/** Waiting in `standby`: the engine is stopped ON PURPOSE, so its own `stopped` is not the softphone's. */
|
|
787
|
+
#standby = false;
|
|
788
|
+
/**
|
|
789
|
+
* Every engine start and stop the line asks for, one after another: a promotion can follow a demotion
|
|
790
|
+
* within a moment, and an engine started while its own stop is still running can end up neither.
|
|
791
|
+
*/
|
|
792
|
+
#engineSteps = Promise.resolve();
|
|
793
|
+
/** Dials in flight (`call()` awaiting `placeCall`): a call about to exist holds the line like one that does. */
|
|
794
|
+
#dialing = 0;
|
|
795
|
+
/**
|
|
796
|
+
* Open from `start()`'s first line until it has decided whether the engine runs — or given up (stopped,
|
|
797
|
+
* destroyed, failed). `call()` waits on it, so it never dials ahead of that decision: not on a phone
|
|
798
|
+
* stopped meanwhile, and not without the line.
|
|
799
|
+
*/
|
|
800
|
+
#startDecision = null;
|
|
801
|
+
/**
|
|
802
|
+
* Who decides whether the engine runs. `off`: nobody — not started, a follower, or `stop()` has begun
|
|
803
|
+
* (whatever its teardown sets off, like a call ending, must not start anything). `deciding`: `start()`
|
|
804
|
+
* has leadership and owes the FIRST decision itself, since it must await the engine start — a line
|
|
805
|
+
* message meanwhile only records what it says. `on`: the line decides (`#applyLine`).
|
|
806
|
+
*/
|
|
807
|
+
#lineMode = "off";
|
|
808
|
+
/** Ends `start()`'s wait for the server's first word on the line early (`#waitForFirstLine`). */
|
|
809
|
+
#releaseFirstLine = null;
|
|
810
|
+
/** The `takeLine()` in flight — one at a time; a second caller shares it. */
|
|
811
|
+
#claim = null;
|
|
812
|
+
/** Pending: what the server said about the line stops counting (`LINE_LAPSE_MS`). */
|
|
813
|
+
#lineLapseTimer = null;
|
|
814
|
+
// ── The start retry (incoming-call-reliability.md §4.6) ─────────────────────────────────────────────────
|
|
815
|
+
// An engine start that failed for a passing reason (a 5xx, the network, a timeout) is attempted again on its
|
|
816
|
+
// own, with backoff, instead of leaving the phone `offline` until someone presses "Reintentar".
|
|
817
|
+
/** Failed attempts in the current streak; back to 0 when the engine starts, or the softphone stops. */
|
|
818
|
+
#startRetries = 0;
|
|
819
|
+
/** The wait before the next attempt, or `null` when none is pending. */
|
|
820
|
+
#startRetryTimer = null;
|
|
821
|
+
/** No attempt before this (epoch ms): a `Retry-After` the failure carried, which a wake-up must honour too. */
|
|
822
|
+
#startRetryNotBefore = 0;
|
|
823
|
+
/** Unsubscribes the `online` / tab-visible wake-ups, armed only while an attempt is pending. */
|
|
824
|
+
#unwatchWake = null;
|
|
646
825
|
/**
|
|
647
826
|
* `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
|
|
648
827
|
* internal seam, not something an integrator tunes). Tests construct this class directly with a
|
|
@@ -660,7 +839,9 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
660
839
|
this.sources = config.coventia || config.apiBase ? coventiaBackend(config.coventia ?? {}, cachedTokenSource(config.tokenSource), config.apiBase) : null;
|
|
661
840
|
this.#resolveContact = config.resolveContact ?? this.sources?.resolveContact;
|
|
662
841
|
this.#engine.onIncoming((engineCall) => this.#onIncoming(engineCall));
|
|
663
|
-
this.#engine.onStateChange((state) =>
|
|
842
|
+
this.#engine.onStateChange((state) => {
|
|
843
|
+
if (!this.#standby) this.#setState(state);
|
|
844
|
+
});
|
|
664
845
|
this.#engine.onError?.((error) => this.#onError(error));
|
|
665
846
|
}
|
|
666
847
|
get state() {
|
|
@@ -678,6 +859,15 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
678
859
|
get presence() {
|
|
679
860
|
return this.#presenceAgents;
|
|
680
861
|
}
|
|
862
|
+
get line() {
|
|
863
|
+
return this.#line;
|
|
864
|
+
}
|
|
865
|
+
get lineWhere() {
|
|
866
|
+
return this.#lineWhere;
|
|
867
|
+
}
|
|
868
|
+
get lineClaim() {
|
|
869
|
+
return this.#lineClaim;
|
|
870
|
+
}
|
|
681
871
|
get canPlayAudio() {
|
|
682
872
|
return true;
|
|
683
873
|
}
|
|
@@ -693,15 +883,34 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
693
883
|
}
|
|
694
884
|
liveInstance = true;
|
|
695
885
|
this.#startedHere = true;
|
|
696
|
-
this.#
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
886
|
+
const run = this.#run;
|
|
887
|
+
const decision = this.#openStartDecision();
|
|
888
|
+
try {
|
|
889
|
+
this.#setState("starting");
|
|
890
|
+
await this.#tabLock.acquireLeadership();
|
|
891
|
+
if (this.#destroyed || run !== this.#run) return;
|
|
892
|
+
this.#lineMode = "deciding";
|
|
893
|
+
this.#setLeader(true);
|
|
894
|
+
if (this.#presenceWatch && this.#line === "unknown") await this.#waitForFirstLine();
|
|
895
|
+
if (this.#destroyed || run !== this.#run) return;
|
|
896
|
+
this.#lineMode = "on";
|
|
897
|
+
const engineStart = this.#applyLine();
|
|
898
|
+
decision.end();
|
|
899
|
+
await engineStart;
|
|
900
|
+
} finally {
|
|
901
|
+
decision.end();
|
|
902
|
+
}
|
|
701
903
|
}
|
|
702
904
|
async stop() {
|
|
703
905
|
if (!this.#startedHere) return;
|
|
906
|
+
this.#run += 1;
|
|
907
|
+
this.#cancelStartRetry();
|
|
908
|
+
this.#lineMode = "off";
|
|
704
909
|
this.#sayGoodbye();
|
|
910
|
+
this.#startDecision?.end();
|
|
911
|
+
this.#standby = false;
|
|
912
|
+
this.#engineOn = false;
|
|
913
|
+
this.#engineRunning = false;
|
|
705
914
|
this.#tabLock.release();
|
|
706
915
|
if (this.#isLeader) {
|
|
707
916
|
await this.#engine.stop();
|
|
@@ -723,16 +932,51 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
723
932
|
this.#setState("destroyed");
|
|
724
933
|
this.clear();
|
|
725
934
|
}
|
|
935
|
+
async takeLine() {
|
|
936
|
+
this.#assertUsable();
|
|
937
|
+
if (this.#line === "here") return;
|
|
938
|
+
if (this.#claim) return this.#claim.promise;
|
|
939
|
+
const watch = this.#presenceWatch;
|
|
940
|
+
if (!watch) {
|
|
941
|
+
this.#setLineClaim("failed");
|
|
942
|
+
throw createError("line/unavailable");
|
|
943
|
+
}
|
|
944
|
+
const claim = this.#openClaim();
|
|
945
|
+
if (!watch.claimLine()) this.#settleClaim(createError("line/unavailable"));
|
|
946
|
+
return claim;
|
|
947
|
+
}
|
|
726
948
|
async call(destination, options) {
|
|
727
949
|
this.#assertUsable();
|
|
950
|
+
const run = this.#run;
|
|
951
|
+
if (this.#startRetryTimer !== null) this.#wakeStartRetry();
|
|
952
|
+
if (this.#startDecision) {
|
|
953
|
+
let timer;
|
|
954
|
+
const decided = await Promise.race([
|
|
955
|
+
this.#startDecision.promise.then(() => true),
|
|
956
|
+
new Promise((resolve) => {
|
|
957
|
+
timer = setTimeout(() => resolve(false), CALL_DECISION_TIMEOUT_MS);
|
|
958
|
+
})
|
|
959
|
+
]);
|
|
960
|
+
clearTimeout(timer);
|
|
961
|
+
if (!decided) throw createError("engine/not-started");
|
|
962
|
+
}
|
|
963
|
+
if (run === this.#run && this.#state === "standby") await this.takeLine();
|
|
964
|
+
this.#assertUsable();
|
|
965
|
+
if (run !== this.#run) throw createError("engine/not-started");
|
|
728
966
|
if (this.#state !== "online" && this.#state !== "offline" && this.#state !== "starting") {
|
|
729
967
|
throw new Error(`Cannot place a call while softphone is '${this.#state}'.`);
|
|
730
968
|
}
|
|
731
|
-
if (this.#activeCall) {
|
|
969
|
+
if (this.#activeCall || this.#dialing > 0) {
|
|
732
970
|
throw new Error("A call is already in progress. Hang up before placing another.");
|
|
733
971
|
}
|
|
734
|
-
|
|
735
|
-
|
|
972
|
+
this.#dialing += 1;
|
|
973
|
+
try {
|
|
974
|
+
const engineCall = await this.#engine.placeCall(destination, options);
|
|
975
|
+
return this.#adopt(engineCall);
|
|
976
|
+
} finally {
|
|
977
|
+
this.#dialing -= 1;
|
|
978
|
+
this.#syncLine();
|
|
979
|
+
}
|
|
736
980
|
}
|
|
737
981
|
// ── internals ───────────────────────────────────────────────────────────────
|
|
738
982
|
#onIncoming(engineCall) {
|
|
@@ -761,6 +1005,7 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
761
1005
|
this.#activeCall = null;
|
|
762
1006
|
this.emit("activeCallChanged" /* ActiveCallChanged */, { call: null });
|
|
763
1007
|
this.#syncCallState();
|
|
1008
|
+
this.#syncLine();
|
|
764
1009
|
}
|
|
765
1010
|
});
|
|
766
1011
|
if (this.#resolveContact) {
|
|
@@ -790,7 +1035,8 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
790
1035
|
/**
|
|
791
1036
|
* Reports the current on-a-call value to the backend. The watch de-duplicates (it only sends on change) and
|
|
792
1037
|
* remembers the latest value to replay after a reconnect, so this can be called on every transition without
|
|
793
|
-
* ceremony
|
|
1038
|
+
* ceremony — and holds it back while the line is elsewhere, since only the owner's reports count (line-owner
|
|
1039
|
+
* R8). A no-op while there is no presence watch (stopped, not the leader, no backend configured).
|
|
794
1040
|
*/
|
|
795
1041
|
#syncCallState() {
|
|
796
1042
|
this.#presenceWatch?.reportCallState(this.#isOnCall());
|
|
@@ -811,17 +1057,22 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
811
1057
|
* `presence-socket.ts`'s own `getIsLeader` is checked at start and before each reconnect attempt,
|
|
812
1058
|
* not on a poll tick the way `startTransferWatch`'s is — see that module's own doc comment for why.
|
|
813
1059
|
* This still passes `getIsLeader: () => this.#isLeader` for that narrower, defensive recheck; the
|
|
814
|
-
* BROAD
|
|
1060
|
+
* BROAD started/leader gate — whether to be connected AT ALL — is owned entirely here, the same
|
|
815
1061
|
* split `#onTransfer` already has with `startTransferWatch`.
|
|
816
1062
|
*
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
1063
|
+
* The watch runs for a leader that is `starting`, `online` or in `standby` — not only `online`, as it did
|
|
1064
|
+
* before the line owner (`docs/specs/line-owner.md` §5.4): while starting it is how this softphone learns
|
|
1065
|
+
* whether to listen at all, and in standby how it learns the line came back.
|
|
1066
|
+
*
|
|
1067
|
+
* A leader that is merely `offline` or `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a
|
|
1068
|
+
* blip shorter than that never touches the presence socket at all; a longer outage closes it, which lets
|
|
1069
|
+
* the server hand the line to a softphone that can still take calls. Anything else (stopped, destroyed, or
|
|
1070
|
+
* no longer the leader) tears it down at once: those are not blips, and a follower must never hold a
|
|
1071
|
+
* connection.
|
|
821
1072
|
*/
|
|
822
1073
|
#syncPresenceWatch() {
|
|
823
1074
|
const eligible = Boolean(this.sources) && this.#isLeader;
|
|
824
|
-
const shouldRun = eligible && this.#state
|
|
1075
|
+
const shouldRun = eligible && this.#presenceRunsIn(this.#state);
|
|
825
1076
|
const blip = eligible && (this.#state === "offline" || this.#state === "reconnecting");
|
|
826
1077
|
if (!blip) this.#cancelPresenceTeardown();
|
|
827
1078
|
if (blip && this.#presenceWatch !== null) {
|
|
@@ -830,13 +1081,25 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
830
1081
|
}
|
|
831
1082
|
if (shouldRun === (this.#presenceWatch !== null)) return;
|
|
832
1083
|
if (shouldRun) {
|
|
1084
|
+
this.#clientId ??= lineClientId();
|
|
833
1085
|
this.#presenceWatch = startPresenceSocket({
|
|
834
1086
|
source: this.sources.presence,
|
|
835
1087
|
getIsLeader: () => this.#isLeader,
|
|
836
1088
|
onAgentsChanged: (agents) => {
|
|
837
1089
|
this.#presenceAgents = agents;
|
|
838
1090
|
this.emit("presenceChanged" /* PresenceChanged */, { agents });
|
|
839
|
-
}
|
|
1091
|
+
},
|
|
1092
|
+
clientId: this.#clientId,
|
|
1093
|
+
onLine: (update) => this.#onLine(update),
|
|
1094
|
+
onClaimResult: (result) => this.#onClaimResult(result),
|
|
1095
|
+
// The calls offered to this line, straight to the engine: after a decline, they say when the declined
|
|
1096
|
+
// call's re-offers stop (incoming-call-reliability.md §4.4). Not public — nothing a host acts on.
|
|
1097
|
+
onOffer: (offer) => this.#engine.noteOffer?.(offer),
|
|
1098
|
+
// A source that cannot take part will never answer, and one that cannot connect cannot answer yet:
|
|
1099
|
+
// `start()` need not wait out its 4 s for either (R7). A later answer still applies.
|
|
1100
|
+
onLineUnsupported: () => this.#releaseFirstLine?.(),
|
|
1101
|
+
onConnectFailed: () => this.#releaseFirstLine?.(),
|
|
1102
|
+
onDisconnect: () => this.#onPresenceDisconnect()
|
|
840
1103
|
});
|
|
841
1104
|
this.#syncCallState();
|
|
842
1105
|
} else {
|
|
@@ -851,19 +1114,24 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
851
1114
|
if (this.#presenceTeardownTimer !== null) return;
|
|
852
1115
|
this.#presenceTeardownTimer = setTimeout(() => {
|
|
853
1116
|
this.#presenceTeardownTimer = null;
|
|
854
|
-
if (this.#
|
|
1117
|
+
if (this.#isLeader && this.#presenceRunsIn(this.#state)) return;
|
|
855
1118
|
this.#stopPresence();
|
|
856
1119
|
}, PRESENCE_TEARDOWN_GRACE_MS);
|
|
857
1120
|
}
|
|
1121
|
+
/** The states in which a leader keeps its presence watch with no clock running (`#syncPresenceWatch`). */
|
|
1122
|
+
#presenceRunsIn(state) {
|
|
1123
|
+
return state === "online" || state === "starting" || state === "standby";
|
|
1124
|
+
}
|
|
858
1125
|
#cancelPresenceTeardown() {
|
|
859
1126
|
if (this.#presenceTeardownTimer === null) return;
|
|
860
1127
|
clearTimeout(this.#presenceTeardownTimer);
|
|
861
1128
|
this.#presenceTeardownTimer = null;
|
|
862
1129
|
}
|
|
863
1130
|
/**
|
|
864
|
-
* Stops the presence watch and forgets its roster. A STATE-DRIVEN teardown (
|
|
865
|
-
*
|
|
866
|
-
* Any pending grace-period teardown is cancelled — there is nothing
|
|
1131
|
+
* Stops the presence watch and forgets its roster, and the line. A STATE-DRIVEN teardown (an outage
|
|
1132
|
+
* outlasted the grace, the engine stopped, or leadership was lost) passes nothing and says no goodbye;
|
|
1133
|
+
* only a deliberate stop passes `bye`. Any pending grace-period teardown is cancelled — there is nothing
|
|
1134
|
+
* left for it to do.
|
|
867
1135
|
*/
|
|
868
1136
|
#stopPresence(options) {
|
|
869
1137
|
this.#cancelPresenceTeardown();
|
|
@@ -872,6 +1140,8 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
872
1140
|
this.#presenceWatch = null;
|
|
873
1141
|
this.#presenceAgents = [];
|
|
874
1142
|
this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
|
|
1143
|
+
this.#forgetLine();
|
|
1144
|
+
this.#engine.noteOffer?.(null);
|
|
875
1145
|
}
|
|
876
1146
|
/**
|
|
877
1147
|
* A DELIBERATE stop (`stop()` / `destroy()`): say any call is over, then say goodbye, so the backend drops
|
|
@@ -881,6 +1151,278 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
881
1151
|
this.#presenceWatch?.reportCallState(false);
|
|
882
1152
|
this.#stopPresence({ bye: true });
|
|
883
1153
|
}
|
|
1154
|
+
// ── the line owner (`docs/specs/line-owner.md` §5) ──────────────────────────────────────────────
|
|
1155
|
+
/**
|
|
1156
|
+
* THE decision: from where the line is and whether a call is in progress, whether the engine runs, and
|
|
1157
|
+
* the state that says so. Synchronous, so the state moves the instant the line does — the waiting screen
|
|
1158
|
+
* goes the moment the line comes here, and a `call()` that took the line dials from `starting` without
|
|
1159
|
+
* waiting out the engine start. Only the engine start or stop is queued (`#queueEngine`). Returns that
|
|
1160
|
+
* queued step, or `null` when nothing had to change.
|
|
1161
|
+
*/
|
|
1162
|
+
#applyLine() {
|
|
1163
|
+
if (this.#lineMode !== "on") return null;
|
|
1164
|
+
const run = this.#run;
|
|
1165
|
+
if (this.#line === "elsewhere") {
|
|
1166
|
+
if (this.#standby || this.#activeCall || this.#dialing > 0) return null;
|
|
1167
|
+
this.#standby = true;
|
|
1168
|
+
this.#setState("standby");
|
|
1169
|
+
if (!this.#engineOn) return null;
|
|
1170
|
+
this.#engineOn = false;
|
|
1171
|
+
return this.#queueEngine(run);
|
|
1172
|
+
}
|
|
1173
|
+
if (this.#engineOn) return null;
|
|
1174
|
+
this.#engineOn = true;
|
|
1175
|
+
if (this.#standby) {
|
|
1176
|
+
this.#standby = false;
|
|
1177
|
+
this.#setState("starting");
|
|
1178
|
+
}
|
|
1179
|
+
return this.#queueEngine(run);
|
|
1180
|
+
}
|
|
1181
|
+
/** `#applyLine` from an event — nobody to hand a failure to, and `#engineStartFailed` has reported it. */
|
|
1182
|
+
#syncLine() {
|
|
1183
|
+
void this.#applyLine()?.catch(() => {
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Brings the engine to what the line wants (`#engineOn`) once every step already queued has run — and does
|
|
1188
|
+
* nothing when it is already there: the line can flip twice while a start is still running, and the stop
|
|
1189
|
+
* and start queued meanwhile must not take the engine (and the softphone's state) down and up for nothing.
|
|
1190
|
+
* Nothing at all once `stop()` has run since `run` was read: a stopped softphone's engine is `stop()`'s
|
|
1191
|
+
* alone. Rejects with a failed start's cause; the queue itself never rejects, so one failure blocks nothing.
|
|
1192
|
+
*/
|
|
1193
|
+
#queueEngine(run) {
|
|
1194
|
+
const step = this.#engineSteps.then(async () => {
|
|
1195
|
+
if (run !== this.#run || this.#engineOn === this.#engineRunning) return;
|
|
1196
|
+
this.#engineRunning = this.#engineOn;
|
|
1197
|
+
if (this.#engineRunning) await this.#startEngine(run);
|
|
1198
|
+
else await this.#engine.stop();
|
|
1199
|
+
});
|
|
1200
|
+
this.#engineSteps = step.catch(() => {
|
|
1201
|
+
});
|
|
1202
|
+
return step;
|
|
1203
|
+
}
|
|
1204
|
+
/** Resolves when the engine started — or when its failure is being retried on its own (§4.6). */
|
|
1205
|
+
async #startEngine(run) {
|
|
1206
|
+
const reported = this.#lastError;
|
|
1207
|
+
try {
|
|
1208
|
+
await this.#engine.start();
|
|
1209
|
+
if (run === this.#run) this.#startRetries = 0;
|
|
1210
|
+
} catch (cause) {
|
|
1211
|
+
if (run === this.#run && this.#engineStartFailed(cause, reported)) return;
|
|
1212
|
+
throw cause;
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* An engine start the line asked for failed. Kept as it was, this softphone would stay the owner on the
|
|
1217
|
+
* server — `starting`, its presence socket renewing the line — with no push socket anywhere: nobody would
|
|
1218
|
+
* listen. So it gives the line up the ordinary way, by closing its presence socket without a goodbye (the
|
|
1219
|
+
* phone was not switched off): the server hands the line on after its grace (R4).
|
|
1220
|
+
*
|
|
1221
|
+
* Then it depends on why (incoming-call-reliability.md §4.6, F5):
|
|
1222
|
+
* - **a passing failure** (a 5xx, the network, a timeout — `transientStartFailure`) is attempted again on its
|
|
1223
|
+
* own, after a backoff, as `reconnecting`, with no error gate. Found live: a voice token answered 500 after
|
|
1224
|
+
* 10 s left a background tab `offline` for good, and every call was lost until someone pressed "Reintentar".
|
|
1225
|
+
* Returns `true`.
|
|
1226
|
+
* - **anything else** (a refused credential, no extension) is reported like any engine failure —
|
|
1227
|
+
* `SoftphoneEvent.Error` and `lastError`, which is the widget's error gate and its "Reintentar" (`stop()`
|
|
1228
|
+
* then `start()`, which asks for the line again) — and nothing retries on its own. Returns `false`.
|
|
1229
|
+
*/
|
|
1230
|
+
#engineStartFailed(cause, reportedBefore) {
|
|
1231
|
+
this.#engineOn = false;
|
|
1232
|
+
this.#engineRunning = false;
|
|
1233
|
+
this.#lineMode = "off";
|
|
1234
|
+
const transient = this.#destroyed ? null : transientStartFailure(cause);
|
|
1235
|
+
this.#stopPresence();
|
|
1236
|
+
if (transient) {
|
|
1237
|
+
this.#scheduleStartRetry(transient.retryAfterMs, cause);
|
|
1238
|
+
this.#setState("reconnecting");
|
|
1239
|
+
return true;
|
|
1240
|
+
}
|
|
1241
|
+
if (this.#lastError === reportedBefore) {
|
|
1242
|
+
this.#onError(isSoftphoneError(cause) && isFatal(cause) ? cause : createError("net/transport-failed", cause));
|
|
1243
|
+
}
|
|
1244
|
+
this.#setState("offline");
|
|
1245
|
+
return false;
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* The next attempt, after the shared backoff (1 s doubling to 30 s, jittered — the presence socket's own) and
|
|
1249
|
+
* never before a `Retry-After` the failure carried. A plain timer: in a hidden tab it can stretch to about a
|
|
1250
|
+
* minute, which is why the browser coming back `online`, or the tab becoming visible, attempts at once
|
|
1251
|
+
* (`watchWake`). One warning per streak, so a console says what is going on without filling up.
|
|
1252
|
+
*/
|
|
1253
|
+
#scheduleStartRetry(retryAfterMs, cause) {
|
|
1254
|
+
this.#startRetries += 1;
|
|
1255
|
+
const wait = Math.max(backoffDelayMs(this.#startRetries), retryAfterMs);
|
|
1256
|
+
if (this.#startRetries === 1) {
|
|
1257
|
+
console.warn("[softphone] the phone could not start; it keeps trying on its own", cause);
|
|
1258
|
+
}
|
|
1259
|
+
this.#startRetryNotBefore = Date.now() + retryAfterMs;
|
|
1260
|
+
if (this.#startRetryTimer !== null) clearTimeout(this.#startRetryTimer);
|
|
1261
|
+
const run = this.#run;
|
|
1262
|
+
this.#startRetryTimer = setTimeout(() => void this.#retryStart(run), wait);
|
|
1263
|
+
this.#unwatchWake ??= watchWake(() => this.#wakeStartRetry());
|
|
1264
|
+
}
|
|
1265
|
+
/** The pending attempt, now — unless a `Retry-After` says not yet, in which case it moves up to that moment. */
|
|
1266
|
+
#wakeStartRetry() {
|
|
1267
|
+
if (this.#startRetryTimer === null) return;
|
|
1268
|
+
const run = this.#run;
|
|
1269
|
+
clearTimeout(this.#startRetryTimer);
|
|
1270
|
+
const wait = this.#startRetryNotBefore - Date.now();
|
|
1271
|
+
if (wait > 0) {
|
|
1272
|
+
this.#startRetryTimer = setTimeout(() => void this.#retryStart(run), wait);
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
void this.#retryStart(run);
|
|
1276
|
+
}
|
|
1277
|
+
/** The wait and the wake-ups go; the streak's count stays (the next failure doubles on from it). */
|
|
1278
|
+
#clearStartRetryWait() {
|
|
1279
|
+
if (this.#startRetryTimer !== null) clearTimeout(this.#startRetryTimer);
|
|
1280
|
+
this.#startRetryTimer = null;
|
|
1281
|
+
this.#unwatchWake?.();
|
|
1282
|
+
this.#unwatchWake = null;
|
|
1283
|
+
}
|
|
1284
|
+
#cancelStartRetry() {
|
|
1285
|
+
this.#clearStartRetryWait();
|
|
1286
|
+
this.#startRetries = 0;
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* One attempt: what "Reintentar" does after its `stop()`, minus the goodbye and the leadership (both kept) —
|
|
1290
|
+
* the presence socket opens again and its `hello` asks for the line, the line decides (`standby` if it went
|
|
1291
|
+
* elsewhere meanwhile, as at any start), and the engine starts. Without the goodbye, an attempt inside the
|
|
1292
|
+
* server's 10 s grace gets the line straight back. Never for a softphone stopped since it was scheduled.
|
|
1293
|
+
*/
|
|
1294
|
+
async #retryStart(run) {
|
|
1295
|
+
this.#clearStartRetryWait();
|
|
1296
|
+
if (this.#destroyed || run !== this.#run || !this.#startedHere || !this.#isLeader) return;
|
|
1297
|
+
const decision = this.#openStartDecision();
|
|
1298
|
+
try {
|
|
1299
|
+
this.#lineMode = "deciding";
|
|
1300
|
+
this.#setState("starting");
|
|
1301
|
+
if (this.#presenceWatch && this.#line === "unknown") await this.#waitForFirstLine();
|
|
1302
|
+
if (this.#destroyed || run !== this.#run) return;
|
|
1303
|
+
this.#lineMode = "on";
|
|
1304
|
+
const engineStart = this.#applyLine();
|
|
1305
|
+
if (!engineStart) this.#startRetries = 0;
|
|
1306
|
+
decision.end();
|
|
1307
|
+
await engineStart?.catch(() => {
|
|
1308
|
+
});
|
|
1309
|
+
} finally {
|
|
1310
|
+
decision.end();
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
/** `start()`'s decision (`#startDecision`): ended once, by whichever comes first — the decision or its end. */
|
|
1314
|
+
#openStartDecision() {
|
|
1315
|
+
let resolve;
|
|
1316
|
+
const promise = new Promise((r) => {
|
|
1317
|
+
resolve = r;
|
|
1318
|
+
});
|
|
1319
|
+
const decision = {
|
|
1320
|
+
promise,
|
|
1321
|
+
end: () => {
|
|
1322
|
+
resolve();
|
|
1323
|
+
if (this.#startDecision === decision) this.#startDecision = null;
|
|
1324
|
+
}
|
|
1325
|
+
};
|
|
1326
|
+
this.#startDecision = decision;
|
|
1327
|
+
return decision;
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* `start()`'s wait for the server's first word on the line — released by that word, by a source that
|
|
1331
|
+
* will never say it, by the watch going away (`stop()` among them), or after `LINE_ANSWER_TIMEOUT_MS`.
|
|
1332
|
+
*/
|
|
1333
|
+
#waitForFirstLine() {
|
|
1334
|
+
return new Promise((resolve) => {
|
|
1335
|
+
const release = () => {
|
|
1336
|
+
clearTimeout(timer);
|
|
1337
|
+
if (this.#releaseFirstLine === release) this.#releaseFirstLine = null;
|
|
1338
|
+
resolve();
|
|
1339
|
+
};
|
|
1340
|
+
const timer = setTimeout(release, LINE_ANSWER_TIMEOUT_MS);
|
|
1341
|
+
this.#releaseFirstLine = release;
|
|
1342
|
+
});
|
|
1343
|
+
}
|
|
1344
|
+
#onLine(update) {
|
|
1345
|
+
this.#cancelLineLapse();
|
|
1346
|
+
this.#setLine(update.state, update.state === "here" ? null : update.where);
|
|
1347
|
+
if (this.#releaseFirstLine) this.#releaseFirstLine();
|
|
1348
|
+
else this.#syncLine();
|
|
1349
|
+
if (update.state === "here") this.#settleClaim();
|
|
1350
|
+
}
|
|
1351
|
+
#onClaimResult(result) {
|
|
1352
|
+
if (result.ok) {
|
|
1353
|
+
if (this.#line === "here") this.#settleClaim();
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
this.#settleClaim(createError(result.reason === "on_call" ? "line/claim-refused" : "line/unavailable"));
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* The presence socket dropped; the watch is reconnecting. A claim sent on it will never be answered, so
|
|
1360
|
+
* it fails now rather than on its timeout. What the server said about the line holds for `LINE_LAPSE_MS`
|
|
1361
|
+
* — a reconnect is normally answered well inside that — and then stops counting: `unknown`, which a
|
|
1362
|
+
* waiting softphone answers by listening again.
|
|
1363
|
+
*/
|
|
1364
|
+
#onPresenceDisconnect() {
|
|
1365
|
+
this.#engine.noteOffer?.(null);
|
|
1366
|
+
this.#settleClaim(createError("line/unavailable"));
|
|
1367
|
+
if (this.#line === "unknown" || this.#lineLapseTimer !== null) return;
|
|
1368
|
+
this.#lineLapseTimer = setTimeout(() => {
|
|
1369
|
+
this.#lineLapseTimer = null;
|
|
1370
|
+
this.#setLine("unknown", null);
|
|
1371
|
+
this.#syncLine();
|
|
1372
|
+
}, LINE_LAPSE_MS);
|
|
1373
|
+
}
|
|
1374
|
+
#cancelLineLapse() {
|
|
1375
|
+
if (this.#lineLapseTimer === null) return;
|
|
1376
|
+
clearTimeout(this.#lineLapseTimer);
|
|
1377
|
+
this.#lineLapseTimer = null;
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* The presence watch is gone, so nothing the server said about the line holds any more: it reads
|
|
1381
|
+
* `unknown`, and every wait on it ends. Not followed by `#applyLine` — this runs inside a state change
|
|
1382
|
+
* (`#syncPresenceWatch`) or a stop, and a softphone whose watch is gone is not in `standby` anyway: that
|
|
1383
|
+
* state keeps the watch.
|
|
1384
|
+
*/
|
|
1385
|
+
#forgetLine() {
|
|
1386
|
+
this.#cancelLineLapse();
|
|
1387
|
+
this.#releaseFirstLine?.();
|
|
1388
|
+
this.#settleClaim(createError("line/unavailable"));
|
|
1389
|
+
this.#setLine("unknown", null);
|
|
1390
|
+
this.#setLineClaim("idle");
|
|
1391
|
+
}
|
|
1392
|
+
#setLine(line, where) {
|
|
1393
|
+
if (line === "here") this.#setLineClaim("idle");
|
|
1394
|
+
if (line === this.#line && where === this.#lineWhere) return;
|
|
1395
|
+
this.#line = line;
|
|
1396
|
+
this.#lineWhere = where;
|
|
1397
|
+
this.emit("lineChanged" /* LineChanged */, { line, where });
|
|
1398
|
+
}
|
|
1399
|
+
#setLineClaim(claim) {
|
|
1400
|
+
if (claim === this.#lineClaim) return;
|
|
1401
|
+
this.#lineClaim = claim;
|
|
1402
|
+
this.emit("lineClaimChanged" /* LineClaimChanged */, { claim });
|
|
1403
|
+
}
|
|
1404
|
+
#openClaim() {
|
|
1405
|
+
let settle;
|
|
1406
|
+
const promise = new Promise((resolve, reject) => {
|
|
1407
|
+
const timer = setTimeout(() => this.#settleClaim(createError("line/unavailable")), LINE_CLAIM_TIMEOUT_MS);
|
|
1408
|
+
settle = (error) => {
|
|
1409
|
+
clearTimeout(timer);
|
|
1410
|
+
if (error) reject(error);
|
|
1411
|
+
else resolve();
|
|
1412
|
+
};
|
|
1413
|
+
});
|
|
1414
|
+
this.#claim = { promise, settle };
|
|
1415
|
+
this.#setLineClaim("claiming");
|
|
1416
|
+
return promise;
|
|
1417
|
+
}
|
|
1418
|
+
/** Ends the claim in flight, if any — resolved without `error`, rejected with it. */
|
|
1419
|
+
#settleClaim(error) {
|
|
1420
|
+
const claim = this.#claim;
|
|
1421
|
+
if (!claim) return;
|
|
1422
|
+
this.#claim = null;
|
|
1423
|
+
this.#setLineClaim(!error ? "idle" : error.code === "line/claim-refused" ? "refused" : "failed");
|
|
1424
|
+
claim.settle(error);
|
|
1425
|
+
}
|
|
884
1426
|
/**
|
|
885
1427
|
* Phase A (transfer outcome relay, `docs/specs/transfer-outcome-relay.md`) — `CallImpl.transfer()`'s
|
|
886
1428
|
* internal seam calls this. No-ops entirely when no Coventia backend is configured (`sources` is
|
|
@@ -929,6 +1471,7 @@ var SoftphoneImpl = class extends Emitter {
|
|
|
929
1471
|
};
|
|
930
1472
|
|
|
931
1473
|
// src/core/default-provider.ts
|
|
1474
|
+
var MAX_PENDING_OFFERS = 20;
|
|
932
1475
|
var LazyRelay = class {
|
|
933
1476
|
#listeners = /* @__PURE__ */ new Set();
|
|
934
1477
|
#realOff = /* @__PURE__ */ new Map();
|
|
@@ -951,16 +1494,20 @@ var LazyRelay = class {
|
|
|
951
1494
|
function defaultZadarmaEngine(config) {
|
|
952
1495
|
let real = null;
|
|
953
1496
|
let pending = null;
|
|
1497
|
+
let run = 0;
|
|
954
1498
|
const incoming = new LazyRelay();
|
|
955
1499
|
const stateChange = new LazyRelay();
|
|
956
1500
|
const error = new LazyRelay();
|
|
1501
|
+
let pendingOffers = [];
|
|
957
1502
|
function load() {
|
|
958
|
-
pending ??= import('./zadarma-engine-
|
|
1503
|
+
pending ??= import('./zadarma-engine-VYIXLBQG.js').then(({ createZadarmaEngine }) => {
|
|
959
1504
|
const engine = createZadarmaEngine(config);
|
|
960
1505
|
incoming.attach({ on: (l) => engine.onIncoming(l) });
|
|
961
1506
|
stateChange.attach({ on: (l) => engine.onStateChange(l) });
|
|
962
1507
|
error.attach({ on: (l) => engine.onError?.(l) ?? (() => {
|
|
963
1508
|
}) });
|
|
1509
|
+
for (const offer of pendingOffers) engine.noteOffer?.(offer);
|
|
1510
|
+
pendingOffers = [];
|
|
964
1511
|
real = engine;
|
|
965
1512
|
return engine;
|
|
966
1513
|
}).catch((cause) => {
|
|
@@ -974,23 +1521,52 @@ function defaultZadarmaEngine(config) {
|
|
|
974
1521
|
return real?.state ?? "stopped";
|
|
975
1522
|
},
|
|
976
1523
|
async start() {
|
|
977
|
-
|
|
1524
|
+
const current = run;
|
|
1525
|
+
const engine = await load();
|
|
1526
|
+
if (current !== run) return;
|
|
1527
|
+
await engine.start();
|
|
978
1528
|
},
|
|
979
1529
|
async stop() {
|
|
1530
|
+
run += 1;
|
|
980
1531
|
if (real) await real.stop();
|
|
981
1532
|
},
|
|
982
1533
|
async placeCall(destination, options) {
|
|
983
|
-
|
|
1534
|
+
const current = run;
|
|
1535
|
+
const engine = await load();
|
|
1536
|
+
if (current !== run) throw createError("engine/not-started");
|
|
1537
|
+
return engine.placeCall(destination, options);
|
|
984
1538
|
},
|
|
985
1539
|
onIncoming: (listener) => incoming.subscribe(listener),
|
|
986
1540
|
onStateChange: (listener) => stateChange.subscribe(listener),
|
|
987
|
-
onError: (listener) => error.subscribe(listener)
|
|
1541
|
+
onError: (listener) => error.subscribe(listener),
|
|
1542
|
+
/*
|
|
1543
|
+
* Relayed like the listeners above, or the decline guard on this — the default — path would never hear the
|
|
1544
|
+
* backend (incoming-call-reliability.md §4.4). Before the engine loads, kept (a few) rather than loading it:
|
|
1545
|
+
* a softphone in `standby` hears offers without ever starting an engine.
|
|
1546
|
+
*/
|
|
1547
|
+
noteOffer: (offer) => {
|
|
1548
|
+
if (real) {
|
|
1549
|
+
real.noteOffer?.(offer);
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
pendingOffers.push(offer);
|
|
1553
|
+
if (pendingOffers.length > MAX_PENDING_OFFERS) pendingOffers.shift();
|
|
1554
|
+
}
|
|
988
1555
|
};
|
|
989
1556
|
}
|
|
990
1557
|
|
|
991
1558
|
// src/core/create.ts
|
|
992
1559
|
function createSoftphone(config) {
|
|
993
|
-
|
|
1560
|
+
const resolved = withNormalizedBases(config);
|
|
1561
|
+
return new SoftphoneImpl(resolveEngine(resolved), resolved);
|
|
1562
|
+
}
|
|
1563
|
+
function withNormalizedBases(config) {
|
|
1564
|
+
const { apiBase, coventia } = config;
|
|
1565
|
+
return {
|
|
1566
|
+
...config,
|
|
1567
|
+
...apiBase ? { apiBase: normalizeApiBase(apiBase) } : {},
|
|
1568
|
+
...coventia?.baseUrl ? { coventia: { ...coventia, baseUrl: normalizeApiBase(coventia.baseUrl) } } : {}
|
|
1569
|
+
};
|
|
994
1570
|
}
|
|
995
1571
|
function resolveEngine(config) {
|
|
996
1572
|
if (config.provider !== void 0) {
|
|
@@ -1015,7 +1591,7 @@ function isCallEngine(value) {
|
|
|
1015
1591
|
}
|
|
1016
1592
|
|
|
1017
1593
|
// src/index.ts
|
|
1018
|
-
var VERSION = "0.
|
|
1594
|
+
var VERSION = "0.5.1";
|
|
1019
1595
|
|
|
1020
1596
|
export { CALL_TRANSITIONS, INITIAL_STATES, VERSION, canTransition, createSoftphone, isTerminal };
|
|
1021
1597
|
//# sourceMappingURL=index.js.map
|