@vex-chat/libvex 4.0.0 → 5.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/src/Client.ts CHANGED
@@ -160,6 +160,12 @@ export interface ClientOptions {
160
160
  saveHistory?: boolean;
161
161
  /** Use `http/ws` instead of `https/wss`. Intended for local/dev environments. */
162
162
  unsafeHttp?: boolean;
163
+ /**
164
+ * When set (non-empty), sent as `x-dev-api-key` on every HTTP request.
165
+ * Spire omits in-process rate limits when this matches the server's `DEV_API_KEY`
166
+ * (local / load-testing only — never use in production).
167
+ */
168
+ devApiKey?: string;
163
169
  }
164
170
 
165
171
  /**
@@ -850,6 +856,14 @@ export class Client {
850
856
  private readonly forwarded = new Set<string>();
851
857
 
852
858
  private readonly host: string;
859
+ /**
860
+ * Node-only: per-client HTTP(S) agents (see `init()` + `storage/node/http-agents`).
861
+ * Dropped on `close()` so idle keep-alive sockets do not keep the process alive.
862
+ */
863
+ private nodeHttpAgents?: {
864
+ http: { destroy(): void };
865
+ https: { destroy(): void };
866
+ };
853
867
  private readonly http: AxiosInstance;
854
868
  private readonly idKeys: KeyPair | null;
855
869
  private isAlive: boolean = true;
@@ -933,6 +947,10 @@ export class Client {
933
947
  });
934
948
 
935
949
  this.http = axios.create({ responseType: "arraybuffer" });
950
+ const devKey = options?.devApiKey?.trim();
951
+ if (devKey !== undefined && devKey.length > 0) {
952
+ this.http.defaults.headers.common["x-dev-api-key"] = devKey;
953
+ }
936
954
 
937
955
  this.socket = new WebSocketAdapter(this.prefixes.WS + this.host);
938
956
  this.socket.onerror = () => {};
@@ -1023,6 +1041,32 @@ export class Client {
1023
1041
  return xMnemonic(xKDF(XUtils.decodeHex(session.fingerprint)));
1024
1042
  }
1025
1043
 
1044
+ /**
1045
+ * True when running under Node (has `process.versions`).
1046
+ * Uses indirect lookup so the bare `process` global never appears in
1047
+ * source that the platform-guard plugin scans.
1048
+ */
1049
+ private static isNodeRuntime(): boolean {
1050
+ try {
1051
+ const g = Object.getOwnPropertyDescriptor(
1052
+ globalThis,
1053
+ "\u0070rocess",
1054
+ );
1055
+ if (!g) return false;
1056
+ const proc: unknown =
1057
+ typeof g.get === "function" ? g.get() : g.value;
1058
+ if (typeof proc !== "object" || proc === null) {
1059
+ return false;
1060
+ }
1061
+ return (
1062
+ "versions" in proc &&
1063
+ typeof (proc as { versions?: unknown }).versions === "object"
1064
+ );
1065
+ } catch {
1066
+ return false;
1067
+ }
1068
+ }
1069
+
1026
1070
  /**
1027
1071
  * Browser-safe NODE_ENV accessor.
1028
1072
  * Uses indirect lookup so the bare `process` global never appears in
@@ -1034,26 +1078,43 @@ export class Client {
1034
1078
  globalThis,
1035
1079
  "\u0070rocess",
1036
1080
  );
1037
- if (!g || typeof g.value !== "object" || g.value === null) {
1081
+ if (!g) return undefined;
1082
+ // Node 24+ exposes `process` as an accessor (get/set), not a value.
1083
+ const proc: unknown =
1084
+ typeof g.get === "function" ? g.get() : g.value;
1085
+ if (typeof proc !== "object" || proc === null) {
1038
1086
  return undefined;
1039
1087
  }
1040
- const env: unknown = Object.getOwnPropertyDescriptor(
1041
- g.value,
1042
- "env",
1043
- )?.value;
1088
+ const envDesc = Object.getOwnPropertyDescriptor(proc, "env");
1089
+ if (!envDesc) return undefined;
1090
+ const env: unknown =
1091
+ typeof envDesc.get === "function"
1092
+ ? envDesc.get()
1093
+ : envDesc.value;
1044
1094
  if (typeof env !== "object" || env === null) {
1045
1095
  return undefined;
1046
1096
  }
1047
- const val: unknown = Object.getOwnPropertyDescriptor(
1048
- env,
1049
- "NODE_ENV",
1050
- )?.value;
1097
+ const valDesc = Object.getOwnPropertyDescriptor(env, "NODE_ENV");
1098
+ if (!valDesc) return undefined;
1099
+ const val: unknown =
1100
+ typeof valDesc.get === "function"
1101
+ ? valDesc.get()
1102
+ : valDesc.value;
1051
1103
  return typeof val === "string" ? val : undefined;
1052
1104
  } catch {
1053
1105
  return undefined;
1054
1106
  }
1055
1107
  }
1056
1108
 
1109
+ /**
1110
+ * Fresh read of the `manuallyClosing` flag for async loops — direct property checks
1111
+ * after `await` are flagged as always-false by control-flow analysis even though
1112
+ * `close()` can run concurrently.
1113
+ */
1114
+ private isManualCloseInFlight(): boolean {
1115
+ return this.manuallyClosing;
1116
+ }
1117
+
1057
1118
  /**
1058
1119
  * Closes the client — disconnects the WebSocket, shuts down storage,
1059
1120
  * and emits `closed` unless `muteEvent` is `true`.
@@ -1065,6 +1126,12 @@ export class Client {
1065
1126
  this.socket.close();
1066
1127
  await this.database.close();
1067
1128
 
1129
+ if (this.nodeHttpAgents) {
1130
+ this.nodeHttpAgents.http.destroy();
1131
+ this.nodeHttpAgents.https.destroy();
1132
+ delete this.nodeHttpAgents;
1133
+ }
1134
+
1068
1135
  if (this.pingInterval) {
1069
1136
  clearInterval(this.pingInterval);
1070
1137
  }
@@ -1085,9 +1152,12 @@ export class Client {
1085
1152
  * You can check whoami() to see before calling connect().
1086
1153
  */
