@coventia/softphone 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/{call-DUVnDxsz.d.cts → call-VKq1I8yP.d.cts} +6 -0
  2. package/dist/{call-DUVnDxsz.d.ts → call-VKq1I8yP.d.ts} +6 -0
  3. package/dist/{chunk-YWI6EQYS.js → chunk-EYEDPD7Y.js} +108 -15
  4. package/dist/chunk-EYEDPD7Y.js.map +1 -0
  5. package/dist/element/index.d.ts +8 -2
  6. package/dist/element/index.js +334 -249
  7. package/dist/element/index.js.map +1 -1
  8. package/dist/{engine-CDjkrXHY.d.cts → engine-BlQBo2Ys.d.cts} +1 -1
  9. package/dist/{engine-EPCKf-Bl.d.ts → engine-C0BOkIcW.d.ts} +1 -1
  10. package/dist/index.cjs +199 -23
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +5 -5
  13. package/dist/index.d.ts +5 -5
  14. package/dist/index.js +90 -11
  15. package/dist/index.js.map +1 -1
  16. package/dist/react/index.d.cts +2 -2
  17. package/dist/react/index.d.ts +2 -2
  18. package/dist/{softphone-BtdgZWkm.d.ts → softphone-Cyq3hiIM.d.ts} +1 -1
  19. package/dist/{softphone-DHG9MTNg.d.cts → softphone-DlGV12Xp.d.cts} +1 -1
  20. package/dist/softphone.css +82 -34
  21. package/dist/{sources-5L2R4HTV.js → sources-IRKNQPWW.js} +6 -2
  22. package/dist/sources-IRKNQPWW.js.map +1 -0
  23. package/dist/testing/index.d.cts +2 -2
  24. package/dist/testing/index.d.ts +2 -2
  25. package/dist/ui/index.cjs +332 -247
  26. package/dist/ui/index.cjs.map +1 -1
  27. package/dist/ui/index.d.cts +17 -6
  28. package/dist/ui/index.d.ts +17 -6
  29. package/dist/ui/index.js +332 -247
  30. package/dist/ui/index.js.map +1 -1
  31. package/dist/zadarma/index.cjs +106 -13
  32. package/dist/zadarma/index.cjs.map +1 -1
  33. package/dist/zadarma/index.d.cts +2 -2
  34. package/dist/zadarma/index.d.ts +2 -2
  35. package/dist/zadarma/index.js +1 -1
  36. package/dist/zadarma-engine-L5DEFXUS.js +5 -0
  37. package/dist/{zadarma-engine-4VOPIBXG.js.map → zadarma-engine-L5DEFXUS.js.map} +1 -1
  38. package/package.json +1 -1
  39. package/dist/chunk-YWI6EQYS.js.map +0 -1
  40. package/dist/sources-5L2R4HTV.js.map +0 -1
  41. package/dist/zadarma-engine-4VOPIBXG.js +0 -5
@@ -1,4 +1,4 @@
1
- import { e as CallOptions, b as CallDirection, H as HangupReason, i as HoldOriginator, o as SoftphoneState, k as SoftphoneError } from './call-DUVnDxsz.cjs';
1
+ import { e as CallOptions, b as CallDirection, H as HangupReason, i as HoldOriginator, o as SoftphoneState, k as SoftphoneError } from './call-VKq1I8yP.cjs';
2
2
 
