@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,371 @@
1
+ /**
2
+ * WebSocket Trigger Monitoring Integration Tests
3
+ *
4
+ * Tests that the WebSocket trigger properly integrates with the
5
+ * monitoring infrastructure from TriggerBase:
6
+ * - Health checks for WebSocket server dependencies
7
+ * - Rate limiting per-client message throughput
8
+ * - Circuit breaker for downstream workflow failures
9
+ * - Metrics collection (connections, messages, latency)
10
+ */
11
+
12
+ import type { HelperResponse } from "@blokjs/helper";
13
+ import type { BlokService } from "@blokjs/runner";
14
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
15
+ import { type WebSocketEvent, WebSocketTrigger } from "./WebSocketTrigger";
16
+
17
+ /**
18
+ * Concrete test trigger exposing monitoring methods from TriggerBase
19
+ */
20
+ class MonitoredWebSocketTrigger extends WebSocketTrigger {
21
+ protected override nodes = {} as Record<string, BlokService<unknown>>;
22
+ protected override workflows = {} as Record<string, HelperResponse>;
23
+
24
+ public getClientsMap() {
25
+ return this.clients;
26
+ }
27
+
28
+ public getRoomsMap() {
29
+ return this.rooms;
30
+ }
31
+ }
32
+
33
+ function createMockSocket() {
34
+ return {
35
+ send: vi.fn(),
36
+ close: vi.fn(),
37
+ ping: vi.fn(),
38
+ };
39
+ }
40
+
41
+ describe("WebSocket Trigger - Monitoring Integration", () => {
42
+ let trigger: MonitoredWebSocketTrigger;
43
+
44
+ beforeEach(() => {
45
+ trigger = new MonitoredWebSocketTrigger();
46
+ });
47
+
48
+ afterEach(async () => {
49
+ trigger.destroyMonitoring();
50
+ await trigger.stop();
51
+ });
52
+
53
+ describe("Health Checks", () => {
54
+ it("should report healthy when no dependencies registered", async () => {
55
+ const health = await trigger.getHealth();
56
+ expect(health.status).toBe("healthy");
57
+ expect(health.uptime).toBeGreaterThanOrEqual(0);
58
+ expect(Object.keys(health.checks)).toHaveLength(0);
59
+ });
60
+
61
+ it("should support custom WebSocket dependency checks", async () => {
62
+ trigger.registerHealthDependency("ws-server", async () => ({
63
+ status: "healthy",
64
+ message: "WebSocket server running on port 8080",
65
+ lastChecked: Date.now(),
66
+ }));
67
+
68
+ const health = await trigger.getHealth();
69
+ expect(health.status).toBe("healthy");
70
+ expect(health.checks["ws-server"].status).toBe("healthy");
71
+ });
72
+
73
+ it("should report degraded when WebSocket server has high latency", async () => {
74
+ trigger.registerHealthDependency("ws-server", async () => ({
75
+ status: "degraded",
76
+ message: "High connection latency detected",
77
+ lastChecked: Date.now(),
78
+ }));
79
+
80
+ const health = await trigger.getHealth();
81
+ expect(health.status).toBe("degraded");
82
+ });
83
+
84
+ it("should pass liveness check even with dependency failures", async () => {
85
+ trigger.registerHealthDependency("external-api", async () => {
86
+ throw new Error("API unreachable");
87
+ });
88
+
89
+ const liveness = trigger.getLiveness();
90
+ expect(liveness.status).toBe("ok");
91
+
92
+ const readiness = await trigger.getReadiness();
93
+ expect(readiness.ready).toBe(false);
94
+ expect(readiness.status).toBe("unhealthy");
95
+ });
96
+
97
+ it("should track connection count as a health indicator", async () => {
98
+ const socket = createMockSocket();
99
+
100
+ // Connect 3 clients
101
+ const client1 = await trigger.handleConnection(socket, {});
102
+ const client2 = await trigger.handleConnection(socket, {});
103
+ const client3 = await trigger.handleConnection(socket, {});
104
+
105
+ expect(client1).not.toBeNull();
106
+ expect(client2).not.toBeNull();
107
+ expect(client3).not.toBeNull();
108
+
109
+ const stats = trigger.getStats();
110
+ expect(stats.activeConnections).toBe(3);
111
+ });
112
+ });
113
+
114
+ describe("Rate Limiting", () => {
115
+ it("should rate limit messages per client when enabled", async () => {
116
+ trigger.enableRateLimiting({
117
+ maxTokens: 5,
118
+ refillRate: 2,
119
+ keyStrategy: "client",
120
+ });
121
+
122
+ // First 5 messages should be allowed
123
+ for (let i = 0; i < 5; i++) {
124
+ const result = trigger.checkRateLimit("client-1");
125
+ expect(result.allowed).toBe(true);
126
+ }
127
+
128
+ // 6th message should be rejected
129
+ const rejected = trigger.checkRateLimit("client-1");
130
+ expect(rejected.allowed).toBe(false);
131
+ expect(rejected.retryAfterMs).toBeGreaterThan(0);
132
+ });
133
+
134
+ it("should isolate rate limits between different clients", () => {
135
+ trigger.enableRateLimiting({
136
+ maxTokens: 3,
137
+ refillRate: 1,
138
+ });
139
+
140
+ // Use up all tokens for client-1
141
+ trigger.checkRateLimit("client-1");
142
+ trigger.checkRateLimit("client-1");
143
+ trigger.checkRateLimit("client-1");
144
+
145
+ // client-1 is rate limited
146
+ expect(trigger.checkRateLimit("client-1").allowed).toBe(false);
147
+
148
+ // client-2 should still have tokens
149
+ expect(trigger.checkRateLimit("client-2").allowed).toBe(true);
150
+ });
151
+
152
+ it("should allow unlimited traffic when rate limiting is disabled", () => {
153
+ // Rate limiting not enabled
154
+ for (let i = 0; i < 100; i++) {
155
+ const result = trigger.checkRateLimit(`client-${i}`);
156
+ expect(result.allowed).toBe(true);
157
+ expect(result.remaining).toBe(Number.MAX_SAFE_INTEGER);
158
+ }
159
+ });
160
+ });
161
+
162
+ describe("Circuit Breaker", () => {
163
+ it("should be null when not enabled", () => {
164
+ // Access through trigger metrics instead of exposing internal state
165
+ // The circuit breaker is protected, so we test its behavior
166
+ const metrics = trigger.getTriggerMetrics();
167
+ expect(metrics.triggerType).toBe("MonitoredWebSocketTrigger");
168
+ });
169
+
170
+ it("should support enabling circuit breaker for workflow execution", () => {
171
+ trigger.enableCircuitBreaker({
172
+ failureThreshold: 5,
173
+ resetTimeoutMs: 30000,
174
+ halfOpenMaxAttempts: 2,
175
+ });
176
+
177
+ // Circuit breaker is now active - verify through health check
178
+ const liveness = trigger.getLiveness();
179
+ expect(liveness.status).toBe("ok");
180
+ });
181
+ });
182
+
183
+ describe("Trigger Metrics", () => {
184
+ it("should collect trigger-level metrics", () => {
185
+ const metrics = trigger.getTriggerMetrics();
186
+ expect(metrics.triggerType).toBe("MonitoredWebSocketTrigger");
187
+ expect(metrics.throughput.totalRequests).toBe(0);
188
+ expect(metrics.latency.count).toBe(0);
189
+ expect(metrics.errors.total).toBe(0);
190
+ });
191
+
192
+ it("should track connection counts through metrics", async () => {
193
+ const socket = createMockSocket();
194
+
195
+ await trigger.handleConnection(socket, {});
196
+ await trigger.handleConnection(socket, {});
197
+
198
+ const stats = trigger.getStats();
199
+ expect(stats.activeConnections).toBe(2);
200
+ expect(stats.totalMessages).toBe(0);
201
+ });
202
+
203
+ it("should track message counts", async () => {
204
+ const socket = createMockSocket();
205
+ const client = await trigger.handleConnection(socket, {});
206
+ expect(client).not.toBeNull();
207
+
208
+ // Handle a text message (won't execute workflow since none configured)
209
+ await trigger.handleMessage(client!.id, '{"event":"test","data":"hello"}', false);
210
+ await trigger.handleMessage(client!.id, '{"event":"test","data":"world"}', false);
211
+
212
+ const stats = trigger.getStats();
213
+ expect(stats.totalMessages).toBe(2);
214
+ });
215
+ });
216
+
217
+ describe("Connection Lifecycle with Monitoring", () => {
218
+ it("should track full connection lifecycle", async () => {
219
+ const socket = createMockSocket();
220
+
221
+ // Connect
222
+ const client = await trigger.handleConnection(socket, {});
223
+ expect(client).not.toBeNull();
224
+ expect(trigger.getStats().activeConnections).toBe(1);
225
+
226
+ // Join room
227
+ await trigger.joinRoom(client!.id, "lobby");
228
+ expect(trigger.getStats().roomCount).toBe(1);
229
+
230
+ // Send message
231
+ await trigger.handleMessage(client!.id, '{"event":"chat","data":"hi"}', false);
232
+ expect(trigger.getStats().totalMessages).toBe(1);
233
+
234
+ // Leave room
235
+ await trigger.leaveRoom(client!.id, "lobby");
236
+ expect(trigger.getStats().roomCount).toBe(0);
237
+
238
+ // Disconnect
239
+ await trigger.handleClose(client!.id, 1000, "Normal closure");
240
+ expect(trigger.getStats().activeConnections).toBe(0);
241
+ });
242
+
243
+ it("should clean up monitoring on stop", async () => {
244
+ const socket = createMockSocket();
245
+
246
+ // Connect clients
247
+ await trigger.handleConnection(socket, {});
248
+ await trigger.handleConnection(socket, {});
249
+ await trigger.handleConnection(socket, {});
250
+
251
+ expect(trigger.getStats().activeConnections).toBe(3);
252
+
253
+ // Stop trigger
254
+ await trigger.stop();
255
+
256
+ expect(trigger.getStats().activeConnections).toBe(0);
257
+ expect(trigger.getClientsMap().size).toBe(0);
258
+ expect(trigger.getRoomsMap().size).toBe(0);
259
+ });
260
+ });
261
+
262
+ describe("Multi-Client Broadcasting with Monitoring", () => {
263
+ it("should broadcast to room and track metrics", async () => {
264
+ const socket1 = createMockSocket();
265
+ const socket2 = createMockSocket();
266
+ const socket3 = createMockSocket();
267
+
268
+ const client1 = await trigger.handleConnection(socket1, {});
269
+ const client2 = await trigger.handleConnection(socket2, {});
270
+ const client3 = await trigger.handleConnection(socket3, {});
271
+
272
+ // Join room
273
+ await trigger.joinRoom(client1!.id, "chat");
274
+ await trigger.joinRoom(client2!.id, "chat");
275
+ // client3 does NOT join the room
276
+
277
+ // Broadcast to room (excluding sender)
278
+ const sent = trigger.broadcastToRoom("chat", "msg", { text: "hello" }, client1!.id);
279
+ expect(sent).toBe(1); // Only client2 receives
280
+
281
+ // Verify socket2 received the message
282
+ expect(socket2.send).toHaveBeenCalledTimes(1);
283
+ const sentData = JSON.parse(socket2.send.mock.calls[0][0]);
284
+ expect(sentData.event).toBe("msg");
285
+ expect(sentData.data.text).toBe("hello");
286
+
287
+ // socket1 (sender, excluded) and socket3 (not in room) should not receive
288
+ expect(socket1.send).not.toHaveBeenCalled();
289
+ expect(socket3.send).not.toHaveBeenCalled();
290
+ });
291
+
292
+ it("should broadcast to all clients", async () => {
293
+ const sockets = Array.from({ length: 5 }, () => createMockSocket());
294
+ const clients = await Promise.all(sockets.map((s) => trigger.handleConnection(s, {})));
295
+
296
+ const sent = trigger.broadcastToAll("notification", { message: "Server restart" });
297
+ expect(sent).toBe(5);
298
+
299
+ for (const socket of sockets) {
300
+ expect(socket.send).toHaveBeenCalledTimes(1);
301
+ }
302
+ });
303
+ });
304
+
305
+ describe("Authentication with Monitoring", () => {
306
+ it("should reject unauthenticated connections", async () => {
307
+ trigger.setAuthHandler(async (_req, headers) => {
308
+ if (headers.authorization === "Bearer valid-token") {
309
+ return { authenticated: true, clientId: "auth-user-1" };
310
+ }
311
+ return { authenticated: false, error: "Invalid token" };
312
+ });
313
+
314
+ const socket = createMockSocket();
315
+
316
+ // Without valid auth
317
+ const result = await trigger.handleConnection(socket, {}, {});
318
+ expect(result).toBeNull();
319
+ expect(socket.close).toHaveBeenCalledWith(4001, "Invalid token");
320
+
321
+ // With valid auth
322
+ const socket2 = createMockSocket();
323
+ const authedClient = await trigger.handleConnection(
324
+ socket2,
325
+ {},
326
+ {
327
+ authorization: "Bearer valid-token",
328
+ },
329
+ );
330
+ expect(authedClient).not.toBeNull();
331
+ expect(authedClient!.id).toBe("auth-user-1");
332
+ });
333
+
334
+ it("should track authenticated connections in metrics", async () => {
335
+ trigger.setAuthHandler(async () => ({
336
+ authenticated: true,
337
+ clientId: "user-1",
338
+ metadata: { role: "admin" },
339
+ }));
340
+
341
+ const socket = createMockSocket();
342
+ const client = await trigger.handleConnection(socket, {}, {});
343
+
344
+ expect(client).not.toBeNull();
345
+ expect(client!.metadata.role).toBe("admin");
346
+ expect(trigger.getStats().activeConnections).toBe(1);
347
+ });
348
+ });
349
+
350
+ describe("Max Connections Enforcement", () => {
351
+ it("should reject connections when at capacity", async () => {
352
+ // Override max clients to a small number for testing
353
+ (trigger as unknown as { maxClients: number }).maxClients = 3;
354
+
355
+ const sockets = Array.from({ length: 4 }, () => createMockSocket());
356
+
357
+ // First 3 should succeed
358
+ for (let i = 0; i < 3; i++) {
359
+ const client = await trigger.handleConnection(sockets[i], {});
360
+ expect(client).not.toBeNull();
361
+ }
362
+
363
+ // 4th should be rejected
364
+ const rejected = await trigger.handleConnection(sockets[3], {});
365
+ expect(rejected).toBeNull();
366
+ expect(sockets[3].close).toHaveBeenCalledWith(1013, "Server at capacity");
367
+
368
+ expect(trigger.getStats().activeConnections).toBe(3);
369
+ });
370
+ });
371
+ });
package/src/index.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @blokjs/trigger-websocket
3
+ *
4
+ * WebSocket trigger for Blok workflows.
5
+ * Handle real-time bidirectional communication.
6
+ *
7
+ * Features:
8
+ * - Connection management (connect, disconnect, reconnect)
9
+ * - Room/channel support for broadcasting
10
+ * - Message routing to workflows
11
+ * - Heartbeat/ping-pong for connection health
12
+ * - Authentication middleware
13
+ * - Binary message support
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * import { WebSocketTrigger } from "@blokjs/trigger-websocket";
18
+ * import { WebSocketServer } from "ws";
19
+ *
20
+ * class MyWebSocketTrigger extends WebSocketTrigger {
21
+ * protected nodes = myNodes;
22
+ * protected workflows = myWorkflows;
23
+ * }
24
+ *
25
+ * const trigger = new MyWebSocketTrigger();
26
+ * await trigger.listen();
27
+ *
28
+ * // Create WebSocket server
29
+ * const wss = new WebSocketServer({ port: 8080 });
30
+ *
31
+ * wss.on("connection", async (ws, req) => {
32
+ * const headers = req.headers as Record<string, string>;
33
+ * const client = await trigger.handleConnection(
34
+ * {
35
+ * send: (data) => ws.send(data),
36
+ * close: (code, reason) => ws.close(code, reason),
37
+ * ping: () => ws.ping(),
38
+ * },
39
+ * req,
40
+ * headers
41
+ * );
42
+ *
43
+ * if (!client) return;
44
+ *
45
+ * ws.on("message", async (data, isBinary) => {
46
+ * await trigger.handleMessage(client.id, data, isBinary);
47
+ * });
48
+ *
49
+ * ws.on("close", (code, reason) => {
50
+ * trigger.handleClose(client.id, code, reason.toString());
51
+ * });
52
+ *
53
+ * ws.on("error", (error) => {
54
+ * trigger.handleError(client.id, error);
55
+ * });
56
+ *
57
+ * ws.on("ping", () => trigger.handlePing(client.id));
58
+ * ws.on("pong", () => trigger.handlePong(client.id));
59
+ * });
60
+ * ```
61
+ *
62
+ * Workflow Definition:
63
+ * ```typescript
64
+ * Workflow({ name: "chat-message", version: "1.0.0" })
65
+ * .addTrigger("websocket", {
66
+ * events: ["message", "chat.*"],
67
+ * rooms: ["general", "support"],
68
+ * })
69
+ * .addStep({ ... });
70
+ * ```
71
+ *
72
+ * Authentication:
73
+ * ```typescript
74
+ * trigger.setAuthHandler(async (request, headers) => {
75
+ * const token = headers["authorization"]?.replace("Bearer ", "");
76
+ * if (!token) {
77
+ * return { authenticated: false, error: "No token provided" };
78
+ * }
79
+ *
80
+ * const user = await verifyToken(token);
81
+ * if (!user) {
82
+ * return { authenticated: false, error: "Invalid token" };
83
+ * }
84
+ *
85
+ * return {
86
+ * authenticated: true,
87
+ * clientId: user.id,
88
+ * metadata: { userId: user.id, role: user.role },
89
+ * };
90
+ * });
91
+ * ```
92
+ *
93
+ * Room Management:
94
+ * ```typescript
95
+ * // Join a room
96
+ * await trigger.joinRoom(clientId, "room-name");
97
+ *
98
+ * // Leave a room
99
+ * await trigger.leaveRoom(clientId, "room-name");
100
+ *
101
+ * // Broadcast to room
102
+ * trigger.broadcastToRoom("room-name", "event", { message: "Hello!" });
103
+ *
104
+ * // Send to specific client
105
+ * trigger.sendToClient(clientId, "event", { message: "Private message" });
106
+ *
107
+ * // Broadcast to all
108
+ * trigger.broadcastToAll("event", { message: "System message" });
109
+ * ```
110
+ */
111
+
112
+ // Core exports
113
+ export {
114
+ WebSocketTrigger,
115
+ type WebSocketMessage,
116
+ type WebSocketMessageType,
117
+ type WebSocketState,
118
+ type WebSocketClient,
119
+ type WebSocketRoom,
120
+ type WebSocketEventType,
121
+ type WebSocketEvent,
122
+ type AuthResult,
123
+ type AuthHandler,
124
+ } from "./WebSocketTrigger";
125
+
126
+ // Re-export types from helper for convenience
127
+ export type { WebSocketTriggerOpts } from "@blokjs/helper";
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "ts-node": {
3
+ "transpileOnly": true
4
+ },
5
+ "compilerOptions": {
6
+ "target": "ES2022",
7
+ "module": "es2022",
8
+ "moduleResolution": "bundler",
9
+ "declaration": true,
10
+ "declarationMap": true,
11
+ "sourceMap": true,
12
+ "outDir": "./dist",
13
+ "rootDir": "./src",
14
+ "strict": true,
15
+ "esModuleInterop": true,
16
+ "skipLibCheck": true,
17
+ "forceConsistentCasingInFileNames": true,
18
+ "resolveJsonModule": true
19
+ },
20
+ "include": ["src/**/*"],
21
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
22
+ }