@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/README.md +20 -27
- package/dist/Client.d.ts +36 -1
- package/dist/Client.d.ts.map +1 -1
- package/dist/Client.js +163 -29
- package/dist/Client.js.map +1 -1
- package/dist/codecs.d.ts +0 -4
- package/dist/codecs.d.ts.map +1 -1
- package/dist/codecs.js +0 -1
- package/dist/codecs.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 +202 -40
- package/src/__tests__/harness/shared-suite.ts +1 -0
- package/src/codecs.ts +0 -1
- 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,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
|
|
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
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
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
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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.
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
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) {
|
package/src/codecs.ts
CHANGED
|
@@ -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
|
+
}
|