3
3
  /**
4
4
  * The `CallEngine` seam — INTERNAL, not exported from src/index.ts.
@@ -1,4 +1,4 @@
1
- import { e as CallOptions, b as CallDirection, H as HangupReason, i as HoldOriginator, o as SoftphoneState, k as SoftphoneError } from './call-DUVnDxsz.js';
1
+ import { e as CallOptions, b as CallDirection, H as HangupReason, i as HoldOriginator, o as SoftphoneState, k as SoftphoneError } from './call-VKq1I8yP.js';
2
2
 
3
3
  /**
4
4
  * The `CallEngine` seam — INTERNAL, not exported from src/index.ts.
package/dist/index.cjs CHANGED
@@ -293,6 +293,10 @@ function coventiaSources(config) {
293
293
  if (ws.readyState !== WS.OPEN) return;
294
294
  ws.send(JSON.stringify({ type: "call-state", onCall }));
295
295
  },
296
+ bye: () => {
297
+ if (ws.readyState !== WS.OPEN) return;
298
+ ws.send(JSON.stringify({ type: "bye" }));
299
+ },
296
300
  close: () => {
297
301
  ws.onclose = null;
298
302
  ws.onerror = null;
@@ -1222,11 +1226,13 @@ function buildPushUrl(key, sip) {
1222
1226
  const params = new URLSearchParams({ key, integrationType: "site", sipId: sip });
1223
1227
  return `wss://ws.zadarma.com/webrtc_pushes?${params.toString()}`;
1224
1228
  }
1225
- var PING, KEY_REFRESH_BUFFER_MS, ZADARMA_QUIRKS, ZadarmaPushSignal, ZadarmaProvider;
1229
+ var PING, KEY_REFRESH_BUFFER_MS, REBUILD_CONNECT_TIMEOUT_MS, REBUILD_RETRY_MS, ZADARMA_QUIRKS, ZadarmaPushSignal, ZadarmaProvider;
1226
1230
  var init_zadarma = __esm({
1227
1231
  "src/transports/providers/zadarma.ts"() {
1228
1232
  PING = { localMs: 3e4, globalMs: 6e4 };
1229
1233
  KEY_REFRESH_BUFFER_MS = 2 * 60 * 60 * 1e3;
1234
+ REBUILD_CONNECT_TIMEOUT_MS = 1e4;
1235
+ REBUILD_RETRY_MS = 3e4;
1230
1236
  ZADARMA_QUIRKS = {
1231
1237
  // Zadarma's registrar REFUSES to register a WebRTC client with any other User-Agent — a clean 401
1232
1238
  // after a valid digest challenge, indistinguishable from a wrong password (§3.1). The old CRM
@@ -1250,6 +1256,12 @@ var init_zadarma = __esm({
1250
1256
  #connected = false;
1251
1257
  #pingTimers = [];
1252
1258
  #refreshTimer;
1259
+ /** The replacement socket of a rebuild in flight, not yet current. */
1260
+ #candidate = null;
1261
+ /** Abandons the rebuild in flight (closing its candidate). */
1262
+ #abortRebuild = null;
1263
+ #rebuilding = false;
1264
+ #retryTimer;
1253
1265
  #unwatchBackground;
1254
1266
  #closed = false;
1255
1267
  #listeners = /* @__PURE__ */ new Set();
