UNPKG

@devgrid/netron

Version:

A powerful TypeScript library for building distributed systems with event bus, streaming capabilities, and remote object invocation. Features WebSocket-based bidirectional communication between Node.js and browser environments, service discovery, and type

307 lines 12.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.ServiceDiscovery = void 0; const node_fs_1 = __importDefault(require("node:fs")); const node_path_1 = __importDefault(require("node:path")); const ioredis_1 = require("ioredis"); const DEFAULT_HEARTBEAT_INTERVAL = 5000; const DEFAULT_HEARTBEAT_TTL = 15000; const REGISTER_HEARTBEAT_SCRIPT = node_fs_1.default.readFileSync(node_path_1.default.join(__dirname, '../../lua/register-heartbeat.lua'), 'utf8'); class ServiceDiscovery { constructor(redis, netron, address, services, options) { this.stopped = false; this.registered = false; if (!redis) { throw new Error('Redis instance must be provided'); } this.redis = redis; this.nodeId = netron.id; this.logger = netron.logger.child({ serviceDiscovery: true }); this.address = address; this.services = services; this.options = { heartbeatInterval: options?.heartbeatInterval ?? DEFAULT_HEARTBEAT_INTERVAL, heartbeatTTL: options?.heartbeatTTL ?? DEFAULT_HEARTBEAT_TTL, pubSubEnabled: options?.pubSubEnabled ?? false, pubSubChannel: options?.pubSubChannel ?? 'netron:discovery:events', clientMode: options?.clientMode ?? false, }; if (this.options.clientMode) { this.logger.info('ServiceDiscovery started in client mode (no heartbeat or node registration)'); } else { this.logger.info('ServiceDiscovery started in server mode', { nodeId: this.nodeId, address: this.address, services: this.services, }); } this.pubSubChannel = this.options.pubSubChannel; } startHeartbeat() { if (this.options.clientMode) { this.logger.info(`Heartbeat disabled (clientMode=true) for node '${this.nodeId}'`); return; } this.publishHeartbeat(); this.heartbeatTimer = setInterval(() => this.publishHeartbeat(), this.options.heartbeatInterval); } async shutdown() { if (this.stopped) { this.logger.info(`Graceful shutdown already initiated for node '${this.nodeId}'`); return this.shutdownPromise; } this.stopped = true; this.shutdownPromise = (async () => { this.logger.info(`Initiating graceful shutdown for node '${this.nodeId}'`); if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = undefined; this.logger.info(`Heartbeat interval cleared for node '${this.nodeId}'`); } try { await this.deregisterNodeById(this.nodeId); this.registered = false; this.logger.info(`Node '${this.nodeId}' deregistered successfully`); } catch (error) { this.logger.error(`Error deregistering node '${this.nodeId}'`, { error }); } try { await this.unsubscribeFromEvents(); this.logger.info(`Unsubscribed from Redis events for node '${this.nodeId}'`); } catch (error) { this.logger.error(`Error during Redis Pub/Sub unsubscribe for node '${this.nodeId}'`, { error }); } this.logger.info(`Graceful shutdown completed for node '${this.nodeId}'`); })(); return this.shutdownPromise; } async publishHeartbeat() { if (this.stopped || this.options.clientMode) { this.logger.warn(`Attempted to publish heartbeat after shutdown initiated for node '${this.nodeId}'`); return; } const nodeKey = `netron:discovery:nodes:${this.nodeId}`; const heartbeatKey = `netron:discovery:heartbeat:${this.nodeId}`; const nodesIndexKey = 'netron:discovery:index:nodes'; const maxRetries = 3; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await this.redis.eval(REGISTER_HEARTBEAT_SCRIPT, 3, nodeKey, heartbeatKey, nodesIndexKey, this.nodeId, this.address, JSON.stringify(this.services), Date.now().toString(), Math.ceil(this.options.heartbeatTTL / 1000).toString(), this.options.heartbeatTTL.toString()); const eventType = this.registered ? 'NODE_UPDATED' : 'NODE_REGISTERED'; if (!this.registered) { this.registered = true; } if (attempt > 1) { this.logger.info(`Heartbeat succeeded after ${attempt} attempts`); } await this.publishEvent(eventType); return; } catch (error) { this.logger.error(`Heartbeat attempt ${attempt} failed`, { error }); if (attempt === maxRetries) { this.logger.error(`All ${maxRetries} heartbeat attempts failed.`); throw error; } await new Promise((res) => setTimeout(res, 500 * attempt)); } } } async getActiveNodes() { try { const nodeIds = await this.redis.smembers('netron:discovery:index:nodes'); if (!nodeIds.length) return []; const pipeline = this.redis.pipeline(); nodeIds.forEach((id) => { pipeline.hgetall(`netron:discovery:nodes:${id}`); pipeline.exists(`netron:discovery:heartbeat:${id}`); }); const results = await pipeline.exec(); const activeNodes = []; const nodesToDeregister = []; if (!results) return activeNodes; for (let i = 0; i < nodeIds.length; i++) { const nodeId = nodeIds[i]; if (!nodeId) continue; const nodeDataResult = results[i * 2]; const heartbeatResult = results[i * 2 + 1]; if (!nodeDataResult || !heartbeatResult) { nodesToDeregister.push(nodeId); continue; } const [nodeDataErr, nodeData] = nodeDataResult; const [heartbeatErr, heartbeatExists] = heartbeatResult; if (nodeDataErr || heartbeatErr || !heartbeatExists || !nodeData || typeof nodeData !== 'object') { nodesToDeregister.push(nodeId); continue; } const address = typeof nodeData['address'] === 'string' ? nodeData['address'] : null; const servicesRaw = typeof nodeData['services'] === 'string' ? nodeData['services'] : null; const timestampRaw = typeof nodeData['timestamp'] === 'string' ? nodeData['timestamp'] : null; if (!address || !servicesRaw || !timestampRaw) { nodesToDeregister.push(nodeId); continue; } let services; try { services = JSON.parse(servicesRaw); } catch { nodesToDeregister.push(nodeId); continue; } activeNodes.push({ nodeId, address, services, timestamp: Number(timestampRaw), }); } if (nodesToDeregister.length > 0) { const deregisterPipeline = this.redis.pipeline(); nodesToDeregister.forEach((id) => { deregisterPipeline .del(`netron:discovery:nodes:${id}`) .del(`netron:discovery:heartbeat:${id}`) .srem('netron:discovery:index:nodes', id); }); await deregisterPipeline.exec(); } return activeNodes; } catch (error) { this.logger.error(`Error fetching active nodes`, { error }); throw error; } } async findNodesByService(name, version) { try { const activeNodes = await this.getActiveNodes(); return activeNodes.filter(node => node.services.some(svc => svc.name === name && (!version || svc.version === version))); } catch (error) { this.logger.error(`Error finding nodes by service '${name}' (version: ${version})`, { error }); throw error; } } async subscribeToEvents(handler) { if (!this.options.pubSubEnabled || this.stopped) return; this.subscriber = new ioredis_1.Redis(this.redis.options); await this.subscriber.subscribe(this.pubSubChannel); this.subscriber.on('message', (_, message) => { if (this.stopped) return; try { const event = JSON.parse(message); handler(event); } catch (error) { this.logger.error('Error processing Redis event:', error); } }); } async deregisterNodeById(nodeId) { const nodeKey = `netron:discovery:nodes:${nodeId}`; const heartbeatKey = `netron:discovery:heartbeat:${nodeId}`; const maxRetries = 3; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await this.redis .multi() .del(nodeKey, heartbeatKey) .srem('netron:discovery:index:nodes', nodeId) .exec(); if (attempt > 1) { this.logger.info(`Deregistration of node '${nodeId}' succeeded after ${attempt} attempts`); } await this.publishEvent('NODE_DEREGISTERED'); return; } catch (error) { this.logger.error(`Deregistration attempt ${attempt} for node '${nodeId}' failed`, { error }); if (attempt === maxRetries) { this.logger.error(`All ${maxRetries} deregistration attempts for node '${nodeId}' failed.`); throw error; } await new Promise((res) => setTimeout(res, 500 * attempt)); } } } async isNodeActive(nodeId) { try { const heartbeatKey = `netron:discovery:heartbeat:${nodeId}`; const exists = await this.redis.exists(heartbeatKey); if (!exists) { await this.deregisterNodeById(nodeId); } return exists === 1; } catch (error) { this.logger.error(`Error checking if node '${nodeId}' is active`, { error }); throw error; } } async updateServices(services) { if (this.stopped) { this.logger.warn(`Attempted to update services after shutdown initiated for node '${this.nodeId}'`); return; } this.services = services; try { await this.publishHeartbeat(); } catch (error) { this.logger.error(`Error updating services`, { error }); throw error; } } async updateAddress(address) { if (this.stopped) { this.logger.warn(`Attempted to update address after shutdown initiated for node '${this.nodeId}'`); return; } this.address = address; try { await this.publishHeartbeat(); } catch (error) { this.logger.error(`Error updating address to '${address}'`, { error }); throw error; } } async publishEvent(type) { if (!this.options.pubSubEnabled) return; const eventPayload = { type, nodeId: this.nodeId, address: this.address, services: this.services, timestamp: Date.now(), }; await this.redis.publish(this.pubSubChannel, JSON.stringify(eventPayload)); } async unsubscribeFromEvents() { if (this.subscriber) { await this.subscriber.unsubscribe(this.pubSubChannel); this.subscriber.disconnect(); this.subscriber = undefined; } } } exports.ServiceDiscovery = ServiceDiscovery; //# sourceMappingURL=service-discovery.js.map