@blokjs/trigger-websocket 0.2.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.
@@ -0,0 +1,490 @@
1
+ import type { HelperResponse } from "@blokjs/helper";
2
+ import type { BlokService } from "@blokjs/runner";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+ import {
5
+ type AuthResult,
6
+ type WebSocketClient,
7
+ type WebSocketEvent,
8
+ type WebSocketMessage,
9
+ WebSocketTrigger,
10
+ } from "./WebSocketTrigger";
11
+
12
+ // Mock implementations
13
+ class TestWebSocketTrigger extends WebSocketTrigger {
14
+ protected override nodes = {} as Record<string, BlokService<unknown>>;
15
+ protected override workflows = {} as Record<string, HelperResponse>;
16
+
17
+ // Expose protected methods for testing
18
+ public getWebSocketWorkflowsTest() {
19
+ return this.getWebSocketWorkflows();
20
+ }
21
+
22
+ public findMatchingWorkflowTest(event: WebSocketEvent) {
23
+ return this.findMatchingWorkflow(event);
24
+ }
25
+
26
+ public setWorkflows(workflows: Record<string, HelperResponse>) {
27
+ this.workflows = workflows;
28
+ this.loadWorkflows();
29
+ }
30
+
31
+ public getClientsMap() {
32
+ return this.clients;
33
+ }
34
+
35
+ public getRoomsMap() {
36
+ return this.rooms;
37
+ }
38
+ }
39
+
40
+ describe("WebSocketTrigger", () => {
41
+ let trigger: TestWebSocketTrigger;
42
+
43
+ beforeEach(() => {
44
+ trigger = new TestWebSocketTrigger();
45
+ });
46
+
47
+ afterEach(async () => {
48
+ await trigger.stop();
49
+ });
50
+
51
+ describe("WebSocketMessage Interface", () => {
52
+ it("should define message structure correctly", () => {
53
+ const message: WebSocketMessage = {
54
+ id: "msg-123",
55
+ type: "text",
56
+ event: "chat.message",
57
+ data: { text: "Hello, World!" },
58
+ timestamp: new Date(),
59
+ raw: '{"event":"chat.message","data":{"text":"Hello, World!"}}',
60
+ };
61
+
62
+ expect(message.id).toBe("msg-123");
63
+ expect(message.type).toBe("text");
64
+ expect(message.event).toBe("chat.message");
65
+ expect(message.data).toEqual({ text: "Hello, World!" });
66
+ expect(message.timestamp).toBeInstanceOf(Date);
67
+ });
68
+
69
+ it("should support binary message type", () => {
70
+ const binaryData = Buffer.from([0x01, 0x02, 0x03]);
71
+ const message: WebSocketMessage = {
72
+ id: "msg-456",
73
+ type: "binary",
74
+ event: "binary",
75
+ data: binaryData,
76
+ timestamp: new Date(),
77
+ raw: binaryData,
78
+ };
79
+
80
+ expect(message.type).toBe("binary");
81
+ expect(message.raw).toBeInstanceOf(Buffer);
82
+ });
83
+ });
84
+
85
+ describe("WebSocketClient Interface", () => {
86
+ it("should define client structure correctly", () => {
87
+ const mockSend = vi.fn();
88
+ const mockClose = vi.fn();
89
+ const mockPing = vi.fn();
90
+
91
+ const client: WebSocketClient = {
92
+ id: "client-123",
93
+ state: "connected",
94
+ rooms: new Set(["general", "support"]),
95
+ metadata: { userId: "user-456", role: "admin" },
96
+ connectedAt: new Date(),
97
+ lastActivity: new Date(),
98
+ send: mockSend,
99
+ close: mockClose,
100
+ ping: mockPing,
101
+ };
102
+
103
+ expect(client.id).toBe("client-123");
104
+ expect(client.state).toBe("connected");
105
+ expect(client.rooms.has("general")).toBe(true);
106
+ expect(client.rooms.has("support")).toBe(true);
107
+ expect(client.metadata.userId).toBe("user-456");
108
+ });
109
+ });
110
+
111
+ describe("WebSocketEvent Interface", () => {
112
+ it("should define connection event", () => {
113
+ const event: WebSocketEvent = {
114
+ type: "connection",
115
+ clientId: "client-123",
116
+ };
117
+
118
+ expect(event.type).toBe("connection");
119
+ expect(event.clientId).toBe("client-123");
120
+ });
121
+
122
+ it("should define message event with payload", () => {
123
+ const event: WebSocketEvent = {
124
+ type: "message",
125
+ clientId: "client-123",
126
+ message: {
127
+ id: "msg-123",
128
+ type: "text",
129
+ event: "chat.message",
130
+ data: { text: "Hello" },
131
+ timestamp: new Date(),
132
+ },
133
+ };
134
+
135
+ expect(event.type).toBe("message");
136
+ expect(event.message?.event).toBe("chat.message");
137
+ });
138
+
139
+ it("should define close event with code and reason", () => {
140
+ const event: WebSocketEvent = {
141
+ type: "close",
142
+ clientId: "client-123",
143
+ closeCode: 1000,
144
+ closeReason: "Normal closure",
145
+ };
146
+
147
+ expect(event.type).toBe("close");
148
+ expect(event.closeCode).toBe(1000);
149
+ expect(event.closeReason).toBe("Normal closure");
150
+ });
151
+
152
+ it("should define room events", () => {
153
+ const joinEvent: WebSocketEvent = {
154
+ type: "join_room",
155
+ clientId: "client-123",
156
+ room: "general",
157
+ };
158
+
159
+ const leaveEvent: WebSocketEvent = {
160
+ type: "leave_room",
161
+ clientId: "client-123",
162
+ room: "general",
163
+ };
164
+
165
+ expect(joinEvent.type).toBe("join_room");
166
+ expect(joinEvent.room).toBe("general");
167
+ expect(leaveEvent.type).toBe("leave_room");
168
+ });
169
+ });
170
+
171
+ describe("Connection Management", () => {
172
+ it("should handle new connections", async () => {
173
+ const mockSocket = {
174
+ send: vi.fn(),
175
+ close: vi.fn(),
176
+ ping: vi.fn(),
177
+ };
178
+
179
+ const client = await trigger.handleConnection(mockSocket, {}, {});
180
+
181
+ expect(client).not.toBeNull();
182
+ expect(client?.id).toBeDefined();
183
+ expect(client?.state).toBe("connected");
184
+ expect(trigger.getClientsMap().size).toBe(1);
185
+ });
186
+
187
+ it("should reject connections when at max capacity", async () => {
188
+ // Set max to 1
189
+ (trigger as unknown as { maxClients: number }).maxClients = 1;
190
+
191
+ const mockSocket1 = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
192
+ const mockSocket2 = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
193
+
194
+ await trigger.handleConnection(mockSocket1, {}, {});
195
+ const client2 = await trigger.handleConnection(mockSocket2, {}, {});
196
+
197
+ expect(client2).toBeNull();
198
+ expect(mockSocket2.close).toHaveBeenCalledWith(1013, "Server at capacity");
199
+ });
200
+
201
+ it("should handle authentication", async () => {
202
+ trigger.setAuthHandler((_request, headers) => {
203
+ if (headers.authorization === "Bearer valid-token") {
204
+ return {
205
+ authenticated: true,
206
+ clientId: "authenticated-user",
207
+ metadata: { role: "admin" },
208
+ };
209
+ }
210
+ return { authenticated: false, error: "Invalid token" };
211
+ });
212
+
213
+ const mockSocket = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
214
+
215
+ // Test successful auth
216
+ const validClient = await trigger.handleConnection(mockSocket, {}, { authorization: "Bearer valid-token" });
217
+ expect(validClient?.id).toBe("authenticated-user");
218
+ expect(validClient?.metadata.role).toBe("admin");
219
+
220
+ // Test failed auth
221
+ const invalidClient = await trigger.handleConnection(mockSocket, {}, { authorization: "Bearer invalid-token" });
222
+ expect(invalidClient).toBeNull();
223
+ expect(mockSocket.close).toHaveBeenCalledWith(4001, "Invalid token");
224
+ });
225
+
226
+ it("should handle disconnection", async () => {
227
+ const mockSocket = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
228
+ const client = await trigger.handleConnection(mockSocket, {}, {});
229
+
230
+ expect(trigger.getClientsMap().size).toBe(1);
231
+
232
+ await trigger.handleClose(client!.id, 1000, "Normal closure");
233
+
234
+ expect(trigger.getClientsMap().size).toBe(0);
235
+ });
236
+ });
237
+
238
+ describe("Room Management", () => {
239
+ it("should allow clients to join rooms", async () => {
240
+ const mockSocket = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
241
+ const client = await trigger.handleConnection(mockSocket, {}, {});
242
+
243
+ await trigger.joinRoom(client!.id, "general");
244
+
245
+ expect(client!.rooms.has("general")).toBe(true);
246
+ expect(trigger.getRoomsMap().get("general")?.clients.has(client!.id)).toBe(true);
247
+ });
248
+
249
+ it("should allow clients to leave rooms", async () => {
250
+ const mockSocket = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
251
+ const client = await trigger.handleConnection(mockSocket, {}, {});
252
+
253
+ await trigger.joinRoom(client!.id, "general");
254
+ await trigger.leaveRoom(client!.id, "general");
255
+
256
+ expect(client!.rooms.has("general")).toBe(false);
257
+ // Room should be deleted when empty
258
+ expect(trigger.getRoomsMap().has("general")).toBe(false);
259
+ });
260
+
261
+ it("should clean up rooms when client disconnects", async () => {
262
+ const mockSocket = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
263
+ const client = await trigger.handleConnection(mockSocket, {}, {});
264
+
265
+ await trigger.joinRoom(client!.id, "room1");
266
+ await trigger.joinRoom(client!.id, "room2");
267
+
268
+ await trigger.handleClose(client!.id, 1000, "");
269
+
270
+ // Rooms should be cleaned up
271
+ expect(trigger.getRoomsMap().size).toBe(0);
272
+ });
273
+ });
274
+
275
+ describe("Message Sending", () => {
276
+ it("should send message to specific client", async () => {
277
+ const mockSocket = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
278
+ const client = await trigger.handleConnection(mockSocket, {}, {});
279
+
280
+ const success = trigger.sendToClient(client!.id, "notification", { message: "Hello" });
281
+
282
+ expect(success).toBe(true);
283
+ expect(mockSocket.send).toHaveBeenCalledWith(
284
+ JSON.stringify({ event: "notification", data: { message: "Hello" } }),
285
+ );
286
+ });
287
+
288
+ it("should broadcast to room", async () => {
289
+ const mockSocket1 = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
290
+ const mockSocket2 = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
291
+ const mockSocket3 = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
292
+
293
+ const client1 = await trigger.handleConnection(mockSocket1, {}, {});
294
+ const client2 = await trigger.handleConnection(mockSocket2, {}, {});
295
+ const client3 = await trigger.handleConnection(mockSocket3, {}, {});
296
+
297
+ await trigger.joinRoom(client1!.id, "general");
298
+ await trigger.joinRoom(client2!.id, "general");
299
+ // client3 is not in the room
300
+
301
+ const count = trigger.broadcastToRoom("general", "chat", { text: "Hello room!" });
302
+
303
+ expect(count).toBe(2);
304
+ expect(mockSocket1.send).toHaveBeenCalled();
305
+ expect(mockSocket2.send).toHaveBeenCalled();
306
+ expect(mockSocket3.send).not.toHaveBeenCalled();
307
+ });
308
+
309
+ it("should broadcast to room excluding sender", async () => {
310
+ const mockSocket1 = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
311
+ const mockSocket2 = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
312
+
313
+ const client1 = await trigger.handleConnection(mockSocket1, {}, {});
314
+ const client2 = await trigger.handleConnection(mockSocket2, {}, {});
315
+
316
+ await trigger.joinRoom(client1!.id, "general");
317
+ await trigger.joinRoom(client2!.id, "general");
318
+
319
+ // Broadcast excluding client1
320
+ const count = trigger.broadcastToRoom("general", "chat", { text: "Hello!" }, client1!.id);
321
+
322
+ expect(count).toBe(1);
323
+ expect(mockSocket1.send).not.toHaveBeenCalled();
324
+ expect(mockSocket2.send).toHaveBeenCalled();
325
+ });
326
+
327
+ it("should broadcast to all clients", async () => {
328
+ const mockSocket1 = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
329
+ const mockSocket2 = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
330
+
331
+ await trigger.handleConnection(mockSocket1, {}, {});
332
+ await trigger.handleConnection(mockSocket2, {}, {});
333
+
334
+ const count = trigger.broadcastToAll("system", { message: "Server maintenance" });
335
+
336
+ expect(count).toBe(2);
337
+ expect(mockSocket1.send).toHaveBeenCalled();
338
+ expect(mockSocket2.send).toHaveBeenCalled();
339
+ });
340
+ });
341
+
342
+ describe("Message Handling", () => {
343
+ it("should parse JSON messages", async () => {
344
+ const mockSocket = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
345
+ const client = await trigger.handleConnection(mockSocket, {}, {});
346
+
347
+ const jsonMessage = JSON.stringify({ event: "chat.message", data: { text: "Hello" } });
348
+ const result = await trigger.handleMessage(client!.id, jsonMessage, false);
349
+
350
+ // No matching workflow, so should return null
351
+ expect(result).toBeNull();
352
+ });
353
+
354
+ it("should handle plain text messages", async () => {
355
+ const mockSocket = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
356
+ const client = await trigger.handleConnection(mockSocket, {}, {});
357
+
358
+ const result = await trigger.handleMessage(client!.id, "Hello World", false);
359
+
360
+ expect(result).toBeNull();
361
+ });
362
+
363
+ it("should handle binary messages", async () => {
364
+ const mockSocket = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
365
+ const client = await trigger.handleConnection(mockSocket, {}, {});
366
+
367
+ const binaryData = Buffer.from([0x01, 0x02, 0x03]);
368
+ const result = await trigger.handleMessage(client!.id, binaryData, true);
369
+
370
+ expect(result).toBeNull();
371
+ });
372
+
373
+ it("should update last activity on message", async () => {
374
+ const mockSocket = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
375
+ const client = await trigger.handleConnection(mockSocket, {}, {});
376
+
377
+ const initialActivity = client!.lastActivity;
378
+
379
+ // Wait a bit
380
+ await new Promise((resolve) => setTimeout(resolve, 10));
381
+
382
+ await trigger.handleMessage(client!.id, "test", false);
383
+
384
+ expect(client!.lastActivity.getTime()).toBeGreaterThan(initialActivity.getTime());
385
+ });
386
+ });
387
+
388
+ describe("Statistics", () => {
389
+ it("should track connection stats", async () => {
390
+ const mockSocket1 = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
391
+ const mockSocket2 = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
392
+
393
+ await trigger.handleConnection(mockSocket1, {}, {});
394
+ const client2 = await trigger.handleConnection(mockSocket2, {}, {});
395
+
396
+ await trigger.joinRoom(client2!.id, "general");
397
+
398
+ const stats = trigger.getStats();
399
+
400
+ expect(stats.activeConnections).toBe(2);
401
+ expect(stats.roomCount).toBe(1);
402
+ expect(stats.clientsByRoom.general).toBe(1);
403
+ });
404
+
405
+ it("should track message count", async () => {
406
+ const mockSocket = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
407
+ const client = await trigger.handleConnection(mockSocket, {}, {});
408
+
409
+ await trigger.handleMessage(client!.id, "msg1", false);
410
+ await trigger.handleMessage(client!.id, "msg2", false);
411
+ await trigger.handleMessage(client!.id, "msg3", false);
412
+
413
+ const stats = trigger.getStats();
414
+ expect(stats.totalMessages).toBe(3);
415
+ });
416
+ });
417
+
418
+ describe("Lifecycle", () => {
419
+ it("should initialize successfully", async () => {
420
+ const elapsed = await trigger.listen();
421
+ expect(elapsed).toBeGreaterThanOrEqual(0);
422
+ });
423
+
424
+ it("should stop and clean up", async () => {
425
+ const mockSocket = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
426
+ await trigger.handleConnection(mockSocket, {}, {});
427
+
428
+ await trigger.stop();
429
+
430
+ expect(trigger.getClientsMap().size).toBe(0);
431
+ expect(trigger.getRoomsMap().size).toBe(0);
432
+ expect(mockSocket.close).toHaveBeenCalledWith(1001, "Server shutting down");
433
+ });
434
+ });
435
+
436
+ describe("Client Retrieval", () => {
437
+ it("should get client by ID", async () => {
438
+ const mockSocket = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
439
+ const client = await trigger.handleConnection(mockSocket, {}, {});
440
+
441
+ const retrieved = trigger.getClient(client!.id);
442
+ expect(retrieved).toBe(client);
443
+ });
444
+
445
+ it("should get clients in room", async () => {
446
+ const mockSocket1 = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
447
+ const mockSocket2 = { send: vi.fn(), close: vi.fn(), ping: vi.fn() };
448
+
449
+ const client1 = await trigger.handleConnection(mockSocket1, {}, {});
450
+ const client2 = await trigger.handleConnection(mockSocket2, {}, {});
451
+
452
+ await trigger.joinRoom(client1!.id, "general");
453
+ await trigger.joinRoom(client2!.id, "general");
454
+
455
+ const clients = trigger.getClientsInRoom("general");
456
+ expect(clients.length).toBe(2);
457
+ });
458
+ });
459
+ });
460
+
461
+ describe("WebSocketTriggerOpts Schema", () => {
462
+ it("should validate with default values", async () => {
463
+ const { WebSocketTriggerOptsSchema } = await import("@blokjs/helper");
464
+
465
+ const opts = WebSocketTriggerOptsSchema.parse({});
466
+
467
+ expect(opts.events).toEqual(["*"]);
468
+ expect(opts.maxConnections).toBe(10000);
469
+ expect(opts.heartbeatInterval).toBe(30000);
470
+ expect(opts.messageRateLimit).toBe(100);
471
+ });
472
+
473
+ it("should validate custom configuration", async () => {
474
+ const { WebSocketTriggerOptsSchema } = await import("@blokjs/helper");
475
+
476
+ const opts = WebSocketTriggerOptsSchema.parse({
477
+ events: ["chat.*", "notification"],
478
+ rooms: ["general", "support"],
479
+ path: "/ws",
480
+ maxConnections: 5000,
481
+ heartbeatInterval: 15000,
482
+ messageRateLimit: 50,
483
+ });
484
+
485
+ expect(opts.events).toEqual(["chat.*", "notification"]);
486
+ expect(opts.rooms).toEqual(["general", "support"]);
487
+ expect(opts.path).toBe("/ws");
488
+ expect(opts.maxConnections).toBe(5000);
489
+ });
490
+ });