@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/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
- this.baseURL = config;
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 = parseInt(
624
- response.headers.get("retry-after") || "60",
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
- console.log(`Rate limited. Retrying after ${retryAfter} seconds...`);
630
- await this.sleep(retryAfter);
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 = 10;
730
+ const retryDelay = this.backoffSeconds(attempt);
658
731
  console.log(
659
- `Service unavailable. Retrying after ${retryDelay} seconds...`,
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 = 3;
676
- console.log(`Network error. Retrying after ${retryDelay} seconds...`);
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 body = await response.text();
1835
- for (const line of body.split("\n")) {
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) continue;
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.token!);
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
- if (val && typeof val === "object" && "value" in val)
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
- if (val && typeof val === "object" && "value" in val)
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 token: string;
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
- { resolve: (value: any) => void; reject: (reason: any) => void }
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
- constructor(wsURL: string, token: string) {
3294
- this.wsURL = wsURL;
3295
- this.token = token;
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
- * Connect and start the dispatcher.
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 ${this.token}`,
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.ws.on("message", (data: Buffer) => {
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
- this.ws.on("close", () => {
3346
- this.dispatcherRunning = false;
3347
- // Notify all pending requests
3348
- for (const [, pending] of this.pendingRequests) {
3349
- pending.reject(new Error("WebSocket connection closed"));
3350
- }
3351
- this.pendingRequests.clear();
3352
- // Close all chat streams
3353
- for (const [, stream] of this.chatStreams) {
3354
- stream.emit("event", { type: "error", error: "Connection closed" });
3355
- stream.close();
3356
- }
3357
- this.chatStreams.clear();
3358
- // Close all subscriptions
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.ws = null;
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
- const pending = this.pendingRequests.get(messageId)!;
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
- if (!matched && this.pendingRequests.size === 1) {
3401
- const entry = this.pendingRequests.entries().next().value!;
3402
- const key = entry[0];
3403
- const pending = entry[1];
3404
- this.pendingRequests.delete(key);
3405
- if (msg.type === "Error") {
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
- this.pendingRequests.set(messageId, { resolve, reject });
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;