farvex 1.0.0 → 2.0.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/index.cjs CHANGED
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ var libphonenumberJs = require('libphonenumber-js');
3
4
  var centrifuge = require('centrifuge');
4
5
 
5
6
  // src/api/generated/core/bodySerializer.gen.ts
@@ -804,7 +805,7 @@ var apiClient = (config) => {
804
805
  var unwrap = async (result, auth) => {
805
806
  const response = await result;
806
807
  if (response.error !== void 0) {
807
- if (response.response?.status === 401) {
808
+ if (response.response?.status === 401 || response.response?.status === 403) {
808
809
  await auth?.onUnauthorized?.();
809
810
  }
810
811
  throw errorFrom(response.error, response.response?.status);
@@ -943,9 +944,23 @@ var createEmitter = () => {
943
944
  emit
944
945
  };
945
946
  };
946
- var createRealtime = (client2, auth, store, emitter) => {
947
+ var normalizeE164Phone = (phone, defaultCountry = "NG") => {
948
+ const trimmed = phone.trim();
949
+ const parsed = libphonenumberJs.parsePhoneNumberFromString(trimmed);
950
+ if (parsed?.isValid() && parsed.country) {
951
+ return parsed.number;
952
+ }
953
+ const fallback = libphonenumberJs.parsePhoneNumberFromString(trimmed, defaultCountry);
954
+ if (fallback?.isValid()) {
955
+ return fallback.number;
956
+ }
957
+ throw new Error("Enter a valid phone number");
958
+ };
959
+ var createRealtime = (client2, auth, store, emitter, recover) => {
947
960
  let rt = null;
961
+ let connectPromise = null;
948
962
  let disposed = false;
963
+ let attempt = 0;
949
964
  const fetchToken = async () => unwrap(getRealtimeToken({ client: client2 }), auth);
950
965
  const refreshToken = async () => {
951
966
  try {
@@ -982,6 +997,8 @@ var createRealtime = (client2, auth, store, emitter) => {
982
997
  emitter.emit("status", status);
983
998
  };
984
999
  const disconnect = async () => {
1000
+ attempt += 1;
1001
+ connectPromise = null;
985
1002
  rt?.disconnect();
986
1003
  rt = null;
987
1004
  if (!disposed) {
@@ -990,33 +1007,50 @@ var createRealtime = (client2, auth, store, emitter) => {
990
1007
  };
991
1008
  return {
992
1009
  connect: async () => {
1010
+ if (connectPromise) {
1011
+ return connectPromise;
1012
+ }
993
1013
  if (rt) {
994
1014
  return;
995
1015
  }
996
1016
  disposed = false;
997
- setStatus("connecting");
998
- try {
1017
+ const currentAttempt = ++attempt;
1018
+ const promise = (async () => {
1019
+ setStatus("connecting");
999
1020
  const first = await fetchToken();
1000
- rt = new centrifuge.Centrifuge(first.url, {
1021
+ if (disposed || currentAttempt !== attempt) {
1022
+ return;
1023
+ }
1024
+ const next = new centrifuge.Centrifuge(first.url, {
1001
1025
  token: first.token,
1002
1026
  getToken: refreshToken
1003
1027
  });
1004
- rt.on("connecting", () => {
1005
- const next = store.getStatus() === "ready" ? "reconnecting" : "connecting";
1006
- setStatus(next);
1028
+ rt = next;
1029
+ next.on("connecting", () => {
1030
+ const status = store.getStatus() === "ready" ? "reconnecting" : "connecting";
1031
+ setStatus(status);
1007
1032
  });
1008
- rt.on("connected", () => {
1033
+ next.on("connected", () => {
1034
+ const shouldRecover = store.getStatus() === "reconnecting" || store.getStatus() === "offline";
1009
1035
  setStatus("ready");
1036
+ if (shouldRecover && recover) {
1037
+ void recover().catch((err) => {
1038
+ emitter.emit(
1039
+ "error",
1040
+ err instanceof Error ? err : new Error("Realtime recovery failed")
1041
+ );
1042
+ });
1043
+ }
1010
1044
  });
1011
- rt.on("disconnected", () => {
1045
+ next.on("disconnected", () => {
1012
1046
  if (!disposed) {
1013
1047
  setStatus("offline");
1014
1048
  }
1015
1049
  });
1016
- rt.on("error", (ctx) => {
1050
+ next.on("error", (ctx) => {
1017
1051
  emitter.emit("error", new Error(ctx.error.message));
1018
1052
  });
1019
- rt.on("publication", (ctx) => {
1053
+ next.on("publication", (ctx) => {
1020
1054
  try {
1021
1055
  const message = parseMessage(ctx.data);
1022
1056
  if (message) {
@@ -1026,16 +1060,34 @@ var createRealtime = (client2, auth, store, emitter) => {
1026
1060
  emitter.emit("error", err instanceof Error ? err : new Error("Realtime event failed"));
1027
1061
  }
1028
1062
  });
1029
- rt.connect();
1030
- await rt.ready();
1063
+ next.connect();
1064
+ await next.ready();
1065
+ if (disposed || currentAttempt !== attempt) {
1066
+ next.disconnect();
1067
+ if (rt === next) {
1068
+ rt = null;
1069
+ }
1070
+ }
1071
+ })();
1072
+ connectPromise = promise;
1073
+ try {
1074
+ await promise;
1031
1075
  } catch (err) {
1032
- await disconnect();
1076
+ if (currentAttempt === attempt) {
1077
+ await disconnect();
1078
+ }
1033
1079
  throw err;
1080
+ } finally {
1081
+ if (connectPromise === promise) {
1082
+ connectPromise = null;
1083
+ }
1034
1084
  }
1035
1085
  },
1036
1086
  disconnect,
1037
1087
  dispose: async () => {
1038
1088
  disposed = true;
1089
+ attempt += 1;
1090
+ connectPromise = null;
1039
1091
  rt?.disconnect();
1040
1092
  rt = null;
1041
1093
  setStatus("disposed");
@@ -1047,24 +1099,453 @@ var parseMessage = (value) => {
1047
1099
  return null;
1048
1100
  }
1049
1101
  const message = value;
1050
- if (message.event === "session.upsert" && message.data) {
1051
- return message;
1102
+ if (message.event === "session.upsert" && isVoiceSession(message.data)) {
1103
+ return { event: "session.upsert", data: message.data };
1052
1104
  }
1053
- if (message.event === "session.remove" && message.data) {
1054
- return message;
1105
+ if (message.event === "session.remove" && isRemovePayload(message.data)) {
1106
+ return { event: "session.remove", data: message.data };
1055
1107
  }
1056
1108
  return null;
1057
1109
  };
1110
+ var isVoiceSession = (value) => {
1111
+ if (!value || typeof value !== "object") {
1112
+ return false;
1113
+ }
1114
+ const session = value;
1115
+ return typeof session.id === "string" && typeof session.vendor === "string" && typeof session.version === "number" && (session.state === "ringing" || session.state === "active" || session.state === "ended") && Array.isArray(session.participants);
1116
+ };
1117
+ var isRemovePayload = (value) => {
1118
+ if (!value || typeof value !== "object") {
1119
+ return false;
1120
+ }
1121
+ const payload = value;
1122
+ return typeof payload.id === "string" && (payload.vendor === void 0 || typeof payload.vendor === "string") && (payload.version === void 0 || typeof payload.version === "number");
1123
+ };
1124
+
1125
+ // src/core/session.ts
1126
+ var createCurrentSessionController = (deps) => {
1127
+ const subscribers = /* @__PURE__ */ new Set();
1128
+ const cleanup = [];
1129
+ let current = null;
1130
+ let pending = null;
1131
+ let op = 0;
1132
+ let mediaConnected = false;
1133
+ let snapshot = {
1134
+ current,
1135
+ incoming: deps.store.invites(),
1136
+ busy: false
1137
+ };
1138
+ const publish = () => {
1139
+ const next = {
1140
+ current,
1141
+ incoming: deps.store.invites(),
1142
+ busy: pending !== null || isBusy(current)
1143
+ };
1144
+ if (snapshot.current === next.current && snapshot.incoming === next.incoming && snapshot.busy === next.busy) {
1145
+ return;
1146
+ }
1147
+ snapshot = next;
1148
+ for (const fn of subscribers) {
1149
+ fn();
1150
+ }
1151
+ deps.emitter.emit("session.current.changed", snapshot);
1152
+ };
1153
+ const commit = (next) => {
1154
+ const prev = current;
1155
+ current = next;
1156
+ publish();
1157
+ if (!next) {
1158
+ return;
1159
+ }
1160
+ if (!prev || prev.sessionId !== next.sessionId) {
1161
+ deps.emitter.emit("session.current.started", next);
1162
+ }
1163
+ if (prev?.phase !== "active" && next.phase === "active") {
1164
+ deps.emitter.emit("session.current.connected", next);
1165
+ }
1166
+ if (prev?.phase !== "ended" && next.phase === "ended") {
1167
+ deps.emitter.emit("session.current.ended", next);
1168
+ }
1169
+ if (prev?.phase !== "failed" && next.phase === "failed" && next.error) {
1170
+ deps.emitter.emit("session.current.failed", { session: next, error: next.error });
1171
+ }
1172
+ };
1173
+ const commitFailed = (session, err) => {
1174
+ const error = toCallpadError(err);
1175
+ if (session) {
1176
+ commit(
1177
+ fromSession(session, {
1178
+ phase: "failed",
1179
+ join: null,
1180
+ participantId: null,
1181
+ startedBy: current?.startedBy ?? "join",
1182
+ error
1183
+ })
1184
+ );
1185
+ } else {
1186
+ deps.emitter.emit("session.current.failed", { session: current, error });
1187
+ publish();
1188
+ }
1189
+ return error;
1190
+ };
1191
+ const nextOp = () => {
1192
+ op += 1;
1193
+ return op;
1194
+ };
1195
+ const run = (kind, task) => {
1196
+ if (pending) {
1197
+ if (kind === "end" && pending.kind === "end") {
1198
+ return pending.promise;
1199
+ }
1200
+ return Promise.reject(busyError());
1201
+ }
1202
+ const id = nextOp();
1203
+ const promise = task(id).finally(() => {
1204
+ if (pending?.op === id) {
1205
+ pending = null;
1206
+ publish();
1207
+ }
1208
+ });
1209
+ pending = { kind, op: id, promise };
1210
+ publish();
1211
+ return promise;
1212
+ };
1213
+ const assertFresh = (id) => {
1214
+ if (id !== op) {
1215
+ throw new CallpadError("session.command_stale", "Session command was superseded");
1216
+ }
1217
+ };
1218
+ const canReplaceCurrent = (replaceCurrent) => {
1219
+ return replaceCurrent === true || !isBusy(current);
1220
+ };
1221
+ const reconcile = () => {
1222
+ if (!current) {
1223
+ publish();
1224
+ return;
1225
+ }
1226
+ const session = deps.store.get(current.sessionId);
1227
+ if (!session) {
1228
+ mediaConnected = false;
1229
+ commit({
1230
+ ...current,
1231
+ phase: "ended",
1232
+ session: null,
1233
+ join: null,
1234
+ endedAt: current.endedAt ?? (/* @__PURE__ */ new Date()).toISOString()
1235
+ });
1236
+ return;
1237
+ }
1238
+ const next = reconcileCurrent(current, session, mediaConnected);
1239
+ if (next !== current) {
1240
+ if (isTerminal(next.phase)) {
1241
+ mediaConnected = false;
1242
+ }
1243
+ commit(next);
1244
+ return;
1245
+ }
1246
+ publish();
1247
+ };
1248
+ cleanup.push(deps.store.subscribe("sessions", reconcile));
1249
+ cleanup.push(deps.store.subscribe("invites", publish));
1250
+ const start = (input, options = {}) => {
1251
+ const behavior = options.behavior ?? "rejectWhileBusy";
1252
+ if (behavior !== "allowParallel" && isBusy(current)) {
1253
+ if (behavior === "focusExisting") {
1254
+ const existing = findTargetSession(deps.store.list(), input.target);
1255
+ if (existing) {
1256
+ return join(existing.id, { replaceCurrent: true });
1257
+ }
1258
+ }
1259
+ return Promise.reject(busyError());
1260
+ }
1261
+ return run("start", async (id) => {
1262
+ try {
1263
+ const started = await deps.start(input);
1264
+ assertFresh(id);
1265
+ const next = fromStarted(started, "start", options.media);
1266
+ mediaConnected = false;
1267
+ commit(next);
1268
+ if (next.phase === "failed" && next.error) {
1269
+ throw next.error;
1270
+ }
1271
+ return next;
1272
+ } catch (err) {
1273
+ if (shouldRethrowCommandError(err)) {
1274
+ throw err;
1275
+ }
1276
+ throw commitFailed(null, err);
1277
+ }
1278
+ });
1279
+ };
1280
+ const accept = (input, options = {}) => {
1281
+ if (!canReplaceCurrent(options.replaceCurrent)) {
1282
+ return Promise.reject(busyError());
1283
+ }
1284
+ return run("accept", async (id) => {
1285
+ try {
1286
+ const started = await deps.accept(input);
1287
+ assertFresh(id);
1288
+ const next = fromStarted(started, "accept", options.media);
1289
+ mediaConnected = false;
1290
+ commit(next);
1291
+ if (next.phase === "failed" && next.error) {
1292
+ throw next.error;
1293
+ }
1294
+ return next;
1295
+ } catch (err) {
1296
+ if (shouldRethrowCommandError(err)) {
1297
+ throw err;
1298
+ }
1299
+ throw commitFailed(deps.store.get(input.sessionId), err);
1300
+ }
1301
+ });
1302
+ };
1303
+ const join = (sessionId, options = {}) => {
1304
+ if (!canReplaceCurrent(options.replaceCurrent)) {
1305
+ return Promise.reject(busyError());
1306
+ }
1307
+ const session = deps.store.get(sessionId);
1308
+ if (!session) {
1309
+ return Promise.reject(new CallpadError("session.not_found", "Session was not found"));
1310
+ }
1311
+ return run("join", async (id) => {
1312
+ try {
1313
+ const grant = await deps.join(sessionId);
1314
+ assertFresh(id);
1315
+ const latest = deps.store.get(sessionId) ?? session;
1316
+ const next = fromSession(latest, {
1317
+ phase: latest.state === "active" ? "active" : "connecting",
1318
+ join: grant,
1319
+ participantId: grant.participantId,
1320
+ startedBy: "join",
1321
+ error: null
1322
+ });
1323
+ mediaConnected = next.phase === "active";
1324
+ commit(next);
1325
+ return next;
1326
+ } catch (err) {
1327
+ if (shouldRethrowCommandError(err)) {
1328
+ throw err;
1329
+ }
1330
+ throw commitFailed(session, err);
1331
+ }
1332
+ });
1333
+ };
1334
+ const end = async () => {
1335
+ const target = current;
1336
+ if (!target || isTerminal(target.phase)) {
1337
+ return;
1338
+ }
1339
+ await run("end", async (id) => {
1340
+ const session = deps.store.get(target.sessionId);
1341
+ commit({ ...target, phase: "ending" });
1342
+ try {
1343
+ if (session && deps.end && deps.store.can(session, "end")) {
1344
+ await deps.end(target.sessionId);
1345
+ } else {
1346
+ await deps.leave(target.sessionId);
1347
+ }
1348
+ assertFresh(id);
1349
+ mediaConnected = false;
1350
+ const ended = deps.store.get(target.sessionId);
1351
+ commit({
1352
+ ...target,
1353
+ phase: "ended",
1354
+ session: ended,
1355
+ join: null,
1356
+ endedAt: ended?.endedAt ?? (/* @__PURE__ */ new Date()).toISOString()
1357
+ });
1358
+ } catch (err) {
1359
+ if (shouldRethrowCommandError(err)) {
1360
+ throw err;
1361
+ }
1362
+ throw commitFailed(session, err);
1363
+ }
1364
+ });
1365
+ };
1366
+ const clear = () => {
1367
+ nextOp();
1368
+ pending = null;
1369
+ mediaConnected = false;
1370
+ commit(null);
1371
+ };
1372
+ const mediaConnectedFor = (sessionId) => {
1373
+ if (!current || sessionId && current.sessionId !== sessionId || isTerminal(current.phase)) {
1374
+ return;
1375
+ }
1376
+ mediaConnected = true;
1377
+ const session = deps.store.get(current.sessionId);
1378
+ commit({
1379
+ ...current,
1380
+ phase: "active",
1381
+ session,
1382
+ answeredAt: session?.answeredAt ?? current.answeredAt
1383
+ });
1384
+ };
1385
+ const mediaDisconnectedFor = (sessionId) => {
1386
+ if (!current || sessionId && current.sessionId !== sessionId || isTerminal(current.phase)) {
1387
+ return;
1388
+ }
1389
+ mediaConnected = false;
1390
+ const session = deps.store.get(current.sessionId);
1391
+ if (session?.state === "ended") {
1392
+ reconcile();
1393
+ return;
1394
+ }
1395
+ commit({
1396
+ ...current,
1397
+ phase: current.join ? "connecting" : current.direction === "inbound" ? "incoming" : "outgoing",
1398
+ session
1399
+ });
1400
+ };
1401
+ const mediaFailedFor = (error, sessionId) => {
1402
+ if (!current || sessionId && current.sessionId !== sessionId || isTerminal(current.phase)) {
1403
+ return;
1404
+ }
1405
+ mediaConnected = false;
1406
+ commit({
1407
+ ...current,
1408
+ phase: "failed",
1409
+ join: null,
1410
+ error: toCallpadError(error, "session.media_failed")
1411
+ });
1412
+ };
1413
+ return {
1414
+ get: () => snapshot,
1415
+ start,
1416
+ accept,
1417
+ join,
1418
+ end,
1419
+ clear,
1420
+ mediaConnected: mediaConnectedFor,
1421
+ mediaDisconnected: mediaDisconnectedFor,
1422
+ mediaFailed: mediaFailedFor,
1423
+ subscribe: (fn) => {
1424
+ subscribers.add(fn);
1425
+ return () => {
1426
+ subscribers.delete(fn);
1427
+ };
1428
+ },
1429
+ dispose: () => {
1430
+ for (const unsubscribe of cleanup) {
1431
+ unsubscribe();
1432
+ }
1433
+ subscribers.clear();
1434
+ }
1435
+ };
1436
+ };
1437
+ var fromStarted = (started, startedBy, media = "required") => {
1438
+ if (!started.join && media === "required") {
1439
+ return fromSession(started.session, {
1440
+ phase: "failed",
1441
+ join: null,
1442
+ participantId: null,
1443
+ startedBy,
1444
+ error: new CallpadError(
1445
+ "session.join_unavailable",
1446
+ "Session did not include a browser media join grant"
1447
+ )
1448
+ });
1449
+ }
1450
+ const phase = started.join ? started.session.state === "active" ? "active" : "connecting" : started.session.direction === "inbound" ? "incoming" : "outgoing";
1451
+ return fromSession(started.session, {
1452
+ phase,
1453
+ join: started.join,
1454
+ participantId: started.join?.participantId ?? null,
1455
+ startedBy,
1456
+ error: null
1457
+ });
1458
+ };
1459
+ var fromSession = (session, values) => ({
1460
+ phase: values.phase,
1461
+ direction: session.direction,
1462
+ sessionId: session.id,
1463
+ participantId: values.participantId,
1464
+ session,
1465
+ join: values.join,
1466
+ startedBy: values.startedBy,
1467
+ error: values.error,
1468
+ createdAt: session.createdAt,
1469
+ answeredAt: session.answeredAt,
1470
+ endedAt: session.endedAt
1471
+ });
1472
+ var reconcileCurrent = (current, session, mediaConnected) => {
1473
+ if (session.state === "ended") {
1474
+ return {
1475
+ ...current,
1476
+ phase: "ended",
1477
+ session,
1478
+ join: null,
1479
+ answeredAt: session.answeredAt,
1480
+ endedAt: session.endedAt ?? current.endedAt
1481
+ };
1482
+ }
1483
+ if (isTerminal(current.phase)) {
1484
+ return {
1485
+ ...current,
1486
+ session,
1487
+ answeredAt: session.answeredAt,
1488
+ endedAt: session.endedAt
1489
+ };
1490
+ }
1491
+ const phase = mediaConnected || session.state === "active" ? "active" : current.join ? "connecting" : current.phase === "ending" ? "ending" : session.direction === "inbound" ? "incoming" : "outgoing";
1492
+ if (current.phase === phase && current.session === session && current.answeredAt === session.answeredAt && current.endedAt === session.endedAt) {
1493
+ return current;
1494
+ }
1495
+ return {
1496
+ ...current,
1497
+ phase,
1498
+ session,
1499
+ answeredAt: session.answeredAt,
1500
+ endedAt: session.endedAt
1501
+ };
1502
+ };
1503
+ var findTargetSession = (sessions, target) => {
1504
+ const normalized = normalizeTarget(target);
1505
+ return sessions.find(
1506
+ (session) => session.state !== "ended" && session.participants.some((participant) => {
1507
+ if (normalized.type === "vendor_user") {
1508
+ return participant.kind === "vendor_user" && participant.userId === normalized.userId;
1509
+ }
1510
+ if (normalized.type === "customer") {
1511
+ return participant.kind === "customer" && participant.userId === normalized.userId;
1512
+ }
1513
+ if (normalized.type === "contact") {
1514
+ return participant.contactId === normalized.contactId;
1515
+ }
1516
+ return participant.phone === normalized.phone;
1517
+ })
1518
+ ) ?? null;
1519
+ };
1520
+ var normalizeTarget = (target) => {
1521
+ if (target.type === "phone") {
1522
+ return { ...target, phone: normalizeE164Phone(target.phone) };
1523
+ }
1524
+ return target;
1525
+ };
1526
+ var isBusy = (session) => session !== null && !isTerminal(session.phase);
1527
+ var isTerminal = (phase) => phase === "ended" || phase === "failed";
1528
+ var busyError = () => new CallpadError("session.busy", "A session is already active");
1529
+ var shouldRethrowCommandError = (err) => err instanceof CallpadError && (err.code === "session.join_unavailable" || err.code === "session.command_stale");
1530
+ var toCallpadError = (err, code = "session.failed") => {
1531
+ if (err instanceof CallpadError) {
1532
+ return err;
1533
+ }
1534
+ if (err instanceof Error) {
1535
+ return new CallpadError(code, err.message);
1536
+ }
1537
+ return new CallpadError(code, "Session command failed");
1538
+ };
1058
1539
 
1059
1540
  // src/core/store.ts
1060
1541
  var createSessionStore = (userId) => {
1061
1542
  const sessions = /* @__PURE__ */ new Map();
1062
1543
  const subscribers = /* @__PURE__ */ new Map();
1063
1544
  let status = "idle";
1064
- let activeId = null;
1065
1545
  let listCache = [];
1066
1546
  let invitesCache = [];
1067
1547
  let inviteKey = "";
1548
+ let inviteExpiryTimer = null;
1068
1549
  const emit = (topic) => {
1069
1550
  const fns = subscribers.get(topic);
1070
1551
  if (!fns) {
@@ -1092,18 +1573,11 @@ var createSessionStore = (userId) => {
1092
1573
  listCache = Array.from(sessions.values()).sort(
1093
1574
  (a, b) => b.createdAt.localeCompare(a.createdAt)
1094
1575
  );
1095
- if (activeId) {
1096
- const active = sessions.get(activeId);
1097
- if (!active || active.state === "ended") {
1098
- activeId = null;
1099
- }
1100
- }
1101
- if (!activeId) {
1102
- activeId = listCache.find((session) => session.state !== "ended")?.id ?? null;
1103
- }
1104
1576
  };
1105
1577
  const refreshInvites = () => {
1106
- const invites = listCache.map((session) => inviteFrom(session, userId)).filter((invite) => invite !== null);
1578
+ const now = Date.now();
1579
+ const invites = listCache.map((session) => inviteFrom(session, userId, now)).filter((invite) => invite !== null);
1580
+ scheduleInviteExpiry(invites, now);
1107
1581
  const nextKey = invites.map(
1108
1582
  (invite) => `${invite.session.id}:${invite.participant.id}:${invite.participant.state}:${invite.participant.inviteExpiresAt ?? ""}`
1109
1583
  ).join("|");
@@ -1115,6 +1589,35 @@ var createSessionStore = (userId) => {
1115
1589
  invitesCache = invites;
1116
1590
  return true;
1117
1591
  };
1592
+ const clearInviteExpiryTimer = () => {
1593
+ if (inviteExpiryTimer) {
1594
+ globalThis.clearTimeout(inviteExpiryTimer);
1595
+ inviteExpiryTimer = null;
1596
+ }
1597
+ };
1598
+ const scheduleInviteExpiry = (invites, now) => {
1599
+ clearInviteExpiryTimer();
1600
+ const nextExpiry = invites.reduce((min, invite) => {
1601
+ const expiresAt = inviteExpiryMs(invite.participant);
1602
+ if (expiresAt === null) {
1603
+ return min;
1604
+ }
1605
+ return min === null ? expiresAt : Math.min(min, expiresAt);
1606
+ }, null);
1607
+ if (nextExpiry === null) {
1608
+ return;
1609
+ }
1610
+ inviteExpiryTimer = globalThis.setTimeout(
1611
+ () => {
1612
+ inviteExpiryTimer = null;
1613
+ if (refreshInvites()) {
1614
+ emit("invites");
1615
+ emit("sessions");
1616
+ }
1617
+ },
1618
+ Math.max(0, nextExpiry - now)
1619
+ );
1620
+ };
1118
1621
  const notifySessionChange = (sessionId) => {
1119
1622
  refresh();
1120
1623
  const invitesChanged = refreshInvites();
@@ -1124,15 +1627,7 @@ var createSessionStore = (userId) => {
1124
1627
  emit("invites");
1125
1628
  }
1126
1629
  };
1127
- const get = (sessionId) => {
1128
- if (sessionId) {
1129
- return sessions.get(sessionId) ?? null;
1130
- }
1131
- if (!activeId) {
1132
- return null;
1133
- }
1134
- return sessions.get(activeId) ?? null;
1135
- };
1630
+ const get = (sessionId) => sessions.get(sessionId) ?? null;
1136
1631
  const upsert = (session, mode = "event") => {
1137
1632
  const prev = sessions.get(session.id);
1138
1633
  const stale = prev ? mode === "command" ? session.version < prev.version : session.version <= prev.version : false;
@@ -1156,18 +1651,33 @@ var createSessionStore = (userId) => {
1156
1651
  get,
1157
1652
  invites: () => invitesCache,
1158
1653
  invite: (sessionId) => invitesCache.find((invite) => invite.session.id === sessionId) ?? null,
1159
- sync: (items) => {
1654
+ sync: (items, options) => {
1655
+ const changedIds = /* @__PURE__ */ new Set();
1160
1656
  for (const session of items) {
1161
1657
  const prev = sessions.get(session.id);
1162
1658
  if (!prev || session.version >= prev.version) {
1163
1659
  sessions.set(session.id, session);
1660
+ changedIds.add(session.id);
1661
+ }
1662
+ }
1663
+ const removedIds = [];
1664
+ if (options?.mode === "replaceVisible" && options.shouldReplace) {
1665
+ const visibleIds = new Set(items.map((session) => session.id));
1666
+ for (const session of sessions.values()) {
1667
+ if (!visibleIds.has(session.id) && options.shouldReplace(session)) {
1668
+ sessions.delete(session.id);
1669
+ removedIds.push(session.id);
1670
+ }
1164
1671
  }
1165
1672
  }
1166
1673
  refresh();
1167
1674
  const invitesChanged = refreshInvites();
1168
1675
  emit("sessions");
1169
- for (const session of items) {
1170
- emit(`session:${session.id}`);
1676
+ for (const sessionId of changedIds) {
1677
+ emit(`session:${sessionId}`);
1678
+ }
1679
+ for (const sessionId of removedIds) {
1680
+ emit(`session:${sessionId}`);
1171
1681
  }
1172
1682
  if (invitesChanged) {
1173
1683
  emit("invites");
@@ -1201,13 +1711,17 @@ var createSessionStore = (userId) => {
1201
1711
  return next;
1202
1712
  },
1203
1713
  can: (session, action) => can(session, userId, action),
1714
+ dispose: () => {
1715
+ clearInviteExpiryTimer();
1716
+ subscribers.clear();
1717
+ },
1204
1718
  subscribe
1205
1719
  };
1206
1720
  };
1207
1721
  var selfParticipant = (session, userId) => session.participants.find((participant) => participant.userId === userId) ?? null;
1208
- var inviteFrom = (session, userId) => {
1722
+ var inviteFrom = (session, userId, now) => {
1209
1723
  const participant = selfParticipant(session, userId);
1210
- if (!participant || participant.state !== "invited") {
1724
+ if (!participant || session.state === "ended" || !isActiveInvite(participant, now)) {
1211
1725
  return null;
1212
1726
  }
1213
1727
  return { session, participant };
@@ -1220,7 +1734,7 @@ var can = (session, userId, action) => {
1220
1734
  switch (action) {
1221
1735
  case "accept":
1222
1736
  case "reject":
1223
- return self.state === "invited";
1737
+ return isActiveInvite(self);
1224
1738
  case "join":
1225
1739
  return self.transport === "webrtc" && (self.state === "accepted" || self.state === "joined");
1226
1740
  case "leave":
@@ -1235,60 +1749,113 @@ var can = (session, userId, action) => {
1235
1749
  return isHost(self) && session.recording.status === "recording";
1236
1750
  }
1237
1751
  };
1752
+ var isActiveInvite = (participant, now = Date.now()) => {
1753
+ const expiresAt = inviteExpiryMs(participant);
1754
+ return participant.state === "invited" && expiresAt !== null && expiresAt > now;
1755
+ };
1756
+ var inviteExpiryMs = (participant) => {
1757
+ if (!participant.inviteExpiresAt) {
1758
+ return null;
1759
+ }
1760
+ const expiresAt = Date.parse(participant.inviteExpiresAt);
1761
+ return Number.isFinite(expiresAt) ? expiresAt : null;
1762
+ };
1238
1763
  var isHost = (participant) => participant.role === "host" && (participant.kind === "agent" || participant.kind === "vendor_user");
1239
1764
 
1240
1765
  // src/core/client.ts
1241
1766
  var createSessionClient = (config) => {
1242
1767
  const core = createCore(config);
1768
+ const sessions = {
1769
+ ...createCommonSessions(core),
1770
+ start: (input) => startSession(core, input.vendor, input.target)
1771
+ };
1772
+ const session = createCurrentSessionController({
1773
+ store: core.store,
1774
+ emitter: core.emitter,
1775
+ start: sessions.start,
1776
+ accept: sessions.accept,
1777
+ join: sessions.join,
1778
+ leave: sessions.leave
1779
+ });
1243
1780
  return {
1244
1781
  kind: "session",
1245
1782
  get status() {
1246
1783
  return core.store.getStatus();
1247
1784
  },
1248
- sessions: {
1249
- ...createCommonSessions(core),
1250
- start: (input) => startSession(core, input.vendor, input.target)
1251
- },
1785
+ sessions,
1252
1786
  invites: {
1253
1787
  list: core.store.invites,
1254
1788
  get: core.store.invite
1255
1789
  },
1790
+ session,
1256
1791
  connect: core.connect,
1257
1792
  disconnect: core.realtime.disconnect,
1258
- dispose: core.realtime.dispose,
1793
+ dispose: async () => {
1794
+ session.dispose();
1795
+ await core.dispose();
1796
+ },
1259
1797
  on: core.emitter.on,
1260
1798
  subscribe: core.store.subscribe
1261
1799
  };
1262
1800
  };
1263
1801
  var createHostClient = (config) => {
1264
1802
  const core = createCore(config);
1803
+ const sessions = {
1804
+ ...createCommonSessions(core),
1805
+ start: (input) => startSession(core, config.vendor, input.target)
1806
+ };
1807
+ const host = createHostControls(core);
1808
+ const session = createCurrentSessionController({
1809
+ store: core.store,
1810
+ emitter: core.emitter,
1811
+ start: sessions.start,
1812
+ accept: sessions.accept,
1813
+ join: sessions.join,
1814
+ leave: sessions.leave,
1815
+ end: host.end
1816
+ });
1265
1817
  return {
1266
1818
  kind: "host",
1267
1819
  vendor: config.vendor,
1268
1820
  get status() {
1269
1821
  return core.store.getStatus();
1270
1822
  },
1271
- sessions: {
1272
- ...createCommonSessions(core),
1273
- start: (input) => startSession(core, config.vendor, input.target)
1274
- },
1823
+ sessions,
1275
1824
  invites: {
1276
1825
  list: core.store.invites,
1277
1826
  get: core.store.invite
1278
1827
  },
1279
- host: createHostControls(core),
1828
+ session,
1829
+ host,
1280
1830
  connect: core.connect,
1281
1831
  disconnect: core.realtime.disconnect,
1282
- dispose: core.realtime.dispose,
1832
+ dispose: async () => {
1833
+ session.dispose();
1834
+ await core.dispose();
1835
+ },
1283
1836
  on: core.emitter.on,
1284
1837
  subscribe: core.store.subscribe
1285
1838
  };
1286
1839
  };
1840
+ var normalizePhoneTarget = (target) => {
1841
+ if (target.type === "phone") {
1842
+ return { ...target, phone: normalizeE164Phone(target.phone) };
1843
+ }
1844
+ return target;
1845
+ };
1287
1846
  var createCore = (config) => {
1288
1847
  const api = apiClient(config);
1289
1848
  const store = createSessionStore(config.userId);
1290
1849
  const emitter = createEmitter();
1291
- const realtime = createRealtime(api, config.auth, store, emitter);
1850
+ const syncActive = async () => {
1851
+ const query = { state: "active" };
1852
+ const response = await unwrap(listSessions({ client: api, query }), config.auth);
1853
+ store.sync(response.items, {
1854
+ mode: "replaceVisible",
1855
+ shouldReplace: (session) => matchesVisibleQuery(session, query)
1856
+ });
1857
+ };
1858
+ const realtime = createRealtime(api, config.auth, store, emitter, syncActive);
1292
1859
  const applySession = (session, mode = "event") => {
1293
1860
  const change = store.upsert(session, mode);
1294
1861
  if (change === "added") {
@@ -1318,6 +1885,10 @@ var createCore = (config) => {
1318
1885
  throw err;
1319
1886
  }
1320
1887
  };
1888
+ const dispose = async () => {
1889
+ await realtime.dispose();
1890
+ store.dispose();
1891
+ };
1321
1892
  return {
1322
1893
  api,
1323
1894
  auth: config.auth,
@@ -1325,6 +1896,7 @@ var createCore = (config) => {
1325
1896
  emitter,
1326
1897
  realtime,
1327
1898
  connect,
1899
+ dispose,
1328
1900
  applySession,
1329
1901
  applyStarted,
1330
1902
  patchRecording
@@ -1333,9 +1905,12 @@ var createCore = (config) => {
1333
1905
  var createCommonSessions = (core) => ({
1334
1906
  list: core.store.list,
1335
1907
  get: core.store.get,
1336
- sync: async (query) => {
1908
+ sync: async (query, options) => {
1337
1909
  const response = await unwrap(listSessions({ client: core.api, query }), core.auth);
1338
- core.store.sync(response.items);
1910
+ core.store.sync(response.items, {
1911
+ mode: options?.mode,
1912
+ shouldReplace: options?.mode === "replaceVisible" && canReplaceVisibleQuery(query) ? (session) => matchesVisibleQuery(session, query) : void 0
1913
+ });
1339
1914
  return core.store.list();
1340
1915
  },
1341
1916
  join: (sessionId) => unwrap(createSessionToken({ client: core.api, path: { sessionId } }), core.auth),
@@ -1383,7 +1958,7 @@ var createHostControls = (core) => ({
1383
1958
  addSessionParticipant({
1384
1959
  client: core.api,
1385
1960
  path: { sessionId: input.sessionId },
1386
- body: { target: input.target }
1961
+ body: { target: normalizePhoneTarget(input.target) }
1387
1962
  }),
1388
1963
  core.auth
1389
1964
  ),
@@ -1414,12 +1989,28 @@ var startSession = async (core, vendor, target) => core.applyStarted(
1414
1989
  createSession({
1415
1990
  client: core.api,
1416
1991
  path: { vendor },
1417
- body: { target }
1992
+ body: { target: normalizePhoneTarget(target) }
1418
1993
  }),
1419
1994
  core.auth
1420
1995
  ),
1421
1996
  "command"
1422
1997
  );
1998
+ var matchesVisibleQuery = (session, query) => {
1999
+ if (query?.vendor && session.vendor !== query.vendor) {
2000
+ return false;
2001
+ }
2002
+ const state = query?.state ?? "active";
2003
+ if (state === "all") {
2004
+ return true;
2005
+ }
2006
+ if (state === "ended") {
2007
+ return session.state === "ended";
2008
+ }
2009
+ return session.state !== "ended";
2010
+ };
2011
+ var canReplaceVisibleQuery = (query) => {
2012
+ return !query?.before && query?.limit === void 0;
2013
+ };
1423
2014
 
1424
2015
  exports.CallpadError = CallpadError;
1425
2016
  exports.createHostClient = createHostClient;