@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/src/client.ts
CHANGED
|
@@ -379,6 +379,20 @@ export interface FunctionStageConfig {
|
|
|
379
379
|
[key: string]: any;
|
|
380
380
|
}
|
|
381
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
|
+
|
|
382
396
|
export class EkoDBClient {
|
|
383
397
|
private baseURL: string;
|
|
384
398
|
private apiKey: string;
|
|
@@ -392,13 +406,15 @@ export class EkoDBClient {
|
|
|
392
406
|
constructor(config: string | ClientConfig, apiKey?: string) {
|
|
393
407
|
// Support both old (baseURL, apiKey) and new (config object) signatures
|
|
394
408
|
if (typeof config === "string") {
|
|
395
|
-
|
|
409
|
+
// Strip trailing slashes so `${baseURL}/api/...` never produces a
|
|
410
|
+
// double-slash path (some servers/proxies reject `//api/...`).
|
|
411
|
+
this.baseURL = stripTrailingSlashes(config);
|
|
396
412
|
this.apiKey = apiKey!;
|
|
397
413
|
this.shouldRetry = true;
|
|
398
414
|
this.maxRetries = 3;
|
|
399
415
|
this.format = SerializationFormat.MessagePack; // Default to MessagePack for 2-3x performance
|
|
400
416
|
} else {
|
|
401
|
-
this.baseURL = config.baseURL;
|
|
417
|
+
this.baseURL = stripTrailingSlashes(config.baseURL);
|
|
402
418
|
this.apiKey = config.apiKey;
|
|
403
419
|
this.shouldRetry = config.shouldRetry ?? true;
|
|
404
420
|
this.maxRetries = config.maxRetries ?? 3;
|
|
@@ -555,6 +571,41 @@ export class EkoDBClient {
|
|
|
555
571
|
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
|
556
572
|
}
|
|
557
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
|
+
|
|
558
609
|
/**
|
|
559
610
|
* Helper to determine if a path should use JSON
|
|
560
611
|
* Only CRUD operations (insert/update/delete/batch) use MessagePack
|
|
@@ -640,14 +691,16 @@ export class EkoDBClient {
|
|
|
640
691
|
|
|
641
692
|
// Handle rate limiting (429)
|
|
642
693
|
if (response.status === 429) {
|
|
643
|
-
const retryAfter =
|
|
644
|
-
response.headers.get("retry-after")
|
|
645
|
-
10,
|
|
694
|
+
const retryAfter = this.parseRetryAfter(
|
|
695
|
+
response.headers.get("retry-after"),
|
|
646
696
|
);
|
|
647
697
|
|
|
648
698
|
if (this.shouldRetry && attempt < this.maxRetries) {
|
|
649
|
-
|
|
650
|
-
|
|
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);
|
|
651
704
|
return this.makeRequest<T>(
|
|
652
705
|
method,
|
|
653
706
|
path,
|
|
@@ -674,9 +727,9 @@ export class EkoDBClient {
|
|
|
674
727
|
this.shouldRetry &&
|
|
675
728
|
attempt < this.maxRetries
|
|
676
729
|
) {
|
|
677
|
-
const retryDelay =
|
|
730
|
+
const retryDelay = this.backoffSeconds(attempt);
|
|
678
731
|
console.log(
|
|
679
|
-
`Service unavailable. Retrying after ${retryDelay}
|
|
732
|
+
`Service unavailable. Retrying after ${retryDelay.toFixed(2)}s...`,
|
|
680
733
|
);
|
|
681
734
|
await this.sleep(retryDelay);
|
|
682
735
|
return this.makeRequest<T>(method, path, data, attempt + 1, forceJson);
|
|
@@ -692,8 +745,10 @@ export class EkoDBClient {
|
|
|
692
745
|
this.shouldRetry &&
|
|
693
746
|
attempt < this.maxRetries
|
|
694
747
|
) {
|
|
695
|
-
const retryDelay =
|
|
696
|
-
console.log(
|
|
748
|
+
const retryDelay = this.backoffSeconds(attempt);
|
|
749
|
+
console.log(
|
|
750
|
+
`Network error. Retrying after ${retryDelay.toFixed(2)}s...`,
|
|
751
|
+
);
|
|
697
752
|
await this.sleep(retryDelay);
|
|
698
753
|
return this.makeRequest<T>(method, path, data, attempt + 1, forceJson);
|
|
699
754
|
}
|
|
@@ -1824,10 +1879,10 @@ export class EkoDBClient {
|
|
|
1824
1879
|
|
|
1825
1880
|
(async () => {
|
|
1826
1881
|
try {
|
|
1827
|
-
let token = this.getToken();
|
|
1882
|
+
let token = await this.getToken();
|
|
1828
1883
|
if (!token) {
|
|
1829
1884
|
await this.refreshToken();
|
|
1830
|
-
token = this.getToken();
|
|
1885
|
+
token = await this.getToken();
|
|
1831
1886
|
}
|
|
1832
1887
|
const url = `${this.baseURL}/api/chat/${chatId}/messages/stream`;
|
|
1833
1888
|
|
|
@@ -1851,11 +1906,10 @@ export class EkoDBClient {
|
|
|
1851
1906
|
return;
|
|
1852
1907
|
}
|
|
1853
1908
|
|
|
1854
|
-
const
|
|
1855
|
-
|
|
1856
|
-
if (!line.startsWith("data:")) continue;
|
|
1909
|
+
const emitLine = (line: string) => {
|
|
1910
|
+
if (!line.startsWith("data:")) return;
|
|
1857
1911
|
const dataStr = line.slice(5).trim();
|
|
1858
|
-
if (!dataStr)
|
|
1912
|
+
if (!dataStr) return;
|
|
1859
1913
|
try {
|
|
1860
1914
|
const eventData = JSON.parse(dataStr);
|
|
1861
1915
|
if (eventData.error) {
|
|
@@ -1881,6 +1935,30 @@ export class EkoDBClient {
|
|
|
1881
1935
|
} catch {
|
|
1882
1936
|
// skip malformed SSE data
|
|
1883
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);
|
|
1884
1962
|
}
|
|
1885
1963
|
stream.close();
|
|
1886
1964
|
} catch (err: any) {
|
|
@@ -2956,10 +3034,18 @@ export class EkoDBClient {
|
|
|
2956
3034
|
}
|
|
2957
3035
|
|
|
2958
3036
|
/**
|
|
2959
|
-
* 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.
|
|
2960
3046
|
*/
|
|
2961
|
-
websocket(wsURL: string): WebSocketClient {
|
|
2962
|
-
return new WebSocketClient(wsURL, this.
|
|
3047
|
+
websocket(wsURL: string, options?: WebSocketClientOptions): WebSocketClient {
|
|
3048
|
+
return new WebSocketClient(wsURL, () => this.getToken(), options);
|
|
2963
3049
|
}
|
|
2964
3050
|
|
|
2965
3051
|
// ========== RAG Helper Methods ==========
|
|
@@ -3164,6 +3250,39 @@ export interface SubscribeOptions {
|
|
|
3164
3250
|
filterValue?: string;
|
|
3165
3251
|
}
|
|
3166
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
|
+
|
|
3167
3286
|
/** EventEmitter-like interface for subscriptions and chat streams. */
|
|
3168
3287
|
export class EventStream<_T = unknown> {
|
|
3169
3288
|
private listeners: Map<string, Array<(data: any) => void>> = new Map();
|
|
@@ -3305,13 +3424,17 @@ export function extractRecordId(
|
|
|
3305
3424
|
for (const key of extraCandidates) {
|
|
3306
3425
|
const val = record[key];
|
|
3307
3426
|
if (typeof val === "string") return val;
|
|
3308
|
-
|
|
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)
|
|
3309
3430
|
return String(val.value);
|
|
3310
3431
|
}
|
|
3311
3432
|
for (const key of ["id", "_id"]) {
|
|
3312
3433
|
const val = record[key];
|
|
3313
3434
|
if (typeof val === "string") return val;
|
|
3314
|
-
|
|
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)
|
|
3315
3438
|
return String(val.value);
|
|
3316
3439
|
}
|
|
3317
3440
|
return undefined;
|
|
@@ -3319,27 +3442,65 @@ export function extractRecordId(
|
|
|
3319
3442
|
|
|
3320
3443
|
export class WebSocketClient {
|
|
3321
3444
|
private wsURL: string;
|
|
3322
|
-
private
|
|
3445
|
+
private tokenProvider: () => string | null | Promise<string | null>;
|
|
3323
3446
|
private ws: any = null;
|
|
3324
3447
|
private dispatcherRunning = false;
|
|
3325
3448
|
private schemaCache: SchemaCache | null = null;
|
|
3326
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
|
+
|
|
3327
3464
|
// Dispatcher state
|
|
3328
3465
|
private pendingRequests: Map<
|
|
3329
3466
|
string,
|
|
3330
|
-
{
|
|
3467
|
+
{
|
|
3468
|
+
resolve: (value: any) => void;
|
|
3469
|
+
reject: (reason: any) => void;
|
|
3470
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
3471
|
+
}
|
|
3331
3472
|
> = new Map();
|
|
3332
3473
|
private subscriptions: Map<string, EventStream<MutationNotification>> =
|
|
3333
3474
|
new Map();
|
|
3475
|
+
/** Bookkeeping so subscriptions can be replayed on reconnect. */
|
|
3476
|
+
private subscriptionParams: Map<string, SubscribeOptions | undefined> =
|
|
3477
|
+
new Map();
|
|
3334
3478
|
private chatStreams: Map<string, EventStream<ChatStreamEvent>> = new Map();
|
|
3335
3479
|
private registerToolsAck: {
|
|
3336
3480
|
resolve: (value: any) => void;
|
|
3337
3481
|
reject: (reason: any) => void;
|
|
3338
3482
|
} | null = null;
|
|
3339
3483
|
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
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;
|
|
3343
3504
|
}
|
|
3344
3505
|
|
|
3345
3506
|
private messageCounter = 0;
|
|
@@ -3350,11 +3511,39 @@ export class WebSocketClient {
|
|
|
3350
3511
|
}
|
|
3351
3512
|
|
|
3352
3513
|
/**
|
|
3353
|
-
*
|
|
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.
|
|
3354
3531
|
*/
|
|
3355
3532
|
private async ensureConnected(): Promise<void> {
|
|
3356
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
|
+
}
|
|
3357
3545
|
|
|
3546
|
+
private async openSocket(): Promise<void> {
|
|
3358
3547
|
const WebSocket = (await import("ws")).default;
|
|
3359
3548
|
|
|
3360
3549
|
let url = this.wsURL;
|
|
@@ -3362,9 +3551,19 @@ export class WebSocketClient {
|
|
|
3362
3551
|
url += "/api/ws";
|
|
3363
3552
|
}
|
|
3364
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
|
+
|
|
3365
3564
|
this.ws = new WebSocket(url, {
|
|
3366
3565
|
headers: {
|
|
3367
|
-
Authorization: `Bearer ${
|
|
3566
|
+
Authorization: `Bearer ${token}`,
|
|
3368
3567
|
},
|
|
3369
3568
|
});
|
|
3370
3569
|
|
|
@@ -3380,7 +3579,13 @@ export class WebSocketClient {
|
|
|
3380
3579
|
if (this.dispatcherRunning) return;
|
|
3381
3580
|
this.dispatcherRunning = true;
|
|
3382
3581
|
|
|
3383
|
-
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;
|
|
3384
3589
|
try {
|
|
3385
3590
|
const msg = JSON.parse(data.toString());
|
|
3386
3591
|
this.routeMessage(msg);
|
|
@@ -3389,26 +3594,155 @@ export class WebSocketClient {
|
|
|
3389
3594
|
}
|
|
3390
3595
|
});
|
|
3391
3596
|
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
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.
|
|
3406
3641
|
for (const [, stream] of this.subscriptions) {
|
|
3407
3642
|
stream.close();
|
|
3408
3643
|
}
|
|
3409
3644
|
this.subscriptions.clear();
|
|
3410
|
-
this.
|
|
3411
|
-
}
|
|
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
|
+
}
|
|
3412
3746
|
}
|
|
3413
3747
|
|
|
3414
3748
|
private routeMessage(msg: any): void {
|
|
@@ -3423,14 +3757,7 @@ export class WebSocketClient {
|
|
|
3423
3757
|
msg.payload?.messageId;
|
|
3424
3758
|
let matched = false;
|
|
3425
3759
|
if (messageId && this.pendingRequests.has(messageId)) {
|
|
3426
|
-
|
|
3427
|
-
this.pendingRequests.delete(messageId);
|
|
3428
|
-
if (msg.type === "Error") {
|
|
3429
|
-
pending.reject(new Error(msg.message || "Unknown error"));
|
|
3430
|
-
} else {
|
|
3431
|
-
pending.resolve(msg.payload);
|
|
3432
|
-
}
|
|
3433
|
-
matched = true;
|
|
3760
|
+
matched = this.settlePending(messageId, msg.type === "Error", msg);
|
|
3434
3761
|
}
|
|
3435
3762
|
if (!matched && this.registerToolsAck) {
|
|
3436
3763
|
const ack = this.registerToolsAck;
|
|
@@ -3442,18 +3769,14 @@ export class WebSocketClient {
|
|
|
3442
3769
|
}
|
|
3443
3770
|
matched = true;
|
|
3444
3771
|
}
|
|
3445
|
-
// 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
|
|
3446
3773
|
// request, deliver the response to it (sequential request/response).
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
this.pendingRequests.
|
|
3452
|
-
|
|
3453
|
-
pending.reject(new Error(msg.message || "Unknown error"));
|
|
3454
|
-
} else {
|
|
3455
|
-
pending.resolve(msg.payload);
|
|
3456
|
-
}
|
|
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);
|
|
3457
3780
|
}
|
|
3458
3781
|
break;
|
|
3459
3782
|
}
|
|
@@ -3555,16 +3878,52 @@ export class WebSocketClient {
|
|
|
3555
3878
|
const messageId = request.messageId || request.message_id;
|
|
3556
3879
|
|
|
3557
3880
|
return new Promise((resolve, reject) => {
|
|
3558
|
-
|
|
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 });
|
|
3559
3899
|
try {
|
|
3560
3900
|
this.ws.send(JSON.stringify(request));
|
|
3561
3901
|
} catch (err) {
|
|
3562
3902
|
this.pendingRequests.delete(messageId);
|
|
3903
|
+
if (timer) clearTimeout(timer);
|
|
3563
3904
|
reject(err);
|
|
3564
3905
|
}
|
|
3565
3906
|
});
|
|
3566
3907
|
}
|
|
3567
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
|
+
|
|
3568
3927
|
/**
|
|
3569
3928
|
* Find all records in a collection via WebSocket.
|
|
3570
3929
|
*/
|
|
@@ -3595,6 +3954,8 @@ export class WebSocketClient {
|
|
|
3595
3954
|
const messageId = this.genMessageId();
|
|
3596
3955
|
const stream = new EventStream<MutationNotification>();
|
|
3597
3956
|
this.subscriptions.set(collection, stream);
|
|
3957
|
+
// Track params so the subscription can be replayed on reconnect.
|
|
3958
|
+
this.subscriptionParams.set(collection, options);
|
|
3598
3959
|
|
|
3599
3960
|
const request: any = {
|
|
3600
3961
|
type: "Subscribe",
|
|
@@ -3611,11 +3972,25 @@ export class WebSocketClient {
|
|
|
3611
3972
|
await this.sendRequest(request);
|
|
3612
3973
|
} catch (err) {
|
|
3613
3974
|
this.subscriptions.delete(collection);
|
|
3975
|
+
this.subscriptionParams.delete(collection);
|
|
3614
3976
|
throw err;
|
|
3615
3977
|
}
|
|
3616
3978
|
return stream;
|
|
3617
3979
|
}
|
|
3618
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
|
+
|
|
3619
3994
|
/**
|
|
3620
3995
|
* Send a chat message and receive a streaming response.
|
|
3621
3996
|
* Returns an EventStream that emits "event" with ChatStreamEvent objects.
|
|
@@ -3930,8 +4305,45 @@ export class WebSocketClient {
|
|
|
3930
4305
|
|
|
3931
4306
|
/**
|
|
3932
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.
|
|
3933
4312
|
*/
|
|
3934
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
|
+
|
|
3935
4347
|
if (this.ws) {
|
|
3936
4348
|
this.ws.close();
|
|
3937
4349
|
this.ws = null;
|