@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
JavaScript
"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