@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,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;