@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/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
- 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);
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 = parseInt(
644
- response.headers.get("retry-after") || "60",
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
- console.log(`Rate limited. Retrying after ${retryAfter} seconds...`);
650
- 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);
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 = 10;
730
+ const retryDelay = this.backoffSeconds(attempt);
678
731
  console.log(
679
- `Service unavailable. Retrying after ${retryDelay} seconds...`,
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 = 3;
696
- 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
+ );
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 body = await response.text();
1855
- for (const line of body.split("\n")) {
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) continue;
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.token!);
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
- 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)
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
- 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)
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 token: string;
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
- { 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
+ }
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
- constructor(wsURL: string, token: string) {
3341
- this.wsURL = wsURL;
3342
- 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;
3343
3504
  }
3344
3505
 
3345
3506
  private messageCounter = 0;
@@ -3350,11 +3511,39 @@ export class WebSocketClient {
3350
3511
  }
3351
3512
 
3352
3513
  /**
3353
- * 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.
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 ${this.token}`,
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.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;
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
- this.ws.on("close", () => {
3393
- this.dispatcherRunning = false;
3394
- // Notify all pending requests
3395
- for (const [, pending] of this.pendingRequests) {
3396
- pending.reject(new Error("WebSocket connection closed"));
3397
- }
3398
- this.pendingRequests.clear();
3399
- // Close all chat streams
3400
- for (const [, stream] of this.chatStreams) {
3401
- stream.emit("event", { type: "error", error: "Connection closed" });
3402
- stream.close();
3403
- }
3404
- this.chatStreams.clear();
3405
- // 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.
3406
3641
  for (const [, stream] of this.subscriptions) {
3407
3642
  stream.close();
3408
3643
  }
3409
3644
  this.subscriptions.clear();
3410
- this.ws = null;
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
- const pending = this.pendingRequests.get(messageId)!;
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
- if (!matched && this.pendingRequests.size === 1) {
3448
- const entry = this.pendingRequests.entries().next().value!;
3449
- const key = entry[0];
3450
- const pending = entry[1];
3451
- this.pendingRequests.delete(key);
3452
- if (msg.type === "Error") {
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
- 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 });
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;