@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.
- package/CHANGELOG.md +22 -0
- package/dist/WebSocketTrigger.d.ts +264 -0
- package/dist/WebSocketTrigger.d.ts.map +1 -0
- package/dist/WebSocketTrigger.js +626 -0
- package/dist/WebSocketTrigger.js.map +1 -0
- package/dist/index.d.ts +113 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +117 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
- package/src/WebSocketTrigger.test.ts +490 -0
- package/src/WebSocketTrigger.ts +869 -0
- package/src/WebSocketTriggerMonitoring.test.ts +371 -0
- package/src/index.ts +127 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,869 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebSocketTrigger - Real-time bidirectional communication trigger
|
|
3
|
+
*
|
|
4
|
+
* Extends TriggerBase to handle WebSocket connections for:
|
|
5
|
+
* - Real-time messaging
|
|
6
|
+
* - Live updates and notifications
|
|
7
|
+
* - Collaborative features
|
|
8
|
+
* - Streaming data
|
|
9
|
+
*
|
|
10
|
+
* Features:
|
|
11
|
+
* - Connection management (connect, disconnect, reconnect)
|
|
12
|
+
* - Room/channel support for broadcasting
|
|
13
|
+
* - Message routing to workflows
|
|
14
|
+
* - Heartbeat/ping-pong for connection health
|
|
15
|
+
* - Authentication middleware
|
|
16
|
+
* - Binary message support
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { HelperResponse, WebSocketTriggerOpts } from "@blokjs/helper";
|
|
20
|
+
import {
|
|
21
|
+
DefaultLogger,
|
|
22
|
+
type GlobalOptions,
|
|
23
|
+
type BlokService,
|
|
24
|
+
NodeMap,
|
|
25
|
+
TriggerBase,
|
|
26
|
+
type TriggerResponse,
|
|
27
|
+
} from "@blokjs/runner";
|
|
28
|
+
import type { Context, RequestContext } from "@blokjs/shared";
|
|
29
|
+
import { type Span, SpanStatusCode, metrics, trace } from "@opentelemetry/api";
|
|
30
|
+
import { v4 as uuid } from "uuid";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* WebSocket message types
|
|
34
|
+
*/
|
|
35
|
+
export type WebSocketMessageType = "text" | "binary" | "ping" | "pong";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* WebSocket connection state
|
|
39
|
+
*/
|
|
40
|
+
export type WebSocketState = "connecting" | "connected" | "disconnecting" | "disconnected";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* WebSocket message structure
|
|
44
|
+
*/
|
|
45
|
+
export interface WebSocketMessage {
|
|
46
|
+
/** Unique message ID */
|
|
47
|
+
id: string;
|
|
48
|
+
/** Message type */
|
|
49
|
+
type: WebSocketMessageType;
|
|
50
|
+
/** Event name (for routing) */
|
|
51
|
+
event: string;
|
|
52
|
+
/** Message payload */
|
|
53
|
+
data: unknown;
|
|
54
|
+
/** Timestamp */
|
|
55
|
+
timestamp: Date;
|
|
56
|
+
/** Raw message data */
|
|
57
|
+
raw?: Buffer | string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* WebSocket client connection
|
|
62
|
+
*/
|
|
63
|
+
export interface WebSocketClient {
|
|
64
|
+
/** Unique client ID */
|
|
65
|
+
id: string;
|
|
66
|
+
/** Connection state */
|
|
67
|
+
state: WebSocketState;
|
|
68
|
+
/** Rooms/channels the client is subscribed to */
|
|
69
|
+
rooms: Set<string>;
|
|
70
|
+
/** Client metadata */
|
|
71
|
+
metadata: Record<string, unknown>;
|
|
72
|
+
/** Connection timestamp */
|
|
73
|
+
connectedAt: Date;
|
|
74
|
+
/** Last activity timestamp */
|
|
75
|
+
lastActivity: Date;
|
|
76
|
+
/** Send message to client */
|
|
77
|
+
send(data: string | Buffer): void;
|
|
78
|
+
/** Close connection */
|
|
79
|
+
close(code?: number, reason?: string): void;
|
|
80
|
+
/** Ping the client */
|
|
81
|
+
ping(): void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* WebSocket room/channel for broadcasting
|
|
86
|
+
*/
|
|
87
|
+
export interface WebSocketRoom {
|
|
88
|
+
/** Room name */
|
|
89
|
+
name: string;
|
|
90
|
+
/** Clients in the room */
|
|
91
|
+
clients: Set<string>;
|
|
92
|
+
/** Room metadata */
|
|
93
|
+
metadata: Record<string, unknown>;
|
|
94
|
+
/** Created timestamp */
|
|
95
|
+
createdAt: Date;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* WebSocket event types for lifecycle hooks
|
|
100
|
+
*/
|
|
101
|
+
export type WebSocketEventType =
|
|
102
|
+
| "connection"
|
|
103
|
+
| "message"
|
|
104
|
+
| "close"
|
|
105
|
+
| "error"
|
|
106
|
+
| "ping"
|
|
107
|
+
| "pong"
|
|
108
|
+
| "join_room"
|
|
109
|
+
| "leave_room";
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* WebSocket event for workflow triggering
|
|
113
|
+
*/
|
|
114
|
+
export interface WebSocketEvent {
|
|
115
|
+
/** Event type */
|
|
116
|
+
type: WebSocketEventType;
|
|
117
|
+
/** Client ID */
|
|
118
|
+
clientId: string;
|
|
119
|
+
/** Message (for message events) */
|
|
120
|
+
message?: WebSocketMessage;
|
|
121
|
+
/** Room name (for room events) */
|
|
122
|
+
room?: string;
|
|
123
|
+
/** Error (for error events) */
|
|
124
|
+
error?: Error;
|
|
125
|
+
/** Close code (for close events) */
|
|
126
|
+
closeCode?: number;
|
|
127
|
+
/** Close reason (for close events) */
|
|
128
|
+
closeReason?: string;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Authentication result
|
|
133
|
+
*/
|
|
134
|
+
export interface AuthResult {
|
|
135
|
+
authenticated: boolean;
|
|
136
|
+
clientId?: string;
|
|
137
|
+
metadata?: Record<string, unknown>;
|
|
138
|
+
error?: string;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Authentication handler function type
|
|
143
|
+
*/
|
|
144
|
+
export type AuthHandler = (request: unknown, headers: Record<string, string>) => Promise<AuthResult> | AuthResult;
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Workflow model with WebSocket trigger configuration
|
|
148
|
+
*/
|
|
149
|
+
interface WebSocketWorkflowModel {
|
|
150
|
+
path: string;
|
|
151
|
+
config: {
|
|
152
|
+
name: string;
|
|
153
|
+
version: string;
|
|
154
|
+
trigger?: {
|
|
155
|
+
websocket?: WebSocketTriggerOpts;
|
|
156
|
+
[key: string]: unknown;
|
|
157
|
+
};
|
|
158
|
+
[key: string]: unknown;
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* WebSocketTrigger - Handle WebSocket connections and messages
|
|
164
|
+
*/
|
|
165
|
+
export abstract class WebSocketTrigger extends TriggerBase {
|
|
166
|
+
protected nodeMap: GlobalOptions = {} as GlobalOptions;
|
|
167
|
+
protected readonly tracer = trace.getTracer(
|
|
168
|
+
process.env.PROJECT_NAME || "trigger-websocket-workflow",
|
|
169
|
+
process.env.PROJECT_VERSION || "0.0.1",
|
|
170
|
+
);
|
|
171
|
+
protected readonly logger = new DefaultLogger();
|
|
172
|
+
protected websocketWorkflows: WebSocketWorkflowModel[] = [];
|
|
173
|
+
|
|
174
|
+
// Connection management
|
|
175
|
+
protected clients: Map<string, WebSocketClient> = new Map();
|
|
176
|
+
protected rooms: Map<string, WebSocketRoom> = new Map();
|
|
177
|
+
|
|
178
|
+
// Metrics
|
|
179
|
+
protected activeConnections = 0;
|
|
180
|
+
protected totalMessages = 0;
|
|
181
|
+
|
|
182
|
+
// Configuration
|
|
183
|
+
protected heartbeatInterval: NodeJS.Timeout | null = null;
|
|
184
|
+
protected heartbeatIntervalMs = 30000; // 30 seconds
|
|
185
|
+
protected maxClients = 10000;
|
|
186
|
+
protected messageRateLimit = 100; // messages per second per client
|
|
187
|
+
|
|
188
|
+
// Subclasses provide these
|
|
189
|
+
protected abstract nodes: Record<string, BlokService<unknown>>;
|
|
190
|
+
protected abstract workflows: Record<string, HelperResponse>;
|
|
191
|
+
|
|
192
|
+
// Optional auth handler
|
|
193
|
+
protected authHandler?: AuthHandler;
|
|
194
|
+
|
|
195
|
+
constructor() {
|
|
196
|
+
super();
|
|
197
|
+
this.loadNodes();
|
|
198
|
+
this.loadWorkflows();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Load nodes into the node map
|
|
203
|
+
*/
|
|
204
|
+
loadNodes(): void {
|
|
205
|
+
this.nodeMap.nodes = new NodeMap();
|
|
206
|
+
if (this.nodes) {
|
|
207
|
+
const nodeKeys = Object.keys(this.nodes);
|
|
208
|
+
for (const key of nodeKeys) {
|
|
209
|
+
this.nodeMap.nodes.addNode(key, this.nodes[key]);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Load workflows into the workflow map
|
|
216
|
+
*/
|
|
217
|
+
loadWorkflows(): void {
|
|
218
|
+
this.nodeMap.workflows = this.workflows || {};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Set authentication handler
|
|
223
|
+
*/
|
|
224
|
+
setAuthHandler(handler: AuthHandler): void {
|
|
225
|
+
this.authHandler = handler;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Initialize WebSocket trigger
|
|
230
|
+
*/
|
|
231
|
+
async listen(): Promise<number> {
|
|
232
|
+
const startTime = this.startCounter();
|
|
233
|
+
|
|
234
|
+
// Find all workflows with WebSocket triggers
|
|
235
|
+
this.websocketWorkflows = this.getWebSocketWorkflows();
|
|
236
|
+
|
|
237
|
+
if (this.websocketWorkflows.length === 0) {
|
|
238
|
+
this.logger.log("No workflows with WebSocket triggers found");
|
|
239
|
+
} else {
|
|
240
|
+
this.logger.log(`WebSocket trigger initialized. ${this.websocketWorkflows.length} workflow(s) registered`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Start heartbeat monitoring
|
|
244
|
+
this.startHeartbeat();
|
|
245
|
+
|
|
246
|
+
// Enable HMR in development mode
|
|
247
|
+
if (process.env.BLOK_HMR === "true" || process.env.NODE_ENV === "development") {
|
|
248
|
+
await this.enableHotReload();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return this.endCounter(startTime);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Stop the WebSocket trigger
|
|
256
|
+
*/
|
|
257
|
+
async stop(): Promise<void> {
|
|
258
|
+
// Stop heartbeat
|
|
259
|
+
this.stopHeartbeat();
|
|
260
|
+
|
|
261
|
+
// Close all client connections
|
|
262
|
+
for (const client of this.clients.values()) {
|
|
263
|
+
client.close(1001, "Server shutting down");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Clear state
|
|
267
|
+
this.clients.clear();
|
|
268
|
+
this.rooms.clear();
|
|
269
|
+
this.websocketWorkflows = [];
|
|
270
|
+
this.activeConnections = 0;
|
|
271
|
+
|
|
272
|
+
this.logger.log("WebSocket trigger stopped");
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
protected override async onHmrWorkflowChange(): Promise<void> {
|
|
276
|
+
// Lightweight: refresh workflow list without disconnecting clients
|
|
277
|
+
this.loadWorkflows();
|
|
278
|
+
this.websocketWorkflows = this.getWebSocketWorkflows();
|
|
279
|
+
this.logger.log(`[HMR] WebSocket workflows reloaded. ${this.websocketWorkflows.length} workflow(s) registered`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Handle new WebSocket connection
|
|
284
|
+
*/
|
|
285
|
+
async handleConnection(
|
|
286
|
+
socket: {
|
|
287
|
+
send: (data: string | Buffer) => void;
|
|
288
|
+
close: (code?: number, reason?: string) => void;
|
|
289
|
+
ping: () => void;
|
|
290
|
+
},
|
|
291
|
+
request: unknown,
|
|
292
|
+
headers: Record<string, string> = {},
|
|
293
|
+
): Promise<WebSocketClient | null> {
|
|
294
|
+
// Check max connections
|
|
295
|
+
if (this.clients.size >= this.maxClients) {
|
|
296
|
+
this.logger.error("Max connections reached, rejecting new connection");
|
|
297
|
+
socket.close(1013, "Server at capacity");
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Authenticate if handler is set
|
|
302
|
+
let clientId = uuid();
|
|
303
|
+
let metadata: Record<string, unknown> = {};
|
|
304
|
+
|
|
305
|
+
if (this.authHandler) {
|
|
306
|
+
const authResult = await this.authHandler(request, headers);
|
|
307
|
+
if (!authResult.authenticated) {
|
|
308
|
+
this.logger.error(`Authentication failed: ${authResult.error}`);
|
|
309
|
+
socket.close(4001, authResult.error || "Authentication failed");
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
if (authResult.clientId) {
|
|
313
|
+
clientId = authResult.clientId;
|
|
314
|
+
}
|
|
315
|
+
if (authResult.metadata) {
|
|
316
|
+
metadata = authResult.metadata;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Create client object
|
|
321
|
+
const client: WebSocketClient = {
|
|
322
|
+
id: clientId,
|
|
323
|
+
state: "connected",
|
|
324
|
+
rooms: new Set(),
|
|
325
|
+
metadata,
|
|
326
|
+
connectedAt: new Date(),
|
|
327
|
+
lastActivity: new Date(),
|
|
328
|
+
send: (data: string | Buffer) => {
|
|
329
|
+
if (client.state === "connected") {
|
|
330
|
+
socket.send(data);
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
close: (code?: number, reason?: string) => {
|
|
334
|
+
client.state = "disconnecting";
|
|
335
|
+
socket.close(code, reason);
|
|
336
|
+
},
|
|
337
|
+
ping: () => {
|
|
338
|
+
socket.ping();
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
// Register client
|
|
343
|
+
this.clients.set(clientId, client);
|
|
344
|
+
this.activeConnections++;
|
|
345
|
+
|
|
346
|
+
// Trigger connection event
|
|
347
|
+
await this.triggerEvent({
|
|
348
|
+
type: "connection",
|
|
349
|
+
clientId,
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
this.logger.log(`Client connected: ${clientId} (${this.activeConnections} active)`);
|
|
353
|
+
|
|
354
|
+
return client;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Handle WebSocket message
|
|
359
|
+
*/
|
|
360
|
+
async handleMessage(clientId: string, data: string | Buffer, isBinary: boolean): Promise<TriggerResponse | null> {
|
|
361
|
+
const client = this.clients.get(clientId);
|
|
362
|
+
if (!client) {
|
|
363
|
+
this.logger.error(`Message from unknown client: ${clientId}`);
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Update activity timestamp
|
|
368
|
+
client.lastActivity = new Date();
|
|
369
|
+
this.totalMessages++;
|
|
370
|
+
|
|
371
|
+
// Parse message
|
|
372
|
+
let message: WebSocketMessage;
|
|
373
|
+
try {
|
|
374
|
+
if (isBinary) {
|
|
375
|
+
message = {
|
|
376
|
+
id: uuid(),
|
|
377
|
+
type: "binary",
|
|
378
|
+
event: "binary",
|
|
379
|
+
data: data,
|
|
380
|
+
timestamp: new Date(),
|
|
381
|
+
raw: data as Buffer,
|
|
382
|
+
};
|
|
383
|
+
} else {
|
|
384
|
+
const text = data.toString();
|
|
385
|
+
let parsed: { event?: string; data?: unknown } = {};
|
|
386
|
+
try {
|
|
387
|
+
parsed = JSON.parse(text);
|
|
388
|
+
} catch {
|
|
389
|
+
parsed = { event: "message", data: text };
|
|
390
|
+
}
|
|
391
|
+
message = {
|
|
392
|
+
id: uuid(),
|
|
393
|
+
type: "text",
|
|
394
|
+
event: parsed.event || "message",
|
|
395
|
+
data: parsed.data ?? parsed,
|
|
396
|
+
timestamp: new Date(),
|
|
397
|
+
raw: text,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
} catch (error) {
|
|
401
|
+
this.logger.error(`Failed to parse message: ${(error as Error).message}`);
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Trigger message event
|
|
406
|
+
return this.triggerEvent({
|
|
407
|
+
type: "message",
|
|
408
|
+
clientId,
|
|
409
|
+
message,
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Handle WebSocket close
|
|
415
|
+
*/
|
|
416
|
+
async handleClose(clientId: string, code: number, reason: string): Promise<void> {
|
|
417
|
+
const client = this.clients.get(clientId);
|
|
418
|
+
if (!client) return;
|
|
419
|
+
|
|
420
|
+
client.state = "disconnected";
|
|
421
|
+
|
|
422
|
+
// Remove from all rooms
|
|
423
|
+
for (const roomName of client.rooms) {
|
|
424
|
+
const room = this.rooms.get(roomName);
|
|
425
|
+
if (room) {
|
|
426
|
+
room.clients.delete(clientId);
|
|
427
|
+
// Clean up empty rooms
|
|
428
|
+
if (room.clients.size === 0) {
|
|
429
|
+
this.rooms.delete(roomName);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Unregister client
|
|
435
|
+
this.clients.delete(clientId);
|
|
436
|
+
this.activeConnections--;
|
|
437
|
+
|
|
438
|
+
// Trigger close event
|
|
439
|
+
await this.triggerEvent({
|
|
440
|
+
type: "close",
|
|
441
|
+
clientId,
|
|
442
|
+
closeCode: code,
|
|
443
|
+
closeReason: reason,
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
this.logger.log(`Client disconnected: ${clientId} (code: ${code}, reason: ${reason})`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Handle WebSocket error
|
|
451
|
+
*/
|
|
452
|
+
async handleError(clientId: string, error: Error): Promise<void> {
|
|
453
|
+
this.logger.error(`WebSocket error for client ${clientId}: ${error.message}`);
|
|
454
|
+
|
|
455
|
+
await this.triggerEvent({
|
|
456
|
+
type: "error",
|
|
457
|
+
clientId,
|
|
458
|
+
error,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Handle ping from client
|
|
464
|
+
*/
|
|
465
|
+
handlePing(clientId: string): void {
|
|
466
|
+
const client = this.clients.get(clientId);
|
|
467
|
+
if (client) {
|
|
468
|
+
client.lastActivity = new Date();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Handle pong from client
|
|
474
|
+
*/
|
|
475
|
+
handlePong(clientId: string): void {
|
|
476
|
+
const client = this.clients.get(clientId);
|
|
477
|
+
if (client) {
|
|
478
|
+
client.lastActivity = new Date();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Join a room/channel
|
|
484
|
+
*/
|
|
485
|
+
async joinRoom(clientId: string, roomName: string): Promise<boolean> {
|
|
486
|
+
const client = this.clients.get(clientId);
|
|
487
|
+
if (!client) return false;
|
|
488
|
+
|
|
489
|
+
// Create room if it doesn't exist
|
|
490
|
+
if (!this.rooms.has(roomName)) {
|
|
491
|
+
this.rooms.set(roomName, {
|
|
492
|
+
name: roomName,
|
|
493
|
+
clients: new Set(),
|
|
494
|
+
metadata: {},
|
|
495
|
+
createdAt: new Date(),
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const room = this.rooms.get(roomName)!;
|
|
500
|
+
room.clients.add(clientId);
|
|
501
|
+
client.rooms.add(roomName);
|
|
502
|
+
|
|
503
|
+
// Trigger join event
|
|
504
|
+
await this.triggerEvent({
|
|
505
|
+
type: "join_room",
|
|
506
|
+
clientId,
|
|
507
|
+
room: roomName,
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
this.logger.log(`Client ${clientId} joined room: ${roomName}`);
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Leave a room/channel
|
|
516
|
+
*/
|
|
517
|
+
async leaveRoom(clientId: string, roomName: string): Promise<boolean> {
|
|
518
|
+
const client = this.clients.get(clientId);
|
|
519
|
+
if (!client) return false;
|
|
520
|
+
|
|
521
|
+
const room = this.rooms.get(roomName);
|
|
522
|
+
if (!room) return false;
|
|
523
|
+
|
|
524
|
+
room.clients.delete(clientId);
|
|
525
|
+
client.rooms.delete(roomName);
|
|
526
|
+
|
|
527
|
+
// Clean up empty rooms
|
|
528
|
+
if (room.clients.size === 0) {
|
|
529
|
+
this.rooms.delete(roomName);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Trigger leave event
|
|
533
|
+
await this.triggerEvent({
|
|
534
|
+
type: "leave_room",
|
|
535
|
+
clientId,
|
|
536
|
+
room: roomName,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
this.logger.log(`Client ${clientId} left room: ${roomName}`);
|
|
540
|
+
return true;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Send message to a specific client
|
|
545
|
+
*/
|
|
546
|
+
sendToClient(clientId: string, event: string, data: unknown): boolean {
|
|
547
|
+
const client = this.clients.get(clientId);
|
|
548
|
+
if (!client || client.state !== "connected") return false;
|
|
549
|
+
|
|
550
|
+
const message = JSON.stringify({ event, data });
|
|
551
|
+
client.send(message);
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Broadcast message to all clients in a room
|
|
557
|
+
*/
|
|
558
|
+
broadcastToRoom(roomName: string, event: string, data: unknown, excludeClient?: string): number {
|
|
559
|
+
const room = this.rooms.get(roomName);
|
|
560
|
+
if (!room) return 0;
|
|
561
|
+
|
|
562
|
+
const message = JSON.stringify({ event, data });
|
|
563
|
+
let sent = 0;
|
|
564
|
+
|
|
565
|
+
for (const clientId of room.clients) {
|
|
566
|
+
if (excludeClient && clientId === excludeClient) continue;
|
|
567
|
+
|
|
568
|
+
const client = this.clients.get(clientId);
|
|
569
|
+
if (client && client.state === "connected") {
|
|
570
|
+
client.send(message);
|
|
571
|
+
sent++;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
return sent;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Broadcast message to all connected clients
|
|
580
|
+
*/
|
|
581
|
+
broadcastToAll(event: string, data: unknown, excludeClient?: string): number {
|
|
582
|
+
const message = JSON.stringify({ event, data });
|
|
583
|
+
let sent = 0;
|
|
584
|
+
|
|
585
|
+
for (const [clientId, client] of this.clients) {
|
|
586
|
+
if (excludeClient && clientId === excludeClient) continue;
|
|
587
|
+
|
|
588
|
+
if (client.state === "connected") {
|
|
589
|
+
client.send(message);
|
|
590
|
+
sent++;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return sent;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Get client by ID
|
|
599
|
+
*/
|
|
600
|
+
getClient(clientId: string): WebSocketClient | undefined {
|
|
601
|
+
return this.clients.get(clientId);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Get all clients in a room
|
|
606
|
+
*/
|
|
607
|
+
getClientsInRoom(roomName: string): WebSocketClient[] {
|
|
608
|
+
const room = this.rooms.get(roomName);
|
|
609
|
+
if (!room) return [];
|
|
610
|
+
|
|
611
|
+
const clients: WebSocketClient[] = [];
|
|
612
|
+
for (const clientId of room.clients) {
|
|
613
|
+
const client = this.clients.get(clientId);
|
|
614
|
+
if (client) {
|
|
615
|
+
clients.push(client);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return clients;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Get connection stats
|
|
623
|
+
*/
|
|
624
|
+
getStats(): {
|
|
625
|
+
activeConnections: number;
|
|
626
|
+
totalMessages: number;
|
|
627
|
+
roomCount: number;
|
|
628
|
+
clientsByRoom: Record<string, number>;
|
|
629
|
+
} {
|
|
630
|
+
const clientsByRoom: Record<string, number> = {};
|
|
631
|
+
for (const [name, room] of this.rooms) {
|
|
632
|
+
clientsByRoom[name] = room.clients.size;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
activeConnections: this.activeConnections,
|
|
637
|
+
totalMessages: this.totalMessages,
|
|
638
|
+
roomCount: this.rooms.size,
|
|
639
|
+
clientsByRoom,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* Get all workflows that have WebSocket triggers
|
|
645
|
+
*/
|
|
646
|
+
protected getWebSocketWorkflows(): WebSocketWorkflowModel[] {
|
|
647
|
+
const workflows: WebSocketWorkflowModel[] = [];
|
|
648
|
+
|
|
649
|
+
for (const [path, workflow] of Object.entries(this.nodeMap.workflows || {})) {
|
|
650
|
+
const workflowConfig = (workflow as unknown as { _config: WebSocketWorkflowModel["config"] })._config;
|
|
651
|
+
|
|
652
|
+
if (workflowConfig?.trigger) {
|
|
653
|
+
const triggerType = Object.keys(workflowConfig.trigger)[0];
|
|
654
|
+
|
|
655
|
+
if (triggerType === "websocket" && workflowConfig.trigger.websocket) {
|
|
656
|
+
workflows.push({
|
|
657
|
+
path,
|
|
658
|
+
config: workflowConfig,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return workflows;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Find workflow matching the WebSocket event
|
|
669
|
+
*/
|
|
670
|
+
protected findMatchingWorkflow(event: WebSocketEvent): WebSocketWorkflowModel | null {
|
|
671
|
+
for (const workflow of this.websocketWorkflows) {
|
|
672
|
+
const config = workflow.config.trigger?.websocket;
|
|
673
|
+
if (!config) continue;
|
|
674
|
+
|
|
675
|
+
// Check event type match
|
|
676
|
+
if (config.events && config.events.length > 0) {
|
|
677
|
+
const eventName = event.type === "message" ? event.message?.event || "message" : event.type;
|
|
678
|
+
|
|
679
|
+
const matches = config.events.some((pattern) => {
|
|
680
|
+
if (pattern === "*") return true;
|
|
681
|
+
if (pattern.endsWith(".*")) {
|
|
682
|
+
const prefix = pattern.slice(0, -2);
|
|
683
|
+
return eventName.startsWith(prefix);
|
|
684
|
+
}
|
|
685
|
+
return pattern === eventName;
|
|
686
|
+
});
|
|
687
|
+
if (!matches) continue;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Check room filter
|
|
691
|
+
if (config.rooms && config.rooms.length > 0 && event.room) {
|
|
692
|
+
if (!config.rooms.includes(event.room)) continue;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return workflow;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* Trigger a workflow based on WebSocket event
|
|
703
|
+
*/
|
|
704
|
+
protected async triggerEvent(event: WebSocketEvent): Promise<TriggerResponse | null> {
|
|
705
|
+
// Find matching workflow
|
|
706
|
+
const workflow = this.findMatchingWorkflow(event);
|
|
707
|
+
if (!workflow) {
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const config = workflow.config.trigger?.websocket as WebSocketTriggerOpts;
|
|
712
|
+
return this.executeWorkflow(event, workflow, config);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Execute a workflow for a WebSocket event
|
|
717
|
+
*/
|
|
718
|
+
protected async executeWorkflow(
|
|
719
|
+
event: WebSocketEvent,
|
|
720
|
+
workflow: WebSocketWorkflowModel,
|
|
721
|
+
_config: WebSocketTriggerOpts,
|
|
722
|
+
): Promise<TriggerResponse> {
|
|
723
|
+
const executionId = uuid();
|
|
724
|
+
|
|
725
|
+
const defaultMeter = metrics.getMeter("default");
|
|
726
|
+
const wsExecutions = defaultMeter.createCounter("websocket_executions", {
|
|
727
|
+
description: "WebSocket workflow executions",
|
|
728
|
+
});
|
|
729
|
+
const wsErrors = defaultMeter.createCounter("websocket_errors", {
|
|
730
|
+
description: "WebSocket execution errors",
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
return new Promise((resolve) => {
|
|
734
|
+
this.tracer.startActiveSpan(`websocket:${event.type}`, async (span: Span) => {
|
|
735
|
+
try {
|
|
736
|
+
const start = performance.now();
|
|
737
|
+
|
|
738
|
+
// Initialize configuration for this workflow
|
|
739
|
+
await this.configuration.init(workflow.path, this.nodeMap);
|
|
740
|
+
|
|
741
|
+
// Create context
|
|
742
|
+
const ctx: Context = this.createContext(undefined, workflow.path, executionId);
|
|
743
|
+
|
|
744
|
+
// Get client info
|
|
745
|
+
const client = this.clients.get(event.clientId);
|
|
746
|
+
|
|
747
|
+
// Populate request with WebSocket event
|
|
748
|
+
ctx.request = {
|
|
749
|
+
body: event.message?.data ?? event,
|
|
750
|
+
headers: {},
|
|
751
|
+
query: {},
|
|
752
|
+
params: {
|
|
753
|
+
clientId: event.clientId,
|
|
754
|
+
eventType: event.type,
|
|
755
|
+
messageEvent: event.message?.event,
|
|
756
|
+
room: event.room,
|
|
757
|
+
},
|
|
758
|
+
} as unknown as RequestContext;
|
|
759
|
+
|
|
760
|
+
// Store WebSocket context in vars (use type assertion for flexibility)
|
|
761
|
+
if (!ctx.vars) ctx.vars = {};
|
|
762
|
+
(ctx.vars as Record<string, unknown>)._websocket = {
|
|
763
|
+
clientId: event.clientId,
|
|
764
|
+
eventType: event.type,
|
|
765
|
+
messageId: event.message?.id,
|
|
766
|
+
messageEvent: event.message?.event,
|
|
767
|
+
room: event.room,
|
|
768
|
+
clientRooms: client ? Array.from(client.rooms) : [],
|
|
769
|
+
clientMetadata: client?.metadata || {},
|
|
770
|
+
timestamp: new Date().toISOString(),
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
// Add helper functions to context for sending responses
|
|
774
|
+
(ctx.vars as Record<string, unknown>)._websocket_send = (data: unknown) => {
|
|
775
|
+
this.sendToClient(event.clientId, "response", data);
|
|
776
|
+
};
|
|
777
|
+
(ctx.vars as Record<string, unknown>)._websocket_broadcast = (room: string, data: unknown) => {
|
|
778
|
+
this.broadcastToRoom(room, "broadcast", data, event.clientId);
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
ctx.logger.log(`Processing WebSocket event: ${event.type} from ${event.clientId}`);
|
|
782
|
+
|
|
783
|
+
// Execute workflow
|
|
784
|
+
const response: TriggerResponse = await this.run(ctx);
|
|
785
|
+
const end = performance.now();
|
|
786
|
+
|
|
787
|
+
// Set span attributes
|
|
788
|
+
span.setAttribute("success", true);
|
|
789
|
+
span.setAttribute("client_id", event.clientId);
|
|
790
|
+
span.setAttribute("event_type", event.type);
|
|
791
|
+
span.setAttribute("workflow_path", workflow.path);
|
|
792
|
+
span.setAttribute("elapsed_ms", end - start);
|
|
793
|
+
span.setStatus({ code: SpanStatusCode.OK });
|
|
794
|
+
|
|
795
|
+
// Record metrics
|
|
796
|
+
wsExecutions.add(1, {
|
|
797
|
+
env: process.env.NODE_ENV,
|
|
798
|
+
event_type: event.type,
|
|
799
|
+
workflow_name: this.configuration.name,
|
|
800
|
+
success: "true",
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
ctx.logger.log(`WebSocket event processed in ${(end - start).toFixed(2)}ms`);
|
|
804
|
+
|
|
805
|
+
resolve(response);
|
|
806
|
+
} catch (error) {
|
|
807
|
+
const errorMessage = (error as Error).message;
|
|
808
|
+
|
|
809
|
+
// Set span error
|
|
810
|
+
span.setAttribute("success", false);
|
|
811
|
+
span.recordException(error as Error);
|
|
812
|
+
span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage });
|
|
813
|
+
|
|
814
|
+
// Record error metrics
|
|
815
|
+
wsErrors.add(1, {
|
|
816
|
+
env: process.env.NODE_ENV,
|
|
817
|
+
event_type: event.type,
|
|
818
|
+
workflow_name: this.configuration?.name || "unknown",
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
this.logger.error(`WebSocket workflow failed: ${errorMessage}`, (error as Error).stack);
|
|
822
|
+
|
|
823
|
+
throw error;
|
|
824
|
+
} finally {
|
|
825
|
+
span.end();
|
|
826
|
+
}
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Start heartbeat monitoring
|
|
833
|
+
*/
|
|
834
|
+
protected startHeartbeat(): void {
|
|
835
|
+
this.heartbeatInterval = setInterval(() => {
|
|
836
|
+
const now = Date.now();
|
|
837
|
+
const staleThreshold = this.heartbeatIntervalMs * 2;
|
|
838
|
+
|
|
839
|
+
for (const [clientId, client] of this.clients) {
|
|
840
|
+
const lastActivity = client.lastActivity.getTime();
|
|
841
|
+
|
|
842
|
+
// Check for stale connections
|
|
843
|
+
if (now - lastActivity > staleThreshold) {
|
|
844
|
+
this.logger.log(`Closing stale connection: ${clientId}`);
|
|
845
|
+
client.close(1000, "Connection timed out");
|
|
846
|
+
} else {
|
|
847
|
+
// Ping active connections
|
|
848
|
+
try {
|
|
849
|
+
client.ping();
|
|
850
|
+
} catch (error) {
|
|
851
|
+
this.logger.error(`Ping failed for ${clientId}: ${(error as Error).message}`);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}, this.heartbeatIntervalMs);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Stop heartbeat monitoring
|
|
860
|
+
*/
|
|
861
|
+
protected stopHeartbeat(): void {
|
|
862
|
+
if (this.heartbeatInterval) {
|
|
863
|
+
clearInterval(this.heartbeatInterval);
|
|
864
|
+
this.heartbeatInterval = null;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
export default WebSocketTrigger;
|