@vex-chat/libvex 5.0.0 → 5.2.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,16 @@ 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
+ };
867
+ /** Cancels in-flight axios work on `close()` so `postAuth`/`getMail` cannot hang forever. */
868
+ private readonly httpAbortController = new AbortController();
853
869
  private readonly http: AxiosInstance;
854
870
  private readonly idKeys: KeyPair | null;
855
871
  private isAlive: boolean = true;
@@ -932,7 +948,14 @@ export class Client {
932
948
  void this.close(true);
933
949
  });
934
950
 
935
- this.http = axios.create({ responseType: "arraybuffer" });
951
+ this.http = axios.create({
952
+ responseType: "arraybuffer",
953
+ signal: this.httpAbortController.signal,
954
+ });
955
+ const devKey = options?.devApiKey?.trim();
956
+ if (devKey !== undefined && devKey.length > 0) {
957
+ this.http.defaults.headers.common["x-dev-api-key"] = devKey;
958
+ }
936
959
 
937
960
  this.socket = new WebSocketAdapter(this.prefixes.WS + this.host);
938
961
  this.socket.onerror = () => {};
@@ -1023,6 +1046,32 @@ export class Client {
1023
1046
  return xMnemonic(xKDF(XUtils.decodeHex(session.fingerprint)));
1024
1047
  }
1025
1048
 
1049
+ /**
1050
+ * True when running under Node (has `process.versions`).
1051
+ * Uses indirect lookup so the bare `process` global never appears in
1052
+ * source that the platform-guard plugin scans.
1053
+ */
1054
+ private static isNodeRuntime(): boolean {
1055
+ try {
1056
+ const g = Object.getOwnPropertyDescriptor(
1057
+ globalThis,
1058
+ "\u0070rocess",
1059
+ );
1060
+ if (!g) return false;
1061
+ const proc: unknown =
1062
+ typeof g.get === "function" ? g.get() : g.value;
1063
+ if (typeof proc !== "object" || proc === null) {
1064
+ return false;
1065
+ }
1066
+ return (
1067
+ "versions" in proc &&
1068
+ typeof (proc as { versions?: unknown }).versions === "object"
1069
+ );
1070
+ } catch {
1071
+ return false;
1072
+ }
1073
+ }
1074
+
1026
1075
  /**
1027
1076
  * Browser-safe NODE_ENV accessor.
1028
1077
  * Uses indirect lookup so the bare `process` global never appears in
@@ -1062,6 +1111,15 @@ export class Client {
1062
1111
  }
1063
1112
  }
1064
1113
 
1114
+ /**
1115
+ * Fresh read of the `manuallyClosing` flag for async loops — direct property checks
1116
+ * after `await` are flagged as always-false by control-flow analysis even though
1117
+ * `close()` can run concurrently.
1118
+ */
1119
+ private isManualCloseInFlight(): boolean {
1120
+ return this.manuallyClosing;
1121
+ }
1122
+
1065
1123
  /**
1066
1124
  * Closes the client — disconnects the WebSocket, shuts down storage,
1067
1125
  * and emits `closed` unless `muteEvent` is `true`.
@@ -1070,9 +1128,16 @@ export class Client {
1070
1128
  */
