@ekodb/ekodb-client 0.20.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 -0
- package/dist/client.d.ts +90 -12
- package/dist/client.js +253 -56
- package/dist/client.test.js +170 -0
- package/dist/websocket.test.js +159 -5
- package/package.json +1 -1
- package/src/client.test.ts +244 -0
- package/src/client.ts +344 -65
- package/src/websocket.test.ts +225 -5
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
|
|
|
@@ -460,6 +516,99 @@ describe("WebSocketClient", () => {
|
|
|
460
516
|
});
|
|
461
517
|
|
|
462
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
|
+
|
|
463
612
|
// sendToolResult
|
|
464
613
|
// --------------------------------------------------------------------------
|
|
465
614
|
|
|
@@ -982,4 +1131,75 @@ describe("WebSocketClient", () => {
|
|
|
982
1131
|
client.close();
|
|
983
1132
|
});
|
|
984
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
|
+
});
|
|
985
1205
|
});
|