@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/README.md +20 -27
- package/dist/Client.d.ts +44 -0
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +174 -34
- package/dist/Client.js.map +1 -1
- package/dist/storage/node/http-agents.d.ts +15 -0
- package/dist/storage/node/http-agents.d.ts.map +1 -0
- package/dist/storage/node/http-agents.js +21 -0
- package/dist/storage/node/http-agents.js.map +1 -0
- package/package.json +2 -3
- package/src/Client.ts +212 -41
- package/src/storage/node/http-agents.ts +33 -0
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({
|
|
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.
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
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
|
-
|
|
2096
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
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
|
+
}
|