@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.
@@ -7,6 +7,7 @@
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
8
  const vitest_1 = require("vitest");
9
9
  const ws_1 = require("ws");
10
+ const msgpack_1 = require("@msgpack/msgpack");
10
11
  const client_1 = require("./client");
11
12
  // ============================================================================
12
13
  // Test Helpers
@@ -14,11 +15,48 @@ const client_1 = require("./client");
14
15
  let wss;
15
16
  let port;
16
17
  let serverConnections = [];
18
+ // Format the mock server Welcomes during the handshake. Default "json" keeps
19
+ // the existing tests on the text transport they assert against; the binary
20
+ // negotiation test sets "msgpack" before connecting.
21
+ let welcomeFormat = "json";
22
+ const recvState = new Map();
17
23
  function startServer() {
18
24
  return new Promise((resolve) => {
19
25
  wss = new ws_1.WebSocketServer({ port: 0 });
20
26
  wss.on("connection", (ws) => {
21
27
  serverConnections.push(ws);
28
+ const state = {
29
+ queue: [],
30
+ waiters: [],
31
+ handshakeDone: false,
32
+ lastBinary: false,
33
+ };
34
+ recvState.set(ws, state);
35
+ ws.on("message", (data, isBinary) => {
36
+ let msg;
37
+ try {
38
+ msg = isBinary ? (0, msgpack_1.decode)(data) : JSON.parse(data.toString());
39
+ }
40
+ catch {
41
+ return;
42
+ }
43
+ // Answer the additive Hello handshake exactly like the real server, and
44
+ // never surface it to the test. The Hello is always text.
45
+ if (!state.handshakeDone && msg?.type === "Hello") {
46
+ state.handshakeDone = true;
47
+ ws.send(JSON.stringify({
48
+ type: "Welcome",
49
+ payload: { format: welcomeFormat },
50
+ }));
51
+ return;
52
+ }
53
+ state.lastBinary = isBinary;
54
+ const waiter = state.waiters.shift();
55
+ if (waiter)
56
+ waiter(msg);
57
+ else
58
+ state.queue.push(msg);
59
+ });
22
60
  });
23
61
  wss.on("listening", () => {
24
62
  const addr = wss.address();
@@ -29,12 +67,15 @@ function startServer() {
29
67
  function getLastConnection() {
30
68
  return serverConnections[serverConnections.length - 1];
31
69
  }
70
+ // Resolve with the next real (post-handshake) frame, draining any already
71
+ // buffered. Decoded per the negotiated frame type, so it works for both JSON
72
+ // and msgpack connections without the caller caring which.
32
73
  function waitForMessage(ws) {
33
- return new Promise((resolve) => {
34
- ws.once("message", (data) => {
35
- resolve(JSON.parse(data.toString()));
36
- });
37
- });
74
+ const state = recvState.get(ws);
75
+ const queued = state.queue.shift();
76
+ if (queued !== undefined)
77
+ return Promise.resolve(queued);
78
+ return new Promise((resolve) => state.waiters.push(resolve));
38
79
  }
39
80
  // ============================================================================
40
81
  // Tests
@@ -42,6 +83,8 @@ function waitForMessage(ws) {
42
83
  (0, vitest_1.describe)("WebSocketClient", () => {
43
84
  (0, vitest_1.beforeEach)(async () => {
44
85
  serverConnections = [];
86
+ recvState.clear();
87
+ welcomeFormat = "json";
45
88
  port = await startServer();
46
89
  });
47
90
  (0, vitest_1.afterEach)(() => {
@@ -91,6 +134,31 @@ function waitForMessage(ws) {
91
134
  });
92
135
  });
93
136
  // --------------------------------------------------------------------------
137
+ // URL normalization
138
+ // --------------------------------------------------------------------------
139
+ (0, vitest_1.describe)("URL normalization", () => {
140
+ function captureRequestPath() {
141
+ return new Promise((resolve) => {
142
+ wss.once("connection", (_ws, req) => resolve(req.url ?? ""));
143
+ });
144
+ }
145
+ (0, vitest_1.it)("appends /api/ws without a double slash when the base URL has a trailing slash", async () => {
146
+ const pathPromise = captureRequestPath();
147
+ const client = new client_1.WebSocketClient(`ws://localhost:${port}/`, "test-token");
148
+ // Trigger a connect; we only assert the path the server receives.
149
+ client.findAll("users").catch(() => { });
150
+ (0, vitest_1.expect)(await pathPromise).toBe("/api/ws");
151
+ client.close();
152
+ });
153
+ (0, vitest_1.it)("does not duplicate /api/ws when the URL already ends with it plus a trailing slash", async () => {
154
+ const pathPromise = captureRequestPath();
155
+ const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws/`, "test-token");
156
+ client.findAll("users").catch(() => { });
157
+ (0, vitest_1.expect)(await pathPromise).toBe("/api/ws");
158
+ client.close();
159
+ });
160
+ });
161
+ // --------------------------------------------------------------------------
94
162
  // subscribe
95
163
  // --------------------------------------------------------------------------
96
164
  (0, vitest_1.describe)("subscribe", () => {
@@ -292,6 +360,70 @@ function waitForMessage(ws) {
292
360
  });
293
361
  });
294
362
  // --------------------------------------------------------------------------
363
+ // unsubscribe
364
+ // --------------------------------------------------------------------------
365
+ (0, vitest_1.describe)("unsubscribe", () => {
366
+ (0, vitest_1.it)("sends an Unsubscribe frame to the server", async () => {
367
+ const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token");
368
+ const streamPromise = client.subscribe("orders");
369
+ await new Promise((r) => wss.once("connection", r));
370
+ const ws = getLastConnection();
371
+ const subMsg = await waitForMessage(ws);
372
+ (0, vitest_1.expect)(subMsg.type).toBe("Subscribe");
373
+ ws.send(JSON.stringify({
374
+ type: "Success",
375
+ payload: { message_id: subMsg.messageId, status: "subscribed" },
376
+ }));
377
+ await streamPromise;
378
+ const unsubMsg = waitForMessage(ws);
379
+ client.unsubscribe("orders");
380
+ const sent = await unsubMsg;
381
+ (0, vitest_1.expect)(sent.type).toBe("Unsubscribe");
382
+ (0, vitest_1.expect)(sent.payload.collection).toBe("orders");
383
+ client.close();
384
+ });
385
+ });
386
+ // cancelChat
387
+ // --------------------------------------------------------------------------
388
+ (0, vitest_1.describe)("cancelChat", () => {
389
+ (0, vitest_1.it)("sends CancelChat frame", async () => {
390
+ const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token");
391
+ // Open the connection via a chat stream first.
392
+ const streamPromise = client.chatSend("chat-1", "test");
393
+ await new Promise((r) => wss.once("connection", r));
394
+ const ws = getLastConnection();
395
+ await waitForMessage(ws); // ChatSend
396
+ await streamPromise;
397
+ const cancelMsg = waitForMessage(ws);
398
+ await client.cancelChat("chat-1");
399
+ const sent = await cancelMsg;
400
+ (0, vitest_1.expect)(sent.type).toBe("CancelChat");
401
+ (0, vitest_1.expect)(sent.payload.chat_id).toBe("chat-1");
402
+ // A correlation id must be attached so a Success ack can't be misrouted by
403
+ // the dispatcher's single-pending fallback.
404
+ (0, vitest_1.expect)(typeof sent.messageId).toBe("string");
405
+ (0, vitest_1.expect)(sent.messageId.length).toBeGreaterThan(0);
406
+ client.close();
407
+ });
408
+ (0, vitest_1.it)("attaches a unique messageId on each CancelChat frame", async () => {
409
+ const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token");
410
+ const streamPromise = client.chatSend("chat-1", "test");
411
+ await new Promise((r) => wss.once("connection", r));
412
+ const ws = getLastConnection();
413
+ await waitForMessage(ws); // ChatSend
414
+ await streamPromise;
415
+ const first = waitForMessage(ws);
416
+ await client.cancelChat("chat-1");
417
+ const firstSent = await first;
418
+ const second = waitForMessage(ws);
419
+ await client.cancelChat("chat-1");
420
+ const secondSent = await second;
421
+ (0, vitest_1.expect)(firstSent.messageId).toBeTruthy();
422
+ (0, vitest_1.expect)(secondSent.messageId).toBeTruthy();
423
+ (0, vitest_1.expect)(firstSent.messageId).not.toBe(secondSent.messageId);
424
+ client.close();
425
+ });
426
+ });
295
427
  // sendToolResult
296
428
  // --------------------------------------------------------------------------
297
429
  (0, vitest_1.describe)("sendToolResult", () => {
@@ -493,4 +625,206 @@ function waitForMessage(ws) {
493
625
  client.close();
494
626
  });
495
627
  });
628
+ // --------------------------------------------------------------------------
629
+ // Per-request timeout
630
+ // --------------------------------------------------------------------------
631
+ (0, vitest_1.describe)("per-request timeout", () => {
632
+ (0, vitest_1.it)("rejects a pending request when no response arrives", async () => {
633
+ const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token", { requestTimeoutMs: 50, autoReconnect: false });
634
+ const resultPromise = client.findAll("users");
635
+ await new Promise((r) => wss.once("connection", r));
636
+ const ws = getLastConnection();
637
+ await waitForMessage(ws); // receive FindAll but never respond
638
+ await (0, vitest_1.expect)(resultPromise).rejects.toThrow(/timed out after 50ms/);
639
+ client.close();
640
+ });
641
+ (0, vitest_1.it)("does not time out when a response arrives in time", async () => {
642
+ const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token", { requestTimeoutMs: 500, autoReconnect: false });
643
+ const resultPromise = client.findAll("users");
644
+ await new Promise((r) => wss.once("connection", r));
645
+ const ws = getLastConnection();
646
+ const msg = await waitForMessage(ws);
647
+ ws.send(JSON.stringify({
648
+ type: "Success",
649
+ payload: { message_id: msg.messageId, data: [{ id: "1" }] },
650
+ }));
651
+ await (0, vitest_1.expect)(resultPromise).resolves.toEqual([{ id: "1" }]);
652
+ client.close();
653
+ });
654
+ });
655
+ // --------------------------------------------------------------------------
656
+ // Reject pending requests on disconnect
657
+ // --------------------------------------------------------------------------
658
+ (0, vitest_1.describe)("disconnect handling", () => {
659
+ (0, vitest_1.it)("rejects in-flight requests when the socket drops", async () => {
660
+ const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token", { autoReconnect: false });
661
+ const resultPromise = client.findAll("users");
662
+ await new Promise((r) => wss.once("connection", r));
663
+ const ws = getLastConnection();
664
+ await waitForMessage(ws); // FindAll received, never answered
665
+ // Server drops the connection.
666
+ ws.close();
667
+ await (0, vitest_1.expect)(resultPromise).rejects.toThrow(/connection closed/i);
668
+ client.close();
669
+ });
670
+ });
671
+ // --------------------------------------------------------------------------
672
+ // Backoff helper
673
+ // --------------------------------------------------------------------------
674
+ (0, vitest_1.describe)("computeBackoff", () => {
675
+ (0, vitest_1.it)("grows exponentially, stays within jittered bounds, and caps", () => {
676
+ const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token", { reconnectInitialDelayMs: 200, reconnectMaxDelayMs: 5000 });
677
+ // attempt 0 -> base 200, +/-25% => [150, 250]
678
+ for (let i = 0; i < 20; i++) {
679
+ const d0 = client.computeBackoff(0);
680
+ (0, vitest_1.expect)(d0).toBeGreaterThanOrEqual(150);
681
+ (0, vitest_1.expect)(d0).toBeLessThanOrEqual(250);
682
+ }
683
+ // attempt 3 -> base 1600, +/-25% => [1200, 2000]
684
+ const d3 = client.computeBackoff(3);
685
+ (0, vitest_1.expect)(d3).toBeGreaterThanOrEqual(1200);
686
+ (0, vitest_1.expect)(d3).toBeLessThanOrEqual(2000);
687
+ // attempt 20 -> capped at 5000, +/-25% => [3750, 6250]
688
+ const dBig = client.computeBackoff(20);
689
+ (0, vitest_1.expect)(dBig).toBeGreaterThanOrEqual(3750);
690
+ (0, vitest_1.expect)(dBig).toBeLessThanOrEqual(6250);
691
+ client.close();
692
+ });
693
+ });
694
+ // --------------------------------------------------------------------------
695
+ // Auto-reconnect + re-subscribe + fresh token
696
+ // --------------------------------------------------------------------------
697
+ (0, vitest_1.describe)("auto-reconnect", () => {
698
+ (0, vitest_1.it)("re-subscribes after a socket drop and re-evaluates the token", async () => {
699
+ // Token provider returns a new token each call so we can assert the
700
+ // reconnect used a freshly-obtained token (not a stale snapshot).
701
+ let tokenCalls = 0;
702
+ const tokenProvider = () => `token-${++tokenCalls}`;
703
+ const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, tokenProvider, {
704
+ autoReconnect: true,
705
+ reconnectInitialDelayMs: 10,
706
+ reconnectMaxDelayMs: 30,
707
+ });
708
+ // Establish the subscription on the first connection.
709
+ const streamPromise = client.subscribe("orders");
710
+ await new Promise((r) => wss.once("connection", r));
711
+ const ws1 = getLastConnection();
712
+ const sub1 = await waitForMessage(ws1);
713
+ (0, vitest_1.expect)(sub1.type).toBe("Subscribe");
714
+ (0, vitest_1.expect)(sub1.payload.collection).toBe("orders");
715
+ ws1.send(JSON.stringify({
716
+ type: "Success",
717
+ payload: { message_id: sub1.messageId, status: "subscribed" },
718
+ }));
719
+ const stream = await streamPromise;
720
+ (0, vitest_1.expect)(tokenCalls).toBe(1);
721
+ // Arm the next-connection handler BEFORE dropping so we don't miss it.
722
+ const nextConn = new Promise((resolve) => {
723
+ wss.once("connection", (ws) => resolve(ws));
724
+ });
725
+ // Simulate a transient drop: server closes the socket.
726
+ ws1.close();
727
+ // The client should auto-reconnect (new connection) and re-send Subscribe.
728
+ const ws2 = await nextConn;
729
+ const sub2 = await waitForMessage(ws2);
730
+ (0, vitest_1.expect)(sub2.type).toBe("Subscribe");
731
+ (0, vitest_1.expect)(sub2.payload.collection).toBe("orders");
732
+ // Reconnect re-evaluated the token provider.
733
+ (0, vitest_1.expect)(tokenCalls).toBeGreaterThanOrEqual(2);
734
+ // Ack the re-subscribe.
735
+ ws2.send(JSON.stringify({
736
+ type: "Success",
737
+ payload: { message_id: sub2.messageId, status: "subscribed" },
738
+ }));
739
+ // The SAME stream still delivers mutations over the new socket.
740
+ const mutationPromise = new Promise((resolve) => {
741
+ stream.on("mutation", resolve);
742
+ });
743
+ ws2.send(JSON.stringify({
744
+ type: "MutationNotification",
745
+ payload: {
746
+ collection: "orders",
747
+ event: "insert",
748
+ record_ids: ["order-9"],
749
+ timestamp: "2026-06-02T00:00:00Z",
750
+ },
751
+ }));
752
+ const mutation = await mutationPromise;
753
+ (0, vitest_1.expect)(mutation.recordIds).toEqual(["order-9"]);
754
+ (0, vitest_1.expect)(stream.closed).toBe(false);
755
+ client.close();
756
+ });
757
+ (0, vitest_1.it)("does not reconnect after an intentional close()", async () => {
758
+ const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token", { autoReconnect: true, reconnectInitialDelayMs: 10 });
759
+ const streamPromise = client.subscribe("widgets");
760
+ await new Promise((r) => wss.once("connection", r));
761
+ const ws = getLastConnection();
762
+ const sub = await waitForMessage(ws);
763
+ ws.send(JSON.stringify({
764
+ type: "Success",
765
+ payload: { message_id: sub.messageId, status: "subscribed" },
766
+ }));
767
+ await streamPromise;
768
+ const connectionsBefore = serverConnections.length;
769
+ // Intentional close: must NOT reconnect.
770
+ client.close();
771
+ // Give any erroneous reconnect time to land.
772
+ await new Promise((r) => setTimeout(r, 80));
773
+ (0, vitest_1.expect)(serverConnections.length).toBe(connectionsBefore);
774
+ });
775
+ });
776
+ (0, vitest_1.describe)("auth token validation", () => {
777
+ (0, vitest_1.it)("rejects connect when the token provider returns null (no Bearer null)", async () => {
778
+ const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, () => null);
779
+ await (0, vitest_1.expect)(client.findAll("users")).rejects.toThrow(/token is unavailable/i);
780
+ client.close();
781
+ });
782
+ });
783
+ // --------------------------------------------------------------------------
784
+ // Binary (msgpack) negotiation
785
+ // --------------------------------------------------------------------------
786
+ (0, vitest_1.describe)("msgpack negotiation", () => {
787
+ (0, vitest_1.it)("negotiates msgpack and round-trips a binary request/response", async () => {
788
+ welcomeFormat = "msgpack";
789
+ const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token");
790
+ const resultPromise = client.findAll("users");
791
+ await new Promise((r) => wss.once("connection", r));
792
+ const ws = getLastConnection();
793
+ const msg = await waitForMessage(ws);
794
+ // The request arrived as a binary msgpack frame, decoded to the same
795
+ // object shape as JSON would produce.
796
+ (0, vitest_1.expect)(recvState.get(ws).lastBinary).toBe(true);
797
+ (0, vitest_1.expect)(msg.type).toBe("FindAll");
798
+ (0, vitest_1.expect)(msg.payload.collection).toBe("users");
799
+ // Respond with a binary msgpack frame; the client must decode it
800
+ // transparently.
801
+ ws.send((0, msgpack_1.encode)({
802
+ type: "Success",
803
+ payload: {
804
+ message_id: msg.messageId,
805
+ data: [{ id: "1", name: "Alice" }],
806
+ },
807
+ }));
808
+ const result = await resultPromise;
809
+ (0, vitest_1.expect)(result).toEqual([{ id: "1", name: "Alice" }]);
810
+ client.close();
811
+ });
812
+ (0, vitest_1.it)("stays on JSON text when the server welcomes only json", async () => {
813
+ welcomeFormat = "json";
814
+ const client = new client_1.WebSocketClient(`ws://localhost:${port}/api/ws`, "test-token");
815
+ const resultPromise = client.findAll("users");
816
+ await new Promise((r) => wss.once("connection", r));
817
+ const ws = getLastConnection();
818
+ const msg = await waitForMessage(ws);
819
+ // The request is a JSON text frame, not binary.
820
+ (0, vitest_1.expect)(recvState.get(ws).lastBinary).toBe(false);
821
+ (0, vitest_1.expect)(msg.type).toBe("FindAll");
822
+ ws.send(JSON.stringify({
823
+ type: "Success",
824
+ payload: { message_id: msg.messageId, data: [] },
825
+ }));
826
+ await resultPromise;
827
+ client.close();
828
+ });
829
+ });
496
830
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ekodb/ekodb-client",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Official TypeScript/JavaScript client for ekoDB",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",