@coventia/softphone 0.1.0
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/3x2-2HQTJDKZ.js +6583 -0
- package/dist/3x2-2HQTJDKZ.js.map +1 -0
- package/dist/3x2-INKWPNLA.js +6585 -0
- package/dist/3x2-INKWPNLA.js.map +1 -0
- package/dist/call-BTYlT2SU.d.cts +680 -0
- package/dist/call-BTYlT2SU.d.ts +680 -0
- package/dist/chunk-2ZSOGGPO.js +169 -0
- package/dist/chunk-2ZSOGGPO.js.map +1 -0
- package/dist/chunk-3V2L6UTK.js +129 -0
- package/dist/chunk-3V2L6UTK.js.map +1 -0
- package/dist/chunk-5BGEHMPP.js +945 -0
- package/dist/chunk-5BGEHMPP.js.map +1 -0
- package/dist/chunk-5WRI5ZAA.js +29 -0
- package/dist/chunk-5WRI5ZAA.js.map +1 -0
- package/dist/chunk-KAXEMJ7E.js +11 -0
- package/dist/chunk-KAXEMJ7E.js.map +1 -0
- package/dist/chunk-MHOJHG5E.js +39 -0
- package/dist/chunk-MHOJHG5E.js.map +1 -0
- package/dist/chunk-MP7CSA24.js +299 -0
- package/dist/chunk-MP7CSA24.js.map +1 -0
- package/dist/chunk-N2N2EQQJ.js +29 -0
- package/dist/chunk-N2N2EQQJ.js.map +1 -0
- package/dist/element/index.d.ts +853 -0
- package/dist/element/index.js +9635 -0
- package/dist/element/index.js.map +1 -0
- package/dist/engine-D9nbeY08.d.cts +115 -0
- package/dist/engine-Doa6Brkm.d.ts +115 -0
- package/dist/index.cjs +2133 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +62 -0
- package/dist/index.d.ts +62 -0
- package/dist/index.js +630 -0
- package/dist/index.js.map +1 -0
- package/dist/min-64GIMWWL.js +5456 -0
- package/dist/min-64GIMWWL.js.map +1 -0
- package/dist/min-ATXYR23R.js +5458 -0
- package/dist/min-ATXYR23R.js.map +1 -0
- package/dist/react/index.cjs +138 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +68 -0
- package/dist/react/index.d.ts +68 -0
- package/dist/react/index.js +4 -0
- package/dist/react/index.js.map +1 -0
- package/dist/softphone-DHmhotbf.d.ts +128 -0
- package/dist/softphone-DhuhcKCy.d.cts +128 -0
- package/dist/softphone.css +1923 -0
- package/dist/sources-N4BOEDRL.js +226 -0
- package/dist/sources-N4BOEDRL.js.map +1 -0
- package/dist/testing/index.cjs +345 -0
- package/dist/testing/index.cjs.map +1 -0
- package/dist/testing/index.d.cts +66 -0
- package/dist/testing/index.d.ts +66 -0
- package/dist/testing/index.js +186 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/ui/index.cjs +15428 -0
- package/dist/ui/index.cjs.map +1 -0
- package/dist/ui/index.d.cts +188 -0
- package/dist/ui/index.d.ts +188 -0
- package/dist/ui/index.js +2256 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/zadarma/index.cjs +1144 -0
- package/dist/zadarma/index.cjs.map +1 -0
- package/dist/zadarma/index.d.cts +86 -0
- package/dist/zadarma/index.d.ts +86 -0
- package/dist/zadarma/index.js +5 -0
- package/dist/zadarma/index.js.map +1 -0
- package/dist/zadarma-engine-DN2SZSQ3.js +5 -0
- package/dist/zadarma-engine-DN2SZSQ3.js.map +1 -0
- package/package.json +123 -0
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
import { createError, isSoftphoneError } from './chunk-2ZSOGGPO.js';
|
|
2
|
+
import { cachedTokenSource, invalidateToken } from './chunk-MHOJHG5E.js';
|
|
3
|
+
|
|
4
|
+
// src/transports/sip/media.ts
|
|
5
|
+
function buildAudioConstraints(deviceId) {
|
|
6
|
+
return { audio: deviceId ? { deviceId: { exact: deviceId } } : true, video: false };
|
|
7
|
+
}
|
|
8
|
+
function capIceGathering(session, capMs) {
|
|
9
|
+
let settled = false;
|
|
10
|
+
let timer;
|
|
11
|
+
const settle = (ready) => {
|
|
12
|
+
if (settled) return;
|
|
13
|
+
settled = true;
|
|
14
|
+
if (timer) clearTimeout(timer);
|
|
15
|
+
try {
|
|
16
|
+
ready();
|
|
17
|
+
} catch {
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
session.on("icecandidate", ({ ready }) => {
|
|
21
|
+
if (!timer) timer = setTimeout(() => settle(ready), capMs);
|
|
22
|
+
});
|
|
23
|
+
return () => {
|
|
24
|
+
if (timer) clearTimeout(timer);
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// src/transports/sip/ice.ts
|
|
29
|
+
var DEFAULT_ICE_SERVERS = [
|
|
30
|
+
{ urls: "stun:stun.l.google.com:19302" }
|
|
31
|
+
];
|
|
32
|
+
var PC_CONFIG = { iceServers: [...DEFAULT_ICE_SERVERS] };
|
|
33
|
+
|
|
34
|
+
// src/transports/sip/session.ts
|
|
35
|
+
function mapEndReason(e) {
|
|
36
|
+
if (e.originator === "local") return "local";
|
|
37
|
+
if (e.originator === "remote") return "remote";
|
|
38
|
+
return "failed";
|
|
39
|
+
}
|
|
40
|
+
function mapFailReason(e) {
|
|
41
|
+
switch (e.cause) {
|
|
42
|
+
// 486 Busy Here, 600 Busy Everywhere. Also what most endpoints send for a MANUAL DECLINE — SIP
|
|
43
|
+
// does not distinguish the two, and neither can we, which is why the copy covers both.
|
|
44
|
+
case "Busy":
|
|
45
|
+
return "busy";
|
|
46
|
+
/*
|
|
47
|
+
`Rejected` is 403 / 603 Decline — an explicit refusal, sent by the endpoints that bother to.
|
|
48
|
+
`Redirected` is 3xx: not literally a rejection, but it ends the same way — not connected, and
|
|
49
|
+
nothing to retry.
|
|
50
|
+
|
|
51
|
+
(Both comments sit ABOVE the group rather than between the labels. A comment between two `case`
|
|
52
|
+
lines makes the first one non-empty to `no-fallthrough`, which then demands a `break` that
|
|
53
|
+
would be unreachable.)
|
|
54
|
+
*/
|
|
55
|
+
case "Rejected":
|
|
56
|
+
case "Redirected":
|
|
57
|
+
return "rejected";
|
|
58
|
+
/*
|
|
59
|
+
`Unavailable` is 480 Temporarily Unavailable / 410 / 408 Request Timeout / 430 — THE ring-out
|
|
60
|
+
case, and the one that was missing. `No Answer` and `Expires` are local: our own no-answer
|
|
61
|
+
timer, and a session that expired.
|
|
62
|
+
*/
|
|
63
|
+
case "Unavailable":
|
|
64
|
+
case "No Answer":
|
|
65
|
+
case "Expires":
|
|
66
|
+
return "no-answer";
|
|
67
|
+
case "Canceled":
|
|
68
|
+
return e.originator === "local" ? "local" : "remote";
|
|
69
|
+
// Everything else is a genuine failure worth naming: Not Found (404/604), Address Incomplete,
|
|
70
|
+
// Incompatible SDP, Authentication Error, WebRTC Error, RTP Timeout, Connection Error…
|
|
71
|
+
default:
|
|
72
|
+
return "failed";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function remoteNumberOf(session) {
|
|
76
|
+
const id = session.remote_identity;
|
|
77
|
+
return id?.uri?.user ?? id?.uri?.toString() ?? "";
|
|
78
|
+
}
|
|
79
|
+
var SipEngineCall = class {
|
|
80
|
+
id;
|
|
81
|
+
direction;
|
|
82
|
+
remoteNumber;
|
|
83
|
+
#session;
|
|
84
|
+
#deps;
|
|
85
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
86
|
+
#ended = false;
|
|
87
|
+
#cancelIceCap = null;
|
|
88
|
+
/**
|
|
89
|
+
* The stream behind a LIVE device swap (`swapInputDevice`), if one has happened. Distinct from
|
|
90
|
+
* JsSIP's own original capture: that one is `_localMediaStreamLocallyGenerated` and JsSIP stops it
|
|
91
|
+
* itself on close (see `#end`'s comment). This one is ours — obtained with our own `getUserMedia`
|
|
92
|
+
* call, attached via `replaceTrack`, and never known to JsSIP at all — so nothing else will ever
|
|
93
|
+
* stop it unless we do, here.
|
|
94
|
+
*/
|
|
95
|
+
#swappedStream = null;
|
|
96
|
+
constructor(session, deps) {
|
|
97
|
+
this.#session = session;
|
|
98
|
+
this.#deps = deps;
|
|
99
|
+
this.id = deps.id;
|
|
100
|
+
this.direction = session.direction === "incoming" ? "inbound" : "outbound";
|
|
101
|
+
this.remoteNumber = remoteNumberOf(session);
|
|
102
|
+
this.#wire();
|
|
103
|
+
}
|
|
104
|
+
/** @internal — the underlying session, for `completeTransfer`'s REFER…Replaces. */
|
|
105
|
+
get session() {
|
|
106
|
+
return this.#session;
|
|
107
|
+
}
|
|
108
|
+
onEvent(listener) {
|
|
109
|
+
this.#listeners.add(listener);
|
|
110
|
+
return () => this.#listeners.delete(listener);
|
|
111
|
+
}
|
|
112
|
+
// ── JsSIP events → raw engine signals ─────────────────────────────────────
|
|
113
|
+
#wire() {
|
|
114
|
+
const s = this.#session;
|
|
115
|
+
if (this.direction === "outbound") {
|
|
116
|
+
s.on("progress", () => this.#fire({ type: "progress", response: "ringing" }));
|
|
117
|
+
}
|
|
118
|
+
const wireRemoteTrack = (pc) => {
|
|
119
|
+
pc.ontrack = ({ streams }) => {
|
|
120
|
+
if (streams[0]) this.#fire({ type: "stream", stream: streams[0] });
|
|
121
|
+
};
|
|
122
|
+
};
|
|
123
|
+
s.on("peerconnection", ({ peerconnection }) => wireRemoteTrack(peerconnection));
|
|
124
|
+
if (s.connection) wireRemoteTrack(s.connection);
|
|
125
|
+
if (this.direction === "outbound" && this.#deps.iceGatheringCapMs) {
|
|
126
|
+
this.#cancelIceCap = capIceGathering(s, this.#deps.iceGatheringCapMs);
|
|
127
|
+
}
|
|
128
|
+
s.on("accepted", () => this.#onAccepted());
|
|
129
|
+
s.on("ended", (e) => this.#end(mapEndReason(e), e.cause));
|
|
130
|
+
s.on("failed", (e) => this.#end(mapFailReason(e), e.cause));
|
|
131
|
+
s.on("hold", ({ originator }) => this.#fire({ type: "held", originator }));
|
|
132
|
+
s.on("unhold", ({ originator }) => this.#fire({ type: "unheld", originator }));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The call is live. Nothing to swap: JsSIP opened the real microphone when the session was created,
|
|
136
|
+
* so the far end has had our actual audio from the first packet.
|
|
137
|
+
*
|
|
138
|
+
* There used to be a silent-mic dance here — place the call with a synthetic AudioContext track,
|
|
139
|
+
* swap the real mic in on accept — so a Bluetooth headset stayed in A2DP while the phone rang. It
|
|
140
|
+
* cost more than it bought: with no active capture, Firefox restricts ICE gathering and the offer
|
|
141
|
+
* goes out with no candidates at all, which no STUN or TURN server can fix.
|
|
142
|
+
*/
|
|
143
|
+
#onAccepted() {
|
|
144
|
+
this.#fire({ type: "accepted" });
|
|
145
|
+
}
|
|
146
|
+
// ── Commands ───────────────────────────────────────────────────────────────
|
|
147
|
+
async answer() {
|
|
148
|
+
const deviceId = this.#deps.getInputDeviceId?.() ?? null;
|
|
149
|
+
this.#session.answer({
|
|
150
|
+
mediaConstraints: buildAudioConstraints(deviceId),
|
|
151
|
+
pcConfig: this.#deps.pcConfig ?? PC_CONFIG
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Live-swap the outgoing microphone track — `CallEngine.setInputDevice` on a call that is already
|
|
156
|
+
* connected. Never touches the FIRST call's capture: that one is JsSIP's own `getUserMedia`, opened
|
|
157
|
+
* inside `answer()`/`ua.call()` (see `#onAccepted`'s comment on why that stays that way). This is
|
|
158
|
+
* only for a device chosen AFTER the call is already live.
|
|
159
|
+
*
|
|
160
|
+
* `RTCRtpSender.replaceTrack` rather than a fresh `answer()`/re-INVITE: it needs no SDP
|
|
161
|
+
* renegotiation and the far end never sees an interruption — exactly the mechanism real softphones
|
|
162
|
+
* use for "switch mic mid-call."
|
|
163
|
+
*/
|
|
164
|
+
async swapInputDevice(deviceId) {
|
|
165
|
+
const pc = this.#session.connection;
|
|
166
|
+
if (!pc || this.#ended) return;
|
|
167
|
+
const sender = pc.getSenders().find((s) => s.track?.kind === "audio");
|
|
168
|
+
if (!sender) return;
|
|
169
|
+
let stream;
|
|
170
|
+
try {
|
|
171
|
+
stream = await navigator.mediaDevices.getUserMedia(buildAudioConstraints(deviceId));
|
|
172
|
+
} catch {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (this.#ended) {
|
|
176
|
+
for (const track2 of stream.getTracks()) track2.stop();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const [track] = stream.getAudioTracks();
|
|
180
|
+
if (!track) return;
|
|
181
|
+
await sender.replaceTrack(track);
|
|
182
|
+
for (const oldTrack of this.#swappedStream?.getTracks() ?? []) oldTrack.stop();
|
|
183
|
+
this.#swappedStream = stream;
|
|
184
|
+
}
|
|
185
|
+
async reject() {
|
|
186
|
+
this.#deps.onReject?.();
|
|
187
|
+
this.#session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
|
|
188
|
+
}
|
|
189
|
+
async hangup() {
|
|
190
|
+
this.#session.terminate();
|
|
191
|
+
}
|
|
192
|
+
mute(muted) {
|
|
193
|
+
if (muted) this.#session.mute({ audio: true });
|
|
194
|
+
else this.#session.unmute({ audio: true });
|
|
195
|
+
}
|
|
196
|
+
async hold() {
|
|
197
|
+
this.#session.hold();
|
|
198
|
+
}
|
|
199
|
+
async unhold() {
|
|
200
|
+
this.#session.unhold();
|
|
201
|
+
}
|
|
202
|
+
sendDigits(digits) {
|
|
203
|
+
this.#session.sendDTMF(digits);
|
|
204
|
+
}
|
|
205
|
+
async transfer(target) {
|
|
206
|
+
this.#session.refer(this.#deps.buildTarget(target));
|
|
207
|
+
}
|
|
208
|
+
async startConsult(target) {
|
|
209
|
+
return this.#deps.placeConsult(target);
|
|
210
|
+
}
|
|
211
|
+
async completeTransfer(consult) {
|
|
212
|
+
const consultSession = consult.session;
|
|
213
|
+
this.#session.refer(consultSession.remote_identity.uri.toString(), { replaces: consultSession });
|
|
214
|
+
}
|
|
215
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
216
|
+
#end(reason, cause) {
|
|
217
|
+
if (this.#ended) return;
|
|
218
|
+
this.#ended = true;
|
|
219
|
+
this.#cancelIceCap?.();
|
|
220
|
+
for (const track of this.#swappedStream?.getTracks() ?? []) track.stop();
|
|
221
|
+
this.#swappedStream = null;
|
|
222
|
+
this.#fire({ type: "ended", reason, cause });
|
|
223
|
+
this.#listeners.clear();
|
|
224
|
+
}
|
|
225
|
+
#fire(event) {
|
|
226
|
+
if (this.#ended && event.type !== "ended") return;
|
|
227
|
+
for (const listener of [...this.#listeners]) listener(event);
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
// src/transports/sip/ua.ts
|
|
232
|
+
var DEFAULT_ICE_CAP_MS = 3e3;
|
|
233
|
+
var REGISTER_EXPIRES = 580;
|
|
234
|
+
var SIGNAL_REFRESH_DEBOUNCE_MS = 3e4;
|
|
235
|
+
var ERROR_DEBOUNCE_MS = 3e4;
|
|
236
|
+
var CALL_END_CODES = {
|
|
237
|
+
busy: "call/busy",
|
|
238
|
+
"no-answer": "call/no-answer",
|
|
239
|
+
rejected: "call/rejected",
|
|
240
|
+
failed: "call/failed"
|
|
241
|
+
};
|
|
242
|
+
function domainOf(credentials) {
|
|
243
|
+
const at = credentials.uri.lastIndexOf("@");
|
|
244
|
+
return at === -1 ? "" : credentials.uri.slice(at + 1);
|
|
245
|
+
}
|
|
246
|
+
var callSeq = 0;
|
|
247
|
+
var nextCallId = () => `sip-${++callSeq}`;
|
|
248
|
+
var SipCallEngine = class {
|
|
249
|
+
constructor(provider, factory, options = {}) {
|
|
250
|
+
this.provider = provider;
|
|
251
|
+
this.factory = factory;
|
|
252
|
+
this.now = options.now ?? Date.now;
|
|
253
|
+
}
|
|
254
|
+
provider;
|
|
255
|
+
factory;
|
|
256
|
+
#state = "stopped";
|
|
257
|
+
#ua = null;
|
|
258
|
+
/** In-flight `#createUA()`. THE concurrency guard — see `#ensureUA`. */
|
|
259
|
+
#uaInFlight = null;
|
|
260
|
+
#domain = "";
|
|
261
|
+
#activeCall = null;
|
|
262
|
+
/** The chosen microphone, or `null` for the browser default. Read fresh by every new leg's
|
|
263
|
+
* `mediaConstraints`, and pushed live to `#activeCall` by `setInputDevice`. */
|
|
264
|
+
#inputDeviceId = null;
|
|
265
|
+
#incomingListeners = /* @__PURE__ */ new Set();
|
|
266
|
+
#stateListeners = /* @__PURE__ */ new Set();
|
|
267
|
+
#errorListeners = /* @__PURE__ */ new Set();
|
|
268
|
+
#signalUnsub = null;
|
|
269
|
+
#signalConnectionUnsub = null;
|
|
270
|
+
#noInviteTimer;
|
|
271
|
+
#rejectGuardUntil = 0;
|
|
272
|
+
#signalRefreshAfter = 0;
|
|
273
|
+
#errorSentAfter = /* @__PURE__ */ new Map();
|
|
274
|
+
now;
|
|
275
|
+
#resolvedFactory = null;
|
|
276
|
+
async #resolveFactory() {
|
|
277
|
+
if (this.#resolvedFactory) return this.#resolvedFactory;
|
|
278
|
+
this.#resolvedFactory = typeof this.factory === "function" ? await this.factory() : this.factory;
|
|
279
|
+
return this.#resolvedFactory;
|
|
280
|
+
}
|
|
281
|
+
get state() {
|
|
282
|
+
return this.#state;
|
|
283
|
+
}
|
|
284
|
+
onIncoming(listener) {
|
|
285
|
+
this.#incomingListeners.add(listener);
|
|
286
|
+
return () => this.#incomingListeners.delete(listener);
|
|
287
|
+
}
|
|
288
|
+
onStateChange(listener) {
|
|
289
|
+
this.#stateListeners.add(listener);
|
|
290
|
+
return () => this.#stateListeners.delete(listener);
|
|
291
|
+
}
|
|
292
|
+
onError(listener) {
|
|
293
|
+
this.#errorListeners.add(listener);
|
|
294
|
+
return () => this.#errorListeners.delete(listener);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Switch the microphone: stored for every FUTURE leg's `mediaConstraints`, and — if a call is live
|
|
298
|
+
* right now — pushed to it immediately via `SipEngineCall.swapInputDevice` (SPEC §7 Phase 2, device
|
|
299
|
+
* management). `null` resets to the browser's own default device.
|
|
300
|
+
*/
|
|
301
|
+
async setInputDevice(deviceId) {
|
|
302
|
+
this.#inputDeviceId = deviceId;
|
|
303
|
+
await this.#activeCall?.swapInputDevice(deviceId);
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Report a failure, at most once per code per `ERROR_DEBOUNCE_MS`.
|
|
307
|
+
*
|
|
308
|
+
* A failing registrar retries, and one emission per attempt would flood a host's logging and make
|
|
309
|
+
* whatever the widget shows flicker. Debounced per CODE rather than globally, so a transport
|
|
310
|
+
* failure is never swallowed because a registration failure happened to precede it.
|
|
311
|
+
*/
|
|
312
|
+
#reportError(code, cause) {
|
|
313
|
+
this.#emitError(createError(code, cause));
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Emit, debouncing FATAL codes only.
|
|
317
|
+
*
|
|
318
|
+
* A failing registrar retries, and one emission per attempt would flood a host's logging and make
|
|
319
|
+
* whatever the widget shows flicker — so the same fatal code is reported at most once per window,
|
|
320
|
+
* per CODE, so a transport failure is never swallowed because a registration failure preceded it.
|
|
321
|
+
*
|
|
322
|
+
* Non-fatal codes are never debounced: they describe one call each, and two busy tones in a row are
|
|
323
|
+
* two facts, not a repeated one.
|
|
324
|
+
*/
|
|
325
|
+
#emitError(error) {
|
|
326
|
+
if (error.fatal) {
|
|
327
|
+
const now = this.now();
|
|
328
|
+
if (now < (this.#errorSentAfter.get(error.code) ?? 0)) return;
|
|
329
|
+
this.#errorSentAfter.set(error.code, now + ERROR_DEBOUNCE_MS);
|
|
330
|
+
}
|
|
331
|
+
for (const listener of [...this.#errorListeners]) listener(error);
|
|
332
|
+
}
|
|
333
|
+
async start() {
|
|
334
|
+
this.#setState("starting");
|
|
335
|
+
if (this.provider.registrationMode === "persistent") {
|
|
336
|
+
await this.#ensureUA();
|
|
337
|
+
this.#setState("online");
|
|
338
|
+
} else {
|
|
339
|
+
const signal = this.provider.incomingSignal;
|
|
340
|
+
if (signal) {
|
|
341
|
+
await signal.connect();
|
|
342
|
+
this.#signalUnsub = signal.onSignal(() => void this.#onIncomingSignal());
|
|
343
|
+
this.#signalConnectionUnsub = signal.onConnectionChange?.(
|
|
344
|
+
(connected) => this.#setState(connected ? "online" : "offline")
|
|
345
|
+
) ?? null;
|
|
346
|
+
}
|
|
347
|
+
this.#setState(signal?.onConnectionChange ? signal.connected ? "online" : "offline" : "online");
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
async stop() {
|
|
351
|
+
this.#clearNoInviteTimer();
|
|
352
|
+
this.#signalUnsub?.();
|
|
353
|
+
this.#signalUnsub = null;
|
|
354
|
+
this.#signalConnectionUnsub?.();
|
|
355
|
+
this.#signalConnectionUnsub = null;
|
|
356
|
+
this.provider.incomingSignal?.disconnect();
|
|
357
|
+
this.#teardownUA();
|
|
358
|
+
this.#setState("stopped");
|
|
359
|
+
}
|
|
360
|
+
async placeCall(destination, _options) {
|
|
361
|
+
const ua = await this.#ensureUA();
|
|
362
|
+
const session = await this.#callWhenRegistered(ua, this.#buildTarget(destination));
|
|
363
|
+
return this.#wrapCall(session);
|
|
364
|
+
}
|
|
365
|
+
// ── incoming (on-demand: push → register → INVITE) ────────────────────────
|
|
366
|
+
async #onIncomingSignal() {
|
|
367
|
+
if (this.now() < this.#rejectGuardUntil) return;
|
|
368
|
+
if (this.#activeCall) return;
|
|
369
|
+
try {
|
|
370
|
+
await this.#ensureUA();
|
|
371
|
+
this.#armNoInviteTeardown();
|
|
372
|
+
} catch {
|
|
373
|
+
this.#teardownUA();
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
#armNoInviteTeardown() {
|
|
377
|
+
const ms = this.provider.quirks?.noInviteTeardownMs;
|
|
378
|
+
if (!ms) return;
|
|
379
|
+
this.#clearNoInviteTimer();
|
|
380
|
+
this.#noInviteTimer = setTimeout(() => {
|
|
381
|
+
if (!this.#activeCall) this.#teardownUA();
|
|
382
|
+
}, ms);
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Rebuild the incoming-signal channel with fresh credentials, after a registration failure.
|
|
386
|
+
*
|
|
387
|
+
* Registration fails when the credentials behind it have gone stale — and for Zadarma those come
|
|
388
|
+
* from the same WebRTC key the push socket is holding. Tearing down the UA alone leaves that stale
|
|
389
|
+
* key in place, so every subsequent attempt fails the same way and the phone is dead until the page
|
|
390
|
+
* is reloaded. Reconnecting re-fetches the key. The legacy hook did exactly this from both of its
|
|
391
|
+
* `registrationFailed` handlers.
|
|
392
|
+
*
|
|
393
|
+
* Debounced, which the legacy was not: a provider failing every attempt would otherwise become a
|
|
394
|
+
* tight loop against our backend and theirs.
|
|
395
|
+
*/
|
|
396
|
+
async #refreshSignal() {
|
|
397
|
+
const signal = this.provider.incomingSignal;
|
|
398
|
+
if (!signal) return;
|
|
399
|
+
if (this.now() < this.#signalRefreshAfter) return;
|
|
400
|
+
this.#signalRefreshAfter = this.now() + SIGNAL_REFRESH_DEBOUNCE_MS;
|
|
401
|
+
try {
|
|
402
|
+
signal.disconnect();
|
|
403
|
+
await signal.connect();
|
|
404
|
+
} catch {
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
#clearNoInviteTimer() {
|
|
408
|
+
if (this.#noInviteTimer) clearTimeout(this.#noInviteTimer);
|
|
409
|
+
this.#noInviteTimer = void 0;
|
|
410
|
+
}
|
|
411
|
+
#onNewSession(session, originator) {
|
|
412
|
+
if (originator !== "remote") return;
|
|
413
|
+
this.#clearNoInviteTimer();
|
|
414
|
+
if (this.#activeCall || this.now() < this.#rejectGuardUntil) {
|
|
415
|
+
session.terminate({ status_code: 486, reason_phrase: "Busy Here" });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const call = this.#wrapCall(session);
|
|
419
|
+
for (const listener of [...this.#incomingListeners]) listener(call);
|
|
420
|
+
}
|
|
421
|
+
// ── UA lifecycle ───────────────────────────────────────────────────────────
|
|
422
|
+
/**
|
|
423
|
+
* The single UA, created at most once — even under concurrent callers.
|
|
424
|
+
*
|
|
425
|
+
* **The `#ua` check alone is not a guard**, because everything after it awaits: two callers both
|
|
426
|
+
* see `null`, both `provision()`, both build a UA, and both REGISTER the same extension. Zadarma
|
|
427
|
+
* rejects the loser, `registrationFailed` tears down, and the survivor may be the one that was
|
|
428
|
+
* torn down. The visible symptom is two `get_webphone_data.php` calls at startup and a phone that
|
|
429
|
+
* never registers.
|
|
430
|
+
*
|
|
431
|
+
* Concurrency is the normal case here, not an edge: the push socket can signal twice in a
|
|
432
|
+
* heartbeat (SPEC §3.3 — a push is not an INVITE; it fires for transfers, retries and hangups),
|
|
433
|
+
* and `placeCall` can land on top of one. So the in-flight promise IS the guard.
|
|
434
|
+
*/
|
|
435
|
+
async #ensureUA() {
|
|
436
|
+
if (this.#ua) return this.#ua;
|
|
437
|
+
this.#uaInFlight ??= this.#createUA().finally(() => {
|
|
438
|
+
this.#uaInFlight = null;
|
|
439
|
+
});
|
|
440
|
+
return this.#uaInFlight;
|
|
441
|
+
}
|
|
442
|
+
async #createUA() {
|
|
443
|
+
const factory = await this.#resolveFactory();
|
|
444
|
+
let credentials;
|
|
445
|
+
try {
|
|
446
|
+
credentials = await this.provider.provision();
|
|
447
|
+
} catch (cause) {
|
|
448
|
+
if (isSoftphoneError(cause)) this.#emitError(cause);
|
|
449
|
+
else this.#reportError("provider/provision-failed", cause);
|
|
450
|
+
throw cause;
|
|
451
|
+
}
|
|
452
|
+
this.#domain = domainOf(credentials);
|
|
453
|
+
const config = this.#uaConfig(credentials, factory);
|
|
454
|
+
const ua = factory.createUA(config);
|
|
455
|
+
ua.on("newRTCSession", ({ session, originator }) => this.#onNewSession(session, originator));
|
|
456
|
+
ua.on("disconnected", ({ error }) => {
|
|
457
|
+
if (!error) return;
|
|
458
|
+
this.#setState("reconnecting");
|
|
459
|
+
this.#reportError("net/transport-failed", error);
|
|
460
|
+
});
|
|
461
|
+
ua.on("connected", () => this.#setState("online"));
|
|
462
|
+
ua.on("registrationFailed", (e) => {
|
|
463
|
+
const detail = e;
|
|
464
|
+
this.#reportError("net/registration-failed", {
|
|
465
|
+
cause: detail?.cause,
|
|
466
|
+
status: detail?.response?.status_code,
|
|
467
|
+
domain: this.#domain,
|
|
468
|
+
hint: "A bare 401 with no auth challenge usually means the registrar is in the wrong datacenter (SPEC \xA73.1)."
|
|
469
|
+
});
|
|
470
|
+
this.#teardownUA();
|
|
471
|
+
void this.#refreshSignal();
|
|
472
|
+
});
|
|
473
|
+
this.#ua = ua;
|
|
474
|
+
ua.start();
|
|
475
|
+
return ua;
|
|
476
|
+
}
|
|
477
|
+
#uaConfig(credentials, factory) {
|
|
478
|
+
return {
|
|
479
|
+
sockets: [factory.createWebSocket(credentials.wsUrl)],
|
|
480
|
+
uri: credentials.uri,
|
|
481
|
+
authorization_user: credentials.authUser,
|
|
482
|
+
password: credentials.password,
|
|
483
|
+
register: true,
|
|
484
|
+
register_expires: REGISTER_EXPIRES,
|
|
485
|
+
connection_recovery_min_interval: 2,
|
|
486
|
+
connection_recovery_max_interval: 30,
|
|
487
|
+
// Generic — NOT a provider brand string. If a provider ever requires a specific UA header it
|
|
488
|
+
// belongs in the provider, not hard-coded here.
|
|
489
|
+
// NOT ours to choose: Zadarma refuses to register any client whose User-Agent it does not
|
|
490
|
+
// recognise (see ProviderQuirks.userAgent). The provider decides; the engine just sends it.
|
|
491
|
+
user_agent: this.provider.quirks?.userAgent ?? "Coventia Softphone"
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
#teardownUA() {
|
|
495
|
+
this.#uaInFlight = null;
|
|
496
|
+
if (!this.#ua) return;
|
|
497
|
+
try {
|
|
498
|
+
this.#ua.terminateSessions({ status_code: 487 });
|
|
499
|
+
this.#ua.stop();
|
|
500
|
+
} catch {
|
|
501
|
+
}
|
|
502
|
+
this.#ua = null;
|
|
503
|
+
this.#activeCall = null;
|
|
504
|
+
}
|
|
505
|
+
/** Place a call once the UA is registered (its 200 OK for REGISTER has arrived). */
|
|
506
|
+
/**
|
|
507
|
+
* **No `mediaStream`** — JsSIP calls `getUserMedia` itself, which is what opens a real capture.
|
|
508
|
+
*
|
|
509
|
+
* That is load-bearing, not incidental. Firefox restricts ICE gathering on a page with no active
|
|
510
|
+
* capture: it discards every address but the default route's ("Ignoring addr from interface other
|
|
511
|
+
* than …" in about:webrtc) and can end up gathering NOTHING, so the INVITE goes out with
|
|
512
|
+
* `c=IN IP4 0.0.0.0` and no candidates at all. No STUN or TURN server can rescue an offer that
|
|
513
|
+
* contains no candidates. The legacy hook passed `mediaConstraints` and nothing else, for what
|
|
514
|
+
* turns out to be a very good reason.
|
|
515
|
+
*/
|
|
516
|
+
#callWhenRegistered(ua, target) {
|
|
517
|
+
const options = { mediaConstraints: buildAudioConstraints(this.#inputDeviceId), pcConfig: PC_CONFIG };
|
|
518
|
+
if (ua.isRegistered()) return Promise.resolve(ua.call(target, options));
|
|
519
|
+
return new Promise((resolve) => {
|
|
520
|
+
ua.on("registered", () => resolve(ua.call(target, options)));
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
#wrapCall(session) {
|
|
524
|
+
const call = new SipEngineCall(session, {
|
|
525
|
+
id: nextCallId(),
|
|
526
|
+
buildTarget: (t) => this.#buildTarget(t),
|
|
527
|
+
placeConsult: (t) => this.#placeConsult(t),
|
|
528
|
+
// The guard is tied to the reject ACTION, not to `ended` — a normal hangup must not arm it.
|
|
529
|
+
onReject: () => this.#startRejectGuard(),
|
|
530
|
+
iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS,
|
|
531
|
+
// Inbound answers open their own peer connection, so they need the same ICE servers the
|
|
532
|
+
// outbound leg got. Missing here, an ANSWERED call is the one that fails — the half nobody
|
|
533
|
+
// tests until a real person picks up.
|
|
534
|
+
pcConfig: PC_CONFIG,
|
|
535
|
+
// Read fresh at answer() time, not captured now — a device chosen while the phone is still
|
|
536
|
+
// ringing must still apply once it is picked up.
|
|
537
|
+
getInputDeviceId: () => this.#inputDeviceId
|
|
538
|
+
});
|
|
539
|
+
this.#activeCall = call;
|
|
540
|
+
call.onEvent((event) => {
|
|
541
|
+
if (event.type === "ended") {
|
|
542
|
+
const missed = call.direction === "inbound" && event.cause === "Canceled";
|
|
543
|
+
const code = missed ? "call/missed" : CALL_END_CODES[event.reason];
|
|
544
|
+
if (code) this.#reportError(code, event.cause);
|
|
545
|
+
if (this.#activeCall === call) this.#activeCall = null;
|
|
546
|
+
if (this.provider.registrationMode === "on-demand") this.#teardownUA();
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
return call;
|
|
550
|
+
}
|
|
551
|
+
async #placeConsult(target) {
|
|
552
|
+
const ua = await this.#ensureUA();
|
|
553
|
+
const consultSession = ua.call(this.#buildTarget(target), {
|
|
554
|
+
mediaConstraints: buildAudioConstraints(this.#inputDeviceId),
|
|
555
|
+
pcConfig: PC_CONFIG
|
|
556
|
+
});
|
|
557
|
+
return new SipEngineCall(consultSession, {
|
|
558
|
+
id: nextCallId(),
|
|
559
|
+
buildTarget: (x) => this.#buildTarget(x),
|
|
560
|
+
placeConsult: () => Promise.reject(new Error("cannot consult from a consult leg")),
|
|
561
|
+
iceGatheringCapMs: this.provider.quirks?.iceGatheringCapMs ?? DEFAULT_ICE_CAP_MS
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
#startRejectGuard() {
|
|
565
|
+
const ms = this.provider.quirks?.rejectGuardMs;
|
|
566
|
+
if (ms) this.#rejectGuardUntil = this.now() + ms;
|
|
567
|
+
}
|
|
568
|
+
#buildTarget(target) {
|
|
569
|
+
return target.includes("@") ? target : `sip:${target}@${this.#domain}`;
|
|
570
|
+
}
|
|
571
|
+
#setState(state) {
|
|
572
|
+
if (state === this.#state) return;
|
|
573
|
+
this.#state = state;
|
|
574
|
+
for (const listener of [...this.#stateListeners]) listener(state);
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
// src/transports/sip/real-factory.ts
|
|
579
|
+
var cached = null;
|
|
580
|
+
async function loadJsSipFactory() {
|
|
581
|
+
if (cached) return cached;
|
|
582
|
+
const mod = await import('jssip');
|
|
583
|
+
const JsSIP = mod.default ?? mod;
|
|
584
|
+
if (!JsSIP?.UA || !JsSIP?.WebSocketInterface) {
|
|
585
|
+
throw new Error("jssip: unexpected module shape (no UA / WebSocketInterface)");
|
|
586
|
+
}
|
|
587
|
+
cached = {
|
|
588
|
+
createWebSocket: (wsUrl) => new JsSIP.WebSocketInterface(wsUrl),
|
|
589
|
+
createUA: (config) => new JsSIP.UA(config)
|
|
590
|
+
};
|
|
591
|
+
return cached;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/transports/providers/zadarma.ts
|
|
595
|
+
var PING = { localMs: 3e4, globalMs: 6e4 };
|
|
596
|
+
var KEY_REFRESH_BUFFER_MS = 2 * 60 * 60 * 1e3;
|
|
597
|
+
var ZADARMA_QUIRKS = {
|
|
598
|
+
// Zadarma's registrar REFUSES to register a WebRTC client with any other User-Agent — a clean 401
|
|
599
|
+
// after a valid digest challenge, indistinguishable from a wrong password (§3.1). The old CRM
|
|
600
|
+
// hook carried this string; the port dropped it, and registration failed for exactly that reason.
|
|
601
|
+
userAgent: "Zadarma webRTC",
|
|
602
|
+
// Zadarma re-rings a declined call for ~30 s (return_timeout). Swallow those (§3.4).
|
|
603
|
+
rejectGuardMs: 35e3,
|
|
604
|
+
// A push is not an INVITE (§3.3). If none arrives, tear the fresh UA down.
|
|
605
|
+
noInviteTeardownMs: 5e3,
|
|
606
|
+
// A ceiling on ICE gathering, not a target: JsSIP sends as soon as gathering completes. 3 s leaves
|
|
607
|
+
// room for a TURN allocation (whose relay candidates arrive last) while still bounding the ~40 s
|
|
608
|
+
// Chrome can spend against an unreachable server. See `capIceGathering`.
|
|
609
|
+
iceGatheringCapMs: 3e3
|
|
610
|
+
};
|
|
611
|
+
function buildPushUrl(key, sip) {
|
|
612
|
+
const params = new URLSearchParams({ key, integrationType: "site", sipId: sip });
|
|
613
|
+
return `wss://ws.zadarma.com/webrtc_pushes?${params.toString()}`;
|
|
614
|
+
}
|
|
615
|
+
var ZadarmaPushSignal = class {
|
|
616
|
+
constructor(deps) {
|
|
617
|
+
this.deps = deps;
|
|
618
|
+
}
|
|
619
|
+
deps;
|
|
620
|
+
#socket = null;
|
|
621
|
+
#connected = false;
|
|
622
|
+
#pingTimers = [];
|
|
623
|
+
#refreshTimer;
|
|
624
|
+
#unwatchBackground;
|
|
625
|
+
#closed = false;
|
|
626
|
+
#listeners = /* @__PURE__ */ new Set();
|
|
627
|
+
#connectionListeners = /* @__PURE__ */ new Set();
|
|
628
|
+
async connect() {
|
|
629
|
+
if (this.#socket) return;
|
|
630
|
+
this.#closed = false;
|
|
631
|
+
this.#unwatchBackground ??= this.deps.watchBackgroundReconnect?.(() => void this.#reopen());
|
|
632
|
+
await this.#open();
|
|
633
|
+
}
|
|
634
|
+
async #open() {
|
|
635
|
+
const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
|
|
636
|
+
if (this.#closed) return;
|
|
637
|
+
const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
|
|
638
|
+
this.#socket = socket;
|
|
639
|
+
socket.on("connect", () => {
|
|
640
|
+
this.#setConnected(true);
|
|
641
|
+
this.#startPings(socket);
|
|
642
|
+
});
|
|
643
|
+
socket.on("update", () => this.#emit());
|
|
644
|
+
socket.on("disconnect", () => {
|
|
645
|
+
this.#stopPings();
|
|
646
|
+
this.#setConnected(false);
|
|
647
|
+
});
|
|
648
|
+
socket.on("connect_error", () => {
|
|
649
|
+
this.#stopPings();
|
|
650
|
+
this.#setConnected(false);
|
|
651
|
+
});
|
|
652
|
+
this.#armRefresh(keyExpiresAt);
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Presence pings — **the reason an idle tab keeps receiving calls**.
|
|
656
|
+
*
|
|
657
|
+
* Zadarma's gateway decides whether the extension is online, and it decides from these. Without
|
|
658
|
+
* them the socket goes quiet, Zadarma marks the extension offline within minutes, and INVITEs stop
|
|
659
|
+
* being routed — with no error anywhere, because nothing failed. The only visible symptom is that
|
|
660
|
+
* the phone silently stops ringing until the page is reloaded.
|
|
661
|
+
*
|
|
662
|
+
* The names and intervals are Zadarma's, taken from the CRM hook that ran this in production for
|
|
663
|
+
* years. They are not ours to tune.
|
|
664
|
+
*/
|
|
665
|
+
#startPings(socket) {
|
|
666
|
+
this.#stopPings();
|
|
667
|
+
this.#pingTimers = [
|
|
668
|
+
setInterval(() => socket.emit("localPing", {}), PING.localMs),
|
|
669
|
+
setInterval(() => socket.emit("globalPing", {}), PING.globalMs)
|
|
670
|
+
];
|
|
671
|
+
}
|
|
672
|
+
#stopPings() {
|
|
673
|
+
for (const t of this.#pingTimers) clearInterval(t);
|
|
674
|
+
this.#pingTimers = [];
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Rebuild the socket before the WebRTC key expires.
|
|
678
|
+
*
|
|
679
|
+
* The key is baked into the socket URL, so socket.io's own reconnection re-presents whatever it was
|
|
680
|
+
* given at construction. A tab left open past the key's 72 h lifetime would reconnect forever with
|
|
681
|
+
* a dead credential. Absolute, from the backend's `key_expires_at`, so it targets the real expiry
|
|
682
|
+
* rather than restarting a countdown on every reconnect.
|
|
683
|
+
*/
|
|
684
|
+
#armRefresh(keyExpiresAt) {
|
|
685
|
+
clearTimeout(this.#refreshTimer);
|
|
686
|
+
if (!keyExpiresAt) return;
|
|
687
|
+
const ms = new Date(keyExpiresAt).getTime() - Date.now() - KEY_REFRESH_BUFFER_MS;
|
|
688
|
+
if (!Number.isFinite(ms) || ms <= 0) return;
|
|
689
|
+
this.#refreshTimer = setTimeout(() => void this.#reopen(), ms);
|
|
690
|
+
}
|
|
691
|
+
async #reopen() {
|
|
692
|
+
if (this.#closed) return;
|
|
693
|
+
this.#stopPings();
|
|
694
|
+
this.#socket?.close();
|
|
695
|
+
this.#socket = null;
|
|
696
|
+
this.#setConnected(false);
|
|
697
|
+
try {
|
|
698
|
+
await this.#open();
|
|
699
|
+
} catch {
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
disconnect() {
|
|
703
|
+
this.#closed = true;
|
|
704
|
+
this.#stopPings();
|
|
705
|
+
clearTimeout(this.#refreshTimer);
|
|
706
|
+
this.#refreshTimer = void 0;
|
|
707
|
+
this.#unwatchBackground?.();
|
|
708
|
+
this.#unwatchBackground = void 0;
|
|
709
|
+
this.#socket?.close();
|
|
710
|
+
this.#socket = null;
|
|
711
|
+
this.#setConnected(false);
|
|
712
|
+
}
|
|
713
|
+
onSignal(listener) {
|
|
714
|
+
this.#listeners.add(listener);
|
|
715
|
+
return () => this.#listeners.delete(listener);
|
|
716
|
+
}
|
|
717
|
+
onConnectionChange(listener) {
|
|
718
|
+
this.#connectionListeners.add(listener);
|
|
719
|
+
return () => this.#connectionListeners.delete(listener);
|
|
720
|
+
}
|
|
721
|
+
get connected() {
|
|
722
|
+
return this.#connected;
|
|
723
|
+
}
|
|
724
|
+
#setConnected(connected) {
|
|
725
|
+
if (this.#connected === connected) return;
|
|
726
|
+
this.#connected = connected;
|
|
727
|
+
for (const listener of [...this.#connectionListeners]) listener(connected);
|
|
728
|
+
}
|
|
729
|
+
#emit() {
|
|
730
|
+
for (const listener of [...this.#listeners]) listener();
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
var ZadarmaProvider = class {
|
|
734
|
+
constructor(deps) {
|
|
735
|
+
this.deps = deps;
|
|
736
|
+
this.incomingSignal = new ZadarmaPushSignal(deps);
|
|
737
|
+
}
|
|
738
|
+
deps;
|
|
739
|
+
name = "zadarma";
|
|
740
|
+
// Zadarma's PBX ignores a standing registration — it routes INVITEs only to a UA that registered
|
|
741
|
+
// in response to a push (SPEC §3.2). Hence on-demand.
|
|
742
|
+
registrationMode = "on-demand";
|
|
743
|
+
quirks = ZADARMA_QUIRKS;
|
|
744
|
+
incomingSignal;
|
|
745
|
+
/**
|
|
746
|
+
* Per-registration credentialing: fetch a scoped key, then exchange it in the browser for
|
|
747
|
+
* ephemeral SIP creds. The key fetch is cheap and backend-cached; doing it here (rather than
|
|
748
|
+
* sharing state with the push signal) keeps the two paths independent.
|
|
749
|
+
*/
|
|
750
|
+
async provision() {
|
|
751
|
+
const { key, sip } = await this.deps.fetchKey();
|
|
752
|
+
const { pass, domain } = await this.deps.exchangeWebphoneData(key, sip);
|
|
753
|
+
return {
|
|
754
|
+
uri: `sip:${sip}@${domain}`,
|
|
755
|
+
authUser: sip,
|
|
756
|
+
password: pass,
|
|
757
|
+
wsUrl: `wss://${domain}:4443`
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
// src/transports/providers/zadarma-browser.ts
|
|
763
|
+
var UnauthorizedKeyError = class extends Error {
|
|
764
|
+
};
|
|
765
|
+
function makeFetchKey(config) {
|
|
766
|
+
const attempt = async () => {
|
|
767
|
+
let token;
|
|
768
|
+
try {
|
|
769
|
+
({ token } = await config.tokenSource.getToken());
|
|
770
|
+
} catch (cause) {
|
|
771
|
+
throw createError("auth/token-fetch-failed", cause);
|
|
772
|
+
}
|
|
773
|
+
const qs = config.organizationId ? `?organization_id=${encodeURIComponent(config.organizationId)}` : "";
|
|
774
|
+
const res = await fetch(`${config.apiBase}/phone/webrtc/key${qs}`, {
|
|
775
|
+
headers: { Authorization: token }
|
|
776
|
+
});
|
|
777
|
+
if (!res.ok) {
|
|
778
|
+
const body = await res.json().catch(() => null);
|
|
779
|
+
const message = body?.message ?? `webrtc/key HTTP ${res.status}`;
|
|
780
|
+
throw res.status === 401 ? new UnauthorizedKeyError(message) : new Error(message);
|
|
781
|
+
}
|
|
782
|
+
const data = await res.json();
|
|
783
|
+
return {
|
|
784
|
+
key: data.key,
|
|
785
|
+
sip: data.sip,
|
|
786
|
+
...data.key_expires_at ? { keyExpiresAt: data.key_expires_at } : {}
|
|
787
|
+
};
|
|
788
|
+
};
|
|
789
|
+
return async () => {
|
|
790
|
+
try {
|
|
791
|
+
return await attempt();
|
|
792
|
+
} catch (error) {
|
|
793
|
+
if (!(error instanceof UnauthorizedKeyError)) throw error;
|
|
794
|
+
invalidateToken(config.tokenSource);
|
|
795
|
+
try {
|
|
796
|
+
return await attempt();
|
|
797
|
+
} catch (retryError) {
|
|
798
|
+
if (retryError instanceof UnauthorizedKeyError) {
|
|
799
|
+
throw createError("auth/token-rejected", retryError);
|
|
800
|
+
}
|
|
801
|
+
throw retryError;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
async function exchangeWebphoneData(key, sip, timeoutMs = 1e4) {
|
|
807
|
+
const params = new URLSearchParams({
|
|
808
|
+
// Still sent: the endpoint wraps its response either way, and naming the callback keeps the
|
|
809
|
+
// wrapper predictable rather than relying on their default.
|
|
810
|
+
jsonpCallback: "cv",
|
|
811
|
+
integrationType: "site",
|
|
812
|
+
key,
|
|
813
|
+
sipId: sip
|
|
814
|
+
});
|
|
815
|
+
let res;
|
|
816
|
+
try {
|
|
817
|
+
res = await fetch(
|
|
818
|
+
`https://api.zadarma.com/sys/webrtc/get_webphone_data.php?${params.toString()}`,
|
|
819
|
+
{ signal: AbortSignal.timeout(timeoutMs) }
|
|
820
|
+
);
|
|
821
|
+
} catch (cause) {
|
|
822
|
+
const timedOut = cause instanceof DOMException && cause.name === "TimeoutError";
|
|
823
|
+
throw new Error(timedOut ? "get_webphone_data timed out" : "get_webphone_data network error", {
|
|
824
|
+
cause
|
|
825
|
+
});
|
|
826
|
+
}
|
|
827
|
+
if (!res.ok) throw new Error(`get_webphone_data HTTP ${res.status}`);
|
|
828
|
+
const data = parseJsonp(await res.text());
|
|
829
|
+
if (!data || data.error || !data.pass || !data.domain) {
|
|
830
|
+
throw new Error(data?.error?.content ?? "get_webphone_data: missing SIP credentials");
|
|
831
|
+
}
|
|
832
|
+
return { pass: data.pass, domain: data.domain };
|
|
833
|
+
}
|
|
834
|
+
function parseJsonp(text) {
|
|
835
|
+
const open = text.indexOf("(");
|
|
836
|
+
const close = text.lastIndexOf(")");
|
|
837
|
+
const body = open !== -1 && close > open ? text.slice(open + 1, close) : text;
|
|
838
|
+
try {
|
|
839
|
+
return JSON.parse(body);
|
|
840
|
+
} catch {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
function createPushSocket(url) {
|
|
845
|
+
const handlers = /* @__PURE__ */ new Map();
|
|
846
|
+
let client = null;
|
|
847
|
+
let closed = false;
|
|
848
|
+
void (async () => {
|
|
849
|
+
const mod = await import('socket.io-client-v2');
|
|
850
|
+
if (closed) return;
|
|
851
|
+
const connect = mod.default ?? mod.io;
|
|
852
|
+
if (!connect) throw new Error("socket.io-client-v2: no connect export");
|
|
853
|
+
const c = connect(url, {
|
|
854
|
+
transports: ["websocket"],
|
|
855
|
+
reconnection: true,
|
|
856
|
+
// The push socket drops on a schedule under background-tab timer throttling, NOT from network
|
|
857
|
+
// flakiness — every reconnect observed succeeds on the first attempt (push-socket-reliability.md
|
|
858
|
+
// §2.3, §5.1). There is nothing to back off from on that first attempt, so waiting the old 10s
|
|
859
|
+
// before retrying was pure, uncontested exposure — an inbound call landing in that window is
|
|
860
|
+
// lost, unqueued (Zadarma's gateway speaks EIO3, no offline redelivery). `reconnectionDelayMax`
|
|
861
|
+
// stays as the ceiling for a genuinely down gateway, where backing off still matters.
|
|
862
|
+
reconnectionDelay: 300,
|
|
863
|
+
reconnectionDelayMax: 3e4
|
|
864
|
+
});
|
|
865
|
+
client = c;
|
|
866
|
+
for (const [event, cbs] of handlers) for (const cb of cbs) c.on(event, cb);
|
|
867
|
+
})();
|
|
868
|
+
return {
|
|
869
|
+
on(event, cb) {
|
|
870
|
+
const list = handlers.get(event) ?? [];
|
|
871
|
+
list.push(cb);
|
|
872
|
+
handlers.set(event, list);
|
|
873
|
+
client?.on(event, cb);
|
|
874
|
+
},
|
|
875
|
+
emit(event, payload) {
|
|
876
|
+
client?.emit(event, payload);
|
|
877
|
+
},
|
|
878
|
+
close() {
|
|
879
|
+
closed = true;
|
|
880
|
+
client?.close();
|
|
881
|
+
},
|
|
882
|
+
get connected() {
|
|
883
|
+
return client?.connected ?? false;
|
|
884
|
+
}
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
var BACKGROUND_RECONNECT_MS = 2e4;
|
|
888
|
+
var HEARTBEAT_WORKER_SOURCE = `
|
|
889
|
+
let timer;
|
|
890
|
+
self.onmessage = (e) => {
|
|
891
|
+
if (e.data.type === 'start') timer = setInterval(() => self.postMessage('tick'), e.data.intervalMs);
|
|
892
|
+
else if (e.data.type === 'stop') { clearInterval(timer); timer = undefined; }
|
|
893
|
+
};
|
|
894
|
+
`;
|
|
895
|
+
function watchBackgroundReconnect(cb) {
|
|
896
|
+
if (typeof document === "undefined" || typeof Worker === "undefined") return () => {
|
|
897
|
+
};
|
|
898
|
+
let worker = null;
|
|
899
|
+
let blobUrl = null;
|
|
900
|
+
const ensureWorker = () => {
|
|
901
|
+
if (!worker) {
|
|
902
|
+
blobUrl = URL.createObjectURL(new Blob([HEARTBEAT_WORKER_SOURCE], { type: "application/javascript" }));
|
|
903
|
+
worker = new Worker(blobUrl);
|
|
904
|
+
worker.onmessage = (e) => {
|
|
905
|
+
if (e.data === "tick") cb();
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
return worker;
|
|
909
|
+
};
|
|
910
|
+
const onVisibilityChange = () => {
|
|
911
|
+
if (document.visibilityState === "hidden") {
|
|
912
|
+
ensureWorker().postMessage({ type: "start", intervalMs: BACKGROUND_RECONNECT_MS });
|
|
913
|
+
} else {
|
|
914
|
+
worker?.postMessage({ type: "stop" });
|
|
915
|
+
}
|
|
916
|
+
};
|
|
917
|
+
document.addEventListener("visibilitychange", onVisibilityChange);
|
|
918
|
+
if (document.visibilityState === "hidden") onVisibilityChange();
|
|
919
|
+
return () => {
|
|
920
|
+
document.removeEventListener("visibilitychange", onVisibilityChange);
|
|
921
|
+
worker?.postMessage({ type: "stop" });
|
|
922
|
+
worker?.terminate();
|
|
923
|
+
if (blobUrl) URL.revokeObjectURL(blobUrl);
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
function makeZadarmaBrowserDeps(config) {
|
|
927
|
+
return {
|
|
928
|
+
fetchKey: makeFetchKey(config),
|
|
929
|
+
exchangeWebphoneData: (key, sip) => exchangeWebphoneData(key, sip),
|
|
930
|
+
createPushSocket,
|
|
931
|
+
watchBackgroundReconnect
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// src/transports/zadarma-engine.ts
|
|
936
|
+
function createZadarmaEngine(config) {
|
|
937
|
+
const provider = new ZadarmaProvider(
|
|
938
|
+
makeZadarmaBrowserDeps({ ...config, tokenSource: cachedTokenSource(config.tokenSource) })
|
|
939
|
+
);
|
|
940
|
+
return new SipCallEngine(provider, loadJsSipFactory);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
export { ZADARMA_QUIRKS, createZadarmaEngine };
|
|
944
|
+
//# sourceMappingURL=chunk-5BGEHMPP.js.map
|
|
945
|
+
//# sourceMappingURL=chunk-5BGEHMPP.js.map
|