@@ -1265,20 +1277,35 @@ var init_zadarma = __esm({
1265
1277
  if (this.#closed) return;
1266
1278
  const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1267
1279
  this.#socket = socket;
1280
+ this.#attach(socket);
1281
+ this.#armRefresh(keyExpiresAt);
1282
+ }
1283
+ /**
1284
+ * Wires one socket's events. `this.#socket` is read when an event FIRES, not when it is wired: a socket that
1285
+ * has been replaced must stop driving connectivity (our own `close()` of it emits `disconnect`, which would
1286
+ * otherwise flip the state to offline right after a perfectly healthy swap). `update` is the exception — it is
1287
+ * forwarded from any socket not yet closed, because a push seen twice is harmless (the engine ignores a
1288
+ * repeated signal mid-call and reuses a UA that already exists) while one seen by neither is a missed call.
1289
+ *
1290
+ * With `hooks`, the socket is a rebuild's candidate: its first `connect` or failure goes to the rebuild instead
1291
+ * of changing the connection state (it is not current yet).
1292
+ */
1293
+ #attach(socket, hooks) {
1268
1294
  socket.on("connect", () => {
1295
+ if (hooks && socket === this.#candidate) return hooks.onConnect();
1296
+ if (socket !== this.#socket) return;
1269
1297
  this.#setConnected(true);
1270
1298
  this.#startPings(socket);
1271
1299
  });
1272
1300
  socket.on("update", () => this.#emit());
1273
- socket.on("disconnect", () => {
1274
- this.#stopPings();
1275
- this.#setConnected(false);
1276
- });
1277
- socket.on("connect_error", () => {
1301
+ const lost = () => {
1302
+ if (hooks && socket === this.#candidate) return hooks.onFailure();
1303
+ if (socket !== this.#socket) return;
1278
1304
  this.#stopPings();
1279
1305
  this.#setConnected(false);
1280
- });
1281
- this.#armRefresh(keyExpiresAt);
1306
+ };
1307
+ socket.on("disconnect", lost);
1308
+ socket.on("connect_error", lost);
1282
1309
  }
1283
1310
  /**
1284
1311
  * Presence pings — **the reason an idle tab keeps receiving calls**.
@@ -1317,22 +1344,92 @@ var init_zadarma = __esm({
1317
1344
  if (!Number.isFinite(ms) || ms <= 0) return;
1318
1345
  this.#refreshTimer = setTimeout(() => void this.#reopen(), ms);
1319
1346
  }
1347
+ /**
1348
+ * Replaces the push socket with a fresh one — MAKE-BEFORE-BREAK (presence-client-reporting.md §4.1, T8).
1349
+ *
1350
+ * Why it must happen at all: Zadarma's gateway decides the extension is alive from the raw Engine.IO ping, which
1351
+ * a hidden tab's throttled timers silently stop sending, so while the tab is hidden the socket is rebuilt every
1352
+ * 20 s (`push-socket-reliability.md` §5.3), and once before the key expires. Why it must not leave a gap: the
1353
+ * old close-then-reopen reported `connected = false` for the ~0.5 s in between, which took the softphone
1354
+ * `offline`, tore down the presence socket and left Zadarma with no socket to push an incoming call to.
1355
+ *
1356
+ * Now the OLD socket stays up and stays current until the NEW one has actually connected; only then do we swap,
1357
+ * move the pings and close the old one. A healthy rebuild never changes `connected`. A failed one — the key fetch
1358
+ * throws, the socket errors, or it has not connected within `REBUILD_CONNECT_TIMEOUT_MS` — keeps the old socket
1359
+ * and retries, instead of leaving the tab disconnected. Zadarma does not evict the older of two sockets for one
1360
+ * sip (multi-tab-leader-election.md §1), so the overlap is safe.
1361
+ */
1320
1362
  async #reopen() {
1321
- if (this.#closed) return;
1322
- this.#stopPings();
1323
- this.#socket?.close();
1324
- this.#socket = null;
1325
- this.#setConnected(false);
1363
+ if (this.#closed || this.#rebuilding) return;
1364
+ if (!this.#socket) {
1365
+ try {
1366
+ await this.#open();
1367
+ } catch {
1368
+ }
1369
+ return;
1370
+ }
1371
+ this.#rebuilding = true;
1326
1372
  try {
1327
- await this.#open();
1373
+ const { key, sip, keyExpiresAt } = await this.deps.fetchKey();
1374
+ if (this.#closed) return;
1375
+ const swapped = await new Promise((resolve) => {
1376
+ const socket = this.deps.createPushSocket(buildPushUrl(key, sip));
1377
+ this.#candidate = socket;
1378
+ let settled = false;
1379
+ const finish = (ok) => {
1380
+ if (settled) return;
1381
+ settled = true;
1382
+ clearTimeout(timer);
1383
+ this.#abortRebuild = null;
1384
+ if (ok && socket.connected) {
1385
+ this.#swapIn(socket, keyExpiresAt);
1386
+ } else {
1387
+ this.#candidate = null;
1388
+ socket.close();
1389
+ }
1390
+ resolve(ok && socket.connected);
1391
+ };
1392
+ const timer = setTimeout(() => finish(false), REBUILD_CONNECT_TIMEOUT_MS);
1393
+ this.#abortRebuild = () => finish(false);
1394
+ this.#attach(socket, { onConnect: () => finish(true), onFailure: () => finish(false) });
1395
+ });
1396
+ if (!swapped) this.#armRetry();
1328
1397
  } catch {
1398
+ this.#armRetry();
1399
+ } finally {
1400
+ this.#rebuilding = false;
1329
1401
  }
1330
1402
  }
1403
+ /**
1404
+ * The moment of the swap — synchronous, called from the candidate's own `connect` event so nothing can run
1405
+ * between "the new socket is up" and "it is the current one". Order matters: `#socket` moves first, so the old
1406
+ * socket's `disconnect` (our own `close()` of it, just below) is ignored by `#attach`.
1407
+ */
1408
+ #swapIn(socket, keyExpiresAt) {
1409
+ const previous = this.#socket;
1410
+ this.#socket = socket;
1411
+ this.#candidate = null;
1412
+ clearTimeout(this.#retryTimer);
1413
+ this.#retryTimer = void 0;
1414
+ this.#stopPings();
1415
+ this.#startPings(socket);
1416
+ this.#setConnected(true);
1417
+ previous?.close();
1418
+ this.#armRefresh(keyExpiresAt);
1419
+ }
1420
+ #armRetry() {
1421
+ if (this.#closed) return;
1422
+ clearTimeout(this.#retryTimer);
1423
+ this.#retryTimer = setTimeout(() => void this.#reopen(), REBUILD_RETRY_MS);
1424
+ }
1331
1425
  disconnect() {
1332
1426
  this.#closed = true;
1333
1427
  this.#stopPings();
1334
1428
  clearTimeout(this.#refreshTimer);
1335
1429
  this.#refreshTimer = void 0;
1430
+ clearTimeout(this.#retryTimer);
1431
+ this.#retryTimer = void 0;
1432
+ this.#abortRebuild?.();
1336
1433
  this.#unwatchBackground?.();
1337
1434
  this.#unwatchBackground = void 0;
1338
1435
  this.#socket?.close();
@@ -1689,6 +1786,9 @@ function canTransition(from, to) {
1689
1786
  function isTerminal(state) {
1690
1787
  return CALL_TRANSITIONS[state].length === 0;
1691
1788
  }
1789
+ function isOnCallState(state) {
1790
+ return state !== "ringing" && !isTerminal(state);
1791
+ }
1692
1792
 
1693
1793
  // src/core/call-impl.ts
1694
1794
  var CallImpl = class _CallImpl extends Emitter {
@@ -2051,13 +2151,18 @@ function startPresenceSocket(params) {
2051
2151
  let agents = /* @__PURE__ */ new Map();
2052
2152
  let lastReportedOnCall = null;
2053
2153
  let connection = null;
2054
- const stop = () => {
2154
+ let byeOnArrival = false;
2155
+ const stop = (options) => {
2055
2156
  if (stopped) return;
2056
2157
  stopped = true;
2057
2158
  if (reconnectTimer !== null) {
2058
2159
  cancelTimeout(reconnectTimer);
2059
2160
  reconnectTimer = null;
2060
2161
  }
2162
+ if (options?.bye) {
2163
+ byeOnArrival = true;
2164
+ connection?.bye?.();
2165
+ }
2061
2166
  connection?.close();
2062
2167
  connection = null;
2063
2168
  };
@@ -2096,6 +2201,7 @@ function startPresenceSocket(params) {
2096
2201
  return;
2097
2202
  }
2098
2203
  if (stopped) {
2204
+ if (byeOnArrival) opened.bye?.();
2099
2205
  opened.close();
2100
2206
  return;
2101
2207
  }
@@ -2117,6 +2223,7 @@ function startPresenceSocket(params) {
2117
2223
  // src/core/softphone-impl.ts
2118
2224
  init_errors();
2119
2225
  var liveInstance = false;
2226
+ var PRESENCE_TEARDOWN_GRACE_MS = 1e4;
2120
2227
  var BrowserAudioDevices = class {
2121
2228
  inputs = [];
2122
2229
  outputs = [];
@@ -2191,6 +2298,8 @@ var SoftphoneImpl = class extends Emitter {
2191
2298
  * rather than by any one call.
2192
2299
  */
2193
2300
  #presenceWatch = null;
2301
+ /** A pending state-driven teardown of the watch — see `PRESENCE_TEARDOWN_GRACE_MS`. */
2302
+ #presenceTeardownTimer = null;
2194
2303
  #presenceAgents = [];
2195
2304
  /**
2196
2305
  * `tabLock` defaults to the real Web Locks implementation — not part of `SoftphoneConfig` (an
@@ -2250,6 +2359,7 @@ var SoftphoneImpl = class extends Emitter {
2250
2359
  }
2251
2360
  async stop() {
2252
2361
  if (!this.#startedHere) return;
2362
+ this.#sayGoodbye();
2253
2363
  this.#tabLock.release();
2254
2364
  if (this.#isLeader) {
2255
2365
  await this.#engine.stop();
@@ -2265,8 +2375,7 @@ var SoftphoneImpl = class extends Emitter {
2265
2375
  this.#destroyed = true;
2266
2376
  for (const stopWatch of this.#activeWatches) stopWatch();
2267
2377
  this.#activeWatches.clear();
2268
- this.#presenceWatch?.stop();
2269
- this.#presenceWatch = null;
2378
+ this.#sayGoodbye();
2270
2379
  await this.stop().catch(() => {
2271
2380
  });
2272
2381
  this.#setState("destroyed");
@@ -2301,11 +2410,15 @@ var SoftphoneImpl = class extends Emitter {
2301
2410
  (target, callerNumber) => this.#onTransfer(target, callerNumber)
2302
2411
  );
2303
2412
  this.#activeCall = call;
2413
+ const offState = call.on("stateChanged" /* StateChanged */, () => this.#syncCallState());
2414
+ this.#syncCallState();
2304
2415
  const off = call.on("ended" /* Ended */, () => {
2305
2416
  off();
2417
+ offState();
2306
2418
  if (this.#activeCall === call) {
2307
2419
  this.#activeCall = null;
2308
2420
  this.emit("activeCallChanged" /* ActiveCallChanged */, { call: null });
2421
+ this.#syncCallState();
2309
2422
  }
2310
2423
  });
2311
2424
  if (this.#resolveContact) {
@@ -2325,6 +2438,21 @@ var SoftphoneImpl = class extends Emitter {
2325
2438
  this.emit("stateChanged" /* StateChanged */, { state, previous });
2326
2439
  this.#syncPresenceWatch();
2327
2440
  }
2441
+ /**
2442
+ * Whether this softphone is on a call right now, as presence means it (`isOnCallState`). There is a single
2443
+ * active-call slot, and a consult leg is a child of that call, so the parent staying alive covers a consult.
2444
+ */
2445
+ #isOnCall() {
2446
+ return this.#activeCall !== null && isOnCallState(this.#activeCall.state);
2447
+ }
2448
+ /**
2449
+ * Reports the current on-a-call value to the backend. The watch de-duplicates (it only sends on change) and
2450
+ * remembers the latest value to replay after a reconnect, so this can be called on every transition without
2451
+ * ceremony. A no-op while there is no presence watch (not online, not the leader, no backend configured).
2452
+ */
2453
+ #syncCallState() {
2454
+ this.#presenceWatch?.reportCallState(this.#isOnCall());
2455
+ }
2328
2456
  #setLeader(isLeader) {
2329
2457
  if (isLeader === this.#isLeader) return;
2330
2458
  this.#isLeader = isLeader;
@@ -2343,9 +2471,21 @@ var SoftphoneImpl = class extends Emitter {
2343
2471
  * This still passes `getIsLeader: () => this.#isLeader` for that narrower, defensive recheck; the
2344
2472
  * BROAD online/leader gate — whether to be connected AT ALL — is owned entirely here, the same
2345
2473
  * split `#onTransfer` already has with `startTransferWatch`.
2474
+ *
2475
+ * One exception to "tear down the moment `online` is lost": a leader that is merely `offline` or
2476
+ * `reconnecting` keeps its watch for `PRESENCE_TEARDOWN_GRACE_MS` — a blip shorter than that never touches
2477
+ * the presence socket at all. Anything else (stopped, starting, destroyed, or no longer the leader) still
2478
+ * tears it down at once: those are not blips, and a follower must never hold a connection.
2346
2479
  */
2347
2480
  #syncPresenceWatch() {
2348
- const shouldRun = Boolean(this.sources) && this.#state === "online" && this.#isLeader;
2481
+ const eligible = Boolean(this.sources) && this.#isLeader;
2482
+ const shouldRun = eligible && this.#state === "online";
2483
+ const blip = eligible && (this.#state === "offline" || this.#state === "reconnecting");
2484
+ if (!blip) this.#cancelPresenceTeardown();
2485
+ if (blip && this.#presenceWatch !== null) {
2486
+ this.#schedulePresenceTeardown();
2487
+ return;
2488
+ }
2349
2489
  if (shouldRun === (this.#presenceWatch !== null)) return;
2350
2490
  if (shouldRun) {
2351
2491
  this.#presenceWatch = startPresenceSocket({
@@ -2356,13 +2496,49 @@ var SoftphoneImpl = class extends Emitter {
2356
2496
  this.emit("presenceChanged" /* PresenceChanged */, { agents });
2357
2497
  }
2358
2498
  });
2499
+ this.#syncCallState();
2359
2500
  } else {
2360
- this.#presenceWatch?.stop();
2361
- this.#presenceWatch = null;
2362
- this.#presenceAgents = [];
2363
- this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
2501
+ this.#stopPresence();
2364
2502
  }
2365
2503
  }
2504
+ /**
2505
+ * Starts the grace period before an `offline` / `reconnecting` leader loses its presence socket. Idempotent
2506
+ * on purpose: a second notification inside the same blip must NOT push the deadline back.
2507
+ */
2508
+ #schedulePresenceTeardown() {
2509
+ if (this.#presenceTeardownTimer !== null) return;
2510
+ this.#presenceTeardownTimer = setTimeout(() => {
2511
+ this.#presenceTeardownTimer = null;
2512
+ if (this.#state === "online" && this.#isLeader) return;
2513
+ this.#stopPresence();
2514
+ }, PRESENCE_TEARDOWN_GRACE_MS);
2515
+ }
2516
+ #cancelPresenceTeardown() {
2517
+ if (this.#presenceTeardownTimer === null) return;
2518
+ clearTimeout(this.#presenceTeardownTimer);
2519
+ this.#presenceTeardownTimer = null;
2520
+ }
2521
+ /**
2522
+ * Stops the presence watch and forgets its roster. A STATE-DRIVEN teardown (the softphone left `online`
2523
+ * for good, or lost leadership) passes nothing and says no goodbye; only a deliberate stop passes `bye`.
2524
+ * Any pending grace-period teardown is cancelled — there is nothing left for it to do.
2525
+ */
2526
+ #stopPresence(options) {
2527
+ this.#cancelPresenceTeardown();
2528
+ if (!this.#presenceWatch) return;
2529
+ this.#presenceWatch.stop(options);
2530
+ this.#presenceWatch = null;
2531
+ this.#presenceAgents = [];
2532
+ this.emit("presenceChanged" /* PresenceChanged */, { agents: [] });
2533
+ }
2534
+ /**
2535
+ * A DELIBERATE stop (`stop()` / `destroy()`): say any call is over, then say goodbye, so the backend drops
2536
+ * this phone at once instead of waiting out the close grace. Idempotent — a second call finds no watch.
2537
+ */
2538
+ #sayGoodbye() {
2539
+ this.#presenceWatch?.reportCallState(false);
2540
+ this.#stopPresence({ bye: true });
2541
+ }
2366
2542
  /**
2367
2543
  * Phase A (transfer outcome relay, `docs/specs/transfer-outcome-relay.md`) — `CallImpl.transfer()`'s
2368
2544
  * internal seam calls this. No-ops entirely when no Coventia backend is configured (`sources` is
@@ -2498,7 +2674,7 @@ function isCallEngine(value) {
2498
2674
 
2499
2675
  // src/index.ts
2500
2676
  init_errors();
2501
- var VERSION = "0.2.0";
2677
+ var VERSION = "0.2.1";
2502
2678
 
2503
2679
  exports.CALL_TRANSITIONS = CALL_TRANSITIONS;
2504
2680
  exports.CallEvent = CallEvent;