UNPKG

@synet/realtime

Version:

Realtime Communication server/client implementations

457 lines (456 loc) 18.7 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.NatsRealtimeServer = void 0; const nats_1 = require("nats"); const node_crypto_1 = __importDefault(require("node:crypto")); const chalk_1 = __importDefault(require("chalk")); class NatsRealtimeServer { constructor(options = {}, logger) { this.options = options; this.logger = logger; this.clients = new Map(); this.subscriptions = new Map(); // topic -> client ids this.startTime = Date.now(); this.eventHandlers = new Map(); this.natsSubscriptions = new Map(); this.stringCodec = (0, nats_1.StringCodec)(); this.stats = { connectedClients: 0, totalTopics: 0, messagesSent: 0, messagesReceived: 0, uptime: 0, }; } async start() { const natsUrl = this.options.transportOptions?.url || "nats://localhost:4222"; try { // Connect to NATS server this.natsConnection = await (0, nats_1.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() { 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, event) { 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, event) { 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() { return { ...this.stats, connectedClients: this.clients.size, totalTopics: this.subscriptions.size, uptime: Date.now() - this.startTime, }; } on(event, handler) { 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 */ async setupControlSubscriptions() { 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 */ handleClientConnection(connectionData, replySubject) { // Generate client ID if not provided const clientId = connectionData.clientId || node_crypto_1.default.randomUUID(); const topic = connectionData.topic || "default"; this.logger?.info(`Client ${clientId} connecting to topic: ${chalk_1.default.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 = { clientId, topic, timestamp: new Date(), metadata: connectionData.metadata || {}, }; this.emit("client.connected", eventData); this.logger?.info(`Client ${clientId} connected and subscribed to: ${chalk_1.default.bold(topic)}`); // Send confirmation via reply subject if provided if (replySubject && this.natsConnection) { const confirmation = JSON.stringify({ id: node_crypto_1.default.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 */ subscribeClientToTopic(clientId, topic) { // 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 */ unsubscribeClientFromTopic(clientId, topic) { // 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 */ handleClientDisconnection(clientId) { 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 = 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 = { clientId, topics: subscribedTopics, timestamp: new Date(), }; this.emit("client.disconnected", eventData); } /** * Create a NATS subscription for a topic */ async createTopicSubscription(topic) { 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 = { 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 */ removeTopicSubscription(topic) { 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 */ async forwardMessageToSubscribers(topic, event) { 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 = []; 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 */ startStaleClientCleanup() { 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 */ emit(event, data) { 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); } } } } exports.NatsRealtimeServer = NatsRealtimeServer;