UNPKG

@synet/realtime

Version:

Realtime Communication server/client implementations

660 lines (567 loc) 18.7 kB
import { connect, StringCodec, type NatsConnection, type Subscription, } from "nats"; import crypto from "node:crypto"; import type { Logger } from "@synet/logger"; import type { RealtimeServer, RealtimeServerOptions, RealtimeServerStats, ClientConnectedEventData, ClientDisconnectedEventData, MessageReceivedEventData, ServerEventType, RealtimeEvent, Topic, } from "@synet/patterns/realtime"; import chalk from "chalk"; /** * NATS-specific options */ export interface NatsServerOptions { url?: string; user?: string; password?: string; token?: string; reconnect?: { enabled: boolean; maxAttempts: number; delayMs: number; }; } /** * Client information for tracking connections */ interface ClientInfo { clientId: string; subscriptions: Set<string>; lastSeen: number; metadata?: Record<string, unknown>; } // Add to nats-types.ts or directly in nats-realtime-server.ts export interface ControlConnectMessage { clientId?: string; topic?: string; clientInbox?: string; timestamp: Date | string; replyTo?: string; metadata?: Record<string, unknown>; } export interface ControlDisconnectMessage { clientId: string; timestamp: Date | string; topics: Topic[]; } export interface ControlSubscribeMessage { clientId: string; topic: string; } export interface ControlUnsubscribeMessage { clientId: string; topic: string; } // Union type for any control message export type ControlMessage = | ControlConnectMessage | ControlDisconnectMessage | ControlSubscribeMessage | ControlUnsubscribeMessage; export class NatsRealtimeServer implements RealtimeServer { private natsConnection?: NatsConnection; private clients: Map<string, ClientInfo> = 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(); private natsSubscriptions: Map<string, Subscription> = new Map(); private stringCodec = StringCodec(); constructor( private options: RealtimeServerOptions<NatsServerOptions> = {}, private logger?: Logger, ) { this.stats = { connectedClients: 0, totalTopics: 0, messagesSent: 0, messagesReceived: 0, uptime: 0, }; } async start(): Promise<void> { const natsUrl = this.options.transportOptions?.url || "nats://localhost:4222"; try { // Connect to NATS server this.natsConnection = await connect({ servers: natsUrl, user: this.options.transportOptions?.user, pass: this.options.transportOptions?.password, token: this.options.transportOptions?.token, reconnect: this.options.transportOptions?.reconnect?.enabled !== false, maxReconnectAttempts: this.options.transportOptions?.reconnect?.maxAttempts || -1, reconnectTimeWait: this.options.transportOptions?.reconnect?.delayMs || 1000, }); this.logger?.info(`NATS RealtimeServer connected to ${natsUrl}`); // Set up subscription for client connection messages await this.setupControlSubscriptions(); // Start periodic cleanup of stale clients this.startStaleClientCleanup(); } catch (error) { console.error("Failed to connect to NATS server:", error); throw error; } } async stop(): Promise<void> { if (!this.natsConnection) return; // Unsubscribe from all topics for (const subscription of this.natsSubscriptions.values()) { subscription.unsubscribe(); } // Drain and close connection to NATS server await this.natsConnection.drain(); await this.natsConnection.close(); // Clean up resources this.natsSubscriptions.clear(); this.clients.clear(); this.subscriptions.clear(); this.logger?.info("NATS RealtimeServer stopped"); } async broadcast(topic: Topic, event: RealtimeEvent): Promise<void> { if (!this.natsConnection) { throw new Error("NATS server not started"); } try { // Convert event to string for transmission const message = JSON.stringify(event); // Publish to NATS topic this.natsConnection.publish( `topic.${topic}`, this.stringCodec.encode(message), ); // Update stats this.stats.messagesSent++; // Log broadcast this.logger?.debug(`Broadcasted to ${topic}: Event ${event.type}`); } catch (error) { this.logger?.error(`Failed to broadcast to ${topic}:`, error); throw error; } } async sendToClient(clientId: string, event: RealtimeEvent): Promise<void> { if (!this.natsConnection) { throw new Error("NATS server not started"); } const client = this.clients.get(clientId); if (!client) { throw new Error(`Client ${clientId} not connected`); } try { // Convert event to string const message = JSON.stringify(event); // Send directly to client's inbox this.natsConnection.publish( `client.${clientId}`, this.stringCodec.encode(message), ); // Update stats this.stats.messagesSent++; // Log this.logger?.info( `Sent direct message to client ${clientId}: ${event.type}`, ); } catch (error) { console.error(`Failed to send to client ${clientId}:`, error); throw error; } } 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); } } /** * Set up subscriptions for client control messages */ private async setupControlSubscriptions(): Promise<void> { if (!this.natsConnection) return; try { // Subscribe to client connection messages const connectSub = await this.natsConnection.subscribe("control.connect"); this.natsSubscriptions.set("control.connect", connectSub); // Process connection requests (async () => { for await (const msg of connectSub) { try { const data = JSON.parse(this.stringCodec.decode(msg.data)); this.handleClientConnection(data, msg.reply); } catch (error) { console.error("Error processing client connection message:", error); } } })().catch((err) => console.error("Error in control.connect subscription:", err), ); // Subscribe to client disconnection messages const disconnectSub = await this.natsConnection.subscribe("control.disconnect"); this.natsSubscriptions.set("control.disconnect", disconnectSub); // Process disconnection requests (async () => { for await (const msg of disconnectSub) { try { const data = JSON.parse(this.stringCodec.decode(msg.data)); this.handleClientDisconnection(data.clientId); } catch (error) { this.logger?.error( "Error processing client disconnection message:", error, ); } } })().catch((err) => this.logger?.error("Error in control.disconnect subscription:", err), ); // Subscribe to subscription requests const subscribeSub = await this.natsConnection.subscribe("control.subscribe"); this.natsSubscriptions.set("control.subscribe", subscribeSub); // Process subscription requests (async () => { for await (const msg of subscribeSub) { try { const data = JSON.parse(this.stringCodec.decode(msg.data)); this.subscribeClientToTopic(data.clientId, data.topic); // Reply to confirm subscription if a reply subject is provided if (msg.reply) { const response = JSON.stringify({ success: true, topic: data.topic, }); this.natsConnection?.publish( msg.reply, this.stringCodec.encode(response), ); } } catch (error) { this.logger?.error("Error processing subscription request:", error); } } })().catch((err) => this.logger?.error("Error in control.subscribe subscription:", err), ); // Subscribe to unsubscription requests const unsubscribeSub = this.natsConnection.subscribe( "control.unsubscribe", ); this.natsSubscriptions.set("control.unsubscribe", unsubscribeSub); // Process unsubscription requests (async () => { for await (const msg of unsubscribeSub) { try { const data = JSON.parse(this.stringCodec.decode(msg.data)); this.unsubscribeClientFromTopic(data.clientId, data.topic); } catch (error) { this.logger?.error( "Error processing unsubscription request:", error, ); } } })().catch((err) => this.logger?.error("Error in control.unsubscribe subscription:", err), ); this.logger?.info("Control subscriptions set up"); } catch (error) { this.logger?.error("Failed to set up control subscriptions:", error); throw error; } } /** * Handle a new client connection */ private handleClientConnection( connectionData: ControlConnectMessage, replySubject?: string, ): void { // Generate client ID if not provided const clientId = connectionData.clientId || crypto.randomUUID(); const topic = connectionData.topic || "default"; this.logger?.info( `Client ${clientId} connecting to topic: ${chalk.bold(topic)}`, ); this.logger?.debug("Connection data", connectionData); // Store client information this.clients.set(clientId, { clientId, subscriptions: new Set(), lastSeen: Date.now(), metadata: connectionData.metadata || {}, }); // Subscribe to topic if provided if (topic) { this.subscribeClientToTopic(clientId, topic); } // Update stats this.stats.connectedClients = this.clients.size; // Emit connection event const eventData: ClientConnectedEventData = { clientId, topic, timestamp: new Date(), metadata: connectionData.metadata || {}, }; this.emit("client.connected", eventData); this.logger?.info( `Client ${clientId} connected and subscribed to: ${chalk.bold(topic)}`, ); // Send confirmation via reply subject if provided if (replySubject && this.natsConnection) { const confirmation = JSON.stringify({ id: crypto.randomUUID(), type: "connection.established", source: "server", timestamp: new Date(), data: { clientId, subscribedTopic: topic }, }); this.natsConnection.publish( replySubject, this.stringCodec.encode(confirmation), ); } } /** * Subscribe a client to a topic */ private subscribeClientToTopic(clientId: string, topic: Topic): void { // Create topic if it doesn't exist if (!this.subscriptions.has(topic)) { this.subscriptions.set(topic, new Set()); this.createTopicSubscription(topic); } // Add client to topic subscribers const subscribers = this.subscriptions.get(topic); if (subscribers) { subscribers.add(clientId); } // Update client's subscription list const client = this.clients.get(clientId); if (client) { client.subscriptions.add(topic); client.lastSeen = Date.now(); } this.logger?.info(`Client ${clientId} subscribed to topic: ${topic}`); } /** * Unsubscribe a client from a topic */ private unsubscribeClientFromTopic(clientId: string, topic: Topic): void { // Remove client from topic subscribers const subscribers = this.subscriptions.get(topic); if (subscribers) { subscribers.delete(clientId); if (subscribers.size === 0) { this.subscriptions.delete(topic); this.removeTopicSubscription(topic); } } // Update client's subscription list const client = this.clients.get(clientId); if (client) { client.subscriptions.delete(topic); client.lastSeen = Date.now(); } this.logger?.info(`Client ${clientId} unsubscribed from topic: ${topic}`); } /** * Handle client disconnection */ private handleClientDisconnection(clientId: string): void { this.logger?.info(`Client ${clientId} disconnected`); // Get client info const client = this.clients.get(clientId); if (!client) return; // Get subscribed topics before removal const subscribedTopics: Topic[] = Array.from(client.subscriptions); // Remove client this.clients.delete(clientId); // Remove from all topic subscriptions for (const topic of subscribedTopics) { const subscribers = this.subscriptions.get(topic); if (subscribers) { subscribers.delete(clientId); if (subscribers.size === 0) { this.subscriptions.delete(topic); this.removeTopicSubscription(topic); } } } // Update stats this.stats.connectedClients = this.clients.size; // Emit disconnection event const eventData: ClientDisconnectedEventData = { clientId, topics: subscribedTopics, timestamp: new Date(), }; this.emit("client.disconnected", eventData); } /** * Create a NATS subscription for a topic */ private async createTopicSubscription(topic: Topic): Promise<void> { if (!this.natsConnection) return; // Check if we already have a subscription if (this.natsSubscriptions.has(`topic.${topic}`)) return; try { // Create NATS subscription const subscription = await this.natsConnection.subscribe( `topic.${topic}`, ); this.natsSubscriptions.set(`topic.${topic}`, subscription); // Set up message handler (async () => { for await (const msg of subscription) { try { const event = JSON.parse(this.stringCodec.decode(msg.data)); // Update stats this.stats.messagesReceived++; // Emit message received event const messageEvent: MessageReceivedEventData = { clientId: event.clientId || "server", message: event, timestamp: new Date(), }; this.emit("message.received", messageEvent); // Forward to subscribers await this.forwardMessageToSubscribers(topic, event); } catch (error) { this.logger?.error( `Error processing message on topic ${topic}:`, error, ); } } })().catch((err) => this.logger?.error(`Error in topic.${topic} subscription:`, err), ); this.logger?.info(`Created subscription for topic: ${topic}`); } catch (error) { this.logger?.error( `Failed to create subscription for topic ${topic}:`, error, ); } } /** * Remove a NATS subscription for a topic */ private removeTopicSubscription(topic: Topic): void { const subKey = `topic.${topic}`; const subscription = this.natsSubscriptions.get(subKey); if (subscription) { subscription.unsubscribe(); this.natsSubscriptions.delete(subKey); this.logger?.info(`Removed subscription for topic: ${topic}`); } } /** * Forward a message to all clients subscribed to a topic */ private async forwardMessageToSubscribers( topic: Topic, event: RealtimeEvent, ): Promise<void> { if (!this.natsConnection) return; const subscribers = this.subscriptions.get(topic); if (!subscribers || subscribers.size === 0) return; const message = JSON.stringify(event); const messageData = this.stringCodec.encode(message); // Forward to each client's individual inbox const sendPromises: Promise<void>[] = []; for (const clientId of subscribers) { const client = this.clients.get(clientId); if (client) { // Update client's last seen timestamp client.lastSeen = Date.now(); // In NATS, each client would have a unique inbox const clientInbox = `client.${clientId}`; sendPromises.push( (async () => { try { if (this.natsConnection) { this.natsConnection.publish(clientInbox, messageData); this.stats.messagesSent++; } else { console.error( `Cannot forward message to client ${clientId}: No NATS connection`, ); } } catch (error) { console.error( `Failed to forward message to client ${clientId}:`, error, ); } })(), ); } } // Wait for all sends to complete if (sendPromises.length > 0) { await Promise.all(sendPromises); } this.logger?.info( `Forwarded message to ${sendPromises.length} clients on topic ${topic}`, ); } /** * Start periodic cleanup of stale clients */ private startStaleClientCleanup(): void { const cleanupInterval = setInterval(() => { const now = Date.now(); const staleTimeThreshold = 5 * 60 * 1000; // 5 minutes for (const [clientId, client] of this.clients.entries()) { if (now - client.lastSeen > staleTimeThreshold) { this.logger?.info(`Removing stale client: ${clientId}`); this.handleClientDisconnection(clientId); } } }, 60 * 1000); // Check every minute // Make sure cleanup interval doesn't prevent Node from exiting cleanupInterval.unref(); } /** * Emit an event to all registered handlers */ 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); } } } }