@ekodb/ekodb-client 0.19.0 → 0.20.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 +0 -1
- package/dist/client.d.ts +108 -5
- package/dist/client.js +401 -64
- package/dist/client.test.js +117 -0
- package/dist/functions.test.d.ts +1 -2
- package/dist/functions.test.js +1 -2
- package/dist/query-builder.d.ts +0 -4
- package/dist/query-builder.js +2 -14
- package/dist/query-builder.test.js +0 -5
- package/dist/utils.js +7 -1
- package/dist/utils.test.js +4 -0
- package/dist/websocket.test.js +180 -0
- package/package.json +1 -1
- package/src/client.test.ts +150 -1
- package/src/client.ts +478 -66
- package/src/functions.test.ts +1 -2
- package/src/query-builder.test.ts +0 -7
- package/src/query-builder.ts +2 -14
- package/src/utils.test.ts +5 -0
- package/src/utils.ts +9 -1
- package/src/websocket.test.ts +273 -0
package/dist/client.js
CHANGED
|
@@ -69,6 +69,19 @@ var MergeStrategy;
|
|
|
69
69
|
MergeStrategy["LatestOnly"] = "LatestOnly";
|
|
70
70
|
MergeStrategy["Interleaved"] = "Interleaved";
|
|
71
71
|
})(MergeStrategy || (exports.MergeStrategy = MergeStrategy = {}));
|
|
72
|
+
/**
|
|
73
|
+
* Strip trailing slashes from a base URL so path concatenation
|
|
74
|
+
* (`${base}/api/...`) never yields a double-slash path. Uses a linear scan
|
|
75
|
+
* rather than a regex like `/\/+$/`, which CodeQL flags as polynomial-time
|
|
76
|
+
* backtracking on caller-supplied input.
|
|
77
|
+
*/
|
|
78
|
+
function stripTrailingSlashes(url) {
|
|
79
|
+
let end = url.length;
|
|
80
|
+
while (end > 0 && url.charCodeAt(end - 1) === 47 /* "/" */) {
|
|
81
|
+
end--;
|
|
82
|
+
}
|
|
83
|
+
return end === url.length ? url : url.slice(0, end);
|
|
84
|
+
}
|
|
72
85
|
class EkoDBClient {
|
|
73
86
|
constructor(config, apiKey) {
|
|
74
87
|
this.token = null;
|
|
@@ -76,14 +89,16 @@ class EkoDBClient {
|
|
|
76
89
|
this.rateLimitInfo = null;
|
|
77
90
|
// Support both old (baseURL, apiKey) and new (config object) signatures
|
|
78
91
|
if (typeof config === "string") {
|
|
79
|
-
|
|
92
|
+
// Strip trailing slashes so `${baseURL}/api/...` never produces a
|
|
93
|
+
// double-slash path (some servers/proxies reject `//api/...`).
|
|
94
|
+
this.baseURL = stripTrailingSlashes(config);
|
|
80
95
|
this.apiKey = apiKey;
|
|
81
96
|
this.shouldRetry = true;
|
|
82
97
|
this.maxRetries = 3;
|
|
83
98
|
this.format = SerializationFormat.MessagePack; // Default to MessagePack for 2-3x performance
|
|
84
99
|
}
|
|
85
100
|
else {
|
|
86
|
-
this.baseURL = config.baseURL;
|
|
101
|
+
this.baseURL = stripTrailingSlashes(config.baseURL);
|
|
87
102
|
this.apiKey = config.apiKey;
|
|
88
103
|
this.shouldRetry = config.shouldRetry ?? true;
|
|
89
104
|
this.maxRetries = config.maxRetries ?? 3;
|
|
@@ -223,6 +238,38 @@ class EkoDBClient {
|
|
|
223
238
|
sleep(seconds) {
|
|
224
239
|
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
|
225
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Parse a `Retry-After` header into a non-negative delay in seconds.
|
|
243
|
+
*
|
|
244
|
+
* Per RFC 9110 the value is either delay-seconds (an integer) or an
|
|
245
|
+
* HTTP-date. Anything that doesn't resolve to a finite, non-negative number
|
|
246
|
+
* (missing header, garbage, a past date) falls back to `defaultSecs`.
|
|
247
|
+
*/
|
|
248
|
+
parseRetryAfter(header, defaultSecs = 60) {
|
|
249
|
+
if (!header)
|
|
250
|
+
return defaultSecs;
|
|
251
|
+
// delay-seconds form: a bare integer.
|
|
252
|
+
const secs = Number(header.trim());
|
|
253
|
+
if (Number.isFinite(secs))
|
|
254
|
+
return Math.max(0, secs);
|
|
255
|
+
// HTTP-date form: compute the delay from now.
|
|
256
|
+
const dateMs = Date.parse(header);
|
|
257
|
+
if (Number.isFinite(dateMs)) {
|
|
258
|
+
return Math.max(0, (dateMs - Date.now()) / 1000);
|
|
259
|
+
}
|
|
260
|
+
return defaultSecs;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Backoff delay (in seconds) for a 0-indexed retry attempt: a capped
|
|
264
|
+
* exponential schedule (0.2s → 5s) with full jitter, so concurrent clients
|
|
265
|
+
* don't retry in lockstep. Returns a value in [d/2, d].
|
|
266
|
+
*/
|
|
267
|
+
backoffSeconds(attempt) {
|
|
268
|
+
const base = 0.2;
|
|
269
|
+
const max = 5;
|
|
270
|
+
const d = Math.min(base * Math.pow(2, Math.max(0, attempt)), max);
|
|
271
|
+
return d / 2 + Math.random() * (d / 2);
|
|
272
|
+
}
|
|
226
273
|
/**
|
|
227
274
|
* Helper to determine if a path should use JSON
|
|
228
275
|
* Only CRUD operations (insert/update/delete/batch) use MessagePack
|
|
@@ -292,10 +339,13 @@ class EkoDBClient {
|
|
|
292
339
|
}
|
|
293
340
|
// Handle rate limiting (429)
|
|
294
341
|
if (response.status === 429) {
|
|
295
|
-
const retryAfter =
|
|
342
|
+
const retryAfter = this.parseRetryAfter(response.headers.get("retry-after"));
|
|
296
343
|
if (this.shouldRetry && attempt < this.maxRetries) {
|
|
297
|
-
|
|
298
|
-
|
|
344
|
+
// Honor the server's Retry-After, but cap it so a hostile/large value
|
|
345
|
+
// can't pin the client for minutes.
|
|
346
|
+
const wait = Math.min(retryAfter, 60);
|
|
347
|
+
console.log(`Rate limited. Retrying after ${wait} seconds...`);
|
|
348
|
+
await this.sleep(wait);
|
|
299
349
|
return this.makeRequest(method, path, data, attempt + 1, forceJson);
|
|
300
350
|
}
|
|
301
351
|
throw new RateLimitError(retryAfter);
|
|
@@ -311,8 +361,8 @@ class EkoDBClient {
|
|
|
311
361
|
if (response.status === 503 &&
|
|
312
362
|
this.shouldRetry &&
|
|
313
363
|
attempt < this.maxRetries) {
|
|
314
|
-
const retryDelay =
|
|
315
|
-
console.log(`Service unavailable. Retrying after ${retryDelay}
|
|
364
|
+
const retryDelay = this.backoffSeconds(attempt);
|
|
365
|
+
console.log(`Service unavailable. Retrying after ${retryDelay.toFixed(2)}s...`);
|
|
316
366
|
await this.sleep(retryDelay);
|
|
317
367
|
return this.makeRequest(method, path, data, attempt + 1, forceJson);
|
|
318
368
|
}
|
|
@@ -325,8 +375,8 @@ class EkoDBClient {
|
|
|
325
375
|
if (error instanceof TypeError &&
|
|
326
376
|
this.shouldRetry &&
|
|
327
377
|
attempt < this.maxRetries) {
|
|
328
|
-
const retryDelay =
|
|
329
|
-
console.log(`Network error. Retrying after ${retryDelay}
|
|
378
|
+
const retryDelay = this.backoffSeconds(attempt);
|
|
379
|
+
console.log(`Network error. Retrying after ${retryDelay.toFixed(2)}s...`);
|
|
330
380
|
await this.sleep(retryDelay);
|
|
331
381
|
return this.makeRequest(method, path, data, attempt + 1, forceJson);
|
|
332
382
|
}
|
|
@@ -1125,10 +1175,10 @@ class EkoDBClient {
|
|
|
1125
1175
|
const stream = new EventStream();
|
|
1126
1176
|
(async () => {
|
|
1127
1177
|
try {
|
|
1128
|
-
let token = this.getToken();
|
|
1178
|
+
let token = await this.getToken();
|
|
1129
1179
|
if (!token) {
|
|
1130
1180
|
await this.refreshToken();
|
|
1131
|
-
token = this.getToken();
|
|
1181
|
+
token = await this.getToken();
|
|
1132
1182
|
}
|
|
1133
1183
|
const url = `${this.baseURL}/api/chat/${chatId}/messages/stream`;
|
|
1134
1184
|
const response = await fetch(url, {
|
|
@@ -1149,13 +1199,12 @@ class EkoDBClient {
|
|
|
1149
1199
|
stream.close();
|
|
1150
1200
|
return;
|
|
1151
1201
|
}
|
|
1152
|
-
const
|
|
1153
|
-
for (const line of body.split("\n")) {
|
|
1202
|
+
const emitLine = (line) => {
|
|
1154
1203
|
if (!line.startsWith("data:"))
|
|
1155
|
-
|
|
1204
|
+
return;
|
|
1156
1205
|
const dataStr = line.slice(5).trim();
|
|
1157
1206
|
if (!dataStr)
|
|
1158
|
-
|
|
1207
|
+
return;
|
|
1159
1208
|
try {
|
|
1160
1209
|
const eventData = JSON.parse(dataStr);
|
|
1161
1210
|
if (eventData.error) {
|
|
@@ -1184,6 +1233,33 @@ class EkoDBClient {
|
|
|
1184
1233
|
catch {
|
|
1185
1234
|
// skip malformed SSE data
|
|
1186
1235
|
}
|
|
1236
|
+
};
|
|
1237
|
+
const reader = response.body?.getReader?.();
|
|
1238
|
+
if (reader) {
|
|
1239
|
+
// True incremental streaming: decode and emit each SSE line as soon as
|
|
1240
|
+
// it arrives, rather than buffering the entire response body first.
|
|
1241
|
+
const decoder = new TextDecoder();
|
|
1242
|
+
let buffer = "";
|
|
1243
|
+
for (;;) {
|
|
1244
|
+
const { done, value } = await reader.read();
|
|
1245
|
+
if (done)
|
|
1246
|
+
break;
|
|
1247
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1248
|
+
let nl;
|
|
1249
|
+
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
1250
|
+
emitLine(buffer.slice(0, nl));
|
|
1251
|
+
buffer = buffer.slice(nl + 1);
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
buffer += decoder.decode();
|
|
1255
|
+
if (buffer)
|
|
1256
|
+
emitLine(buffer);
|
|
1257
|
+
}
|
|
1258
|
+
else {
|
|
1259
|
+
// Fallback for environments/tests without a readable body stream.
|
|
1260
|
+
const body = await response.text();
|
|
1261
|
+
for (const line of body.split("\n"))
|
|
1262
|
+
emitLine(line);
|
|
1187
1263
|
}
|
|
1188
1264
|
stream.close();
|
|
1189
1265
|
}
|
|
@@ -1736,10 +1812,18 @@ class EkoDBClient {
|
|
|
1736
1812
|
return stream;
|
|
1737
1813
|
}
|
|
1738
1814
|
/**
|
|
1739
|
-
* Create a WebSocket client
|
|
1815
|
+
* Create a WebSocket client.
|
|
1816
|
+
*
|
|
1817
|
+
* The token is supplied as a provider bound to this client's
|
|
1818
|
+
* {@link getToken}, so every (re)connect re-evaluates (and proactively
|
|
1819
|
+
* refreshes) the auth token instead of snapshotting it once. This means a
|
|
1820
|
+
* reconnect after a token rotation uses the current token.
|
|
1821
|
+
*
|
|
1822
|
+
* @param wsURL - The WebSocket URL (e.g. `wss://host`); `/api/ws` is appended if absent.
|
|
1823
|
+
* @param options - Optional reconnect/timeout tunables.
|
|
1740
1824
|
*/
|
|
1741
|
-
websocket(wsURL) {
|
|
1742
|
-
return new WebSocketClient(wsURL, this.
|
|
1825
|
+
websocket(wsURL, options) {
|
|
1826
|
+
return new WebSocketClient(wsURL, () => this.getToken(), options);
|
|
1743
1827
|
}
|
|
1744
1828
|
// ========== RAG Helper Methods ==========
|
|
1745
1829
|
/**
|
|
@@ -1988,50 +2072,108 @@ function extractRecordId(record, extraCandidates = []) {
|
|
|
1988
2072
|
const val = record[key];
|
|
1989
2073
|
if (typeof val === "string")
|
|
1990
2074
|
return val;
|
|
1991
|
-
|
|
2075
|
+
// Unwrap only a genuine typed wrapper (both "type" and "value"), matching
|
|
2076
|
+
// getValue's rule so a user object like { value: 1 } isn't mistaken for one.
|
|
2077
|
+
if (val && typeof val === "object" && "type" in val && "value" in val)
|
|
1992
2078
|
return String(val.value);
|
|
1993
2079
|
}
|
|
1994
2080
|
for (const key of ["id", "_id"]) {
|
|
1995
2081
|
const val = record[key];
|
|
1996
2082
|
if (typeof val === "string")
|
|
1997
2083
|
return val;
|
|
1998
|
-
|
|
2084
|
+
// Unwrap only a genuine typed wrapper (both "type" and "value"), matching
|
|
2085
|
+
// getValue's rule so a user object like { value: 1 } isn't mistaken for one.
|
|
2086
|
+
if (val && typeof val === "object" && "type" in val && "value" in val)
|
|
1999
2087
|
return String(val.value);
|
|
2000
2088
|
}
|
|
2001
2089
|
return undefined;
|
|
2002
2090
|
}
|
|
2003
2091
|
class WebSocketClient {
|
|
2004
|
-
|
|
2092
|
+
/**
|
|
2093
|
+
* @param wsURL - WebSocket URL; `/api/ws` is appended if absent.
|
|
2094
|
+
* @param token - A static token string OR a {@link TokenProvider} function
|
|
2095
|
+
* re-evaluated on every (re)connect (so a refreshed token is used after a drop).
|
|
2096
|
+
* @param options - Optional reconnect/timeout tunables.
|
|
2097
|
+
*/
|
|
2098
|
+
constructor(wsURL, token, options = {}) {
|
|
2005
2099
|
this.ws = null;
|
|
2006
2100
|
this.dispatcherRunning = false;
|
|
2007
2101
|
this.schemaCache = null;
|
|
2102
|
+
// Reconnect state
|
|
2103
|
+
/** Set while close() is in progress so the close handler doesn't reconnect. */
|
|
2104
|
+
this.closed = false;
|
|
2105
|
+
this.reconnectAttempts = 0;
|
|
2106
|
+
this.reconnecting = false;
|
|
2107
|
+
this.connectPromise = null;
|
|
2008
2108
|
// Dispatcher state
|
|
2009
2109
|
this.pendingRequests = new Map();
|
|
2010
2110
|
this.subscriptions = new Map();
|
|
2111
|
+
/** Bookkeeping so subscriptions can be replayed on reconnect. */
|
|
2112
|
+
this.subscriptionParams = new Map();
|
|
2011
2113
|
this.chatStreams = new Map();
|
|
2012
2114
|
this.registerToolsAck = null;
|
|
2013
2115
|
this.messageCounter = 0;
|
|
2014
|
-
|
|
2015
|
-
|
|
2116
|
+
// Strip trailing slashes so appending `/api/ws` can't yield `//api/ws`,
|
|
2117
|
+
// which warp's exact path match (`api / ws`) would reject.
|
|
2118
|
+
this.wsURL = stripTrailingSlashes(wsURL);
|
|
2119
|
+
this.tokenProvider = typeof token === "function" ? token : () => token;
|
|
2120
|
+
this.autoReconnect = options.autoReconnect ?? true;
|
|
2121
|
+
this.reconnectInitialDelayMs = options.reconnectInitialDelayMs ?? 200;
|
|
2122
|
+
this.reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? 5000;
|
|
2123
|
+
this.reconnectMaxAttempts = options.reconnectMaxAttempts ?? 0;
|
|
2124
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 30000;
|
|
2016
2125
|
}
|
|
2017
2126
|
genMessageId() {
|
|
2018
2127
|
const counter = this.messageCounter++;
|
|
2019
2128
|
return `${Date.now()}-${counter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2020
2129
|
}
|
|
2021
2130
|
/**
|
|
2022
|
-
*
|
|
2131
|
+
* Compute the capped exponential backoff (with jitter) for a reconnect
|
|
2132
|
+
* attempt. attempt 0 -> ~initial, growing x2 each time up to the max cap.
|
|
2133
|
+
* Jitter is +/-25% to avoid thundering-herd reconnect storms.
|
|
2134
|
+
* @internal exposed for testing
|
|
2135
|
+
*/
|
|
2136
|
+
computeBackoff(attempt) {
|
|
2137
|
+
const base = Math.min(this.reconnectInitialDelayMs * 2 ** attempt, this.reconnectMaxDelayMs);
|
|
2138
|
+
const jitter = base * 0.25 * (Math.random() * 2 - 1);
|
|
2139
|
+
return Math.max(0, Math.round(base + jitter));
|
|
2140
|
+
}
|
|
2141
|
+
/**
|
|
2142
|
+
* Connect and start the dispatcher. Re-evaluates the token provider so the
|
|
2143
|
+
* current/refreshed token is used for this socket.
|
|
2023
2144
|
*/
|
|
2024
2145
|
async ensureConnected() {
|
|
2025
2146
|
if (this.ws && this.dispatcherRunning)
|
|
2026
2147
|
return;
|
|
2148
|
+
// Coalesce concurrent connect attempts onto a single in-flight promise.
|
|
2149
|
+
if (this.connectPromise)
|
|
2150
|
+
return this.connectPromise;
|
|
2151
|
+
// Clear the intentional-close flag only for user-initiated connects. During
|
|
2152
|
+
// a reconnect cycle this stays untouched so a concurrent close() can't be
|
|
2153
|
+
// undone and have the reconnect proceed against the user's intent.
|
|
2154
|
+
if (!this.reconnecting)
|
|
2155
|
+
this.closed = false;
|
|
2156
|
+
this.connectPromise = this.openSocket().finally(() => {
|
|
2157
|
+
this.connectPromise = null;
|
|
2158
|
+
});
|
|
2159
|
+
return this.connectPromise;
|
|
2160
|
+
}
|
|
2161
|
+
async openSocket() {
|
|
2027
2162
|
const WebSocket = (await Promise.resolve().then(() => __importStar(require("ws")))).default;
|
|
2028
2163
|
let url = this.wsURL;
|
|
2029
2164
|
if (!url.endsWith("/api/ws")) {
|
|
2030
2165
|
url += "/api/ws";
|
|
2031
2166
|
}
|
|
2167
|
+
// Re-evaluate the token on every (re)connect — never a stale snapshot.
|
|
2168
|
+
const token = await this.tokenProvider();
|
|
2169
|
+
if (!token) {
|
|
2170
|
+
// Fail fast with a clear error instead of sending `Bearer null`, which
|
|
2171
|
+
// would surface as a confusing 401 from the server.
|
|
2172
|
+
throw new Error("WebSocket auth token is unavailable (the token provider returned null/empty)");
|
|
2173
|
+
}
|
|
2032
2174
|
this.ws = new WebSocket(url, {
|
|
2033
2175
|
headers: {
|
|
2034
|
-
Authorization: `Bearer ${
|
|
2176
|
+
Authorization: `Bearer ${token}`,
|
|
2035
2177
|
},
|
|
2036
2178
|
});
|
|
2037
2179
|
await new Promise((resolve, reject) => {
|
|
@@ -2044,7 +2186,13 @@ class WebSocketClient {
|
|
|
2044
2186
|
if (this.dispatcherRunning)
|
|
2045
2187
|
return;
|
|
2046
2188
|
this.dispatcherRunning = true;
|
|
2047
|
-
this.
|
|
2189
|
+
// Capture the socket this dispatcher is bound to. After a reconnect, the old
|
|
2190
|
+
// socket may still emit late close/error events; ignore them so they don't
|
|
2191
|
+
// tear down the replacement connection.
|
|
2192
|
+
const socket = this.ws;
|
|
2193
|
+
socket.on("message", (data) => {
|
|
2194
|
+
if (this.ws !== socket)
|
|
2195
|
+
return;
|
|
2048
2196
|
try {
|
|
2049
2197
|
const msg = JSON.parse(data.toString());
|
|
2050
2198
|
this.routeMessage(msg);
|
|
@@ -2053,26 +2201,149 @@ class WebSocketClient {
|
|
|
2053
2201
|
// Ignore malformed messages
|
|
2054
2202
|
}
|
|
2055
2203
|
});
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
this.
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2204
|
+
// Both "close" and "error" mean this socket is dead. ws typically emits
|
|
2205
|
+
// "error" followed by "close", so route both through one handler and let the
|
|
2206
|
+
// identity check dedupe: the first to fire nulls this.ws, the second no-ops.
|
|
2207
|
+
const onDown = () => {
|
|
2208
|
+
if (this.ws !== socket)
|
|
2209
|
+
return;
|
|
2210
|
+
this.handleDisconnect();
|
|
2211
|
+
};
|
|
2212
|
+
socket.on("close", onDown);
|
|
2213
|
+
socket.on("error", onDown);
|
|
2214
|
+
}
|
|
2215
|
+
/**
|
|
2216
|
+
* Reject in-flight requests and tear down the dead socket. If the close was
|
|
2217
|
+
* unexpected (not an explicit `close()`) and auto-reconnect is enabled,
|
|
2218
|
+
* schedule a reconnect that re-sends the active subscriptions.
|
|
2219
|
+
*/
|
|
2220
|
+
handleDisconnect() {
|
|
2221
|
+
this.dispatcherRunning = false;
|
|
2222
|
+
this.ws = null;
|
|
2223
|
+
// Reject all in-flight pending requests so callers don't hang forever.
|
|
2224
|
+
for (const [, pending] of this.pendingRequests) {
|
|
2225
|
+
if (pending.timer)
|
|
2226
|
+
clearTimeout(pending.timer);
|
|
2227
|
+
pending.reject(new Error("WebSocket connection closed"));
|
|
2228
|
+
}
|
|
2229
|
+
this.pendingRequests.clear();
|
|
2230
|
+
if (this.registerToolsAck) {
|
|
2231
|
+
this.registerToolsAck.reject(new Error("WebSocket connection closed"));
|
|
2232
|
+
this.registerToolsAck = null;
|
|
2233
|
+
}
|
|
2234
|
+
// Close all chat streams (they are one-shot; not replayed on reconnect).
|
|
2235
|
+
for (const [, stream] of this.chatStreams) {
|
|
2236
|
+
stream.emit("event", { type: "error", error: "Connection closed" });
|
|
2237
|
+
stream.close();
|
|
2238
|
+
}
|
|
2239
|
+
this.chatStreams.clear();
|
|
2240
|
+
const shouldReconnect = this.autoReconnect && !this.closed && this.subscriptionParams.size > 0;
|
|
2241
|
+
if (shouldReconnect) {
|
|
2242
|
+
this.scheduleReconnect();
|
|
2243
|
+
}
|
|
2244
|
+
else {
|
|
2245
|
+
// No reconnect: tear down subscriptions too.
|
|
2070
2246
|
for (const [, stream] of this.subscriptions) {
|
|
2071
2247
|
stream.close();
|
|
2072
2248
|
}
|
|
2073
2249
|
this.subscriptions.clear();
|
|
2074
|
-
this.
|
|
2075
|
-
}
|
|
2250
|
+
this.subscriptionParams.clear();
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
/**
|
|
2254
|
+
* Reconnect with capped exponential backoff + jitter, then re-send the
|
|
2255
|
+
* subscribe messages for every active subscription so the SAME EventStream
|
|
2256
|
+
* keeps delivering mutations after a transient drop.
|
|
2257
|
+
*/
|
|
2258
|
+
scheduleReconnect() {
|
|
2259
|
+
if (this.reconnecting)
|
|
2260
|
+
return;
|
|
2261
|
+
this.reconnecting = true;
|
|
2262
|
+
const attempt = async () => {
|
|
2263
|
+
// Bail if the client was closed, or if every subscription was torn down
|
|
2264
|
+
// (e.g. unsubscribed) while a reconnect was in-flight — reconnect was only
|
|
2265
|
+
// opted into because subscriptions existed, so there's nothing to restore.
|
|
2266
|
+
if (this.closed || this.subscriptionParams.size === 0) {
|
|
2267
|
+
this.reconnecting = false;
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
if (this.reconnectMaxAttempts > 0 &&
|
|
2271
|
+
this.reconnectAttempts >= this.reconnectMaxAttempts) {
|
|
2272
|
+
// Give up: tear down subscriptions and notify consumers.
|
|
2273
|
+
this.reconnecting = false;
|
|
2274
|
+
for (const [, stream] of this.subscriptions) {
|
|
2275
|
+
stream.emit("error", "WebSocket reconnect failed");
|
|
2276
|
+
stream.close();
|
|
2277
|
+
}
|
|
2278
|
+
this.subscriptions.clear();
|
|
2279
|
+
this.subscriptionParams.clear();
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
const delay = this.computeBackoff(this.reconnectAttempts);
|
|
2283
|
+
this.reconnectAttempts++;
|
|
2284
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
2285
|
+
// Re-check after the backoff delay: close() or a full unsubscribe may have
|
|
2286
|
+
// happened while we were waiting, in which case skip reopening the socket.
|
|
2287
|
+
if (this.closed || this.subscriptionParams.size === 0) {
|
|
2288
|
+
this.reconnecting = false;
|
|
2289
|
+
return;
|
|
2290
|
+
}
|
|
2291
|
+
try {
|
|
2292
|
+
// Route through ensureConnected() so a request-driven connect and this
|
|
2293
|
+
// reconnect share one in-flight connectPromise/socket — opening two live
|
|
2294
|
+
// sockets would misroute responses.
|
|
2295
|
+
await this.ensureConnected();
|
|
2296
|
+
// close() may have been called while the connect was in-flight; if so,
|
|
2297
|
+
// tear down the freshly-opened socket instead of leaving it orphaned.
|
|
2298
|
+
if (this.closed) {
|
|
2299
|
+
try {
|
|
2300
|
+
this.ws?.close?.();
|
|
2301
|
+
}
|
|
2302
|
+
catch {
|
|
2303
|
+
/* already closing */
|
|
2304
|
+
}
|
|
2305
|
+
this.ws = null;
|
|
2306
|
+
this.dispatcherRunning = false;
|
|
2307
|
+
this.reconnecting = false;
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
// Success — reset backoff and replay subscriptions.
|
|
2311
|
+
this.reconnectAttempts = 0;
|
|
2312
|
+
this.reconnecting = false;
|
|
2313
|
+
await this.resubscribeAll();
|
|
2314
|
+
}
|
|
2315
|
+
catch {
|
|
2316
|
+
// Connect failed — schedule the next attempt WITHOUT recursive await so
|
|
2317
|
+
// a prolonged outage can't build an unbounded promise chain.
|
|
2318
|
+
setTimeout(() => void attempt(), 0);
|
|
2319
|
+
}
|
|
2320
|
+
};
|
|
2321
|
+
void attempt();
|
|
2322
|
+
}
|
|
2323
|
+
/** Re-send Subscribe frames for every tracked subscription after a reconnect. */
|
|
2324
|
+
async resubscribeAll() {
|
|
2325
|
+
for (const [collection, options] of this.subscriptionParams) {
|
|
2326
|
+
const stream = this.subscriptions.get(collection);
|
|
2327
|
+
if (!stream || stream.closed)
|
|
2328
|
+
continue;
|
|
2329
|
+
const messageId = this.genMessageId();
|
|
2330
|
+
const request = {
|
|
2331
|
+
type: "Subscribe",
|
|
2332
|
+
messageId,
|
|
2333
|
+
payload: {
|
|
2334
|
+
collection,
|
|
2335
|
+
...(options?.filterField && { filter_field: options.filterField }),
|
|
2336
|
+
...(options?.filterValue && { filter_value: options.filterValue }),
|
|
2337
|
+
},
|
|
2338
|
+
};
|
|
2339
|
+
try {
|
|
2340
|
+
await this.sendRequest(request);
|
|
2341
|
+
}
|
|
2342
|
+
catch {
|
|
2343
|
+
// If the re-subscribe ack fails, leave it tracked; the next
|
|
2344
|
+
// disconnect/reconnect cycle will attempt it again.
|
|
2345
|
+
}
|
|
2346
|
+
}
|
|
2076
2347
|
}
|
|
2077
2348
|
routeMessage(msg) {
|
|
2078
2349
|
switch (msg.type) {
|
|
@@ -2085,15 +2356,7 @@ class WebSocketClient {
|
|
|
2085
2356
|
msg.payload?.messageId;
|
|
2086
2357
|
let matched = false;
|
|
2087
2358
|
if (messageId && this.pendingRequests.has(messageId)) {
|
|
2088
|
-
|
|
2089
|
-
this.pendingRequests.delete(messageId);
|
|
2090
|
-
if (msg.type === "Error") {
|
|
2091
|
-
pending.reject(new Error(msg.message || "Unknown error"));
|
|
2092
|
-
}
|
|
2093
|
-
else {
|
|
2094
|
-
pending.resolve(msg.payload);
|
|
2095
|
-
}
|
|
2096
|
-
matched = true;
|
|
2359
|
+
matched = this.settlePending(messageId, msg.type === "Error", msg);
|
|
2097
2360
|
}
|
|
2098
2361
|
if (!matched && this.registerToolsAck) {
|
|
2099
2362
|
const ack = this.registerToolsAck;
|
|
@@ -2106,19 +2369,14 @@ class WebSocketClient {
|
|
|
2106
2369
|
}
|
|
2107
2370
|
matched = true;
|
|
2108
2371
|
}
|
|
2109
|
-
// Server doesn't echo messageId — if there's exactly one pending
|
|
2372
|
+
// Server doesn't echo messageId at all — if there's exactly one pending
|
|
2110
2373
|
// request, deliver the response to it (sequential request/response).
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
this.pendingRequests.
|
|
2116
|
-
|
|
2117
|
-
pending.reject(new Error(msg.message || "Unknown error"));
|
|
2118
|
-
}
|
|
2119
|
-
else {
|
|
2120
|
-
pending.resolve(msg.payload);
|
|
2121
|
-
}
|
|
2374
|
+
// Only when messageId is absent: a present-but-unmatched id means a late
|
|
2375
|
+
// response for an already-settled/timed-out request, which must NOT be
|
|
2376
|
+
// misrouted to whatever request happens to still be pending.
|
|
2377
|
+
if (!matched && !messageId && this.pendingRequests.size === 1) {
|
|
2378
|
+
const key = this.pendingRequests.keys().next().value;
|
|
2379
|
+
this.settlePending(key, msg.type === "Error", msg);
|
|
2122
2380
|
}
|
|
2123
2381
|
break;
|
|
2124
2382
|
}
|
|
@@ -2205,16 +2463,46 @@ class WebSocketClient {
|
|
|
2205
2463
|
await this.ensureConnected();
|
|
2206
2464
|
const messageId = request.messageId || request.message_id;
|
|
2207
2465
|
return new Promise((resolve, reject) => {
|
|
2208
|
-
|
|
2466
|
+
// Per-request timeout: reject if no response arrives in the window so a
|
|
2467
|
+
// dropped/never-answered response can't leave the promise pending forever.
|
|
2468
|
+
let timer;
|
|
2469
|
+
if (this.requestTimeoutMs > 0) {
|
|
2470
|
+
timer = setTimeout(() => {
|
|
2471
|
+
if (this.pendingRequests.delete(messageId)) {
|
|
2472
|
+
reject(new Error(`WebSocket request "${request.type}" timed out after ${this.requestTimeoutMs}ms`));
|
|
2473
|
+
}
|
|
2474
|
+
}, this.requestTimeoutMs);
|
|
2475
|
+
// Don't keep the process alive just for this timer.
|
|
2476
|
+
timer?.unref?.();
|
|
2477
|
+
}
|
|
2478
|
+
this.pendingRequests.set(messageId, { resolve, reject, timer });
|
|
2209
2479
|
try {
|
|
2210
2480
|
this.ws.send(JSON.stringify(request));
|
|
2211
2481
|
}
|
|
2212
2482
|
catch (err) {
|
|
2213
2483
|
this.pendingRequests.delete(messageId);
|
|
2484
|
+
if (timer)
|
|
2485
|
+
clearTimeout(timer);
|
|
2214
2486
|
reject(err);
|
|
2215
2487
|
}
|
|
2216
2488
|
});
|
|
2217
2489
|
}
|
|
2490
|
+
/** Resolve/reject a pending request, clearing its timeout timer. */
|
|
2491
|
+
settlePending(messageId, isError, msg) {
|
|
2492
|
+
const pending = this.pendingRequests.get(messageId);
|
|
2493
|
+
if (!pending)
|
|
2494
|
+
return false;
|
|
2495
|
+
this.pendingRequests.delete(messageId);
|
|
2496
|
+
if (pending.timer)
|
|
2497
|
+
clearTimeout(pending.timer);
|
|
2498
|
+
if (isError) {
|
|
2499
|
+
pending.reject(new Error(msg.message || "Unknown error"));
|
|
2500
|
+
}
|
|
2501
|
+
else {
|
|
2502
|
+
pending.resolve(msg.payload);
|
|
2503
|
+
}
|
|
2504
|
+
return true;
|
|
2505
|
+
}
|
|
2218
2506
|
/**
|
|
2219
2507
|
* Find all records in a collection via WebSocket.
|
|
2220
2508
|
*/
|
|
@@ -2239,6 +2527,8 @@ class WebSocketClient {
|
|
|
2239
2527
|
const messageId = this.genMessageId();
|
|
2240
2528
|
const stream = new EventStream();
|
|
2241
2529
|
this.subscriptions.set(collection, stream);
|
|
2530
|
+
// Track params so the subscription can be replayed on reconnect.
|
|
2531
|
+
this.subscriptionParams.set(collection, options);
|
|
2242
2532
|
const request = {
|
|
2243
2533
|
type: "Subscribe",
|
|
2244
2534
|
messageId,
|
|
@@ -2254,10 +2544,23 @@ class WebSocketClient {
|
|
|
2254
2544
|
}
|
|
2255
2545
|
catch (err) {
|
|
2256
2546
|
this.subscriptions.delete(collection);
|
|
2547
|
+
this.subscriptionParams.delete(collection);
|
|
2257
2548
|
throw err;
|
|
2258
2549
|
}
|
|
2259
2550
|
return stream;
|
|
2260
2551
|
}
|
|
2552
|
+
/**
|
|
2553
|
+
* Unsubscribe from a collection's mutation notifications. This is an
|
|
2554
|
+
* intentional teardown, so the subscription is NOT replayed on reconnect.
|
|
2555
|
+
*/
|
|
2556
|
+
unsubscribe(collection) {
|
|
2557
|
+
const stream = this.subscriptions.get(collection);
|
|
2558
|
+
this.subscriptions.delete(collection);
|
|
2559
|
+
this.subscriptionParams.delete(collection);
|
|
2560
|
+
if (stream && !stream.closed) {
|
|
2561
|
+
stream.close();
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2261
2564
|
/**
|
|
2262
2565
|
* Send a chat message and receive a streaming response.
|
|
2263
2566
|
* Returns an EventStream that emits "event" with ChatStreamEvent objects.
|
|
@@ -2481,8 +2784,42 @@ class WebSocketClient {
|
|
|
2481
2784
|
}
|
|
2482
2785
|
/**
|
|
2483
2786
|
* Close the WebSocket connection.
|
|
2787
|
+
*
|
|
2788
|
+
* This is an INTENTIONAL close: it disables auto-reconnect, rejects any
|
|
2789
|
+
* in-flight requests, and tears down all subscriptions/chat streams so
|
|
2790
|
+
* nothing is replayed afterward.
|
|
2484
2791
|
*/
|
|
2485
2792
|
close() {
|
|
2793
|
+
// Mark intentional so the close handler doesn't trigger a reconnect.
|
|
2794
|
+
this.closed = true;
|
|
2795
|
+
this.reconnecting = false;
|
|
2796
|
+
// Reject any in-flight requests and clear their timers.
|
|
2797
|
+
for (const [, pending] of this.pendingRequests) {
|
|
2798
|
+
if (pending.timer)
|
|
2799
|
+
clearTimeout(pending.timer);
|
|
2800
|
+
pending.reject(new Error("WebSocket connection closed"));
|
|
2801
|
+
}
|
|
2802
|
+
this.pendingRequests.clear();
|
|
2803
|
+
// Tear down subscriptions + their replay bookkeeping.
|
|
2804
|
+
for (const [, stream] of this.subscriptions) {
|
|
2805
|
+
if (!stream.closed)
|
|
2806
|
+
stream.close();
|
|
2807
|
+
}
|
|
2808
|
+
this.subscriptions.clear();
|
|
2809
|
+
this.subscriptionParams.clear();
|
|
2810
|
+
// Reject any in-flight tool registration ack. Done here (not just in the
|
|
2811
|
+
// ws "close" handler) so it's cleaned up even when this.ws is already null.
|
|
2812
|
+
if (this.registerToolsAck) {
|
|
2813
|
+
this.registerToolsAck.reject(new Error("WebSocket connection closed"));
|
|
2814
|
+
this.registerToolsAck = null;
|
|
2815
|
+
}
|
|
2816
|
+
// Tear down chat streams immediately; they are one-shot and not replayed,
|
|
2817
|
+
// and we can't rely on the underlying ws "close" event having fired.
|
|
2818
|
+
for (const [, stream] of this.chatStreams) {
|
|
2819
|
+
stream.emit("event", { type: "error", error: "Connection closed" });
|
|
2820
|
+
stream.close();
|
|
2821
|
+
}
|
|
2822
|
+
this.chatStreams.clear();
|
|
2486
2823
|
if (this.ws) {
|
|
2487
2824
|
this.ws.close();
|
|
2488
2825
|
this.ws = null;
|