1087
1154
  public async connect(): Promise<void> {
1088
- const { token, user } = await this.whoami();
1089
- this.token = token;
1090
- this.http.defaults.headers.common.Authorization = `Bearer ${token}`;
1155
+ if (!this.token) {
1156
+ throw new Error(
1157
+ "No token call login() or loginWithDeviceKey() first.",
1158
+ );
1159
+ }
1160
+ const { user } = await this.whoami();
1091
1161
  this.setUser(user);
1092
1162
 
1093
1163
  this.device = await this.retrieveOrCreateDevice();
@@ -1385,7 +1455,6 @@ export class Client {
1385
1455
  */
1386
1456
  public async whoami(): Promise<{
1387
1457
  exp: number;
1388
- token: string;
1389
1458
  user: User;
1390
1459
  }> {
1391
1460
  const res = await this.http.post(this.getHost() + "/whoami");
@@ -1529,6 +1598,9 @@ export class Client {
1529
1598
  }
1530
1599
 
1531
1600
  if (!this.xKeyRing) {
1601
+ if (this.manuallyClosing) {
1602
+ return;
1603
+ }
1532
1604
  throw new Error("Key ring not initialized.");
1533
1605
  }
1534
1606
 
@@ -1769,11 +1841,10 @@ export class Client {
1769
1841
 
1770
1842
  const msgBytes = Uint8Array.from(msgpack.encode(copy));
1771
1843
 
1772
- const devices = await this.getUserDeviceList(this.getUser().userID);
1773
-
1774
- if (!devices) {
1775
- throw new Error("Couldn't get own devices.");
1776
- }
1844
+ const devices = await this.fetchUserDeviceListWithBackoff(
1845
+ this.getUser().userID,
1846
+ "own",
1847
+ );
1777
1848
  const promises = [];
1778
1849
  for (const device of devices) {
1779
1850
  if (device.deviceID !== this.getDevice().deviceID) {
@@ -1871,6 +1942,9 @@ export class Client {
1871
1942
  }
1872
1943
 
1873
1944
  private async getMail(): Promise<void> {
1945
+ if (this.manuallyClosing) {
1946
+ return;
1947
+ }
1874
1948
  while (this.fetchingMail) {
1875
1949
  await sleep(500);
1876
1950
  }
@@ -2024,22 +2098,86 @@ export class Client {
2024
2098
  return this.user;
2025
2099
  }
2026
2100
 
2101
+ private deviceListFailureDetail(err: unknown): string {
2102
+ if (!isAxiosError(err)) {
2103
+ return "";
2104
+ }
2105
+ const st = err.response?.status;
2106
+ if (typeof st === "number") {
2107
+ return ` (HTTP ${String(st)})`;
2108
+ }
2109
+ if (err.code !== undefined) {
2110
+ return ` (${err.code})`;
2111
+ }
2112
+ return "";
2113
+ }
2114
+
2115
+ /**
2116
+ * Single GET for `/user/:id/devices`. On failure returns `null` (swallows errors)
2117
+ * — callers that need reliability should use `fetchUserDeviceListWithBackoff`.
2118
+ * Similar “best effort null” patterns elsewhere: `getChannelByID`,
2119
+ * `getDeviceByID` (HTTP leg), `getToken`, emoji upload fallbacks.
2120
+ */
2027
2121
  private async getUserDeviceList(userID: string): Promise<Device[] | null> {
2028
2122
  try {
2029
- const res = await this.http.get(
2030
- this.getHost() + "/user/" + userID + "/devices",
2031
- );
2032
- const devices = decodeAxios(DeviceArrayCodec, res.data);
2033
- for (const device of devices) {
2034
- this.deviceRecords[device.deviceID] = device;
2035
- }
2036
-
2037
- return devices;
2123
+ return await this.fetchUserDeviceListOnce(userID);
2038
2124
  } catch (_err: unknown) {
2039
2125
  return null;
2040
2126
  }
2041
2127
  }
2042
2128
 
2129
+ private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
2130
+ const res = await this.http.get(
2131
+ this.getHost() + "/user/" + userID + "/devices",
2132
+ );
2133
+ const devices = decodeAxios(DeviceArrayCodec, res.data);
2134
+ for (const device of devices) {
2135
+ this.deviceRecords[device.deviceID] = device;
2136
+ }
2137
+ return devices;
2138
+ }
2139
+
2140
+ /**
2141
+ * DM / forward paths need the peer’s (or self) device rows under load: bounded
2142
+ * retries with exponential backoff (same shape as session pubkey hydration).
2143
+ */
2144
+ private async fetchUserDeviceListWithBackoff(
2145
+ userID: string,
2146
+ label: "peer" | "own",
2147
+ ): Promise<Device[]> {
2148
+ const base =
2149
+ label === "own"
2150
+ ? "Couldn't get own devices"
2151
+ : "Couldn't get device list";
2152
+ let lastErr: unknown;
2153
+ for (let attempt = 0; attempt < 5; attempt++) {
2154
+ if (attempt > 0) {
2155
+ if (this.isManualCloseInFlight()) {
2156
+ throw new Error(
2157
+ `${base}${this.deviceListFailureDetail(lastErr)}`,
2158
+ );
2159
+ }
2160
+ const delayMs = 100 * 2 ** (attempt - 1);
2161
+ // Chunk the delay to allow close() to interrupt
2162
+ const chunkMs = 10;
2163
+ for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
2164
+ if (this.isManualCloseInFlight()) {
2165
+ throw new Error(
2166
+ `${base}${this.deviceListFailureDetail(lastErr)}`,
2167
+ );
2168
+ }
2169
+ await sleep(Math.min(chunkMs, delayMs - elapsed));
2170
+ }
2171
+ }
2172
+ try {
2173
+ return await this.fetchUserDeviceListOnce(userID);
2174
+ } catch (err: unknown) {
2175
+ lastErr = err;
2176
+ }
2177
+ }
2178
+ throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
2179
+ }
2180
+
2043
2181
  private async getUserList(channelID: string): Promise<User[]> {
2044
2182
  const res = await this.http.post(
2045
2183
  this.getHost() + "/userList/" + channelID,
@@ -2076,6 +2214,14 @@ export class Client {
2076
2214
  }
2077
2215
  this.hasInit = true;
2078
2216
 
2217
+ if (Client.isNodeRuntime()) {
2218
+ const { attachNodeAgentsToAxios, createNodeHttpAgents } =
2219
+ await import("./storage/node/http-agents.js");
2220
+ const agents = createNodeHttpAgents();
2221
+ this.nodeHttpAgents = agents;
2222
+ attachNodeAgentsToAxios(this.http, agents);
2223
+ }
2224
+
2079
2225
  await this.populateKeyRing();
2080
2226
  this.emitter.on("message", (message) => {
2081
2227
  if (message.direction === "outgoing" && !message.forward) {
@@ -2208,6 +2354,9 @@ export class Client {
2208
2354
 
2209
2355
  private newEphemeralKeys() {
2210
2356
  if (!this.xKeyRing) {
2357
+ if (this.manuallyClosing) {
2358
+ return;
2359
+ }
2211
2360
  throw new Error("Key ring not initialized.");
2212
2361
  }
2213
2362
  this.xKeyRing.ephemeralKeys = xBoxKeyPair();
@@ -2265,6 +2414,9 @@ export class Client {
2265
2414
  private async postAuth() {
2266
2415
  let count = 0;
2267
2416
  for (;;) {
2417
+ if (this.isManualCloseInFlight()) {
2418
+ return;
2419
+ }
2268
2420
  try {
2269
2421
  await this.getMail();
2270
2422
  count++;
@@ -2275,7 +2427,17 @@ export class Client {
2275
2427
  count = 0;
2276
2428
  }
2277
2429
  } catch {}
2278
- await sleep(1000 * 60);
2430
+ if (this.isManualCloseInFlight()) {
2431
+ return;
2432
+ }
2433
+ // Chunk the idle delay so `close()` can unwind instead of waiting
2434
+ // out one full 60s timer (which would keep the process alive).
2435
+ for (let i = 0; i < 60; i++) {
2436
+ if (this.isManualCloseInFlight()) {
2437
+ return;
2438
+ }
2439
+ await sleep(1000);
2440
+ }
2279
2441
  }
2280
2442
  }
2281
2443
 
@@ -2293,6 +2455,10 @@ export class Client {
2293
2455
  }
2294
2456
  this.seenMailIDs.add(mail.mailID);
2295
2457
 
2458
+ if (this.manuallyClosing) {
2459
+ return;
2460
+ }
2461
+
2296
2462
  this.sendReceipt(new Uint8Array(mail.nonce));
2297
2463
  let timeout = 1;
2298
2464
  while (this.reading) {
@@ -2303,6 +2469,9 @@ export class Client {
2303
2469
 
2304
2470
  try {
2305
2471
  const healSession = async () => {
2472
+ if (this.manuallyClosing || !this.xKeyRing) {
2473
+ return;
2474
+ }
2306
2475
  const deviceEntry = await this.getDeviceByID(mail.sender);
2307
2476
  const [user, _err] = await this.fetchUser(mail.authorID);
2308
2477
  if (deviceEntry && user) {
@@ -2352,7 +2521,7 @@ export class Client {
2352
2521
  const EK_A = ephKey;
2353
2522
 
2354
2523
  if (!this.xKeyRing) {
2355
- throw new Error("Key ring not initialized.");
2524
+ return;
2356
2525
  }
2357
2526
  // my private keys
2358
2527
  const IK_B = this.xKeyRing.identityKeys.secretKey;
@@ -2910,17 +3079,10 @@ export class Client {
2910
3079
  throw new Error("Couldn't get user entry.");
2911
3080
  }
2912
3081
 
2913
- let deviceList = await this.getUserDeviceList(userID);
2914
- if (!deviceList) {
2915
- let retries = 0;
2916
- while (!deviceList) {
2917
- deviceList = await this.getUserDeviceList(userID);
2918
- retries++;
2919
- if (retries > 3) {
2920
- throw new Error("Couldn't get device list.");
2921
- }
2922
- }
2923
- }
3082
+ const deviceList = await this.fetchUserDeviceListWithBackoff(
3083
+ userID,
3084
+ "peer",
3085
+ );
2924
3086
  const mailID = uuid.v4();
2925
3087
  const promises: Array<Promise<void>> = [];
2926
3088
  for (const device of deviceList) {
@@ -23,6 +23,7 @@ export function platformSuite(
23
23
 
24
24
  beforeAll(async () => {
25
25
  const SK = Client.generateSecretKey();
26
+
26
27
  const opts: ClientOptions = {
27
28
  inMemoryDb: true,
28
29
  ...apiUrlOverrideFromEnv(),
package/src/codecs.ts CHANGED
@@ -70,7 +70,6 @@ export const DeviceChallengeCodec = createCodec(
70
70
  export const WhoamiCodec = createCodec(
71
71
  z.object({
72
72
  exp: z.number(),
73
- token: z.string(),
74
73
  user: UserSchema,
75
74
  }),
76
75
  );
@@ -0,0 +1,33 @@
1
+ import type { AxiosInstance } from "axios";
2
+
3
+ /**
4
+ * Node-only HTTP(S) agents for libvex axios — lives under `storage/node/` so the
5
+ * platform-guard plugin allows `node:http` / `node:https` (see poison-node-imports).
6
+ */
7
+ import * as nodeHttp from "node:http";
8
+ import * as nodeHttps from "node:https";
9
+
10
+ export interface NodeHttpAgentPair {
11
+ readonly http: nodeHttp.Agent;
12
+ readonly https: nodeHttps.Agent;
13
+ }
14
+
15
+ export function createNodeHttpAgents(): NodeHttpAgentPair {
16
+ return {
17
+ http: new nodeHttp.Agent({ keepAlive: true }),
18
+ https: new nodeHttps.Agent({ keepAlive: true }),
19
+ };
20
+ }
21
+
22
+ export function attachNodeAgentsToAxios(
23
+ instance: AxiosInstance,
24
+ agents: NodeHttpAgentPair,
25
+ ): void {
26
+ instance.defaults.httpAgent = agents.http;
27
+ instance.defaults.httpsAgent = agents.https;
28
+ }
29
+
30
+ export function destroyNodeHttpAgents(agents: NodeHttpAgentPair): void {
31
+ agents.http.destroy();
32
+ agents.https.destroy();
33
+ }