@ekodb/ekodb-client 0.19.0 → 0.21.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 +28 -1
- package/dist/client.d.ts +198 -17
- package/dist/client.js +653 -119
- package/dist/client.test.js +287 -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 +339 -5
- package/package.json +1 -1
- package/src/client.test.ts +394 -1
- package/src/client.ts +821 -130
- 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 +498 -5
package/dist/client.js
CHANGED
|
@@ -69,6 +69,19 @@ var MergeStrategy;
|
|
|
69
69
|
MergeStrategy["LatestOnly"] = "LatestOnly";
|
|
70
70
|
MergeStrategy["Interleaved"] = "Interleaved";
|
|
71
71
|
})(MergeStrategy || (exports.MergeStrategy = MergeStrategy = {}));
|
|
72
|
+
/**
|
|
73
|
+
* Strip trailing slashes from a base URL so path concatenation
|
|
74
|
+
* (`${base}/api/...`) never yields a double-slash path. Uses a linear scan
|
|
75
|
+
* rather than a regex like `/\/+$/`, which CodeQL flags as polynomial-time
|
|
76
|
+
* backtracking on caller-supplied input.
|
|
77
|
+
*/
|
|
78
|
+
function stripTrailingSlashes(url) {
|
|
79
|
+
let end = url.length;
|
|
80
|
+
while (end > 0 && url.charCodeAt(end - 1) === 47 /* "/" */) {
|
|
81
|
+
end--;
|
|
82
|
+
}
|
|
83
|
+
return end === url.length ? url : url.slice(0, end);
|
|
84
|
+
}
|
|
72
85
|
class EkoDBClient {
|
|
73
86
|
constructor(config, apiKey) {
|
|
74
87
|
this.token = null;
|
|
@@ -76,14 +89,16 @@ class EkoDBClient {
|
|
|
76
89
|
this.rateLimitInfo = null;
|
|
77
90
|
// Support both old (baseURL, apiKey) and new (config object) signatures
|
|
78
91
|
if (typeof config === "string") {
|
|
79
|
-
|
|
92
|
+
// Strip trailing slashes so `${baseURL}/api/...` never produces a
|
|
93
|
+
// double-slash path (some servers/proxies reject `//api/...`).
|
|
94
|
+
this.baseURL = stripTrailingSlashes(config);
|
|
80
95
|
this.apiKey = apiKey;
|
|
81
96
|
this.shouldRetry = true;
|
|
82
97
|
this.maxRetries = 3;
|
|
83
98
|
this.format = SerializationFormat.MessagePack; // Default to MessagePack for 2-3x performance
|
|
84
99
|
}
|
|
85
100
|
else {
|
|
86
|
-
this.baseURL = config.baseURL;
|
|
101
|
+
this.baseURL = stripTrailingSlashes(config.baseURL);
|
|
87
102
|
this.apiKey = config.apiKey;
|
|
88
103
|
this.shouldRetry = config.shouldRetry ?? true;
|
|
89
104
|
this.maxRetries = config.maxRetries ?? 3;
|
|
@@ -223,6 +238,38 @@ class EkoDBClient {
|
|
|
223
238
|
sleep(seconds) {
|
|
224
239
|
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
|
225
240
|
}
|
|
241
|
+
/**
|
|
242
|
+
* Parse a `Retry-After` header into a non-negative delay in seconds.
|
|
243
|
+
*
|
|
244
|
+
* Per RFC 9110 the value is either delay-seconds (an integer) or an
|
|
245
|
+
* HTTP-date. Anything that doesn't resolve to a finite, non-negative number
|
|
246
|
+
* (missing header, garbage, a past date) falls back to `defaultSecs`.
|
|
247
|
+
*/
|
|
248
|
+
parseRetryAfter(header, defaultSecs = 60) {
|
|
249
|
+
if (!header)
|
|
250
|
+
return defaultSecs;
|
|
251
|
+
// delay-seconds form: a bare integer.
|
|
252
|
+
const secs = Number(header.trim());
|
|
253
|
+
if (Number.isFinite(secs))
|
|
254
|
+
return Math.max(0, secs);
|
|
255
|
+
// HTTP-date form: compute the delay from now.
|
|
256
|
+
const dateMs = Date.parse(header);
|
|
257
|
+
if (Number.isFinite(dateMs)) {
|
|
258
|
+
return Math.max(0, (dateMs - Date.now()) / 1000);
|
|
259
|
+
}
|
|
260
|
+
return defaultSecs;
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Backoff delay (in seconds) for a 0-indexed retry attempt: a capped
|
|
264
|
+
* exponential schedule (0.2s → 5s) with full jitter, so concurrent clients
|
|
265
|
+
* don't retry in lockstep. Returns a value in [d/2, d].
|
|
266
|
+
*/
|
|
267
|
+
backoffSeconds(attempt) {
|
|
268
|
+
const base = 0.2;
|
|
269
|
+
const max = 5;
|
|
270
|
+
const d = Math.min(base * Math.pow(2, Math.max(0, attempt)), max);
|
|
271
|
+
return d / 2 + Math.random() * (d / 2);
|
|
272
|
+
}
|
|
226
273
|
/**
|
|
227
274
|
* Helper to determine if a path should use JSON
|
|
228
275
|
* Only CRUD operations (insert/update/delete/batch) use MessagePack
|
|
@@ -292,10 +339,13 @@ class EkoDBClient {
|
|
|
292
339
|
}
|
|
293
340
|
// Handle rate limiting (429)
|
|
294
341
|
if (response.status === 429) {
|
|
295
|
-
const retryAfter =
|
|
342
|
+
const retryAfter = this.parseRetryAfter(response.headers.get("retry-after"));
|
|
296
343
|
if (this.shouldRetry && attempt < this.maxRetries) {
|
|
297
|
-
|
|
298
|
-
|
|
344
|
+
// Honor the server's Retry-After, but cap it so a hostile/large value
|
|
345
|
+
// can't pin the client for minutes.
|
|
346
|
+
const wait = Math.min(retryAfter, 60);
|
|
347
|
+
console.log(`Rate limited. Retrying after ${wait} seconds...`);
|
|
348
|
+
await this.sleep(wait);
|
|
299
349
|
return this.makeRequest(method, path, data, attempt + 1, forceJson);
|
|
300
350
|
}
|
|
301
351
|
throw new RateLimitError(retryAfter);
|
|
@@ -311,8 +361,8 @@ class EkoDBClient {
|
|
|
311
361
|
if (response.status === 503 &&
|
|
312
362
|
this.shouldRetry &&
|
|
313
363
|
attempt < this.maxRetries) {
|
|
314
|
-
const retryDelay =
|
|
315
|
-
console.log(`Service unavailable. Retrying after ${retryDelay}
|
|
364
|
+
const retryDelay = this.backoffSeconds(attempt);
|
|
365
|
+
console.log(`Service unavailable. Retrying after ${retryDelay.toFixed(2)}s...`);
|
|
316
366
|
await this.sleep(retryDelay);
|
|
317
367
|
return this.makeRequest(method, path, data, attempt + 1, forceJson);
|
|
318
368
|
}
|
|
@@ -325,8 +375,8 @@ class EkoDBClient {
|
|
|
325
375
|
if (error instanceof TypeError &&
|
|
326
376
|
this.shouldRetry &&
|
|
327
377
|
attempt < this.maxRetries) {
|
|
328
|
-
const retryDelay =
|
|
329
|
-
console.log(`Network error. Retrying after ${retryDelay}
|
|
378
|
+
const retryDelay = this.backoffSeconds(attempt);
|
|
379
|
+
console.log(`Network error. Retrying after ${retryDelay.toFixed(2)}s...`);
|
|
330
380
|
await this.sleep(retryDelay);
|
|
331
381
|
return this.makeRequest(method, path, data, attempt + 1, forceJson);
|
|
332
382
|
}
|
|
@@ -352,8 +402,8 @@ class EkoDBClient {
|
|
|
352
402
|
params.append("transaction_id", options.transactionId);
|
|
353
403
|
}
|
|
354
404
|
const url = params.toString()
|
|
355
|
-
? `/api/insert/${collection}?${params.toString()}`
|
|
356
|
-
: `/api/insert/${collection}`;
|
|
405
|
+
? `/api/insert/${encodeURIComponent(collection)}?${params.toString()}`
|
|
406
|
+
: `/api/insert/${encodeURIComponent(collection)}`;
|
|
357
407
|
return this.makeRequest("POST", url, data);
|
|
358
408
|
}
|
|
359
409
|
/**
|
|
@@ -378,15 +428,57 @@ class EkoDBClient {
|
|
|
378
428
|
* const results = await client.find("users", { limit: 10 });
|
|
379
429
|
* ```
|
|
380
430
|
*/
|
|
381
|
-
async find(collection, query = {}) {
|
|
431
|
+
async find(collection, query = {}, options) {
|
|
382
432
|
const queryObj = query instanceof query_builder_1.QueryBuilder ? query.build() : query;
|
|
383
|
-
|
|
433
|
+
// bypass_ripple and transaction_id are query parameters — the same way every
|
|
434
|
+
// other method (insert/update/findById) carries bypass_ripple — not part of
|
|
435
|
+
// the FindBody. Hoist any bypass_ripple carried on the query object (e.g. from
|
|
436
|
+
// QueryBuilder.bypassRipple()) out of the body so it is ALWAYS sent as a query
|
|
437
|
+
// param; an explicit options.bypassRipple wins.
|
|
438
|
+
let body = queryObj;
|
|
439
|
+
let bypassRipple = options?.bypassRipple;
|
|
440
|
+
if (body && typeof body === "object" && "bypass_ripple" in body) {
|
|
441
|
+
const { bypass_ripple, ...rest } = body;
|
|
442
|
+
body = rest;
|
|
443
|
+
if (bypassRipple === undefined)
|
|
444
|
+
bypassRipple = bypass_ripple;
|
|
445
|
+
}
|
|
446
|
+
const params = new URLSearchParams();
|
|
447
|
+
if (options?.transactionId)
|
|
448
|
+
params.append("transaction_id", options.transactionId);
|
|
449
|
+
if (bypassRipple !== undefined)
|
|
450
|
+
params.append("bypass_ripple", String(bypassRipple));
|
|
451
|
+
const qs = params.toString();
|
|
452
|
+
const url = qs
|
|
453
|
+
? `/api/find/${encodeURIComponent(collection)}?${qs}`
|
|
454
|
+
: `/api/find/${encodeURIComponent(collection)}`;
|
|
455
|
+
return this.makeRequest("POST", url, body);
|
|
384
456
|
}
|
|
385
457
|
/**
|
|
386
|
-
* Find a document by ID
|
|
458
|
+
* Find a document by ID.
|
|
459
|
+
* @param options - Optional read options. `transactionId` reads within a
|
|
460
|
+
* transaction (read-your-writes); see {@link FindByIdOptions}.
|
|
387
461
|
*/
|
|
388
|
-
async findById(collection, id) {
|
|
389
|
-
|
|
462
|
+
async findById(collection, id, options) {
|
|
463
|
+
const params = new URLSearchParams();
|
|
464
|
+
if (options?.selectFields?.length) {
|
|
465
|
+
params.append("select_fields", options.selectFields.join(","));
|
|
466
|
+
}
|
|
467
|
+
if (options?.excludeFields?.length) {
|
|
468
|
+
params.append("exclude_fields", options.excludeFields.join(","));
|
|
469
|
+
}
|
|
470
|
+
// bypass_ripple is a GET query param, the same way the non-transactional
|
|
471
|
+
// findById carries it; it rides alongside transaction_id when both are set.
|
|
472
|
+
if (options?.bypassRipple !== undefined) {
|
|
473
|
+
params.append("bypass_ripple", String(options.bypassRipple));
|
|
474
|
+
}
|
|
475
|
+
if (options?.transactionId) {
|
|
476
|
+
params.append("transaction_id", options.transactionId);
|
|
477
|
+
}
|
|
478
|
+
const url = params.toString()
|
|
479
|
+
? `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
|
|
480
|
+
: `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
|
|
481
|
+
return this.makeRequest("GET", url);
|
|
390
482
|
}
|
|
391
483
|
/**
|
|
392
484
|
* Find a document by ID with field projection
|
|
@@ -394,8 +486,9 @@ class EkoDBClient {
|
|
|
394
486
|
* @param id - Document ID
|
|
395
487
|
* @param selectFields - Fields to include in the result
|
|
396
488
|
* @param excludeFields - Fields to exclude from the result
|
|
489
|
+
* @param transactionId - Read within a transaction (read-your-writes)
|
|
397
490
|
*/
|
|
398
|
-
async findByIdWithProjection(collection, id, selectFields, excludeFields) {
|
|
491
|
+
async findByIdWithProjection(collection, id, selectFields, excludeFields, transactionId) {
|
|
399
492
|
const params = new URLSearchParams();
|
|
400
493
|
if (selectFields?.length) {
|
|
401
494
|
params.append("select_fields", selectFields.join(","));
|
|
@@ -403,9 +496,12 @@ class EkoDBClient {
|
|
|
403
496
|
if (excludeFields?.length) {
|
|
404
497
|
params.append("exclude_fields", excludeFields.join(","));
|
|
405
498
|
}
|
|
499
|
+
if (transactionId) {
|
|
500
|
+
params.append("transaction_id", transactionId);
|
|
501
|
+
}
|
|
406
502
|
const url = params.toString()
|
|
407
|
-
? `/api/find/${collection}/${id}?${params.toString()}`
|
|
408
|
-
: `/api/find/${collection}/${id}`;
|
|
503
|
+
? `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
|
|
504
|
+
: `/api/find/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
|
|
409
505
|
return this.makeRequest("GET", url);
|
|
410
506
|
}
|
|
411
507
|
/**
|
|
@@ -424,8 +520,8 @@ class EkoDBClient {
|
|
|
424
520
|
params.append("transaction_id", options.transactionId);
|
|
425
521
|
}
|
|
426
522
|
const url = params.toString()
|
|
427
|
-
? `/api/update/${collection}/${id}?${params.toString()}`
|
|
428
|
-
: `/api/update/${collection}/${id}`;
|
|
523
|
+
? `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
|
|
524
|
+
: `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
|
|
429
525
|
return this.makeRequest("PUT", url, record);
|
|
430
526
|
}
|
|
431
527
|
/**
|
|
@@ -442,7 +538,7 @@ class EkoDBClient {
|
|
|
442
538
|
* @param value - The value for the action (omit for pop/shift/clear)
|
|
443
539
|
*/
|
|
444
540
|
async updateWithAction(collection, id, action, field, value) {
|
|
445
|
-
const url = `/api/update/${collection}/${id}/action/${action}`;
|
|
541
|
+
const url = `/api/update/${encodeURIComponent(collection)}/${encodeURIComponent(id)}/action/${encodeURIComponent(action)}`;
|
|
446
542
|
return this.makeRequest("PUT", url, {
|
|
447
543
|
field,
|
|
448
544
|
value: value ?? null,
|
|
@@ -459,7 +555,7 @@ class EkoDBClient {
|
|
|
459
555
|
* @param actions - Array of [action, field, value] tuples
|
|
460
556
|
*/
|
|
461
557
|
async updateWithActionSequence(collection, id, actions) {
|
|
462
|
-
const url = `/api/update/sequence/${collection}/${id}`;
|
|
558
|
+
const url = `/api/update/sequence/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
|
|
463
559
|
return this.makeRequest("PUT", url, actions);
|
|
464
560
|
}
|
|
465
561
|
/**
|
|
@@ -477,8 +573,8 @@ class EkoDBClient {
|
|
|
477
573
|
params.append("transaction_id", options.transactionId);
|
|
478
574
|
}
|
|
479
575
|
const url = params.toString()
|
|
480
|
-
? `/api/delete/${collection}/${id}?${params.toString()}`
|
|
481
|
-
: `/api/delete/${collection}/${id}`;
|
|
576
|
+
? `/api/delete/${encodeURIComponent(collection)}/${encodeURIComponent(id)}?${params.toString()}`
|
|
577
|
+
: `/api/delete/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`;
|
|
482
578
|
await this.makeRequest("DELETE", url);
|
|
483
579
|
}
|
|
484
580
|
/**
|
|
@@ -497,8 +593,8 @@ class EkoDBClient {
|
|
|
497
593
|
}
|
|
498
594
|
const inserts = records.map((data) => ({ data }));
|
|
499
595
|
const url = params.toString()
|
|
500
|
-
? `/api/batch/insert/${collection}?${params.toString()}`
|
|
501
|
-
: `/api/batch/insert/${collection}`;
|
|
596
|
+
? `/api/batch/insert/${encodeURIComponent(collection)}?${params.toString()}`
|
|
597
|
+
: `/api/batch/insert/${encodeURIComponent(collection)}`;
|
|
502
598
|
return this.makeRequest("POST", url, { inserts });
|
|
503
599
|
}
|
|
504
600
|
/**
|
|
@@ -510,7 +606,7 @@ class EkoDBClient {
|
|
|
510
606
|
data: u.data,
|
|
511
607
|
bypass_ripple: u.bypassRipple,
|
|
512
608
|
}));
|
|
513
|
-
return this.makeRequest("PUT", `/api/batch/update/${collection}`, { updates: formattedUpdates });
|
|
609
|
+
return this.makeRequest("PUT", `/api/batch/update/${encodeURIComponent(collection)}`, { updates: formattedUpdates });
|
|
514
610
|
}
|
|
515
611
|
/**
|
|
516
612
|
* Batch delete multiple documents
|
|
@@ -520,7 +616,7 @@ class EkoDBClient {
|
|
|
520
616
|
id: id,
|
|
521
617
|
bypass_ripple: bypassRipple,
|
|
522
618
|
}));
|
|
523
|
-
return this.makeRequest("DELETE", `/api/batch/delete/${collection}`, { deletes });
|
|
619
|
+
return this.makeRequest("DELETE", `/api/batch/delete/${encodeURIComponent(collection)}`, { deletes });
|
|
524
620
|
}
|
|
525
621
|
/**
|
|
526
622
|
* Set a key-value pair with optional TTL
|
|
@@ -548,6 +644,12 @@ class EkoDBClient {
|
|
|
548
644
|
async kvDelete(key) {
|
|
549
645
|
await this.makeRequest("DELETE", `/api/kv/delete/${encodeURIComponent(key)}`, undefined, 0, true);
|
|
550
646
|
}
|
|
647
|
+
/**
|
|
648
|
+
* Clear the entire KV store (all keys in the namespace).
|
|
649
|
+
*/
|
|
650
|
+
async kvClear() {
|
|
651
|
+
await this.makeRequest("DELETE", "/api/kv/clear", undefined, 0, true);
|
|
652
|
+
}
|
|
551
653
|
/**
|
|
552
654
|
* Batch get multiple keys
|
|
553
655
|
* @param keys - Array of keys to retrieve
|
|
@@ -613,7 +715,18 @@ class EkoDBClient {
|
|
|
613
715
|
// Transaction Operations
|
|
614
716
|
// ============================================================================
|
|
615
717
|
/**
|
|
616
|
-
* Begin a new transaction
|
|
718
|
+
* Begin a new transaction.
|
|
719
|
+
*
|
|
720
|
+
* Transactions are buffered: statements issued with this `transactionId`
|
|
721
|
+
* (passed via the `transactionId` option on insert/update/delete/find/…) are
|
|
722
|
+
* staged and applied atomically only at {@link commitTransaction}. They are
|
|
723
|
+
* invisible to everyone else until commit, and visible to this transaction's
|
|
724
|
+
* own reads (read-your-writes) only when those reads also carry the
|
|
725
|
+
* `transactionId`. {@link rollbackTransaction} discards the staged writes.
|
|
726
|
+
* `commitTransaction` may reject with a conflict (HTTP 409) if a record this
|
|
727
|
+
* transaction read or wrote was changed by another committed transaction —
|
|
728
|
+
* retry the transaction in that case.
|
|
729
|
+
*
|
|
617
730
|
* @param isolationLevel - Transaction isolation level (default: "ReadCommitted")
|
|
618
731
|
* @returns Transaction ID
|
|
619
732
|
*/
|
|
@@ -637,12 +750,31 @@ class EkoDBClient {
|
|
|
637
750
|
await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/commit`, undefined, 0, true);
|
|
638
751
|
}
|
|
639
752
|
/**
|
|
640
|
-
* Rollback a transaction
|
|
753
|
+
* Rollback a transaction (discards all staged writes; nothing was applied).
|
|
641
754
|
* @param transactionId - The transaction ID to rollback
|
|
642
755
|
*/
|
|
643
756
|
async rollbackTransaction(transactionId) {
|
|
644
757
|
await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/rollback`, undefined, 0, true);
|
|
645
758
|
}
|
|
759
|
+
/**
|
|
760
|
+
* Create a savepoint within a transaction. A later
|
|
761
|
+
* {@link rollbackToSavepoint} discards everything staged after it.
|
|
762
|
+
*/
|
|
763
|
+
async createSavepoint(transactionId, name) {
|
|
764
|
+
await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/savepoints`, { name }, 0, true);
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Roll the transaction back to a savepoint, discarding writes staged after it.
|
|
768
|
+
*/
|
|
769
|
+
async rollbackToSavepoint(transactionId, name) {
|
|
770
|
+
await this.makeRequest("POST", `/api/transactions/${encodeURIComponent(transactionId)}/savepoints/${encodeURIComponent(name)}/rollback`, undefined, 0, true);
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* Release (forget) a savepoint. Staged work is unaffected.
|
|
774
|
+
*/
|
|
775
|
+
async releaseSavepoint(transactionId, name) {
|
|
776
|
+
await this.makeRequest("DELETE", `/api/transactions/${encodeURIComponent(transactionId)}/savepoints/${encodeURIComponent(name)}`, undefined, 0, true);
|
|
777
|
+
}
|
|
646
778
|
// ============================================================================
|
|
647
779
|
// Convenience Methods
|
|
648
780
|
// ============================================================================
|
|
@@ -783,11 +915,18 @@ class EkoDBClient {
|
|
|
783
915
|
const result = await this.makeRequest("GET", "/api/collections", undefined, 0, true);
|
|
784
916
|
return result.collections;
|
|
785
917
|
}
|
|
918
|
+
/**
|
|
919
|
+
* List collections, excluding internal chat/system collections.
|
|
920
|
+
*/
|
|
921
|
+
async listUserCollections() {
|
|
922
|
+
const result = await this.makeRequest("GET", "/api/collections?exclude_internal=true", undefined, 0, true);
|
|
923
|
+
return result.collections;
|
|
924
|
+
}
|
|
786
925
|
/**
|
|
787
926
|
* Delete a collection
|
|
788
927
|
*/
|
|
789
928
|
async deleteCollection(collection) {
|
|
790
|
-
await this.makeRequest("DELETE", `/api/collections/${collection}`, undefined, 0, true);
|
|
929
|
+
await this.makeRequest("DELETE", `/api/collections/${encodeURIComponent(collection)}`, undefined, 0, true);
|
|
791
930
|
}
|
|
792
931
|
/**
|
|
793
932
|
* Restore a deleted record from trash
|
|
@@ -798,7 +937,7 @@ class EkoDBClient {
|
|
|
798
937
|
* @returns true if restored successfully
|
|
799
938
|
*/
|
|
800
939
|
async restoreRecord(collection, id) {
|
|
801
|
-
const result = await this.makeRequest("POST", `/api/trash/${collection}/${id}`, undefined, 0, true);
|
|
940
|
+
const result = await this.makeRequest("POST", `/api/trash/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, undefined, 0, true);
|
|
802
941
|
return result.status === "restored";
|
|
803
942
|
}
|
|
804
943
|
/**
|
|
@@ -809,7 +948,7 @@ class EkoDBClient {
|
|
|
809
948
|
* @returns Number of records restored
|
|
810
949
|
*/
|
|
811
950
|
async restoreCollection(collection) {
|
|
812
|
-
const result = await this.makeRequest("POST", `/api/trash/${collection}`, undefined, 0, true);
|
|
951
|
+
const result = await this.makeRequest("POST", `/api/trash/${encodeURIComponent(collection)}`, undefined, 0, true);
|
|
813
952
|
return { recordsRestored: result.records_restored };
|
|
814
953
|
}
|
|
815
954
|
/**
|
|
@@ -830,7 +969,7 @@ class EkoDBClient {
|
|
|
830
969
|
*/
|
|
831
970
|
async createCollection(collection, schema) {
|
|
832
971
|
const schemaObj = schema instanceof schema_1.SchemaBuilder ? schema.build() : schema;
|
|
833
|
-
await this.makeRequest("POST", `/api/collections/${collection}`, schemaObj, 0, true);
|
|
972
|
+
await this.makeRequest("POST", `/api/collections/${encodeURIComponent(collection)}`, schemaObj, 0, true);
|
|
834
973
|
}
|
|
835
974
|
/**
|
|
836
975
|
* Get collection metadata and schema
|
|
@@ -839,7 +978,7 @@ class EkoDBClient {
|
|
|
839
978
|
* @returns Collection metadata including schema and analytics
|
|
840
979
|
*/
|
|
841
980
|
async getCollection(collection) {
|
|
842
|
-
return this.makeRequest("GET", `/api/collections/${collection}`, undefined, 0, true);
|
|
981
|
+
return this.makeRequest("GET", `/api/collections/${encodeURIComponent(collection)}`, undefined, 0, true);
|
|
843
982
|
}
|
|
844
983
|
/**
|
|
845
984
|
* Get collection schema
|
|
@@ -886,7 +1025,7 @@ class EkoDBClient {
|
|
|
886
1025
|
*/
|
|
887
1026
|
async search(collection, query) {
|
|
888
1027
|
// Ensure all parameters from SearchQuery are sent to server
|
|
889
|
-
return this.makeRequest("POST", `/api/search/${collection}`, query, 0, true);
|
|
1028
|
+
return this.makeRequest("POST", `/api/search/${encodeURIComponent(collection)}`, query, 0, true);
|
|
890
1029
|
}
|
|
891
1030
|
/**
|
|
892
1031
|
* Get distinct (unique) values for a field across all records in a collection.
|
|
@@ -916,7 +1055,7 @@ class EkoDBClient {
|
|
|
916
1055
|
body.bypass_ripple = options.bypassRipple;
|
|
917
1056
|
if (options.bypassCache !== undefined)
|
|
918
1057
|
body.bypass_cache = options.bypassCache;
|
|
919
|
-
return this.makeRequest("POST", `/api/distinct/${collection}/${field}`, body, 0, true);
|
|
1058
|
+
return this.makeRequest("POST", `/api/distinct/${encodeURIComponent(collection)}/${encodeURIComponent(field)}`, body, 0, true);
|
|
920
1059
|
}
|
|
921
1060
|
/**
|
|
922
1061
|
* Health check - verify the ekoDB server is responding
|
|
@@ -1096,14 +1235,14 @@ class EkoDBClient {
|
|
|
1096
1235
|
* Send a message in an existing chat session
|
|
1097
1236
|
*/
|
|
1098
1237
|
async chatMessage(sessionId, request) {
|
|
1099
|
-
return this.makeRequest("POST", `/api/chat/${sessionId}/messages`, request, 0, true);
|
|
1238
|
+
return this.makeRequest("POST", `/api/chat/${encodeURIComponent(sessionId)}/messages`, request, 0, true);
|
|
1100
1239
|
}
|
|
1101
1240
|
/**
|
|
1102
1241
|
* Submit a client tool result for an in-flight SSE chat stream.
|
|
1103
1242
|
* Unblocks ekoDB's tool loop so it can feed the result to the LLM.
|
|
1104
1243
|
*/
|
|
1105
1244
|
async submitChatToolResult(chatId, callId, success, result, error) {
|
|
1106
|
-
await this.makeRequest("POST", `/api/chat/${chatId}/tool-result`, {
|
|
1245
|
+
await this.makeRequest("POST", `/api/chat/${encodeURIComponent(chatId)}/tool-result`, {
|
|
1107
1246
|
call_id: callId,
|
|
1108
1247
|
success,
|
|
1109
1248
|
...(result !== undefined && { result }),
|
|
@@ -1125,12 +1264,12 @@ class EkoDBClient {
|
|
|
1125
1264
|
const stream = new EventStream();
|
|
1126
1265
|
(async () => {
|
|
1127
1266
|
try {
|
|
1128
|
-
let token = this.getToken();
|
|
1267
|
+
let token = await this.getToken();
|
|
1129
1268
|
if (!token) {
|
|
1130
1269
|
await this.refreshToken();
|
|
1131
|
-
token = this.getToken();
|
|
1270
|
+
token = await this.getToken();
|
|
1132
1271
|
}
|
|
1133
|
-
const url = `${this.baseURL}/api/chat/${chatId}/messages/stream`;
|
|
1272
|
+
const url = `${this.baseURL}/api/chat/${encodeURIComponent(chatId)}/messages/stream`;
|
|
1134
1273
|
const response = await fetch(url, {
|
|
1135
1274
|
method: "POST",
|
|
1136
1275
|
headers: {
|
|
@@ -1149,13 +1288,12 @@ class EkoDBClient {
|
|
|
1149
1288
|
stream.close();
|
|
1150
1289
|
return;
|
|
1151
1290
|
}
|
|
1152
|
-
const
|
|
1153
|
-
for (const line of body.split("\n")) {
|
|
1291
|
+
const emitLine = (line) => {
|
|
1154
1292
|
if (!line.startsWith("data:"))
|
|
1155
|
-
|
|
1293
|
+
return;
|
|
1156
1294
|
const dataStr = line.slice(5).trim();
|
|
1157
1295
|
if (!dataStr)
|
|
1158
|
-
|
|
1296
|
+
return;
|
|
1159
1297
|
try {
|
|
1160
1298
|
const eventData = JSON.parse(dataStr);
|
|
1161
1299
|
if (eventData.error) {
|
|
@@ -1184,6 +1322,33 @@ class EkoDBClient {
|
|
|
1184
1322
|
catch {
|
|
1185
1323
|
// skip malformed SSE data
|
|
1186
1324
|
}
|
|
1325
|
+
};
|
|
1326
|
+
const reader = response.body?.getReader?.();
|
|
1327
|
+
if (reader) {
|
|
1328
|
+
// True incremental streaming: decode and emit each SSE line as soon as
|
|
1329
|
+
// it arrives, rather than buffering the entire response body first.
|
|
1330
|
+
const decoder = new TextDecoder();
|
|
1331
|
+
let buffer = "";
|
|
1332
|
+
for (;;) {
|
|
1333
|
+
const { done, value } = await reader.read();
|
|
1334
|
+
if (done)
|
|
1335
|
+
break;
|
|
1336
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1337
|
+
let nl;
|
|
1338
|
+
while ((nl = buffer.indexOf("\n")) >= 0) {
|
|
1339
|
+
emitLine(buffer.slice(0, nl));
|
|
1340
|
+
buffer = buffer.slice(nl + 1);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
buffer += decoder.decode();
|
|
1344
|
+
if (buffer)
|
|
1345
|
+
emitLine(buffer);
|
|
1346
|
+
}
|
|
1347
|
+
else {
|
|
1348
|
+
// Fallback for environments/tests without a readable body stream.
|
|
1349
|
+
const body = await response.text();
|
|
1350
|
+
for (const line of body.split("\n"))
|
|
1351
|
+
emitLine(line);
|
|
1187
1352
|
}
|
|
1188
1353
|
stream.close();
|
|
1189
1354
|
}
|
|
@@ -1201,7 +1366,7 @@ class EkoDBClient {
|
|
|
1201
1366
|
* Get a chat session by ID
|
|
1202
1367
|
*/
|
|
1203
1368
|
async getChatSession(sessionId) {
|
|
1204
|
-
return this.makeRequest("GET", `/api/chat/${sessionId}`, undefined, 0, true);
|
|
1369
|
+
return this.makeRequest("GET", `/api/chat/${encodeURIComponent(sessionId)}`, undefined, 0, true);
|
|
1205
1370
|
}
|
|
1206
1371
|
/**
|
|
1207
1372
|
* List all chat sessions
|
|
@@ -1231,15 +1396,15 @@ class EkoDBClient {
|
|
|
1231
1396
|
params.append("sort", query.sort);
|
|
1232
1397
|
const queryString = params.toString();
|
|
1233
1398
|
const path = queryString
|
|
1234
|
-
? `/api/chat/${sessionId}/messages?${queryString}`
|
|
1235
|
-
: `/api/chat/${sessionId}/messages`;
|
|
1399
|
+
? `/api/chat/${encodeURIComponent(sessionId)}/messages?${queryString}`
|
|
1400
|
+
: `/api/chat/${encodeURIComponent(sessionId)}/messages`;
|
|
1236
1401
|
return this.makeRequest("GET", path, undefined, 0, true);
|
|
1237
1402
|
}
|
|
1238
1403
|
/**
|
|
1239
1404
|
* Update a chat session
|
|
1240
1405
|
*/
|
|
1241
1406
|
async updateChatSession(sessionId, request) {
|
|
1242
|
-
return this.makeRequest("PUT", `/api/chat/${sessionId}`, request, 0, true);
|
|
1407
|
+
return this.makeRequest("PUT", `/api/chat/${encodeURIComponent(sessionId)}`, request, 0, true);
|
|
1243
1408
|
}
|
|
1244
1409
|
/**
|
|
1245
1410
|
* Branch a chat session
|
|
@@ -1251,31 +1416,31 @@ class EkoDBClient {
|
|
|
1251
1416
|
* Delete a chat session
|
|
1252
1417
|
*/
|
|
1253
1418
|
async deleteChatSession(sessionId) {
|
|
1254
|
-
await this.makeRequest("DELETE", `/api/chat/${sessionId}`, undefined, 0, true);
|
|
1419
|
+
await this.makeRequest("DELETE", `/api/chat/${encodeURIComponent(sessionId)}`, undefined, 0, true);
|
|
1255
1420
|
}
|
|
1256
1421
|
/**
|
|
1257
1422
|
* Regenerate an AI response message
|
|
1258
1423
|
*/
|
|
1259
1424
|
async regenerateMessage(sessionId, messageId) {
|
|
1260
|
-
return this.makeRequest("POST", `/api/chat/${sessionId}/messages/${messageId}/regenerate`, undefined, 0, true);
|
|
1425
|
+
return this.makeRequest("POST", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/regenerate`, undefined, 0, true);
|
|
1261
1426
|
}
|
|
1262
1427
|
/**
|
|
1263
1428
|
* Update a specific message
|
|
1264
1429
|
*/
|
|
1265
1430
|
async updateChatMessage(sessionId, messageId, content) {
|
|
1266
|
-
await this.makeRequest("PUT", `/api/chat/${sessionId}/messages/${messageId}`, { content }, 0, true);
|
|
1431
|
+
await this.makeRequest("PUT", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, { content }, 0, true);
|
|
1267
1432
|
}
|
|
1268
1433
|
/**
|
|
1269
1434
|
* Delete a specific message
|
|
1270
1435
|
*/
|
|
1271
1436
|
async deleteChatMessage(sessionId, messageId) {
|
|
1272
|
-
await this.makeRequest("DELETE", `/api/chat/${sessionId}/messages/${messageId}`, undefined, 0, true);
|
|
1437
|
+
await this.makeRequest("DELETE", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, undefined, 0, true);
|
|
1273
1438
|
}
|
|
1274
1439
|
/**
|
|
1275
1440
|
* Toggle the "forgotten" status of a message
|
|
1276
1441
|
*/
|
|
1277
1442
|
async toggleForgottenMessage(sessionId, messageId, forgotten) {
|
|
1278
|
-
await this.makeRequest("PATCH", `/api/chat/${sessionId}/messages/${messageId}/forgotten`, { forgotten }, 0, true);
|
|
1443
|
+
await this.makeRequest("PATCH", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}/forgotten`, { forgotten }, 0, true);
|
|
1279
1444
|
}
|
|
1280
1445
|
/**
|
|
1281
1446
|
* Compact a chat session's history on demand.
|
|
@@ -1292,7 +1457,7 @@ class EkoDBClient {
|
|
|
1292
1457
|
if (keepRecent !== undefined) {
|
|
1293
1458
|
body.keep_recent = keepRecent;
|
|
1294
1459
|
}
|
|
1295
|
-
return this.makeRequest("POST", `/api/chat/${chatId}/compact`, body, 0, true);
|
|
1460
|
+
return this.makeRequest("POST", `/api/chat/${encodeURIComponent(chatId)}/compact`, body, 0, true);
|
|
1296
1461
|
}
|
|
1297
1462
|
/**
|
|
1298
1463
|
* Merge multiple chat sessions into one
|
|
@@ -1330,7 +1495,7 @@ class EkoDBClient {
|
|
|
1330
1495
|
* @returns The chat message record
|
|
1331
1496
|
*/
|
|
1332
1497
|
async getChatMessage(sessionId, messageId) {
|
|
1333
|
-
return this.makeRequest("GET", `/api/chat/${sessionId}/messages/${messageId}`, undefined, 0, true);
|
|
1498
|
+
return this.makeRequest("GET", `/api/chat/${encodeURIComponent(sessionId)}/messages/${encodeURIComponent(messageId)}`, undefined, 0, true);
|
|
1334
1499
|
}
|
|
1335
1500
|
// ========================================================================
|
|
1336
1501
|
// SCRIPTS API
|
|
@@ -1346,7 +1511,7 @@ class EkoDBClient {
|
|
|
1346
1511
|
* Get a function by ID
|
|
1347
1512
|
*/
|
|
1348
1513
|
async getFunction(id) {
|
|
1349
|
-
return this.makeRequest("GET", `/api/functions/${id}`);
|
|
1514
|
+
return this.makeRequest("GET", `/api/functions/${encodeURIComponent(id)}`);
|
|
1350
1515
|
}
|
|
1351
1516
|
/**
|
|
1352
1517
|
* List all functions, optionally filtered by tags
|
|
@@ -1359,19 +1524,19 @@ class EkoDBClient {
|
|
|
1359
1524
|
* Update an existing function by ID
|
|
1360
1525
|
*/
|
|
1361
1526
|
async updateFunction(id, script) {
|
|
1362
|
-
await this.makeRequest("PUT", `/api/functions/${id}`, script);
|
|
1527
|
+
await this.makeRequest("PUT", `/api/functions/${encodeURIComponent(id)}`, script);
|
|
1363
1528
|
}
|
|
1364
1529
|
/**
|
|
1365
1530
|
* Delete a function by ID
|
|
1366
1531
|
*/
|
|
1367
1532
|
async deleteFunction(id) {
|
|
1368
|
-
await this.makeRequest("DELETE", `/api/functions/${id}`);
|
|
1533
|
+
await this.makeRequest("DELETE", `/api/functions/${encodeURIComponent(id)}`);
|
|
1369
1534
|
}
|
|
1370
1535
|
/**
|
|
1371
1536
|
* Call a saved function by ID or label
|
|
1372
1537
|
*/
|
|
1373
1538
|
async callFunction(idOrLabel, params) {
|
|
1374
|
-
return this.makeRequest("POST", `/api/functions/${idOrLabel}`, params || {});
|
|
1539
|
+
return this.makeRequest("POST", `/api/functions/${encodeURIComponent(idOrLabel)}`, params || {});
|
|
1375
1540
|
}
|
|
1376
1541
|
// ========================================================================
|
|
1377
1542
|
// USER FUNCTIONS API
|
|
@@ -1480,15 +1645,15 @@ class EkoDBClient {
|
|
|
1480
1645
|
}
|
|
1481
1646
|
/** Start a goal step (status -> in_progress) */
|
|
1482
1647
|
async goalStepStart(id, stepIndex) {
|
|
1483
|
-
return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/start`, undefined, 0, true);
|
|
1648
|
+
return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/start`, undefined, 0, true);
|
|
1484
1649
|
}
|
|
1485
1650
|
/** Complete a goal step with result */
|
|
1486
1651
|
async goalStepComplete(id, stepIndex, data) {
|
|
1487
|
-
return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/complete`, data, 0, true);
|
|
1652
|
+
return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/complete`, data, 0, true);
|
|
1488
1653
|
}
|
|
1489
1654
|
/** Fail a goal step with error */
|
|
1490
1655
|
async goalStepFail(id, stepIndex, data) {
|
|
1491
|
-
return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${stepIndex}/fail`, data, 0, true);
|
|
1656
|
+
return this.makeRequest("POST", `/api/chat/goals/${encodeURIComponent(id)}/steps/${encodeURIComponent(String(stepIndex))}/fail`, data, 0, true);
|
|
1492
1657
|
}
|
|
1493
1658
|
// ========================================================================
|
|
1494
1659
|
// TASK API
|
|
@@ -1736,10 +1901,18 @@ class EkoDBClient {
|
|
|
1736
1901
|
return stream;
|
|
1737
1902
|
}
|
|
1738
1903
|
/**
|
|
1739
|
-
* Create a WebSocket client
|
|
1904
|
+
* Create a WebSocket client.
|
|
1905
|
+
*
|
|
1906
|
+
* The token is supplied as a provider bound to this client's
|
|
1907
|
+
* {@link getToken}, so every (re)connect re-evaluates (and proactively
|
|
1908
|
+
* refreshes) the auth token instead of snapshotting it once. This means a
|
|
1909
|
+
* reconnect after a token rotation uses the current token.
|
|
1910
|
+
*
|
|
1911
|
+
* @param wsURL - The WebSocket URL (e.g. `wss://host`); `/api/ws` is appended if absent.
|
|
1912
|
+
* @param options - Optional reconnect/timeout tunables.
|
|
1740
1913
|
*/
|
|
1741
|
-
websocket(wsURL) {
|
|
1742
|
-
return new WebSocketClient(wsURL, this.
|
|
1914
|
+
websocket(wsURL, options) {
|
|
1915
|
+
return new WebSocketClient(wsURL, () => this.getToken(), options);
|
|
1743
1916
|
}
|
|
1744
1917
|
// ========== RAG Helper Methods ==========
|
|
1745
1918
|
/**
|
|
@@ -1988,91 +2161,348 @@ function extractRecordId(record, extraCandidates = []) {
|
|
|
1988
2161
|
const val = record[key];
|
|
1989
2162
|
if (typeof val === "string")
|
|
1990
2163
|
return val;
|
|
1991
|
-
|
|
2164
|
+
// Unwrap only a genuine typed wrapper (both "type" and "value"), matching
|
|
2165
|
+
// getValue's rule so a user object like { value: 1 } isn't mistaken for one.
|
|
2166
|
+
if (val && typeof val === "object" && "type" in val && "value" in val)
|
|
1992
2167
|
return String(val.value);
|
|
1993
2168
|
}
|
|
1994
2169
|
for (const key of ["id", "_id"]) {
|
|
1995
2170
|
const val = record[key];
|
|
1996
2171
|
if (typeof val === "string")
|
|
1997
2172
|
return val;
|
|
1998
|
-
|
|
2173
|
+
// Unwrap only a genuine typed wrapper (both "type" and "value"), matching
|
|
2174
|
+
// getValue's rule so a user object like { value: 1 } isn't mistaken for one.
|
|
2175
|
+
if (val && typeof val === "object" && "type" in val && "value" in val)
|
|
1999
2176
|
return String(val.value);
|
|
2000
2177
|
}
|
|
2001
2178
|
return undefined;
|
|
2002
2179
|
}
|
|
2003
2180
|
class WebSocketClient {
|
|
2004
|
-
|
|
2181
|
+
/**
|
|
2182
|
+
* @param wsURL - WebSocket URL; `/api/ws` is appended if absent.
|
|
2183
|
+
* @param token - A static token string OR a {@link TokenProvider} function
|
|
2184
|
+
* re-evaluated on every (re)connect (so a refreshed token is used after a drop).
|
|
2185
|
+
* @param options - Optional reconnect/timeout tunables.
|
|
2186
|
+
*/
|
|
2187
|
+
constructor(wsURL, token, options = {}) {
|
|
2005
2188
|
this.ws = null;
|
|
2006
2189
|
this.dispatcherRunning = false;
|
|
2007
2190
|
this.schemaCache = null;
|
|
2191
|
+
/**
|
|
2192
|
+
* Per-connection wire format, set by negotiateFormat() on every (re)connect:
|
|
2193
|
+
* true once the server has Welcomed msgpack, so frames are sent/received as
|
|
2194
|
+
* binary msgpack; false (JSON text) otherwise, including against an older
|
|
2195
|
+
* server that never Welcomes. Keeps the transport fully back-compatible.
|
|
2196
|
+
*/
|
|
2197
|
+
this.binary = false;
|
|
2198
|
+
// Reconnect state
|
|
2199
|
+
/** Set while close() is in progress so the close handler doesn't reconnect. */
|
|
2200
|
+
this.closed = false;
|
|
2201
|
+
this.reconnectAttempts = 0;
|
|
2202
|
+
this.reconnecting = false;
|
|
2203
|
+
this.connectPromise = null;
|
|
2008
2204
|
// Dispatcher state
|
|
2009
2205
|
this.pendingRequests = new Map();
|
|
2010
2206
|
this.subscriptions = new Map();
|
|
2207
|
+
/** Bookkeeping so subscriptions can be replayed on reconnect. */
|
|
2208
|
+
this.subscriptionParams = new Map();
|
|
2011
2209
|
this.chatStreams = new Map();
|
|
2012
2210
|
this.registerToolsAck = null;
|
|
2013
2211
|
this.messageCounter = 0;
|
|
2014
|
-
|
|
2015
|
-
|
|
2212
|
+
// Strip trailing slashes so appending `/api/ws` can't yield `//api/ws`,
|
|
2213
|
+
// which warp's exact path match (`api / ws`) would reject.
|
|
2214
|
+
this.wsURL = stripTrailingSlashes(wsURL);
|
|
2215
|
+
this.tokenProvider = typeof token === "function" ? token : () => token;
|
|
2216
|
+
this.autoReconnect = options.autoReconnect ?? true;
|
|
2217
|
+
this.reconnectInitialDelayMs = options.reconnectInitialDelayMs ?? 200;
|
|
2218
|
+
this.reconnectMaxDelayMs = options.reconnectMaxDelayMs ?? 5000;
|
|
2219
|
+
this.reconnectMaxAttempts = options.reconnectMaxAttempts ?? 0;
|
|
2220
|
+
this.requestTimeoutMs = options.requestTimeoutMs ?? 30000;
|
|
2016
2221
|
}
|
|
2017
2222
|
genMessageId() {
|
|
2018
2223
|
const counter = this.messageCounter++;
|
|
2019
2224
|
return `${Date.now()}-${counter}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2020
2225
|
}
|
|
2021
2226
|
/**
|
|
2022
|
-
*
|
|
2227
|
+
* Compute the capped exponential backoff (with jitter) for a reconnect
|
|
2228
|
+
* attempt. attempt 0 -> ~initial, growing x2 each time up to the max cap.
|
|
2229
|
+
* Jitter is +/-25% to avoid thundering-herd reconnect storms.
|
|
2230
|
+
* @internal exposed for testing
|
|
2231
|
+
*/
|
|
2232
|
+
computeBackoff(attempt) {
|
|
2233
|
+
const base = Math.min(this.reconnectInitialDelayMs * 2 ** attempt, this.reconnectMaxDelayMs);
|
|
2234
|
+
const jitter = base * 0.25 * (Math.random() * 2 - 1);
|
|
2235
|
+
return Math.max(0, Math.round(base + jitter));
|
|
2236
|
+
}
|
|
2237
|
+
/**
|
|
2238
|
+
* Connect and start the dispatcher. Re-evaluates the token provider so the
|
|
2239
|
+
* current/refreshed token is used for this socket.
|
|
2023
2240
|
*/
|
|
2024
2241
|
async ensureConnected() {
|
|
2025
2242
|
if (this.ws && this.dispatcherRunning)
|
|
2026
2243
|
return;
|
|
2244
|
+
// Coalesce concurrent connect attempts onto a single in-flight promise.
|
|
2245
|
+
if (this.connectPromise)
|
|
2246
|
+
return this.connectPromise;
|
|
2247
|
+
// Clear the intentional-close flag only for user-initiated connects. During
|
|
2248
|
+
// a reconnect cycle this stays untouched so a concurrent close() can't be
|
|
2249
|
+
// undone and have the reconnect proceed against the user's intent.
|
|
2250
|
+
if (!this.reconnecting)
|
|
2251
|
+
this.closed = false;
|
|
2252
|
+
this.connectPromise = this.openSocket().finally(() => {
|
|
2253
|
+
this.connectPromise = null;
|
|
2254
|
+
});
|
|
2255
|
+
return this.connectPromise;
|
|
2256
|
+
}
|
|
2257
|
+
async openSocket() {
|
|
2027
2258
|
const WebSocket = (await Promise.resolve().then(() => __importStar(require("ws")))).default;
|
|
2028
2259
|
let url = this.wsURL;
|
|
2029
2260
|
if (!url.endsWith("/api/ws")) {
|
|
2030
2261
|
url += "/api/ws";
|
|
2031
2262
|
}
|
|
2263
|
+
// Re-evaluate the token on every (re)connect — never a stale snapshot.
|
|
2264
|
+
const token = await this.tokenProvider();
|
|
2265
|
+
if (!token) {
|
|
2266
|
+
// Fail fast with a clear error instead of sending `Bearer null`, which
|
|
2267
|
+
// would surface as a confusing 401 from the server.
|
|
2268
|
+
throw new Error("WebSocket auth token is unavailable (the token provider returned null/empty)");
|
|
2269
|
+
}
|
|
2032
2270
|
this.ws = new WebSocket(url, {
|
|
2033
2271
|
headers: {
|
|
2034
|
-
Authorization: `Bearer ${
|
|
2272
|
+
Authorization: `Bearer ${token}`,
|
|
2035
2273
|
},
|
|
2036
2274
|
});
|
|
2037
2275
|
await new Promise((resolve, reject) => {
|
|
2038
2276
|
this.ws.on("open", () => resolve());
|
|
2039
2277
|
this.ws.on("error", (err) => reject(err));
|
|
2040
2278
|
});
|
|
2279
|
+
// Negotiate the wire format before the dispatcher starts so the Welcome is
|
|
2280
|
+
// consumed here (not by routeMessage), and before any real frame is sent
|
|
2281
|
+
// (resubscribeAll runs only after this resolves).
|
|
2282
|
+
await this.negotiateFormat(this.ws);
|
|
2041
2283
|
this.spawnDispatcher();
|
|
2042
2284
|
}
|
|
2285
|
+
/**
|
|
2286
|
+
* Additive capability handshake: offer msgpack and, if the server Welcomes
|
|
2287
|
+
* it, switch this connection to binary msgpack frames; otherwise stay on JSON
|
|
2288
|
+
* text. The Welcome (a text frame) is read with a one-shot listener and a
|
|
2289
|
+
* timeout so an older server that never answers — or answers with an Error —
|
|
2290
|
+
* simply leaves the connection on JSON. Best-effort and never throws: JSON
|
|
2291
|
+
* always works.
|
|
2292
|
+
*/
|
|
2293
|
+
async negotiateFormat(socket) {
|
|
2294
|
+
this.binary = false;
|
|
2295
|
+
const welcome = await new Promise((resolve) => {
|
|
2296
|
+
const onMsg = (data) => {
|
|
2297
|
+
clearTimeout(timer);
|
|
2298
|
+
try {
|
|
2299
|
+
resolve(JSON.parse(data.toString()));
|
|
2300
|
+
}
|
|
2301
|
+
catch {
|
|
2302
|
+
resolve(null);
|
|
2303
|
+
}
|
|
2304
|
+
};
|
|
2305
|
+
// Only caps the wait when no Welcome comes (a silent/old server); the
|
|
2306
|
+
// listener resolves immediately when it does arrive. 2s comfortably exceeds
|
|
2307
|
+
// the handshake round-trip even on high-latency links.
|
|
2308
|
+
const timer = setTimeout(() => {
|
|
2309
|
+
socket.off("message", onMsg);
|
|
2310
|
+
resolve(null);
|
|
2311
|
+
}, 2000);
|
|
2312
|
+
socket.once("message", onMsg);
|
|
2313
|
+
try {
|
|
2314
|
+
socket.send(JSON.stringify({
|
|
2315
|
+
type: "Hello",
|
|
2316
|
+
payload: { formats: ["msgpack", "json"] },
|
|
2317
|
+
}));
|
|
2318
|
+
}
|
|
2319
|
+
catch {
|
|
2320
|
+
clearTimeout(timer);
|
|
2321
|
+
socket.off("message", onMsg);
|
|
2322
|
+
resolve(null);
|
|
2323
|
+
}
|
|
2324
|
+
});
|
|
2325
|
+
if (welcome &&
|
|
2326
|
+
welcome.type === "Welcome" &&
|
|
2327
|
+
welcome.payload?.format === "msgpack") {
|
|
2328
|
+
this.binary = true;
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Send a request object on the active socket using the negotiated format:
|
|
2333
|
+
* binary msgpack when the server Welcomed it, JSON text otherwise. The single
|
|
2334
|
+
* write point so every request honors the negotiated transport.
|
|
2335
|
+
*/
|
|
2336
|
+
sendFrame(obj) {
|
|
2337
|
+
this.ws.send(this.binary ? (0, msgpack_1.encode)(obj) : JSON.stringify(obj));
|
|
2338
|
+
}
|
|
2043
2339
|
spawnDispatcher() {
|
|
2044
2340
|
if (this.dispatcherRunning)
|
|
2045
2341
|
return;
|
|
2046
2342
|
this.dispatcherRunning = true;
|
|
2047
|
-
this.
|
|
2343
|
+
// Capture the socket this dispatcher is bound to. After a reconnect, the old
|
|
2344
|
+
// socket may still emit late close/error events; ignore them so they don't
|
|
2345
|
+
// tear down the replacement connection.
|
|
2346
|
+
const socket = this.ws;
|
|
2347
|
+
socket.on("message", (data, isBinary) => {
|
|
2348
|
+
if (this.ws !== socket)
|
|
2349
|
+
return;
|
|
2048
2350
|
try {
|
|
2049
|
-
|
|
2351
|
+
// A binary frame is msgpack (the server only sends binary once it has
|
|
2352
|
+
// Welcomed msgpack); a text frame is JSON. Decode by frame type so the
|
|
2353
|
+
// routed value is identical regardless of negotiated transport.
|
|
2354
|
+
const msg = isBinary
|
|
2355
|
+
? (0, msgpack_1.decode)(data)
|
|
2356
|
+
: JSON.parse(data.toString());
|
|
2050
2357
|
this.routeMessage(msg);
|
|
2051
2358
|
}
|
|
2052
2359
|
catch {
|
|
2053
2360
|
// Ignore malformed messages
|
|
2054
2361
|
}
|
|
2055
2362
|
});
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
this.
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2363
|
+
// Both "close" and "error" mean this socket is dead. ws typically emits
|
|
2364
|
+
// "error" followed by "close", so route both through one handler and let the
|
|
2365
|
+
// identity check dedupe: the first to fire nulls this.ws, the second no-ops.
|
|
2366
|
+
const onDown = () => {
|
|
2367
|
+
if (this.ws !== socket)
|
|
2368
|
+
return;
|
|
2369
|
+
this.handleDisconnect();
|
|
2370
|
+
};
|
|
2371
|
+
socket.on("close", onDown);
|
|
2372
|
+
socket.on("error", onDown);
|
|
2373
|
+
}
|
|
2374
|
+
/**
|
|
2375
|
+
* Reject in-flight requests and tear down the dead socket. If the close was
|
|
2376
|
+
* unexpected (not an explicit `close()`) and auto-reconnect is enabled,
|
|
2377
|
+
* schedule a reconnect that re-sends the active subscriptions.
|
|
2378
|
+
*/
|
|
2379
|
+
handleDisconnect() {
|
|
2380
|
+
this.dispatcherRunning = false;
|
|
2381
|
+
this.ws = null;
|
|
2382
|
+
// Reject all in-flight pending requests so callers don't hang forever.
|
|
2383
|
+
for (const [, pending] of this.pendingRequests) {
|
|
2384
|
+
if (pending.timer)
|
|
2385
|
+
clearTimeout(pending.timer);
|
|
2386
|
+
pending.reject(new Error("WebSocket connection closed"));
|
|
2387
|
+
}
|
|
2388
|
+
this.pendingRequests.clear();
|
|
2389
|
+
if (this.registerToolsAck) {
|
|
2390
|
+
this.registerToolsAck.reject(new Error("WebSocket connection closed"));
|
|
2391
|
+
this.registerToolsAck = null;
|
|
2392
|
+
}
|
|
2393
|
+
// Close all chat streams (they are one-shot; not replayed on reconnect).
|
|
2394
|
+
for (const [, stream] of this.chatStreams) {
|
|
2395
|
+
stream.emit("event", { type: "error", error: "Connection closed" });
|
|
2396
|
+
stream.close();
|
|
2397
|
+
}
|
|
2398
|
+
this.chatStreams.clear();
|
|
2399
|
+
const shouldReconnect = this.autoReconnect && !this.closed && this.subscriptionParams.size > 0;
|
|
2400
|
+
if (shouldReconnect) {
|
|
2401
|
+
this.scheduleReconnect();
|
|
2402
|
+
}
|
|
2403
|
+
else {
|
|
2404
|
+
// No reconnect: tear down subscriptions too.
|
|
2070
2405
|
for (const [, stream] of this.subscriptions) {
|
|
2071
2406
|
stream.close();
|
|
2072
2407
|
}
|
|
2073
2408
|
this.subscriptions.clear();
|
|
2074
|
-
this.
|
|
2075
|
-
}
|
|
2409
|
+
this.subscriptionParams.clear();
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
/**
|
|
2413
|
+
* Reconnect with capped exponential backoff + jitter, then re-send the
|
|
2414
|
+
* subscribe messages for every active subscription so the SAME EventStream
|
|
2415
|
+
* keeps delivering mutations after a transient drop.
|
|
2416
|
+
*/
|
|
2417
|
+
scheduleReconnect() {
|
|
2418
|
+
if (this.reconnecting)
|
|
2419
|
+
return;
|
|
2420
|
+
this.reconnecting = true;
|
|
2421
|
+
const attempt = async () => {
|
|
2422
|
+
// Bail if the client was closed, or if every subscription was torn down
|
|
2423
|
+
// (e.g. unsubscribed) while a reconnect was in-flight — reconnect was only
|
|
2424
|
+
// opted into because subscriptions existed, so there's nothing to restore.
|
|
2425
|
+
if (this.closed || this.subscriptionParams.size === 0) {
|
|
2426
|
+
this.reconnecting = false;
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2429
|
+
if (this.reconnectMaxAttempts > 0 &&
|
|
2430
|
+
this.reconnectAttempts >= this.reconnectMaxAttempts) {
|
|
2431
|
+
// Give up: tear down subscriptions and notify consumers.
|
|
2432
|
+
this.reconnecting = false;
|
|
2433
|
+
for (const [, stream] of this.subscriptions) {
|
|
2434
|
+
stream.emit("error", "WebSocket reconnect failed");
|
|
2435
|
+
stream.close();
|
|
2436
|
+
}
|
|
2437
|
+
this.subscriptions.clear();
|
|
2438
|
+
this.subscriptionParams.clear();
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
const delay = this.computeBackoff(this.reconnectAttempts);
|
|
2442
|
+
this.reconnectAttempts++;
|
|
2443
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
2444
|
+
// Re-check after the backoff delay: close() or a full unsubscribe may have
|
|
2445
|
+
// happened while we were waiting, in which case skip reopening the socket.
|
|
2446
|
+
if (this.closed || this.subscriptionParams.size === 0) {
|
|
2447
|
+
this.reconnecting = false;
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
try {
|
|
2451
|
+
// Route through ensureConnected() so a request-driven connect and this
|
|
2452
|
+
// reconnect share one in-flight connectPromise/socket — opening two live
|
|
2453
|
+
// sockets would misroute responses.
|
|
2454
|
+
await this.ensureConnected();
|
|
2455
|
+
// close() may have been called while the connect was in-flight; if so,
|
|
2456
|
+
// tear down the freshly-opened socket instead of leaving it orphaned.
|
|
2457
|
+
if (this.closed) {
|
|
2458
|
+
try {
|
|
2459
|
+
this.ws?.close?.();
|
|
2460
|
+
}
|
|
2461
|
+
catch {
|
|
2462
|
+
/* already closing */
|
|
2463
|
+
}
|
|
2464
|
+
this.ws = null;
|
|
2465
|
+
this.dispatcherRunning = false;
|
|
2466
|
+
this.reconnecting = false;
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2469
|
+
// Success — reset backoff and replay subscriptions.
|
|
2470
|
+
this.reconnectAttempts = 0;
|
|
2471
|
+
this.reconnecting = false;
|
|
2472
|
+
await this.resubscribeAll();
|
|
2473
|
+
}
|
|
2474
|
+
catch {
|
|
2475
|
+
// Connect failed — schedule the next attempt WITHOUT recursive await so
|
|
2476
|
+
// a prolonged outage can't build an unbounded promise chain.
|
|
2477
|
+
setTimeout(() => void attempt(), 0);
|
|
2478
|
+
}
|
|
2479
|
+
};
|
|
2480
|
+
void attempt();
|
|
2481
|
+
}
|
|
2482
|
+
/** Re-send Subscribe frames for every tracked subscription after a reconnect. */
|
|
2483
|
+
async resubscribeAll() {
|
|
2484
|
+
for (const [collection, options] of this.subscriptionParams) {
|
|
2485
|
+
const stream = this.subscriptions.get(collection);
|
|
2486
|
+
if (!stream || stream.closed)
|
|
2487
|
+
continue;
|
|
2488
|
+
const messageId = this.genMessageId();
|
|
2489
|
+
const request = {
|
|
2490
|
+
type: "Subscribe",
|
|
2491
|
+
messageId,
|
|
2492
|
+
payload: {
|
|
2493
|
+
collection,
|
|
2494
|
+
...(options?.filterField && { filter_field: options.filterField }),
|
|
2495
|
+
...(options?.filterValue && { filter_value: options.filterValue }),
|
|
2496
|
+
},
|
|
2497
|
+
};
|
|
2498
|
+
try {
|
|
2499
|
+
await this.sendRequest(request);
|
|
2500
|
+
}
|
|
2501
|
+
catch {
|
|
2502
|
+
// If the re-subscribe ack fails, leave it tracked; the next
|
|
2503
|
+
// disconnect/reconnect cycle will attempt it again.
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2076
2506
|
}
|
|
2077
2507
|
routeMessage(msg) {
|
|
2078
2508
|
switch (msg.type) {
|
|
@@ -2085,15 +2515,7 @@ class WebSocketClient {
|
|
|
2085
2515
|
msg.payload?.messageId;
|
|
2086
2516
|
let matched = false;
|
|
2087
2517
|
if (messageId && this.pendingRequests.has(messageId)) {
|
|
2088
|
-
|
|
2089
|
-
this.pendingRequests.delete(messageId);
|
|
2090
|
-
if (msg.type === "Error") {
|
|
2091
|
-
pending.reject(new Error(msg.message || "Unknown error"));
|
|
2092
|
-
}
|
|
2093
|
-
else {
|
|
2094
|
-
pending.resolve(msg.payload);
|
|
2095
|
-
}
|
|
2096
|
-
matched = true;
|
|
2518
|
+
matched = this.settlePending(messageId, msg.type === "Error", msg);
|
|
2097
2519
|
}
|
|
2098
2520
|
if (!matched && this.registerToolsAck) {
|
|
2099
2521
|
const ack = this.registerToolsAck;
|
|
@@ -2106,19 +2528,14 @@ class WebSocketClient {
|
|
|
2106
2528
|
}
|
|
2107
2529
|
matched = true;
|
|
2108
2530
|
}
|
|
2109
|
-
// Server doesn't echo messageId — if there's exactly one pending
|
|
2531
|
+
// Server doesn't echo messageId at all — if there's exactly one pending
|
|
2110
2532
|
// request, deliver the response to it (sequential request/response).
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
this.pendingRequests.
|
|
2116
|
-
|
|
2117
|
-
pending.reject(new Error(msg.message || "Unknown error"));
|
|
2118
|
-
}
|
|
2119
|
-
else {
|
|
2120
|
-
pending.resolve(msg.payload);
|
|
2121
|
-
}
|
|
2533
|
+
// Only when messageId is absent: a present-but-unmatched id means a late
|
|
2534
|
+
// response for an already-settled/timed-out request, which must NOT be
|
|
2535
|
+
// misrouted to whatever request happens to still be pending.
|
|
2536
|
+
if (!matched && !messageId && this.pendingRequests.size === 1) {
|
|
2537
|
+
const key = this.pendingRequests.keys().next().value;
|
|
2538
|
+
this.settlePending(key, msg.type === "Error", msg);
|
|
2122
2539
|
}
|
|
2123
2540
|
break;
|
|
2124
2541
|
}
|
|
@@ -2205,16 +2622,46 @@ class WebSocketClient {
|
|
|
2205
2622
|
await this.ensureConnected();
|
|
2206
2623
|
const messageId = request.messageId || request.message_id;
|
|
2207
2624
|
return new Promise((resolve, reject) => {
|
|
2208
|
-
|
|
2625
|
+
// Per-request timeout: reject if no response arrives in the window so a
|
|
2626
|
+
// dropped/never-answered response can't leave the promise pending forever.
|
|
2627
|
+
let timer;
|
|
2628
|
+
if (this.requestTimeoutMs > 0) {
|
|
2629
|
+
timer = setTimeout(() => {
|
|
2630
|
+
if (this.pendingRequests.delete(messageId)) {
|
|
2631
|
+
reject(new Error(`WebSocket request "${request.type}" timed out after ${this.requestTimeoutMs}ms`));
|
|
2632
|
+
}
|
|
2633
|
+
}, this.requestTimeoutMs);
|
|
2634
|
+
// Don't keep the process alive just for this timer.
|
|
2635
|
+
timer?.unref?.();
|
|
2636
|
+
}
|
|
2637
|
+
this.pendingRequests.set(messageId, { resolve, reject, timer });
|
|
2209
2638
|
try {
|
|
2210
|
-
this.
|
|
2639
|
+
this.sendFrame(request);
|
|
2211
2640
|
}
|
|
2212
2641
|
catch (err) {
|
|
2213
2642
|
this.pendingRequests.delete(messageId);
|
|
2643
|
+
if (timer)
|
|
2644
|
+
clearTimeout(timer);
|
|
2214
2645
|
reject(err);
|
|
2215
2646
|
}
|
|
2216
2647
|
});
|
|
2217
2648
|
}
|
|
2649
|
+
/** Resolve/reject a pending request, clearing its timeout timer. */
|
|
2650
|
+
settlePending(messageId, isError, msg) {
|
|
2651
|
+
const pending = this.pendingRequests.get(messageId);
|
|
2652
|
+
if (!pending)
|
|
2653
|
+
return false;
|
|
2654
|
+
this.pendingRequests.delete(messageId);
|
|
2655
|
+
if (pending.timer)
|
|
2656
|
+
clearTimeout(pending.timer);
|
|
2657
|
+
if (isError) {
|
|
2658
|
+
pending.reject(new Error(msg.message || "Unknown error"));
|
|
2659
|
+
}
|
|
2660
|
+
else {
|
|
2661
|
+
pending.resolve(msg.payload);
|
|
2662
|
+
}
|
|
2663
|
+
return true;
|
|
2664
|
+
}
|
|
2218
2665
|
/**
|
|
2219
2666
|
* Find all records in a collection via WebSocket.
|
|
2220
2667
|
*/
|
|
@@ -2239,6 +2686,8 @@ class WebSocketClient {
|
|
|
2239
2686
|
const messageId = this.genMessageId();
|
|
2240
2687
|
const stream = new EventStream();
|
|
2241
2688
|
this.subscriptions.set(collection, stream);
|
|
2689
|
+
// Track params so the subscription can be replayed on reconnect.
|
|
2690
|
+
this.subscriptionParams.set(collection, options);
|
|
2242
2691
|
const request = {
|
|
2243
2692
|
type: "Subscribe",
|
|
2244
2693
|
messageId,
|
|
@@ -2254,10 +2703,44 @@ class WebSocketClient {
|
|
|
2254
2703
|
}
|
|
2255
2704
|
catch (err) {
|
|
2256
2705
|
this.subscriptions.delete(collection);
|
|
2706
|
+
this.subscriptionParams.delete(collection);
|
|
2257
2707
|
throw err;
|
|
2258
2708
|
}
|
|
2259
2709
|
return stream;
|
|
2260
2710
|
}
|
|
2711
|
+
/**
|
|
2712
|
+
* Unsubscribe from a collection's mutation notifications. This is an
|
|
2713
|
+
* intentional teardown, so the subscription is NOT replayed on reconnect.
|
|
2714
|
+
*/
|
|
2715
|
+
unsubscribe(collection) {
|
|
2716
|
+
const stream = this.subscriptions.get(collection);
|
|
2717
|
+
this.subscriptions.delete(collection);
|
|
2718
|
+
this.subscriptionParams.delete(collection);
|
|
2719
|
+
if (stream && !stream.closed) {
|
|
2720
|
+
stream.close();
|
|
2721
|
+
}
|
|
2722
|
+
// Best-effort: tell the server to stop streaming this collection (the
|
|
2723
|
+
// server already handles an Unsubscribe frame). If the socket isn't open
|
|
2724
|
+
// the local teardown above suffices, since the server drops subscriptions
|
|
2725
|
+
// when the connection closes. A unique messageId is attached so the
|
|
2726
|
+
// server's Success ack carries a correlation id: it has no pending request
|
|
2727
|
+
// to match, so it is simply ignored — and because the id is present, the
|
|
2728
|
+
// single-pending fallback can't misroute it to an unrelated request.
|
|
2729
|
+
if (this.ws && this.ws.readyState === 1 /* WebSocket.OPEN */) {
|
|
2730
|
+
try {
|
|
2731
|
+
this.sendFrame({
|
|
2732
|
+
type: "Unsubscribe",
|
|
2733
|
+
messageId: this.genMessageId(),
|
|
2734
|
+
payload: { collection },
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2737
|
+
catch {
|
|
2738
|
+
// Best-effort: the socket can close between the readyState check and the
|
|
2739
|
+
// send. Local teardown already happened, so swallow the failure rather
|
|
2740
|
+
// than throw out of a void teardown call.
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
}
|
|
2261
2744
|
/**
|
|
2262
2745
|
* Send a chat message and receive a streaming response.
|
|
2263
2746
|
* Returns an EventStream that emits "event" with ChatStreamEvent objects.
|
|
@@ -2285,7 +2768,7 @@ class WebSocketClient {
|
|
|
2285
2768
|
...(options?.excludeTools && { exclude_tools: options.excludeTools }),
|
|
2286
2769
|
},
|
|
2287
2770
|
};
|
|
2288
|
-
this.
|
|
2771
|
+
this.sendFrame(request);
|
|
2289
2772
|
return stream;
|
|
2290
2773
|
}
|
|
2291
2774
|
/**
|
|
@@ -2305,7 +2788,7 @@ class WebSocketClient {
|
|
|
2305
2788
|
resolve: () => resolve(),
|
|
2306
2789
|
reject: (err) => reject(err),
|
|
2307
2790
|
};
|
|
2308
|
-
this.
|
|
2791
|
+
this.sendFrame(request);
|
|
2309
2792
|
});
|
|
2310
2793
|
}
|
|
2311
2794
|
/**
|
|
@@ -2323,7 +2806,24 @@ class WebSocketClient {
|
|
|
2323
2806
|
...(error !== undefined && { error }),
|
|
2324
2807
|
},
|
|
2325
2808
|
};
|
|
2326
|
-
this.
|
|
2809
|
+
this.sendFrame(request);
|
|
2810
|
+
}
|
|
2811
|
+
/**
|
|
2812
|
+
* Cancel an in-flight streaming chat. Fire-and-forget: tells the server to
|
|
2813
|
+
* stop generating tokens for the given chat.
|
|
2814
|
+
*/
|
|
2815
|
+
async cancelChat(chatId) {
|
|
2816
|
+
await this.ensureConnected();
|
|
2817
|
+
// Attach a unique messageId (same generator as unsubscribe). Any Success ack
|
|
2818
|
+
// from the server then carries a correlation id: it has no pending request to
|
|
2819
|
+
// match, so it is ignored — and because the id is present, the dispatcher's
|
|
2820
|
+
// single-pending fallback can't misroute the ack to an unrelated request.
|
|
2821
|
+
const request = {
|
|
2822
|
+
type: "CancelChat",
|
|
2823
|
+
messageId: this.genMessageId(),
|
|
2824
|
+
payload: { chat_id: chatId },
|
|
2825
|
+
};
|
|
2826
|
+
this.sendFrame(request);
|
|
2327
2827
|
}
|
|
2328
2828
|
/**
|
|
2329
2829
|
* Stateless raw LLM completion via WebSocket.
|
|
@@ -2481,8 +2981,42 @@ class WebSocketClient {
|
|
|
2481
2981
|
}
|
|
2482
2982
|
/**
|
|
2483
2983
|
* Close the WebSocket connection.
|
|
2984
|
+
*
|
|
2985
|
+
* This is an INTENTIONAL close: it disables auto-reconnect, rejects any
|
|
2986
|
+
* in-flight requests, and tears down all subscriptions/chat streams so
|
|
2987
|
+
* nothing is replayed afterward.
|
|
2484
2988
|
*/
|
|
2485
2989
|
close() {
|
|
2990
|
+
// Mark intentional so the close handler doesn't trigger a reconnect.
|
|
2991
|
+
this.closed = true;
|
|
2992
|
+
this.reconnecting = false;
|
|
2993
|
+
// Reject any in-flight requests and clear their timers.
|
|
2994
|
+
for (const [, pending] of this.pendingRequests) {
|
|
2995
|
+
if (pending.timer)
|
|
2996
|
+
clearTimeout(pending.timer);
|
|
2997
|
+
pending.reject(new Error("WebSocket connection closed"));
|
|
2998
|
+
}
|
|
2999
|
+
this.pendingRequests.clear();
|
|
3000
|
+
// Tear down subscriptions + their replay bookkeeping.
|
|
3001
|
+
for (const [, stream] of this.subscriptions) {
|
|
3002
|
+
if (!stream.closed)
|
|
3003
|
+
stream.close();
|
|
3004
|
+
}
|
|
3005
|
+
this.subscriptions.clear();
|
|
3006
|
+
this.subscriptionParams.clear();
|
|
3007
|
+
// Reject any in-flight tool registration ack. Done here (not just in the
|
|
3008
|
+
// ws "close" handler) so it's cleaned up even when this.ws is already null.
|
|
3009
|
+
if (this.registerToolsAck) {
|
|
3010
|
+
this.registerToolsAck.reject(new Error("WebSocket connection closed"));
|
|
3011
|
+
this.registerToolsAck = null;
|
|
3012
|
+
}
|
|
3013
|
+
// Tear down chat streams immediately; they are one-shot and not replayed,
|
|
3014
|
+
// and we can't rely on the underlying ws "close" event having fired.
|
|
3015
|
+
for (const [, stream] of this.chatStreams) {
|
|
3016
|
+
stream.emit("event", { type: "error", error: "Connection closed" });
|
|
3017
|
+
stream.close();
|
|
3018
|
+
}
|
|
3019
|
+
this.chatStreams.clear();
|
|
2486
3020
|
if (this.ws) {
|
|
2487
3021
|
this.ws.close();
|
|
2488
3022
|
this.ws = null;
|