UNPKG

@synet/realtime

Version:

Realtime Communication server/client implementations

279 lines (240 loc) 7.77 kB
import WebSocket, { WebSocketServer } from "ws"; import type { IncomingMessage } from "node:http"; import crypto from "node:crypto"; import type { RealtimeServer, RealtimeServerOptions, RealtimeServerStats, ClientConnectedEventData, ClientDisconnectedEventData, MessageReceivedEventData, ServerEventType, RealtimeEvent, Topic, } from "@synet/patterns/realtime/server"; import type { Logger } from "@synet/logger"; /** * WebSocket-specific options */ export interface WebSocketServerOptions { port?: number; host?: string; heartbeat?: { enabled: boolean; intervalMs: number; timeoutMs: number; }; } export class WebSocketRealtimeServer implements RealtimeServer { private wsServer?: WebSocketServer; private clients: Map<string, WebSocket> = new Map(); private subscriptions: Map<Topic, Set<string>> = new Map(); // topic -> client ids private stats: RealtimeServerStats; private startTime: number = Date.now(); private eventHandlers: Map<string, Set<(data: unknown) => void>> = new Map(); constructor( private options: RealtimeServerOptions<WebSocketServerOptions> = {}, private logger?: Logger, // Use console for simplicity, can be replaced with a proper Logger interface ) { this.stats = { connectedClients: 0, totalTopics: 0, messagesSent: 0, messagesReceived: 0, uptime: 0, }; } async start(): Promise<void> { const port = this.options.transportOptions?.port || 3030; this.wsServer = new WebSocketServer({ port }); this.wsServer.on("connection", (socket: WebSocket, request:IncomingMessage) => { this.handleNewConnection(socket, request); }); this.logger?.info(`Websocket server listening on port ${port}`); } async stop(): Promise<void> { if (!this.wsServer) return; // Close all client connections const disconnectPromises = Array.from(this.clients.values()).map( (client) => new Promise<void>((resolve) => { client.close(); resolve(); }), ); await Promise.all(disconnectPromises); // Close server return new Promise((resolve) => { if (this.wsServer) { this.wsServer.close(() => { this.clients.clear(); this.subscriptions.clear(); resolve(); }); } else { resolve(); } }); } async broadcast(topic: Topic, event: RealtimeEvent): Promise<void> { const clientIds = this.subscriptions.get(topic); if (!clientIds || clientIds.size === 0) { this.logger?.info(`No subscribers for topic: ${topic}`); return; } const message = JSON.stringify(event); let sentCount = 0; for (const clientId of clientIds) { const client = this.clients.get(clientId); if (client && client.readyState === WebSocket.OPEN) { try { client.send(message); sentCount++; } catch (error) { console.error(`Failed to send to client ${clientId}:`, error); } } } this.stats.messagesSent += sentCount; this.logger?.debug( `Broadcasted to ${topic}: ${sentCount} clients received event`, ); } private handleNewConnection( socket: WebSocket, request: IncomingMessage, ): void { const clientId = crypto.randomUUID(); this.clients.set(clientId, socket); // Parse topic from URL path or query parameter const url = new URL(request.url || "/", `http://${request.headers.host}`); const topic = url.searchParams.get("topic") || url.pathname.split("/").filter(Boolean)[0] || "default"; this.subscribeClientToTopic(clientId, topic); this.logger?.info( `Client ${clientId} connected and subscribed to: ${topic}`, ); const eventData: ClientConnectedEventData = { clientId, topic, timestamp: new Date(), metadata: { ip: request.socket.remoteAddress, userAgent: request.headers["user-agent"] as string | undefined, }, }; // Emit with proper typing this.emit("client.connected", eventData); // Handle client disconnection socket.on("close", () => { this.handleClientDisconnection(clientId); }); // Handle client messages (minimal - just for subscribe/unsubscribe) socket.on("message", (data: RealtimeEvent) => { try { const message = JSON.parse(data.toString()); if (message.type === "subscribe" && message.topic) { this.subscribeClientToTopic(clientId, message.topic); } else if (message.type === "unsubscribe" && message.topic) { this.unsubscribeClientFromTopic(clientId, message.topic); } // Note: We don't handle 'publish' here - that's for bidirectional later } catch (error) { console.error("Error processing client message:", error); } }); // Send connection confirmation socket.send( JSON.stringify({ id: crypto.randomUUID(), type: "connection.established", source: "server", timestamp: new Date(), data: { clientId, subscribedTopic: topic }, }), ); } private subscribeClientToTopic(clientId: string, topic: Topic): void { if (!this.subscriptions.has(topic)) { this.subscriptions.set(topic, new Set()); } const subscribers = this.subscriptions.get(topic); if (subscribers) { subscribers.add(clientId); } this.logger?.info(`Client ${clientId} subscribed to topic: ${topic}`); } private unsubscribeClientFromTopic(clientId: string, topic: Topic): void { const subscribers = this.subscriptions.get(topic); if (subscribers) { subscribers.delete(clientId); if (subscribers.size === 0) { this.subscriptions.delete(topic); } } this.logger?.info(`Client ${clientId} unsubscribed from topic: ${topic}`); } private handleClientDisconnection(clientId: string): void { this.logger?.info(`Client ${clientId} disconnected`); const subscribedTopics: Topic[] = []; for (const [topic, subscribers] of this.subscriptions.entries()) { if (subscribers.has(clientId)) { subscribedTopics.push(topic); } } this.clients.delete(clientId); // Remove from all topic subscriptions for (const [topic, subscribers] of this.subscriptions.entries()) { subscribers.delete(clientId); if (subscribers.size === 0) { this.subscriptions.delete(topic); } } // Create properly typed event data const eventData: ClientDisconnectedEventData = { clientId, topics: subscribedTopics, timestamp: new Date(), }; // Emit with proper typing this.emit("client.disconnected", eventData); } getStats(): RealtimeServerStats { return { ...this.stats, connectedClients: this.clients.size, totalTopics: this.subscriptions.size, uptime: Date.now() - this.startTime, }; } on<T extends ServerEventType>( event: T, handler: (data: unknown) => void, ): void { if (!this.eventHandlers.has(event)) { this.eventHandlers.set(event, new Set()); } const handlers = this.eventHandlers.get(event); if (handlers) { handlers.add(handler); } } /** * Emit an event to all registered handlers * @param event The event type * @param data The event data */ private emit(event: ServerEventType, data: unknown): void { const handlers = this.eventHandlers.get(event) || new Set(); for (const handler of handlers) { try { handler(data); } catch (error) { console.error(`Error in event handler for ${event}:`, error); } } } // ... other methods (getStats, on, stop, etc.) }