@ekodb/ekodb-client 0.18.2 → 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 +135 -5
- package/dist/client.js +418 -64
- package/dist/client.test.js +152 -0
- package/dist/functions.test.d.ts +1 -2
- package/dist/functions.test.js +1 -2
- package/dist/index.d.ts +1 -1
- 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 +2 -2
- package/src/client.test.ts +195 -1
- package/src/client.ts +525 -66
- package/src/functions.test.ts +1 -2
- package/src/index.ts +2 -0
- 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/src/client.ts
CHANGED
|
@@ -304,6 +304,26 @@ export interface ChatModels {
|
|
|
304
304
|
perplexity: string[];
|
|
305
305
|
}
|
|
306
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Request to compact a chat session's history on demand.
|
|
309
|
+
*/
|
|
310
|
+
export interface CompactChatRequest {
|
|
311
|
+
keep_recent?: number;
|
|
312
|
+
// No bypass_ripple: compaction writes chat-message records, which the server
|
|
313
|
+
// does not ripple (same convention as all chat-message writes).
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Result of an on-demand chat history compaction.
|
|
318
|
+
*/
|
|
319
|
+
export interface CompactChatResponse {
|
|
320
|
+
folded: number;
|
|
321
|
+
kept_recent: number;
|
|
322
|
+
summary_chars: number;
|
|
323
|
+
summary_message_id: string | null;
|
|
324
|
+
already_compact: boolean;
|
|
325
|
+
}
|
|
326
|
+
|
|
307
327
|
/**
|
|
308
328
|
* Request to generate embeddings
|
|
309
329
|
*/
|
|
@@ -359,6 +379,20 @@ export interface FunctionStageConfig {
|
|
|
359
379
|
[key: string]: any;
|
|
360
380
|
}
|
|
361
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Strip trailing slashes from a base URL so path concatenation
|
|
384
|
+
* (`${base}/api/...`) never yields a double-slash path. Uses a linear scan
|
|
385
|
+
* rather than a regex like `/\/+$/`, which CodeQL flags as polynomial-time
|
|
386
|
+
* backtracking on caller-supplied input.
|
|
387
|
+
*/
|
|
388
|
+
function stripTrailingSlashes(url: string): string {
|
|
389
|
+
let end = url.length;
|
|
390
|
+
while (end > 0 && url.charCodeAt(end - 1) === 47 /* "/" */) {
|
|
391
|
+
end--;
|
|
392
|
+
}
|
|
393
|
+
return end === url.length ? url : url.slice(0, end);
|
|
394
|
+
}
|
|
395
|
+
|
|
362
396
|
export class EkoDBClient {
|
|
363
397
|
private baseURL: string;
|
|
364
398
|
private apiKey: string;
|
|
@@ -372,13 +406,15 @@ export class EkoDBClient {
|
|
|
372
406
|
constructor(config: string | ClientConfig, apiKey?: string) {
|
|
373
407
|
// Support both old (baseURL, apiKey) and new (config object) signatures
|
|
374
408
|
if (typeof config === "string") {
|
|
375
|
-
|
|
409
|
+
// Strip trailing slashes so `${baseURL}/api/...` never produces a
|
|
410
|
+
// double-slash path (some servers/proxies reject `//api/...`).
|
|
411
|
+
this.baseURL = stripTrailingSlashes(config);
|
|
376
412
|
this.apiKey = apiKey!;
|
|
377
413
|
this.shouldRetry = true;
|
|
378
414
|
this.maxRetries = 3;
|
|
379
415
|
this.format = SerializationFormat.MessagePack; // Default to MessagePack for 2-3x performance
|
|
380
416
|
} else {
|
|
381
|
-
this.baseURL = config.baseURL;
|
|
417
|
+
this.baseURL = stripTrailingSlashes(config.baseURL);
|
|
382
418
|
this.apiKey = config.apiKey;
|
|
383
419
|
this.shouldRetry = config.shouldRetry ?? true;
|
|
384
420
|
this.maxRetries = config.maxRetries ?? 3;
|
|
@@ -535,6 +571,41 @@ export class EkoDBClient {
|
|
|
535
571
|
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
|
536
572
|
}
|
|
537
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Parse a `Retry-After` header into a non-negative delay in seconds.
|
|
576
|
+
*
|
|
577
|
+
* Per RFC 9110 the value is either delay-seconds (an integer) or an
|
|
578
|
+
* HTTP-date. Anything that doesn't resolve to a finite, non-negative number
|
|
579
|
+
* (missing header, garbage, a past date) falls back to `defaultSecs`.
|
|
580
|
+
*/
|
|
581
|
+
private parseRetryAfter(header: string | null, defaultSecs = 60): number {
|
|
582
|
+
if (!header) return defaultSecs;
|
|
583
|
+
|
|
584
|
+
// delay-seconds form: a bare integer.
|
|
585
|
+
const secs = Number(header.trim());
|
|
586
|
+
if (Number.isFinite(secs)) return Math.max(0, secs);
|
|
587
|
+
|
|
588
|
+
// HTTP-date form: compute the delay from now.
|
|
589
|
+
const dateMs = Date.parse(header);
|
|
590
|
+
if (Number.isFinite(dateMs)) {
|
|
591
|
+
return Math.max(0, (dateMs - Date.now()) / 1000);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return defaultSecs;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Backoff delay (in seconds) for a 0-indexed retry attempt: a capped
|
|
599
|
+
* exponential schedule (0.2s → 5s) with full jitter, so concurrent clients
|
|
600
|
+
* don't retry in lockstep. Returns a value in [d/2, d].
|
|
601
|
+
*/
|
|
602
|
+
private backoffSeconds(attempt: number): number {
|
|
603
|
+
const base = 0.2;
|
|
604
|
+
const max = 5;
|
|
605
|
+
const d = Math.min(base * Math.pow(2, Math.max(0, attempt)), max);
|
|
606
|
+
return d / 2 + Math.random() * (d / 2);
|
|
607
|
+
}
|
|
608
|
+
|
|
538
609
|
/**
|
|
539
610
|
* Helper to determine if a path should use JSON
|
|
540
611
|
* Only CRUD operations (insert/update/delete/batch) use MessagePack
|
|
@@ -620,14 +691,16 @@ export class EkoDBClient {
|
|
|
620
691
|
|
|
621
692
|
// Handle rate limiting (429)
|
|
622
693
|
if (response.status === 429) {
|
|
623
|
-
const retryAfter =
|
|
624
|
-
response.headers.get("retry-after")
|
|
625
|
-
10,
|
|
694
|
+
const retryAfter = this.parseRetryAfter(
|
|
695
|
+
response.headers.get("retry-after"),
|
|
626
696
|
);
|
|
627
697
|
|
|
628
698
|
if (this.shouldRetry && attempt < this.maxRetries) {
|
|
629
|
-
|
|
630
|
-
|
|
699
|
+
// Honor the server's Retry-After, but cap it so a hostile/large value
|
|
700
|
+
// can't pin the client for minutes.
|
|
701
|
+
const wait = Math.min(retryAfter, 60);
|
|
702
|
+
console.log(`Rate limited. Retrying after ${wait} seconds...`);
|
|
703
|
+
await this.sleep(wait);
|
|
631
704
|
return this.makeRequest<T>(
|
|
632
705
|
method,
|
|
633
706
|
path,
|
|
@@ -654,9 +727,9 @@ export class EkoDBClient {
|
|
|
654
727
|
this.shouldRetry &&
|
|
655
728
|
attempt < this.maxRetries
|
|
656
729
|
) {
|
|
657
|
-
const retryDelay =
|
|
730
|
+
const retryDelay = this.backoffSeconds(attempt);
|
|
658
731
|
console.log(
|
|
659
|
-
`Service unavailable. Retrying after ${retryDelay}
|
|
732
|
+
`Service unavailable. Retrying after ${retryDelay.toFixed(2)}s...`,
|
|
660
733
|
);
|
|
661
734
|
await this.sleep(retryDelay);
|
|
662
735
|
return this.makeRequest<T>(method, path, data, attempt + 1, forceJson);
|
|
@@ -672,8 +745,10 @@ export class EkoDBClient {
|
|
|
672
745
|
this.shouldRetry &&
|
|
673
746
|
attempt < this.maxRetries
|
|
674
747
|
) {
|
|
675
|
-
const retryDelay =
|
|
676
|
-
console.log(
|
|
748
|
+
const retryDelay = this.backoffSeconds(attempt);
|
|
749
|
+
console.log(
|
|
750
|
+
`Network error. Retrying after ${retryDelay.toFixed(2)}s...`,
|
|
751
|
+
);
|
|
677
752
|
await this.sleep(retryDelay);
|
|
678
753
|
return this.makeRequest<T>(method, path, data, attempt + 1, forceJson);
|
|
679
754
|
}
|
|
@@ -1804,10 +1879,10 @@ export class EkoDBClient {
|
|
|
1804
1879
|
|
|
1805
1880
|
(async () => {
|
|
1806
1881
|
try {
|
|
1807
|
-
let token = this.getToken();
|
|
1882
|
+
let token = await this.getToken();
|
|
1808
1883
|
if (!token) {
|
|
1809
1884
|
await this.refreshToken();
|
|
1810
|
-
token = this.getToken();
|
|
1885
|
+
token = await this.getToken();
|
|
1811
1886
|
}
|
|
1812
1887
|
const url = `${this.baseURL}/api/chat/${chatId}/messages/stream`;
|
|
1813
1888
|
|
|
@@ -1831,11 +1906,10 @@ export class EkoDBClient {
|
|
|
1831
1906
|
return;
|
|
1832
1907
|
}
|
|
1833
1908
|
|
|
1834
|
-
const
|
|
1835
|
-
|
|
1836
|
-
if (!line.startsWith("data:")) continue;
|
|
1909
|
+
const emitLine = (line: string) => {
|
|
1910
|
+
if (!line.startsWith("data:")) return;
|
|
1837
1911
|
const dataStr = line.slice(5).trim();
|
|
1838
|
-
if (!dataStr)
|
|
1912
|
+
if (!dataStr) return;
|
|
1839
1913
|
try {
|
|
1840
1914
|
const eventData = JSON.parse(dataStr);
|
|
1841
1915
|
if (eventData.error) {
|
|
@@ -1861,6 +1935,30 @@ export class EkoDBClient {
|
|
|
1861
1935
|
} catch {
|
|
1862
1936
|
// skip malformed SSE data
|
|
1863
1937
|
}
|
|
1938
|
+
};
|
|
1939
|
+
|
|
1940
|
+
const reader = response.body?.getReader?.();
|
|
1941
|
+
if (reader) {
|
|
1942
|
+
// True incremental streaming: decode and emit each SSE line as soon as
|
|
1943
|
+
// it arrives, rather than buffering the entire response body first.
|
|
1944
|
+
const decoder = new TextDecoder();
|
|
1945
|
+
let buffer = "";
|
|
1946
|
+
for (;;) {
|
|
1947
|
+
const { done, value } = await reader.read();
|
|
1948
|
+
if (done) break;
|
|
1949
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1950
|
+
let nl: number;
|
|
1951
|
+
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
1952
|
+
emitLine(buffer.slice(0, nl));
|
|
1953
|
+
buffer = buffer.slice(nl + 1);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
buffer += decoder.decode();
|
|
1957
|
+
if (buffer) emitLine(buffer);
|
|
1958
|
+
} else {
|
|
1959
|
+
// Fallback for environments/tests without a readable body stream.
|
|
1960
|
+
const body = await response.text();
|
|
1961
|
+
for (const line of body.split("\n")) emitLine(line);
|
|
1864
1962
|
}
|
|
1865
1963
|
stream.close();
|
|
1866
1964
|
} catch (err: any) {
|
|
@@ -2042,6 +2140,33 @@ export class EkoDBClient {
|
|
|
2042
2140
|
);
|
|
2043
2141
|
}
|
|
2044
2142
|
|
|
2143
|
+
/**
|
|
2144
|
+
* Compact a chat session's history on demand.
|
|
2145
|
+
*
|
|
2146
|
+
* Folds older messages into a summary while preserving the most recent
|
|
2147
|
+
* messages verbatim, reducing context size for long-running sessions.
|
|
2148
|
+
*
|
|
2149
|
+
* @param chatId - Chat session ID
|
|
2150
|
+
* @param keepRecent - Number of recent messages to preserve verbatim (optional)
|
|
2151
|
+
* @returns Compaction result with counts and the summary message ID
|
|
2152
|
+
*/
|
|
2153
|
+
async compactChat(
|
|
2154
|
+
chatId: string,
|
|
2155
|
+
keepRecent?: number,
|
|
2156
|
+
): Promise<CompactChatResponse> {
|
|
2157
|
+
const body: CompactChatRequest = {};
|
|
2158
|
+
if (keepRecent !== undefined) {
|
|
2159
|
+
body.keep_recent = keepRecent;
|
|
2160
|
+
}
|
|
2161
|
+
return this.makeRequest<CompactChatResponse>(
|
|
2162
|
+
"POST",
|
|
2163
|
+
`/api/chat/${chatId}/compact`,
|
|
2164
|
+
body,
|
|
2165
|
+
0,
|
|
2166
|
+
true, // Force JSON for chat operations
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2045
2170
|
/**
|
|
2046
2171
|
* Merge multiple chat sessions into one
|
|
2047
2172
|
*/
|
|
@@ -2909,10 +3034,18 @@ export class EkoDBClient {
|
|
|
2909
3034
|
}
|
|
2910
3035
|
|
|
2911
3036
|
/**
|
|
2912
|
-
* Create a WebSocket client
|
|
3037
|
+
* Create a WebSocket client.
|
|
3038
|
+
*
|
|
3039
|
+
* The token is supplied as a provider bound to this client's
|
|
3040
|
+
* {@link getToken}, so every (re)connect re-evaluates (and proactively
|
|
3041
|
+
* refreshes) the auth token instead of snapshotting it once. This means a
|
|
3042
|
+
* reconnect after a token rotation uses the current token.
|
|
3043
|
+
*
|
|
3044
|
+
* @param wsURL - The WebSocket URL (e.g. `wss://host`); `/api/ws` is appended if absent.
|
|
3045
|
+
* @param options - Optional reconnect/timeout tunables.
|
|
2913
3046
|
*/
|
|
2914
|
-
websocket(wsURL: string): WebSocketClient {
|
|
2915
|
-
return new WebSocketClient(wsURL, this.
|
|
3047
|
+
websocket(wsURL: string, options?: WebSocketClientOptions): WebSocketClient {
|
|
3048
|
+
return new WebSocketClient(wsURL, () => this.getToken(), options);
|
|
2916
3049
|
}
|
|
2917
3050
|
|
|
2918
3051
|
// ========== RAG Helper Methods ==========
|
|
@@ -3117,6 +3250,39 @@ export interface SubscribeOptions {
|
|
|
3117
3250
|
filterValue?: string;
|
|
3118
3251
|
}
|
|
3119
3252
|
|
|
3253
|
+
/**
|
|
3254
|
+
* A token provider: either a static token string, or a (possibly async)
|
|
3255
|
+
* function that returns a fresh token. When a function is supplied it is
|
|
3256
|
+
* re-invoked on every (re)connect, so a rotated/refreshed token is always
|
|
3257
|
+
* used for the new socket instead of a stale snapshot captured once.
|
|
3258
|
+
*/
|
|
3259
|
+
export type TokenProvider =
|
|
3260
|
+
| string
|
|
3261
|
+
| (() => string | null | Promise<string | null>);
|
|
3262
|
+
|
|
3263
|
+
/** Tunables for the WebSocket client's reconnect + request-timeout behavior. */
|
|
3264
|
+
export interface WebSocketClientOptions {
|
|
3265
|
+
/**
|
|
3266
|
+
* Auto-reconnect after an unexpected socket close/error (not an explicit
|
|
3267
|
+
* `close()`/unsubscribe). Defaults to true.
|
|
3268
|
+
*/
|
|
3269
|
+
autoReconnect?: boolean;
|
|
3270
|
+
/** Initial backoff delay in ms before the first reconnect attempt. Default 200. */
|
|
3271
|
+
reconnectInitialDelayMs?: number;
|
|
3272
|
+
/** Maximum backoff delay in ms (the cap for exponential growth). Default 5000. */
|
|
3273
|
+
reconnectMaxDelayMs?: number;
|
|
3274
|
+
/**
|
|
3275
|
+
* Maximum number of consecutive reconnect attempts before giving up.
|
|
3276
|
+
* 0 or undefined means unlimited. Default unlimited.
|
|
3277
|
+
*/
|
|
3278
|
+
reconnectMaxAttempts?: number;
|
|
3279
|
+
/**
|
|
3280
|
+
* Per-request timeout in ms for request/response WS calls. If no response
|
|
3281
|
+
* arrives in this window the pending promise rejects. Default 30000.
|
|
3282
|
+
*/
|
|
3283
|
+
requestTimeoutMs?: number;
|
|
3284
|
+
}
|
|
3285
|
+
|
|
3120
3286
|
/** EventEmitter-like interface for subscriptions and chat streams. */
|
|
3121
3287
|
export class EventStream<_T = unknown> {
|
|
3122
3288
|
private listeners: Map<string, Array<(data: any) => void>> = new Map();
|
|
@@ -3258,13 +3424,17 @@ export function extractRecordId(
|
|
|
3258
3424
|
for (const key of extraCandidates) {
|
|
3259
3425
|
const val = record[key];
|
|
3260
3426
|
if (typeof val === "string") return val;
|
|
3261
|
-
|
|
3427
|
+
// Unwrap only a genuine typed wrapper (both "type" and "value"), matching
|
|
3428
|
+
// getValue's rule so a user object like { value: 1 } isn't mistaken for one.
|
|
3429
|
+
if (val && typeof val === "object" && "type" in val && "value" in val)
|
|
3262
3430
|
return String(val.value);
|
|
3263
3431
|
}
|
|
3264
3432
|
for (const key of ["id", "_id"]) {
|
|
3265
3433
|
const val = record[key];
|
|
3266
3434
|
if (typeof val === "string") return val;
|
|
3267
|
-
|
|
3435
|
+
// Unwrap only a genuine typed wrapper (both "type" and "value"), matching
|
|
3436
|
+
// getValue's rule so a user object like { value: 1 } isn't mistaken for one.
|
|
3437
|
+
if (val && typeof val === "object" && "type" in val && "value" in val)
|
|
3268
3438
|
return String(val.value);
|
|
3269
3439
|
}
|
|
3270
3440
|
return undefined;
|
|
@@ -3272,27 +3442,65 @@ export function extractRecordId(
|
|
|
3272
3442
|
|
|
3273
3443
|
export class WebSocketClient {
|
|
3274
3444
|
private wsURL: string;
|
|
3275
|
-
private
|
|
3445
|
+
private tokenProvider: () => string | null | Promise<string | null>;
|
|
3276
3446
|
private ws: any = null;
|
|
3277
3447
|
private dispatcherRunning = false;
|
|
3278
3448
|
private schemaCache: SchemaCache | null = null;
|
|
3279
3449
|
|
|
3450
|
+
// Reconnect config
|
|
3451
|
+
private autoReconnect: boolean;
|
|
3452
|
+
private reconnectInitialDelayMs: number;
|
|
3453
|
+
private reconnectMaxDelayMs: number;
|
|
3454
|
+
private reconnectMaxAttempts: number;
|
|
3455
|
+
private requestTimeoutMs: number;
|
|
3456
|
+
|
|
3457
|
+
// Reconnect state
|
|
3458
|
+
/** Set while close() is in progress so the close handler doesn't reconnect. */
|
|
3459
|
+
private closed = false;
|
|
3460
|
+
private reconnectAttempts = 0;
|
|
3461
|
+
private reconnecting = false;
|
|
3462
|
+
private connectPromise: Promise<void> | null = null;
|
|
3463
|
+
|
|
3280
3464
|
// Dispatcher state
|
|
3281
3465
|
private pendingRequests: Map<
|
|
3282
3466
|
string,
|
|
3283
|
-
{
|
|
3467
|
+
{
|
|
3468
|
+
resolve: (value: any) => void;
|
|
3469
|
+
reject: (reason: any) => void;
|
|
3470
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
3471
|
+
}
|
|
3284
3472
|
> = new Map();
|
|
3285
3473
|
private subscriptions: Map<string, EventStream<MutationNotification>> =
|
|
3286
3474
|
new Map();
|
|
3475
|
+
/** Bookkeeping so subscriptions can be replayed on reconnect. */
|
|
3476
|
+
private subscriptionParams: Map<string, SubscribeOptions | undefined> =
|
|
3477
|
+
new Map();
|
|
3287
3478
|
private chatStreams: Map<string, EventStream<ChatStreamEvent>> = new Map();
|
|
3288
3479
|
private registerToolsAck: {
|
|
3289
3480
|
resolve: (value: any) => void;
|
|
3290
3481
|
reject: (reason: any) => void;
|
|
3291
3482
|
} | null = null;
|
|
3292
3483
|
|
|
3293
|
-
|
|
3294
|
-
|
|
3295
|
-
|
|
3484
|
+
/**
|
|
3485
|
+
* @param wsURL - WebSocket URL; `/api/ws` is appended if absent.
|
|
3486
|
+
* @param token - A static token string OR a {@link TokenProvider} function
|
|
3487
|
+
* re-evaluated on every (re)connect (so a refreshed token is used after a drop).
|
|
3488
|
+
* @param options - Optional reconnect/timeout tunables.
|
|
3489
|
+
*/
|
|
3490
|
+
constructor(
|
|
3491
|
+
wsURL: string,
|
|
3492
|
+
token: TokenProvider,
|
|
3493
|
+
options: WebSocketClientOptions = {},
|
|
3494
|
+
) {
|
|
3495
|
+
// Strip trailing slashes so appending `/api/ws` can't yield `//api/ws`,
|
|
3496
|
+
// which warp's exact path match (`api / ws`) would reject.
|
|
3497
|
+
this.wsURL = stripTrailingSlashes(wsURL);
|
|
3498
|
+
this.tokenProvider = typeof token === "function" ? token : () => token;
|
|
3499
|
+
this.autoReconnect = options.autoReconnect ?? true;
|
|
3500
|
+
this.reconnectInitialDelayMs = options.reconnectInitialDelayMs ?? 200;
|
|
3501
|
+
this.reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? 5000;
|
|
3502
|
+
this.reconnectMaxAttempts = options.reconnectMaxAttempts ?? 0;
|
|
3503
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 30000;
|
|
3296
3504
|
}
|
|
3297
3505
|
|
|
3298
3506
|
private messageCounter = 0;
|
|
@@ -3303,11 +3511,39 @@ export class WebSocketClient {
|
|
|
3303
3511
|
}
|
|
3304
3512
|
|
|
3305
3513
|
/**
|
|
3306
|
-
*
|
|
3514
|
+
* Compute the capped exponential backoff (with jitter) for a reconnect
|
|
3515
|
+
* attempt. attempt 0 -> ~initial, growing x2 each time up to the max cap.
|
|
3516
|
+
* Jitter is +/-25% to avoid thundering-herd reconnect storms.
|
|
3517
|
+
* @internal exposed for testing
|
|
3518
|
+
*/
|
|
3519
|
+
computeBackoff(attempt: number): number {
|
|
3520
|
+
const base = Math.min(
|
|
3521
|
+
this.reconnectInitialDelayMs * 2 ** attempt,
|
|
3522
|
+
this.reconnectMaxDelayMs,
|
|
3523
|
+
);
|
|
3524
|
+
const jitter = base * 0.25 * (Math.random() * 2 - 1);
|
|
3525
|
+
return Math.max(0, Math.round(base + jitter));
|
|
3526
|
+
}
|
|
3527
|
+
|
|
3528
|
+
/**
|
|
3529
|
+
* Connect and start the dispatcher. Re-evaluates the token provider so the
|
|
3530
|
+
* current/refreshed token is used for this socket.
|
|
3307
3531
|
*/
|
|
3308
3532
|
private async ensureConnected(): Promise<void> {
|
|
3309
3533
|
if (this.ws && this.dispatcherRunning) return;
|
|
3534
|
+
// Coalesce concurrent connect attempts onto a single in-flight promise.
|
|
3535
|
+
if (this.connectPromise) return this.connectPromise;
|
|
3536
|
+
// Clear the intentional-close flag only for user-initiated connects. During
|
|
3537
|
+
// a reconnect cycle this stays untouched so a concurrent close() can't be
|
|
3538
|
+
// undone and have the reconnect proceed against the user's intent.
|
|
3539
|
+
if (!this.reconnecting) this.closed = false;
|
|
3540
|
+
this.connectPromise = this.openSocket().finally(() => {
|
|
3541
|
+
this.connectPromise = null;
|
|
3542
|
+
});
|
|
3543
|
+
return this.connectPromise;
|
|
3544
|
+
}
|
|
3310
3545
|
|
|
3546
|
+
private async openSocket(): Promise<void> {
|
|
3311
3547
|
const WebSocket = (await import("ws")).default;
|
|
3312
3548
|
|
|
3313
3549
|
let url = this.wsURL;
|
|
@@ -3315,9 +3551,19 @@ export class WebSocketClient {
|
|
|
3315
3551
|
url += "/api/ws";
|
|
3316
3552
|
}
|
|
3317
3553
|
|
|
3554
|
+
// Re-evaluate the token on every (re)connect — never a stale snapshot.
|
|
3555
|
+
const token = await this.tokenProvider();
|
|
3556
|
+
if (!token) {
|
|
3557
|
+
// Fail fast with a clear error instead of sending `Bearer null`, which
|
|
3558
|
+
// would surface as a confusing 401 from the server.
|
|
3559
|
+
throw new Error(
|
|
3560
|
+
"WebSocket auth token is unavailable (the token provider returned null/empty)",
|
|
3561
|
+
);
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3318
3564
|
this.ws = new WebSocket(url, {
|
|
3319
3565
|
headers: {
|
|
3320
|
-
Authorization: `Bearer ${
|
|
3566
|
+
Authorization: `Bearer ${token}`,
|
|
3321
3567
|
},
|
|
3322
3568
|
});
|
|
3323
3569
|
|
|
@@ -3333,7 +3579,13 @@ export class WebSocketClient {
|
|
|
3333
3579
|
if (this.dispatcherRunning) return;
|
|
3334
3580
|
this.dispatcherRunning = true;
|
|
3335
3581
|
|
|
3336
|
-
this.
|
|
3582
|
+
// Capture the socket this dispatcher is bound to. After a reconnect, the old
|
|
3583
|
+
// socket may still emit late close/error events; ignore them so they don't
|
|
3584
|
+
// tear down the replacement connection.
|
|
3585
|
+
const socket = this.ws;
|
|
3586
|
+
|
|
3587
|
+
socket.on("message", (data: Buffer) => {
|
|
3588
|
+
if (this.ws !== socket) return;
|
|
3337
3589
|
try {
|
|
3338
3590
|
const msg = JSON.parse(data.toString());
|
|
3339
3591
|
this.routeMessage(msg);
|
|
@@ -3342,26 +3594,155 @@ export class WebSocketClient {
|
|
|
3342
3594
|
}
|
|
3343
3595
|
});
|
|
3344
3596
|
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3597
|
+
// Both "close" and "error" mean this socket is dead. ws typically emits
|
|
3598
|
+
// "error" followed by "close", so route both through one handler and let the
|
|
3599
|
+
// identity check dedupe: the first to fire nulls this.ws, the second no-ops.
|
|
3600
|
+
const onDown = () => {
|
|
3601
|
+
if (this.ws !== socket) return;
|
|
3602
|
+
this.handleDisconnect();
|
|
3603
|
+
};
|
|
3604
|
+
socket.on("close", onDown);
|
|
3605
|
+
socket.on("error", onDown);
|
|
3606
|
+
}
|
|
3607
|
+
|
|
3608
|
+
/**
|
|
3609
|
+
* Reject in-flight requests and tear down the dead socket. If the close was
|
|
3610
|
+
* unexpected (not an explicit `close()`) and auto-reconnect is enabled,
|
|
3611
|
+
* schedule a reconnect that re-sends the active subscriptions.
|
|
3612
|
+
*/
|
|
3613
|
+
private handleDisconnect(): void {
|
|
3614
|
+
this.dispatcherRunning = false;
|
|
3615
|
+
this.ws = null;
|
|
3616
|
+
|
|
3617
|
+
// Reject all in-flight pending requests so callers don't hang forever.
|
|
3618
|
+
for (const [, pending] of this.pendingRequests) {
|
|
3619
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
3620
|
+
pending.reject(new Error("WebSocket connection closed"));
|
|
3621
|
+
}
|
|
3622
|
+
this.pendingRequests.clear();
|
|
3623
|
+
if (this.registerToolsAck) {
|
|
3624
|
+
this.registerToolsAck.reject(new Error("WebSocket connection closed"));
|
|
3625
|
+
this.registerToolsAck = null;
|
|
3626
|
+
}
|
|
3627
|
+
// Close all chat streams (they are one-shot; not replayed on reconnect).
|
|
3628
|
+
for (const [, stream] of this.chatStreams) {
|
|
3629
|
+
stream.emit("event", { type: "error", error: "Connection closed" });
|
|
3630
|
+
stream.close();
|
|
3631
|
+
}
|
|
3632
|
+
this.chatStreams.clear();
|
|
3633
|
+
|
|
3634
|
+
const shouldReconnect =
|
|
3635
|
+
this.autoReconnect && !this.closed && this.subscriptionParams.size > 0;
|
|
3636
|
+
|
|
3637
|
+
if (shouldReconnect) {
|
|
3638
|
+
this.scheduleReconnect();
|
|
3639
|
+
} else {
|
|
3640
|
+
// No reconnect: tear down subscriptions too.
|
|
3359
3641
|
for (const [, stream] of this.subscriptions) {
|
|
3360
3642
|
stream.close();
|
|
3361
3643
|
}
|
|
3362
3644
|
this.subscriptions.clear();
|
|
3363
|
-
this.
|
|
3364
|
-
}
|
|
3645
|
+
this.subscriptionParams.clear();
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
/**
|
|
3650
|
+
* Reconnect with capped exponential backoff + jitter, then re-send the
|
|
3651
|
+
* subscribe messages for every active subscription so the SAME EventStream
|
|
3652
|
+
* keeps delivering mutations after a transient drop.
|
|
3653
|
+
*/
|
|
3654
|
+
private scheduleReconnect(): void {
|
|
3655
|
+
if (this.reconnecting) return;
|
|
3656
|
+
this.reconnecting = true;
|
|
3657
|
+
|
|
3658
|
+
const attempt = async (): Promise<void> => {
|
|
3659
|
+
// Bail if the client was closed, or if every subscription was torn down
|
|
3660
|
+
// (e.g. unsubscribed) while a reconnect was in-flight — reconnect was only
|
|
3661
|
+
// opted into because subscriptions existed, so there's nothing to restore.
|
|
3662
|
+
if (this.closed || this.subscriptionParams.size === 0) {
|
|
3663
|
+
this.reconnecting = false;
|
|
3664
|
+
return;
|
|
3665
|
+
}
|
|
3666
|
+
if (
|
|
3667
|
+
this.reconnectMaxAttempts > 0 &&
|
|
3668
|
+
this.reconnectAttempts >= this.reconnectMaxAttempts
|
|
3669
|
+
) {
|
|
3670
|
+
// Give up: tear down subscriptions and notify consumers.
|
|
3671
|
+
this.reconnecting = false;
|
|
3672
|
+
for (const [, stream] of this.subscriptions) {
|
|
3673
|
+
stream.emit("error", "WebSocket reconnect failed");
|
|
3674
|
+
stream.close();
|
|
3675
|
+
}
|
|
3676
|
+
this.subscriptions.clear();
|
|
3677
|
+
this.subscriptionParams.clear();
|
|
3678
|
+
return;
|
|
3679
|
+
}
|
|
3680
|
+
|
|
3681
|
+
const delay = this.computeBackoff(this.reconnectAttempts);
|
|
3682
|
+
this.reconnectAttempts++;
|
|
3683
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
3684
|
+
|
|
3685
|
+
// Re-check after the backoff delay: close() or a full unsubscribe may have
|
|
3686
|
+
// happened while we were waiting, in which case skip reopening the socket.
|
|
3687
|
+
if (this.closed || this.subscriptionParams.size === 0) {
|
|
3688
|
+
this.reconnecting = false;
|
|
3689
|
+
return;
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
try {
|
|
3693
|
+
// Route through ensureConnected() so a request-driven connect and this
|
|
3694
|
+
// reconnect share one in-flight connectPromise/socket — opening two live
|
|
3695
|
+
// sockets would misroute responses.
|
|
3696
|
+
await this.ensureConnected();
|
|
3697
|
+
// close() may have been called while the connect was in-flight; if so,
|
|
3698
|
+
// tear down the freshly-opened socket instead of leaving it orphaned.
|
|
3699
|
+
if (this.closed) {
|
|
3700
|
+
try {
|
|
3701
|
+
this.ws?.close?.();
|
|
3702
|
+
} catch {
|
|
3703
|
+
/* already closing */
|
|
3704
|
+
}
|
|
3705
|
+
this.ws = null;
|
|
3706
|
+
this.dispatcherRunning = false;
|
|
3707
|
+
this.reconnecting = false;
|
|
3708
|
+
return;
|
|
3709
|
+
}
|
|
3710
|
+
// Success — reset backoff and replay subscriptions.
|
|
3711
|
+
this.reconnectAttempts = 0;
|
|
3712
|
+
this.reconnecting = false;
|
|
3713
|
+
await this.resubscribeAll();
|
|
3714
|
+
} catch {
|
|
3715
|
+
// Connect failed — schedule the next attempt WITHOUT recursive await so
|
|
3716
|
+
// a prolonged outage can't build an unbounded promise chain.
|
|
3717
|
+
setTimeout(() => void attempt(), 0);
|
|
3718
|
+
}
|
|
3719
|
+
};
|
|
3720
|
+
|
|
3721
|
+
void attempt();
|
|
3722
|
+
}
|
|
3723
|
+
|
|
3724
|
+
/** Re-send Subscribe frames for every tracked subscription after a reconnect. */
|
|
3725
|
+
private async resubscribeAll(): Promise<void> {
|
|
3726
|
+
for (const [collection, options] of this.subscriptionParams) {
|
|
3727
|
+
const stream = this.subscriptions.get(collection);
|
|
3728
|
+
if (!stream || stream.closed) continue;
|
|
3729
|
+
const messageId = this.genMessageId();
|
|
3730
|
+
const request: any = {
|
|
3731
|
+
type: "Subscribe",
|
|
3732
|
+
messageId,
|
|
3733
|
+
payload: {
|
|
3734
|
+
collection,
|
|
3735
|
+
...(options?.filterField && { filter_field: options.filterField }),
|
|
3736
|
+
...(options?.filterValue && { filter_value: options.filterValue }),
|
|
3737
|
+
},
|
|
3738
|
+
};
|
|
3739
|
+
try {
|
|
3740
|
+
await this.sendRequest(request);
|
|
3741
|
+
} catch {
|
|
3742
|
+
// If the re-subscribe ack fails, leave it tracked; the next
|
|
3743
|
+
// disconnect/reconnect cycle will attempt it again.
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3365
3746
|
}
|
|
3366
3747
|
|
|
3367
3748
|
private routeMessage(msg: any): void {
|
|
@@ -3376,14 +3757,7 @@ export class WebSocketClient {
|
|
|
3376
3757
|
msg.payload?.messageId;
|
|
3377
3758
|
let matched = false;
|
|
3378
3759
|
if (messageId && this.pendingRequests.has(messageId)) {
|
|
3379
|
-
|
|
3380
|
-
this.pendingRequests.delete(messageId);
|
|
3381
|
-
if (msg.type === "Error") {
|
|
3382
|
-
pending.reject(new Error(msg.message || "Unknown error"));
|
|
3383
|
-
} else {
|
|
3384
|
-
pending.resolve(msg.payload);
|
|
3385
|
-
}
|
|
3386
|
-
matched = true;
|
|
3760
|
+
matched = this.settlePending(messageId, msg.type === "Error", msg);
|
|
3387
3761
|
}
|
|
3388
3762
|
if (!matched && this.registerToolsAck) {
|
|
3389
3763
|
const ack = this.registerToolsAck;
|
|
@@ -3395,18 +3769,14 @@ export class WebSocketClient {
|
|
|
3395
3769
|
}
|
|
3396
3770
|
matched = true;
|
|
3397
3771
|
}
|
|
3398
|
-
// Server doesn't echo messageId — if there's exactly one pending
|
|
3772
|
+
// Server doesn't echo messageId at all — if there's exactly one pending
|
|
3399
3773
|
// request, deliver the response to it (sequential request/response).
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
this.pendingRequests.
|
|
3405
|
-
|
|
3406
|
-
pending.reject(new Error(msg.message || "Unknown error"));
|
|
3407
|
-
} else {
|
|
3408
|
-
pending.resolve(msg.payload);
|
|
3409
|
-
}
|
|
3774
|
+
// Only when messageId is absent: a present-but-unmatched id means a late
|
|
3775
|
+
// response for an already-settled/timed-out request, which must NOT be
|
|
3776
|
+
// misrouted to whatever request happens to still be pending.
|
|
3777
|
+
if (!matched && !messageId && this.pendingRequests.size === 1) {
|
|
3778
|
+
const key = this.pendingRequests.keys().next().value!;
|
|
3779
|
+
this.settlePending(key, msg.type === "Error", msg);
|
|
3410
3780
|
}
|
|
3411
3781
|
break;
|
|
3412
3782
|
}
|
|
@@ -3508,16 +3878,52 @@ export class WebSocketClient {
|
|
|
3508
3878
|
const messageId = request.messageId || request.message_id;
|
|
3509
3879
|
|
|
3510
3880
|
return new Promise((resolve, reject) => {
|
|
3511
|
-
|
|
3881
|
+
// Per-request timeout: reject if no response arrives in the window so a
|
|
3882
|
+
// dropped/never-answered response can't leave the promise pending forever.
|
|
3883
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
3884
|
+
if (this.requestTimeoutMs > 0) {
|
|
3885
|
+
timer = setTimeout(() => {
|
|
3886
|
+
if (this.pendingRequests.delete(messageId)) {
|
|
3887
|
+
reject(
|
|
3888
|
+
new Error(
|
|
3889
|
+
`WebSocket request "${request.type}" timed out after ${this.requestTimeoutMs}ms`,
|
|
3890
|
+
),
|
|
3891
|
+
);
|
|
3892
|
+
}
|
|
3893
|
+
}, this.requestTimeoutMs);
|
|
3894
|
+
// Don't keep the process alive just for this timer.
|
|
3895
|
+
(timer as any)?.unref?.();
|
|
3896
|
+
}
|
|
3897
|
+
|
|
3898
|
+
this.pendingRequests.set(messageId, { resolve, reject, timer });
|
|
3512
3899
|
try {
|
|
3513
3900
|
this.ws.send(JSON.stringify(request));
|
|
3514
3901
|
} catch (err) {
|
|
3515
3902
|
this.pendingRequests.delete(messageId);
|
|
3903
|
+
if (timer) clearTimeout(timer);
|
|
3516
3904
|
reject(err);
|
|
3517
3905
|
}
|
|
3518
3906
|
});
|
|
3519
3907
|
}
|
|
3520
3908
|
|
|
3909
|
+
/** Resolve/reject a pending request, clearing its timeout timer. */
|
|
3910
|
+
private settlePending(
|
|
3911
|
+
messageId: string,
|
|
3912
|
+
isError: boolean,
|
|
3913
|
+
msg: any,
|
|
3914
|
+
): boolean {
|
|
3915
|
+
const pending = this.pendingRequests.get(messageId);
|
|
3916
|
+
if (!pending) return false;
|
|
3917
|
+
this.pendingRequests.delete(messageId);
|
|
3918
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
3919
|
+
if (isError) {
|
|
3920
|
+
pending.reject(new Error(msg.message || "Unknown error"));
|
|
3921
|
+
} else {
|
|
3922
|
+
pending.resolve(msg.payload);
|
|
3923
|
+
}
|
|
3924
|
+
return true;
|
|
3925
|
+
}
|
|
3926
|
+
|
|
3521
3927
|
/**
|
|
3522
3928
|
* Find all records in a collection via WebSocket.
|
|
3523
3929
|
*/
|
|
@@ -3548,6 +3954,8 @@ export class WebSocketClient {
|
|
|
3548
3954
|
const messageId = this.genMessageId();
|
|
3549
3955
|
const stream = new EventStream<MutationNotification>();
|
|
3550
3956
|
this.subscriptions.set(collection, stream);
|
|
3957
|
+
// Track params so the subscription can be replayed on reconnect.
|
|
3958
|
+
this.subscriptionParams.set(collection, options);
|
|
3551
3959
|
|
|
3552
3960
|
const request: any = {
|
|
3553
3961
|
type: "Subscribe",
|
|
@@ -3564,11 +3972,25 @@ export class WebSocketClient {
|
|
|
3564
3972
|
await this.sendRequest(request);
|
|
3565
3973
|
} catch (err) {
|
|
3566
3974
|
this.subscriptions.delete(collection);
|
|
3975
|
+
this.subscriptionParams.delete(collection);
|
|
3567
3976
|
throw err;
|
|
3568
3977
|
}
|
|
3569
3978
|
return stream;
|
|
3570
3979
|
}
|
|
3571
3980
|
|
|
3981
|
+
/**
|
|
3982
|
+
* Unsubscribe from a collection's mutation notifications. This is an
|
|
3983
|
+
* intentional teardown, so the subscription is NOT replayed on reconnect.
|
|
3984
|
+
*/
|
|
3985
|
+
unsubscribe(collection: string): void {
|
|
3986
|
+
const stream = this.subscriptions.get(collection);
|
|
3987
|
+
this.subscriptions.delete(collection);
|
|
3988
|
+
this.subscriptionParams.delete(collection);
|
|
3989
|
+
if (stream && !stream.closed) {
|
|
3990
|
+
stream.close();
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
|
|
3572
3994
|
/**
|
|
3573
3995
|
* Send a chat message and receive a streaming response.
|
|
3574
3996
|
* Returns an EventStream that emits "event" with ChatStreamEvent objects.
|
|
@@ -3883,8 +4305,45 @@ export class WebSocketClient {
|
|
|
3883
4305
|
|
|
3884
4306
|
/**
|
|
3885
4307
|
* Close the WebSocket connection.
|
|
4308
|
+
*
|
|
4309
|
+
* This is an INTENTIONAL close: it disables auto-reconnect, rejects any
|
|
4310
|
+
* in-flight requests, and tears down all subscriptions/chat streams so
|
|
4311
|
+
* nothing is replayed afterward.
|
|
3886
4312
|
*/
|
|
3887
4313
|
close(): void {
|
|
4314
|
+
// Mark intentional so the close handler doesn't trigger a reconnect.
|
|
4315
|
+
this.closed = true;
|
|
4316
|
+
this.reconnecting = false;
|
|
4317
|
+
|
|
4318
|
+
// Reject any in-flight requests and clear their timers.
|
|
4319
|
+
for (const [, pending] of this.pendingRequests) {
|
|
4320
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
4321
|
+
pending.reject(new Error("WebSocket connection closed"));
|
|
4322
|
+
}
|
|
4323
|
+
this.pendingRequests.clear();
|
|
4324
|
+
|
|
4325
|
+
// Tear down subscriptions + their replay bookkeeping.
|
|
4326
|
+
for (const [, stream] of this.subscriptions) {
|
|
4327
|
+
if (!stream.closed) stream.close();
|
|
4328
|
+
}
|
|
4329
|
+
this.subscriptions.clear();
|
|
4330
|
+
this.subscriptionParams.clear();
|
|
4331
|
+
|
|
4332
|
+
// Reject any in-flight tool registration ack. Done here (not just in the
|
|
4333
|
+
// ws "close" handler) so it's cleaned up even when this.ws is already null.
|
|
4334
|
+
if (this.registerToolsAck) {
|
|
4335
|
+
this.registerToolsAck.reject(new Error("WebSocket connection closed"));
|
|
4336
|
+
this.registerToolsAck = null;
|
|
4337
|
+
}
|
|
4338
|
+
|
|
4339
|
+
// Tear down chat streams immediately; they are one-shot and not replayed,
|
|
4340
|
+
// and we can't rely on the underlying ws "close" event having fired.
|
|
4341
|
+
for (const [, stream] of this.chatStreams) {
|
|
4342
|
+
stream.emit("event", { type: "error", error: "Connection closed" });
|
|
4343
|
+
stream.close();
|
|
4344
|
+
}
|
|
4345
|
+
this.chatStreams.clear();
|
|
4346
|
+
|
|
3888
4347
|
if (this.ws) {
|
|
3889
4348
|
this.ws.close();
|
|
3890
4349
|
this.ws = null;
|