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