1071
1129
  public async close(muteEvent = false): Promise<void> {
1072
1130
  this.manuallyClosing = true;
1131
+ this.httpAbortController.abort();
1073
1132
  this.socket.close();
1074
1133
  await this.database.close();
1075
1134
 
1135
+ if (this.nodeHttpAgents) {
1136
+ this.nodeHttpAgents.http.destroy();
1137
+ this.nodeHttpAgents.https.destroy();
1138
+ delete this.nodeHttpAgents;
1139
+ }
1140
+
1076
1141
  if (this.pingInterval) {
1077
1142
  clearInterval(this.pingInterval);
1078
1143
  }
@@ -1539,6 +1604,9 @@ export class Client {
1539
1604
  }
1540
1605
 
1541
1606
  if (!this.xKeyRing) {
1607
+ if (this.manuallyClosing) {
1608
+ return;
1609
+ }
1542
1610
  throw new Error("Key ring not initialized.");
1543
1611
  }
1544
1612
 
@@ -1765,6 +1833,10 @@ export class Client {
1765
1833
  }
1766
1834
 
1767
1835
  private async forward(message: Message) {
1836
+ if (this.isManualCloseInFlight()) {
1837
+ return;
1838
+ }
1839
+
1768
1840
  const copy = { ...message };
1769
1841
 
1770
1842
  if (this.forwarded.has(copy.mailID)) {
@@ -1779,11 +1851,10 @@ export class Client {
1779
1851
 
1780
1852
  const msgBytes = Uint8Array.from(msgpack.encode(copy));
1781
1853
 
1782
- const devices = await this.getUserDeviceList(this.getUser().userID);
1783
-
1784
- if (!devices) {
1785
- throw new Error("Couldn't get own devices.");
1786
- }
1854
+ const devices = await this.fetchUserDeviceListWithBackoff(
1855
+ this.getUser().userID,
1856
+ "own",
1857
+ );
1787
1858
  const promises = [];
1788
1859
  for (const device of devices) {
1789
1860
  if (device.deviceID !== this.getDevice().deviceID) {
@@ -1881,6 +1952,9 @@ export class Client {
1881
1952
  }
1882
1953
 
1883
1954
  private async getMail(): Promise<void> {
1955
+ if (this.manuallyClosing) {
1956
+ return;
1957
+ }
1884
1958
  while (this.fetchingMail) {
1885
1959
  await sleep(500);
1886
1960
  }
@@ -2034,22 +2108,85 @@ export class Client {
2034
2108
  return this.user;
2035
2109
  }
2036
2110
 
2111
+ private deviceListFailureDetail(err: unknown): string {
2112
+ if (!isAxiosError(err)) {
2113
+ return "";
2114
+ }
2115
+ const st = err.response?.status;
2116
+ if (typeof st === "number") {
2117
+ return ` (HTTP ${String(st)})`;
2118
+ }
2119
+ if (err.code !== undefined) {
2120
+ return ` (${err.code})`;
2121
+ }
2122
+ return "";
2123
+ }
2124
+
2125
+ /**
2126
+ * Single GET for `/user/:id/devices`. On failure returns `null` (swallows errors)
2127
+ * — callers that need reliability should use `fetchUserDeviceListWithBackoff`.
2128
+ * Similar “best effort null” patterns elsewhere: `getChannelByID`,
2129
+ * `getDeviceByID` (HTTP leg), `getToken`, emoji upload fallbacks.
2130
+ */
2037
2131
  private async getUserDeviceList(userID: string): Promise<Device[] | null> {
2038
2132
  try {
2039
- const res = await this.http.get(
2040
- this.getHost() + "/user/" + userID + "/devices",
2041
- );
2042
- const devices = decodeAxios(DeviceArrayCodec, res.data);
2043
- for (const device of devices) {
2044
- this.deviceRecords[device.deviceID] = device;
2045
- }
2046
-
2047
- return devices;
2133
+ return await this.fetchUserDeviceListOnce(userID);
2048
2134
  } catch (_err: unknown) {
2049
2135
  return null;
2050
2136
  }
2051
2137
  }
2052
2138
 
2139
+ private async fetchUserDeviceListOnce(userID: string): Promise<Device[]> {
2140
+ if (this.isManualCloseInFlight()) {
2141
+ return [];
2142
+ }
2143
+ const res = await this.http.get(
2144
+ this.getHost() + "/user/" + userID + "/devices",
2145
+ );
2146
+ const devices = decodeAxios(DeviceArrayCodec, res.data);
2147
+ for (const device of devices) {
2148
+ this.deviceRecords[device.deviceID] = device;
2149
+ }
2150
+ return devices;
2151
+ }
2152
+
2153
+ /**
2154
+ * DM / forward paths need the peer’s (or self) device rows under load: bounded
2155
+ * retries with exponential backoff (same shape as session pubkey hydration).
2156
+ */
2157
+ private async fetchUserDeviceListWithBackoff(
2158
+ userID: string,
2159
+ label: "peer" | "own",
2160
+ ): Promise<Device[]> {
2161
+ const base =
2162
+ label === "own"
2163
+ ? "Couldn't get own devices"
2164
+ : "Couldn't get device list";
2165
+ let lastErr: unknown;
2166
+ for (let attempt = 0; attempt < 5; attempt++) {
2167
+ if (this.isManualCloseInFlight()) {
2168
+ return [];
2169
+ }
2170
+ if (attempt > 0) {
2171
+ const delayMs = 100 * 2 ** (attempt - 1);
2172
+ // Chunk the delay so close() can finish before we retry HTTP.
2173
+ const chunkMs = 10;
2174
+ for (let elapsed = 0; elapsed < delayMs; elapsed += chunkMs) {
2175
+ if (this.isManualCloseInFlight()) {
2176
+ return [];
2177
+ }
2178
+ await sleep(Math.min(chunkMs, delayMs - elapsed));
2179
+ }
2180
+ }
2181
+ try {
2182
+ return await this.fetchUserDeviceListOnce(userID);
2183
+ } catch (err: unknown) {
2184
+ lastErr = err;
2185
+ }
2186
+ }
2187
+ throw new Error(`${base}${this.deviceListFailureDetail(lastErr)}`);
2188
+ }
2189
+
2053
2190
  private async getUserList(channelID: string): Promise<User[]> {
2054
2191
  const res = await this.http.post(
2055
2192
  this.getHost() + "/userList/" + channelID,
@@ -2077,6 +2214,28 @@ export class Client {
2077
2214
  }
2078
2215
  }
2079
2216
 
2217
+ /**
2218
+ * Pipeline for decrypted messages — registered in `init`. After `close()` sets
2219
+ * `manuallyClosing`, this becomes a no-op so fire-and-forget `forward` does not
2220
+ * race HTTP teardown (we avoid `off()` here — it can interact badly with emit).
2221
+ */
2222
+ private readonly onInternalMessage = (message: Message): void => {
2223
+ if (this.isManualCloseInFlight()) {
2224
+ return;
2225
+ }
2226
+ if (message.direction === "outgoing" && !message.forward) {
2227
+ void this.forward(message);
2228
+ }
2229
+
2230
+ if (
2231
+ message.direction === "incoming" &&
2232
+ message.recipient === message.sender
2233
+ ) {
2234
+ return;
2235
+ }
2236
+ void this.database.saveMessage(message);
2237
+ };
2238
+
2080
2239
  /**
2081
2240
  * Initializes the keyring. This must be called before anything else.
2082
2241
  */
@@ -2086,20 +2245,16 @@ export class Client {
2086
2245
  }
2087
2246
  this.hasInit = true;
2088
2247
 
2089
- await this.populateKeyRing();
2090
- this.emitter.on("message", (message) => {
2091
- if (message.direction === "outgoing" && !message.forward) {
2092
- void this.forward(message);
2093
- }
2248
+ if (Client.isNodeRuntime()) {
2249
+ const { attachNodeAgentsToAxios, createNodeHttpAgents } =
2250
+ await import("./storage/node/http-agents.js");
2251
+ const agents = createNodeHttpAgents();
2252
+ this.nodeHttpAgents = agents;
2253
+ attachNodeAgentsToAxios(this.http, agents);
2254
+ }
2094
2255
 
2095
- if (
2096
- message.direction === "incoming" &&
2097
- message.recipient === message.sender
2098
- ) {
2099
- return;
2100
- }
2101
- void this.database.saveMessage(message);
2102
- });
2256
+ await this.populateKeyRing();
2257
+ this.emitter.on("message", this.onInternalMessage);
2103
2258
  this.emitter.emit("ready");
2104
2259
  }
2105
2260
 
@@ -2218,6 +2373,9 @@ export class Client {
2218
2373
 
2219
2374
  private newEphemeralKeys() {
2220
2375
  if (!this.xKeyRing) {
2376
+ if (this.manuallyClosing) {
2377
+ return;
2378
+ }
2221
2379
  throw new Error("Key ring not initialized.");
2222
2380
  }
2223
2381
  this.xKeyRing.ephemeralKeys = xBoxKeyPair();
@@ -2275,6 +2433,9 @@ export class Client {
2275
2433
  private async postAuth() {
2276
2434
  let count = 0;
2277
2435
  for (;;) {
2436
+ if (this.isManualCloseInFlight()) {
2437
+ return;
2438
+ }
2278
2439
  try {
2279
2440
  await this.getMail();
2280
2441
  count++;
@@ -2285,7 +2446,17 @@ export class Client {
2285
2446
  count = 0;
2286
2447
  }
2287
2448
  } catch {}
2288
- await sleep(1000 * 60);
2449
+ if (this.isManualCloseInFlight()) {
2450
+ return;
2451
+ }
2452
+ // Chunk the idle delay so `close()` can unwind instead of waiting
2453
+ // out one full 60s timer (which would keep the process alive).
2454
+ for (let i = 0; i < 60; i++) {
2455
+ if (this.isManualCloseInFlight()) {
2456
+ return;
2457
+ }
2458
+ await sleep(1000);
2459
+ }
2289
2460
  }
2290
2461
  }
2291
2462
 
@@ -2303,6 +2474,10 @@ export class Client {
2303
2474
  }
2304
2475
  this.seenMailIDs.add(mail.mailID);
2305
2476
 
2477
+ if (this.manuallyClosing) {
2478
+ return;
2479
+ }
2480
+
2306
2481
  this.sendReceipt(new Uint8Array(mail.nonce));
2307
2482
  let timeout = 1;
2308
2483
  while (this.reading) {
@@ -2313,6 +2488,9 @@ export class Client {
2313
2488
 
2314
2489
  try {
2315
2490
  const healSession = async () => {
2491
+ if (this.manuallyClosing || !this.xKeyRing) {
2492
+ return;
2493
+ }
2316
2494
  const deviceEntry = await this.getDeviceByID(mail.sender);
2317
2495
  const [user, _err] = await this.fetchUser(mail.authorID);
2318
2496
  if (deviceEntry && user) {
@@ -2362,7 +2540,7 @@ export class Client {
2362
2540
  const EK_A = ephKey;
2363
2541
 
2364
2542
  if (!this.xKeyRing) {
2365
- throw new Error("Key ring not initialized.");
2543
+ return;
2366
2544
  }
2367
2545
  // my private keys
2368
2546
  const IK_B = this.xKeyRing.identityKeys.secretKey;
@@ -2920,17 +3098,10 @@ export class Client {
2920
3098
  throw new Error("Couldn't get user entry.");
2921
3099
  }
2922
3100
 
2923
- let deviceList = await this.getUserDeviceList(userID);
2924
- if (!deviceList) {
2925
- let retries = 0;
2926
- while (!deviceList) {
2927
- deviceList = await this.getUserDeviceList(userID);
2928
- retries++;
2929
- if (retries > 3) {
2930
- throw new Error("Couldn't get device list.");
2931
- }
2932
- }
2933
- }
3101
+ const deviceList = await this.fetchUserDeviceListWithBackoff(
3102
+ userID,
3103
+ "peer",
3104
+ );
2934
3105
  const mailID = uuid.v4();
2935
3106
  const promises: Array<Promise<void>> = [];
2936
3107
  for (const device of deviceList) {
@@ -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
+ }