@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/src/functions.test.ts
CHANGED
|
@@ -3,8 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* These tests cover the pure-data construction helpers and the structural
|
|
5
5
|
* parameter placeholder. They don't hit a running ekoDB — server-side
|
|
6
|
-
* behavior is covered by the
|
|
7
|
-
* `ekodb/ekodb_server/tests/function_parameters_tests.rs`.
|
|
6
|
+
* behavior is covered by the server-side integration tests.
|
|
8
7
|
*/
|
|
9
8
|
|
|
10
9
|
import { describe, it, expect } from "vitest";
|
|
@@ -123,12 +123,6 @@ describe("QueryBuilder string operators", () => {
|
|
|
123
123
|
|
|
124
124
|
expect(query.filter.content.operator).toBe("EndsWith");
|
|
125
125
|
});
|
|
126
|
-
|
|
127
|
-
it("builds regex filter", () => {
|
|
128
|
-
const query = new QueryBuilder().regex("phone", "^\\+1").build();
|
|
129
|
-
|
|
130
|
-
expect(query.filter.content.operator).toBe("Regex");
|
|
131
|
-
});
|
|
132
126
|
});
|
|
133
127
|
|
|
134
128
|
// ============================================================================
|
|
@@ -464,7 +458,6 @@ describe("QueryBuilder chaining", () => {
|
|
|
464
458
|
expect(qb.contains("i", "j")).toBe(qb);
|
|
465
459
|
expect(qb.startsWith("k", "l")).toBe(qb);
|
|
466
460
|
expect(qb.endsWith("m", "n")).toBe(qb);
|
|
467
|
-
expect(qb.regex("o", "p")).toBe(qb);
|
|
468
461
|
expect(qb.sortAsc("q")).toBe(qb);
|
|
469
462
|
expect(qb.sortDesc("r")).toBe(qb);
|
|
470
463
|
expect(qb.limit(1)).toBe(qb);
|
package/src/query-builder.ts
CHANGED
|
@@ -216,20 +216,8 @@ export class QueryBuilder {
|
|
|
216
216
|
return this;
|
|
217
217
|
}
|
|
218
218
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
*/
|
|
222
|
-
regex(field: string, pattern: string): this {
|
|
223
|
-
this.filters.push({
|
|
224
|
-
type: "Condition",
|
|
225
|
-
content: {
|
|
226
|
-
field,
|
|
227
|
-
operator: "Regex",
|
|
228
|
-
value: pattern,
|
|
229
|
-
},
|
|
230
|
-
});
|
|
231
|
-
return this;
|
|
232
|
-
}
|
|
219
|
+
// Note: regex filtering is pending server-side support. The server has no
|
|
220
|
+
// Regex filter operator; use contains/startsWith/endsWith instead.
|
|
233
221
|
|
|
234
222
|
// ========================================================================
|
|
235
223
|
// Logical Operators
|
package/src/utils.test.ts
CHANGED
|
@@ -50,6 +50,11 @@ describe("getValue", () => {
|
|
|
50
50
|
expect(getValue(field)).toBeNull();
|
|
51
51
|
});
|
|
52
52
|
|
|
53
|
+
it("passes through a user object that has a value key but no type (regression #134)", () => {
|
|
54
|
+
const field = { value: 1, currency: "USD" };
|
|
55
|
+
expect(getValue(field)).toEqual(field);
|
|
56
|
+
});
|
|
57
|
+
|
|
53
58
|
it("returns plain string as-is", () => {
|
|
54
59
|
expect(getValue("plain string")).toBe("plain string");
|
|
55
60
|
});
|
package/src/utils.ts
CHANGED
|
@@ -18,7 +18,15 @@
|
|
|
18
18
|
* ```
|
|
19
19
|
*/
|
|
20
20
|
export function getValue<T = any>(field: any): T {
|
|
21
|
-
|
|
21
|
+
// Only unwrap a genuine typed wrapper — one carrying BOTH a "type"
|
|
22
|
+
// discriminator and a "value". A user object that merely has a "value" key
|
|
23
|
+
// (e.g. { value: 1, currency: "USD" }) must pass through untouched.
|
|
24
|
+
if (
|
|
25
|
+
field &&
|
|
26
|
+
typeof field === "object" &&
|
|
27
|
+
"type" in field &&
|
|
28
|
+
"value" in field
|
|
29
|
+
) {
|
|
22
30
|
return field.value as T;
|
|
23
31
|
}
|
|
24
32
|
return field as T;
|
package/src/websocket.test.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
8
8
|
import { WebSocketServer, WebSocket as WS } from "ws";
|
|
9
|
+
import { encode, decode } from "@msgpack/msgpack";
|
|
9
10
|
import { WebSocketClient, EventStream } from "./client";
|
|
10
11
|
|
|
11
12
|
// ============================================================================
|
|
@@ -16,11 +17,62 @@ let wss: WebSocketServer;
|
|
|
16
17
|
let port: number;
|
|
17
18
|
let serverConnections: WS[] = [];
|
|
18
19
|
|
|
20
|
+
// Format the mock server Welcomes during the handshake. Default "json" keeps
|
|
21
|
+
// the existing tests on the text transport they assert against; the binary
|
|
22
|
+
// negotiation test sets "msgpack" before connecting.
|
|
23
|
+
let welcomeFormat: "json" | "msgpack" = "json";
|
|
24
|
+
|
|
25
|
+
// Per-connection receive state. The connection handler is the SOLE message
|
|
26
|
+
// consumer: it answers the client's Hello (so tests never see it) and buffers
|
|
27
|
+
// every subsequent frame, which waitForMessage() drains. This is race-free —
|
|
28
|
+
// unlike a per-test ws.once("message"), which would fire on the Hello that
|
|
29
|
+
// arrives (as loopback I/O) after the test's listener is registered.
|
|
30
|
+
interface RecvState {
|
|
31
|
+
queue: any[];
|
|
32
|
+
waiters: ((m: any) => void)[];
|
|
33
|
+
handshakeDone: boolean;
|
|
34
|
+
// Frame type of the most recently received real (post-handshake) message, so
|
|
35
|
+
// a test can assert the client actually sent binary vs text.
|
|
36
|
+
lastBinary: boolean;
|
|
37
|
+
}
|
|
38
|
+
const recvState = new Map<WS, RecvState>();
|
|
39
|
+
|
|
19
40
|
function startServer(): Promise<number> {
|
|
20
41
|
return new Promise((resolve) => {
|
|
21
42
|
wss = new WebSocketServer({ port: 0 });
|
|
22
43
|
wss.on("connection", (ws) => {
|
|
23
44
|
serverConnections.push(ws);
|
|
45
|
+
const state: RecvState = {
|
|
46
|
+
queue: [],
|
|
47
|
+
waiters: [],
|
|
48
|
+
handshakeDone: false,
|
|
49
|
+
lastBinary: false,
|
|
50
|
+
};
|
|
51
|
+
recvState.set(ws, state);
|
|
52
|
+
ws.on("message", (data: Buffer, isBinary: boolean) => {
|
|
53
|
+
let msg: any;
|
|
54
|
+
try {
|
|
55
|
+
msg = isBinary ? decode(data) : JSON.parse(data.toString());
|
|
56
|
+
} catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
// Answer the additive Hello handshake exactly like the real server, and
|
|
60
|
+
// never surface it to the test. The Hello is always text.
|
|
61
|
+
if (!state.handshakeDone && msg?.type === "Hello") {
|
|
62
|
+
state.handshakeDone = true;
|
|
63
|
+
ws.send(
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
type: "Welcome",
|
|
66
|
+
payload: { format: welcomeFormat },
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
state.lastBinary = isBinary;
|
|
72
|
+
const waiter = state.waiters.shift();
|
|
73
|
+
if (waiter) waiter(msg);
|
|
74
|
+
else state.queue.push(msg);
|
|
75
|
+
});
|
|
24
76
|
});
|
|
25
77
|
wss.on("listening", () => {
|
|
26
78
|
const addr = wss.address();
|
|
@@ -33,12 +85,14 @@ function getLastConnection(): WS {
|
|
|
33
85
|
return serverConnections[serverConnections.length - 1];
|
|
34
86
|
}
|
|
35
87
|
|
|
88
|
+
// Resolve with the next real (post-handshake) frame, draining any already
|
|
89
|
+
// buffered. Decoded per the negotiated frame type, so it works for both JSON
|
|
90
|
+
// and msgpack connections without the caller caring which.
|
|
36
91
|
function waitForMessage(ws: WS): Promise<any> {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
});
|
|
92
|
+
const state = recvState.get(ws)!;
|
|
93
|
+
const queued = state.queue.shift();
|
|
94
|
+
if (queued !== undefined) return Promise.resolve(queued);
|
|
95
|
+
return new Promise((resolve) => state.waiters.push(resolve));
|
|
42
96
|
}
|
|
43
97
|
|
|
44
98
|
// ============================================================================
|
|
@@ -48,6 +102,8 @@ function waitForMessage(ws: WS): Promise<any> {
|
|
|
48
102
|
describe("WebSocketClient", () => {
|
|
49
103
|
beforeEach(async () => {
|
|
50
104
|
serverConnections = [];
|
|
105
|
+
recvState.clear();
|
|
106
|
+
welcomeFormat = "json";
|
|
51
107
|
port = await startServer();
|
|
52
108
|
});
|
|
53
109
|
|
|
@@ -122,6 +178,45 @@ describe("WebSocketClient", () => {
|
|
|
122
178
|
});
|
|
123
179
|
});
|
|
124
180
|
|
|
181
|
+
// --------------------------------------------------------------------------
|
|
182
|
+
// URL normalization
|
|
183
|
+
// --------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
describe("URL normalization", () => {
|
|
186
|
+
function captureRequestPath(): Promise<string> {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
wss.once("connection", (_ws, req) => resolve(req.url ?? ""));
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
it("appends /api/ws without a double slash when the base URL has a trailing slash", async () => {
|
|
193
|
+
const pathPromise = captureRequestPath();
|
|
194
|
+
const client = new WebSocketClient(
|
|
195
|
+
`ws://localhost:${port}/`,
|
|
196
|
+
"test-token",
|
|
197
|
+
);
|
|
198
|
+
// Trigger a connect; we only assert the path the server receives.
|
|
199
|
+
client.findAll("users").catch(() => {});
|
|
200
|
+
|
|
201
|
+
expect(await pathPromise).toBe("/api/ws");
|
|
202
|
+
|
|
203
|
+
client.close();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("does not duplicate /api/ws when the URL already ends with it plus a trailing slash", async () => {
|
|
207
|
+
const pathPromise = captureRequestPath();
|
|
208
|
+
const client = new WebSocketClient(
|
|
209
|
+
`ws://localhost:${port}/api/ws/`,
|
|
210
|
+
"test-token",
|
|
211
|
+
);
|
|
212
|
+
client.findAll("users").catch(() => {});
|
|
213
|
+
|
|
214
|
+
expect(await pathPromise).toBe("/api/ws");
|
|
215
|
+
|
|
216
|
+
client.close();
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
125
220
|
// --------------------------------------------------------------------------
|
|
126
221
|
// subscribe
|
|
127
222
|
// --------------------------------------------------------------------------
|
|
@@ -421,6 +516,99 @@ describe("WebSocketClient", () => {
|
|
|
421
516
|
});
|
|
422
517
|
|
|
423
518
|
// --------------------------------------------------------------------------
|
|
519
|
+
// unsubscribe
|
|
520
|
+
// --------------------------------------------------------------------------
|
|
521
|
+
|
|
522
|
+
describe("unsubscribe", () => {
|
|
523
|
+
it("sends an Unsubscribe frame to the server", async () => {
|
|
524
|
+
const client = new WebSocketClient(
|
|
525
|
+
`ws://localhost:${port}/api/ws`,
|
|
526
|
+
"test-token",
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
const streamPromise = client.subscribe("orders");
|
|
530
|
+
await new Promise((r) => wss.once("connection", r));
|
|
531
|
+
const ws = getLastConnection();
|
|
532
|
+
const subMsg = await waitForMessage(ws);
|
|
533
|
+
expect(subMsg.type).toBe("Subscribe");
|
|
534
|
+
ws.send(
|
|
535
|
+
JSON.stringify({
|
|
536
|
+
type: "Success",
|
|
537
|
+
payload: { message_id: subMsg.messageId, status: "subscribed" },
|
|
538
|
+
}),
|
|
539
|
+
);
|
|
540
|
+
await streamPromise;
|
|
541
|
+
|
|
542
|
+
const unsubMsg = waitForMessage(ws);
|
|
543
|
+
client.unsubscribe("orders");
|
|
544
|
+
|
|
545
|
+
const sent = await unsubMsg;
|
|
546
|
+
expect(sent.type).toBe("Unsubscribe");
|
|
547
|
+
expect(sent.payload.collection).toBe("orders");
|
|
548
|
+
|
|
549
|
+
client.close();
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// cancelChat
|
|
554
|
+
// --------------------------------------------------------------------------
|
|
555
|
+
|
|
556
|
+
describe("cancelChat", () => {
|
|
557
|
+
it("sends CancelChat frame", async () => {
|
|
558
|
+
const client = new WebSocketClient(
|
|
559
|
+
`ws://localhost:${port}/api/ws`,
|
|
560
|
+
"test-token",
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
// Open the connection via a chat stream first.
|
|
564
|
+
const streamPromise = client.chatSend("chat-1", "test");
|
|
565
|
+
await new Promise((r) => wss.once("connection", r));
|
|
566
|
+
const ws = getLastConnection();
|
|
567
|
+
await waitForMessage(ws); // ChatSend
|
|
568
|
+
await streamPromise;
|
|
569
|
+
|
|
570
|
+
const cancelMsg = waitForMessage(ws);
|
|
571
|
+
await client.cancelChat("chat-1");
|
|
572
|
+
|
|
573
|
+
const sent = await cancelMsg;
|
|
574
|
+
expect(sent.type).toBe("CancelChat");
|
|
575
|
+
expect(sent.payload.chat_id).toBe("chat-1");
|
|
576
|
+
// A correlation id must be attached so a Success ack can't be misrouted by
|
|
577
|
+
// the dispatcher's single-pending fallback.
|
|
578
|
+
expect(typeof sent.messageId).toBe("string");
|
|
579
|
+
expect(sent.messageId.length).toBeGreaterThan(0);
|
|
580
|
+
|
|
581
|
+
client.close();
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("attaches a unique messageId on each CancelChat frame", async () => {
|
|
585
|
+
const client = new WebSocketClient(
|
|
586
|
+
`ws://localhost:${port}/api/ws`,
|
|
587
|
+
"test-token",
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
const streamPromise = client.chatSend("chat-1", "test");
|
|
591
|
+
await new Promise((r) => wss.once("connection", r));
|
|
592
|
+
const ws = getLastConnection();
|
|
593
|
+
await waitForMessage(ws); // ChatSend
|
|
594
|
+
await streamPromise;
|
|
595
|
+
|
|
596
|
+
const first = waitForMessage(ws);
|
|
597
|
+
await client.cancelChat("chat-1");
|
|
598
|
+
const firstSent = await first;
|
|
599
|
+
|
|
600
|
+
const second = waitForMessage(ws);
|
|
601
|
+
await client.cancelChat("chat-1");
|
|
602
|
+
const secondSent = await second;
|
|
603
|
+
|
|
604
|
+
expect(firstSent.messageId).toBeTruthy();
|
|
605
|
+
expect(secondSent.messageId).toBeTruthy();
|
|
606
|
+
expect(firstSent.messageId).not.toBe(secondSent.messageId);
|
|
607
|
+
|
|
608
|
+
client.close();
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
424
612
|
// sendToolResult
|
|
425
613
|
// --------------------------------------------------------------------------
|
|
426
614
|
|
|
@@ -709,4 +897,309 @@ describe("WebSocketClient", () => {
|
|
|
709
897
|
client.close();
|
|
710
898
|
});
|
|
711
899
|
});
|
|
900
|
+
|
|
901
|
+
// --------------------------------------------------------------------------
|
|
902
|
+
// Per-request timeout
|
|
903
|
+
// --------------------------------------------------------------------------
|
|
904
|
+
|
|
905
|
+
describe("per-request timeout", () => {
|
|
906
|
+
it("rejects a pending request when no response arrives", async () => {
|
|
907
|
+
const client = new WebSocketClient(
|
|
908
|
+
`ws://localhost:${port}/api/ws`,
|
|
909
|
+
"test-token",
|
|
910
|
+
{ requestTimeoutMs: 50, autoReconnect: false },
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
const resultPromise = client.findAll("users");
|
|
914
|
+
|
|
915
|
+
await new Promise((r) => wss.once("connection", r));
|
|
916
|
+
const ws = getLastConnection();
|
|
917
|
+
await waitForMessage(ws); // receive FindAll but never respond
|
|
918
|
+
|
|
919
|
+
await expect(resultPromise).rejects.toThrow(/timed out after 50ms/);
|
|
920
|
+
|
|
921
|
+
client.close();
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it("does not time out when a response arrives in time", async () => {
|
|
925
|
+
const client = new WebSocketClient(
|
|
926
|
+
`ws://localhost:${port}/api/ws`,
|
|
927
|
+
"test-token",
|
|
928
|
+
{ requestTimeoutMs: 500, autoReconnect: false },
|
|
929
|
+
);
|
|
930
|
+
|
|
931
|
+
const resultPromise = client.findAll("users");
|
|
932
|
+
|
|
933
|
+
await new Promise((r) => wss.once("connection", r));
|
|
934
|
+
const ws = getLastConnection();
|
|
935
|
+
const msg = await waitForMessage(ws);
|
|
936
|
+
ws.send(
|
|
937
|
+
JSON.stringify({
|
|
938
|
+
type: "Success",
|
|
939
|
+
payload: { message_id: msg.messageId, data: [{ id: "1" }] },
|
|
940
|
+
}),
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
await expect(resultPromise).resolves.toEqual([{ id: "1" }]);
|
|
944
|
+
client.close();
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// --------------------------------------------------------------------------
|
|
949
|
+
// Reject pending requests on disconnect
|
|
950
|
+
// --------------------------------------------------------------------------
|
|
951
|
+
|
|
952
|
+
describe("disconnect handling", () => {
|
|
953
|
+
it("rejects in-flight requests when the socket drops", async () => {
|
|
954
|
+
const client = new WebSocketClient(
|
|
955
|
+
`ws://localhost:${port}/api/ws`,
|
|
956
|
+
"test-token",
|
|
957
|
+
{ autoReconnect: false },
|
|
958
|
+
);
|
|
959
|
+
|
|
960
|
+
const resultPromise = client.findAll("users");
|
|
961
|
+
|
|
962
|
+
await new Promise((r) => wss.once("connection", r));
|
|
963
|
+
const ws = getLastConnection();
|
|
964
|
+
await waitForMessage(ws); // FindAll received, never answered
|
|
965
|
+
|
|
966
|
+
// Server drops the connection.
|
|
967
|
+
ws.close();
|
|
968
|
+
|
|
969
|
+
await expect(resultPromise).rejects.toThrow(/connection closed/i);
|
|
970
|
+
|
|
971
|
+
client.close();
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
// --------------------------------------------------------------------------
|
|
976
|
+
// Backoff helper
|
|
977
|
+
// --------------------------------------------------------------------------
|
|
978
|
+
|
|
979
|
+
describe("computeBackoff", () => {
|
|
980
|
+
it("grows exponentially, stays within jittered bounds, and caps", () => {
|
|
981
|
+
const client = new WebSocketClient(
|
|
982
|
+
`ws://localhost:${port}/api/ws`,
|
|
983
|
+
"test-token",
|
|
984
|
+
{ reconnectInitialDelayMs: 200, reconnectMaxDelayMs: 5000 },
|
|
985
|
+
);
|
|
986
|
+
|
|
987
|
+
// attempt 0 -> base 200, +/-25% => [150, 250]
|
|
988
|
+
for (let i = 0; i < 20; i++) {
|
|
989
|
+
const d0 = client.computeBackoff(0);
|
|
990
|
+
expect(d0).toBeGreaterThanOrEqual(150);
|
|
991
|
+
expect(d0).toBeLessThanOrEqual(250);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// attempt 3 -> base 1600, +/-25% => [1200, 2000]
|
|
995
|
+
const d3 = client.computeBackoff(3);
|
|
996
|
+
expect(d3).toBeGreaterThanOrEqual(1200);
|
|
997
|
+
expect(d3).toBeLessThanOrEqual(2000);
|
|
998
|
+
|
|
999
|
+
// attempt 20 -> capped at 5000, +/-25% => [3750, 6250]
|
|
1000
|
+
const dBig = client.computeBackoff(20);
|
|
1001
|
+
expect(dBig).toBeGreaterThanOrEqual(3750);
|
|
1002
|
+
expect(dBig).toBeLessThanOrEqual(6250);
|
|
1003
|
+
|
|
1004
|
+
client.close();
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
// --------------------------------------------------------------------------
|
|
1009
|
+
// Auto-reconnect + re-subscribe + fresh token
|
|
1010
|
+
// --------------------------------------------------------------------------
|
|
1011
|
+
|
|
1012
|
+
describe("auto-reconnect", () => {
|
|
1013
|
+
it("re-subscribes after a socket drop and re-evaluates the token", async () => {
|
|
1014
|
+
// Token provider returns a new token each call so we can assert the
|
|
1015
|
+
// reconnect used a freshly-obtained token (not a stale snapshot).
|
|
1016
|
+
let tokenCalls = 0;
|
|
1017
|
+
const tokenProvider = () => `token-${++tokenCalls}`;
|
|
1018
|
+
|
|
1019
|
+
const client = new WebSocketClient(
|
|
1020
|
+
`ws://localhost:${port}/api/ws`,
|
|
1021
|
+
tokenProvider,
|
|
1022
|
+
{
|
|
1023
|
+
autoReconnect: true,
|
|
1024
|
+
reconnectInitialDelayMs: 10,
|
|
1025
|
+
reconnectMaxDelayMs: 30,
|
|
1026
|
+
},
|
|
1027
|
+
);
|
|
1028
|
+
|
|
1029
|
+
// Establish the subscription on the first connection.
|
|
1030
|
+
const streamPromise = client.subscribe("orders");
|
|
1031
|
+
await new Promise((r) => wss.once("connection", r));
|
|
1032
|
+
const ws1 = getLastConnection();
|
|
1033
|
+
const sub1 = await waitForMessage(ws1);
|
|
1034
|
+
expect(sub1.type).toBe("Subscribe");
|
|
1035
|
+
expect(sub1.payload.collection).toBe("orders");
|
|
1036
|
+
ws1.send(
|
|
1037
|
+
JSON.stringify({
|
|
1038
|
+
type: "Success",
|
|
1039
|
+
payload: { message_id: sub1.messageId, status: "subscribed" },
|
|
1040
|
+
}),
|
|
1041
|
+
);
|
|
1042
|
+
const stream = await streamPromise;
|
|
1043
|
+
expect(tokenCalls).toBe(1);
|
|
1044
|
+
|
|
1045
|
+
// Arm the next-connection handler BEFORE dropping so we don't miss it.
|
|
1046
|
+
const nextConn = new Promise<WS>((resolve) => {
|
|
1047
|
+
wss.once("connection", (ws: WS) => resolve(ws));
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
// Simulate a transient drop: server closes the socket.
|
|
1051
|
+
ws1.close();
|
|
1052
|
+
|
|
1053
|
+
// The client should auto-reconnect (new connection) and re-send Subscribe.
|
|
1054
|
+
const ws2 = await nextConn;
|
|
1055
|
+
const sub2 = await waitForMessage(ws2);
|
|
1056
|
+
expect(sub2.type).toBe("Subscribe");
|
|
1057
|
+
expect(sub2.payload.collection).toBe("orders");
|
|
1058
|
+
|
|
1059
|
+
// Reconnect re-evaluated the token provider.
|
|
1060
|
+
expect(tokenCalls).toBeGreaterThanOrEqual(2);
|
|
1061
|
+
|
|
1062
|
+
// Ack the re-subscribe.
|
|
1063
|
+
ws2.send(
|
|
1064
|
+
JSON.stringify({
|
|
1065
|
+
type: "Success",
|
|
1066
|
+
payload: { message_id: sub2.messageId, status: "subscribed" },
|
|
1067
|
+
}),
|
|
1068
|
+
);
|
|
1069
|
+
|
|
1070
|
+
// The SAME stream still delivers mutations over the new socket.
|
|
1071
|
+
const mutationPromise = new Promise<any>((resolve) => {
|
|
1072
|
+
stream.on("mutation", resolve);
|
|
1073
|
+
});
|
|
1074
|
+
ws2.send(
|
|
1075
|
+
JSON.stringify({
|
|
1076
|
+
type: "MutationNotification",
|
|
1077
|
+
payload: {
|
|
1078
|
+
collection: "orders",
|
|
1079
|
+
event: "insert",
|
|
1080
|
+
record_ids: ["order-9"],
|
|
1081
|
+
timestamp: "2026-06-02T00:00:00Z",
|
|
1082
|
+
},
|
|
1083
|
+
}),
|
|
1084
|
+
);
|
|
1085
|
+
const mutation = await mutationPromise;
|
|
1086
|
+
expect(mutation.recordIds).toEqual(["order-9"]);
|
|
1087
|
+
expect(stream.closed).toBe(false);
|
|
1088
|
+
|
|
1089
|
+
client.close();
|
|
1090
|
+
});
|
|
1091
|
+
|
|
1092
|
+
it("does not reconnect after an intentional close()", async () => {
|
|
1093
|
+
const client = new WebSocketClient(
|
|
1094
|
+
`ws://localhost:${port}/api/ws`,
|
|
1095
|
+
"test-token",
|
|
1096
|
+
{ autoReconnect: true, reconnectInitialDelayMs: 10 },
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
const streamPromise = client.subscribe("widgets");
|
|
1100
|
+
await new Promise((r) => wss.once("connection", r));
|
|
1101
|
+
const ws = getLastConnection();
|
|
1102
|
+
const sub = await waitForMessage(ws);
|
|
1103
|
+
ws.send(
|
|
1104
|
+
JSON.stringify({
|
|
1105
|
+
type: "Success",
|
|
1106
|
+
payload: { message_id: sub.messageId, status: "subscribed" },
|
|
1107
|
+
}),
|
|
1108
|
+
);
|
|
1109
|
+
await streamPromise;
|
|
1110
|
+
|
|
1111
|
+
const connectionsBefore = serverConnections.length;
|
|
1112
|
+
|
|
1113
|
+
// Intentional close: must NOT reconnect.
|
|
1114
|
+
client.close();
|
|
1115
|
+
|
|
1116
|
+
// Give any erroneous reconnect time to land.
|
|
1117
|
+
await new Promise((r) => setTimeout(r, 80));
|
|
1118
|
+
expect(serverConnections.length).toBe(connectionsBefore);
|
|
1119
|
+
});
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
describe("auth token validation", () => {
|
|
1123
|
+
it("rejects connect when the token provider returns null (no Bearer null)", async () => {
|
|
1124
|
+
const client = new WebSocketClient(
|
|
1125
|
+
`ws://localhost:${port}/api/ws`,
|
|
1126
|
+
() => null,
|
|
1127
|
+
);
|
|
1128
|
+
await expect(client.findAll("users")).rejects.toThrow(
|
|
1129
|
+
/token is unavailable/i,
|
|
1130
|
+
);
|
|
1131
|
+
client.close();
|
|
1132
|
+
});
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
// --------------------------------------------------------------------------
|
|
1136
|
+
// Binary (msgpack) negotiation
|
|
1137
|
+
// --------------------------------------------------------------------------
|
|
1138
|
+
|
|
1139
|
+
describe("msgpack negotiation", () => {
|
|
1140
|
+
it("negotiates msgpack and round-trips a binary request/response", async () => {
|
|
1141
|
+
welcomeFormat = "msgpack";
|
|
1142
|
+
const client = new WebSocketClient(
|
|
1143
|
+
`ws://localhost:${port}/api/ws`,
|
|
1144
|
+
"test-token",
|
|
1145
|
+
);
|
|
1146
|
+
|
|
1147
|
+
const resultPromise = client.findAll("users");
|
|
1148
|
+
|
|
1149
|
+
await new Promise((r) => wss.once("connection", r));
|
|
1150
|
+
const ws = getLastConnection();
|
|
1151
|
+
const msg = await waitForMessage(ws);
|
|
1152
|
+
|
|
1153
|
+
// The request arrived as a binary msgpack frame, decoded to the same
|
|
1154
|
+
// object shape as JSON would produce.
|
|
1155
|
+
expect(recvState.get(ws)!.lastBinary).toBe(true);
|
|
1156
|
+
expect(msg.type).toBe("FindAll");
|
|
1157
|
+
expect(msg.payload.collection).toBe("users");
|
|
1158
|
+
|
|
1159
|
+
// Respond with a binary msgpack frame; the client must decode it
|
|
1160
|
+
// transparently.
|
|
1161
|
+
ws.send(
|
|
1162
|
+
encode({
|
|
1163
|
+
type: "Success",
|
|
1164
|
+
payload: {
|
|
1165
|
+
message_id: msg.messageId,
|
|
1166
|
+
data: [{ id: "1", name: "Alice" }],
|
|
1167
|
+
},
|
|
1168
|
+
}),
|
|
1169
|
+
);
|
|
1170
|
+
|
|
1171
|
+
const result = await resultPromise;
|
|
1172
|
+
expect(result).toEqual([{ id: "1", name: "Alice" }]);
|
|
1173
|
+
|
|
1174
|
+
client.close();
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
it("stays on JSON text when the server welcomes only json", async () => {
|
|
1178
|
+
welcomeFormat = "json";
|
|
1179
|
+
const client = new WebSocketClient(
|
|
1180
|
+
`ws://localhost:${port}/api/ws`,
|
|
1181
|
+
"test-token",
|
|
1182
|
+
);
|
|
1183
|
+
|
|
1184
|
+
const resultPromise = client.findAll("users");
|
|
1185
|
+
|
|
1186
|
+
await new Promise((r) => wss.once("connection", r));
|
|
1187
|
+
const ws = getLastConnection();
|
|
1188
|
+
const msg = await waitForMessage(ws);
|
|
1189
|
+
|
|
1190
|
+
// The request is a JSON text frame, not binary.
|
|
1191
|
+
expect(recvState.get(ws)!.lastBinary).toBe(false);
|
|
1192
|
+
expect(msg.type).toBe("FindAll");
|
|
1193
|
+
|
|
1194
|
+
ws.send(
|
|
1195
|
+
JSON.stringify({
|
|
1196
|
+
type: "Success",
|
|
1197
|
+
payload: { message_id: msg.messageId, data: [] },
|
|
1198
|
+
}),
|
|
1199
|
+
);
|
|
1200
|
+
|
|
1201
|
+
await resultPromise;
|
|
1202
|
+
client.close();
|
|
1203
|
+
});
|
|
1204
|
+
});
|
|
712
1205
|
});
|