import axios from 'axios'; import { v4 as uuidv4 } from 'uuid'; import logger from './logger'; import config from './config'; export interface RelayNode { id: string; name: string; host: string; apiPort: number; relayPort: number; status: 'online' | 'offline' | 'busy'; roomCount: number; playerCount: number; maxRooms: number; region: string; lastHeartbeat: Date; registeredAt: Date; } export class NodeManager { private nodes: Map = new Map(); private heartbeatTimers: Map = new Map(); constructor() { if (config.isMaster) { setInterval(() => this.checkNodeHealth(), config.heartbeatInterval); } } registerNode(nodeInfo: { name: string; host: string; apiPort: number; relayPort: number; maxRooms: number; region: string; }): RelayNode { const existing = Array.from(this.nodes.values()).find( n => n.host === nodeInfo.host && n.apiPort === nodeInfo.apiPort ); if (existing) { existing.status = 'online'; existing.lastHeartbeat = new Date(); existing.name = nodeInfo.name; existing.maxRooms = nodeInfo.maxRooms; existing.region = nodeInfo.region; logger.info(`Node re-registered: ${existing.name} (${existing.id})`); return existing; } const node: RelayNode = { id: uuidv4().substring(0, 8), name: nodeInfo.name, host: nodeInfo.host, apiPort: nodeInfo.apiPort, relayPort: nodeInfo.relayPort, status: 'online', roomCount: 0, playerCount: 0, maxRooms: nodeInfo.maxRooms, region: nodeInfo.region, lastHeartbeat: new Date(), registeredAt: new Date(), }; this.nodes.set(node.id, node); logger.info(`New node registered: ${node.name} (${node.id}) at ${node.host}:${node.relayPort}`); return node; } removeNode(nodeId: string): boolean { const timer = this.heartbeatTimers.get(nodeId); if (timer) clearTimeout(timer); this.heartbeatTimers.delete(nodeId); const removed = this.nodes.delete(nodeId); if (removed) { logger.info(`Node removed: ${nodeId}`); } return removed; } updateNodeHeartbeat(nodeId: string, stats: { roomCount: number; playerCount: number }): boolean { const node = this.nodes.get(nodeId); if (!node) return false; node.lastHeartbeat = new Date(); node.roomCount = stats.roomCount; node.playerCount = stats.playerCount; node.status = node.roomCount >= node.maxRooms ? 'busy' : 'online'; return true; } getNode(nodeId: string): RelayNode | undefined { return this.nodes.get(nodeId); } listNodes(): RelayNode[] { return Array.from(this.nodes.values()); } getAvailableNodes(): RelayNode[] { return Array.from(this.nodes.values()).filter(n => n.status === 'online'); } getBestNode(): RelayNode | undefined { const available = this.getAvailableNodes(); if (available.length === 0) return undefined; return available.sort((a, b) => { const aLoad = a.roomCount / a.maxRooms; const bLoad = b.roomCount / b.maxRooms; return aLoad - bLoad; })[0]; } private async checkNodeHealth(): Promise { const now = new Date(); for (const [nodeId, node] of this.nodes) { const timeSinceHeartbeat = now.getTime() - node.lastHeartbeat.getTime(); if (timeSinceHeartbeat > config.heartbeatInterval * 3) { node.status = 'offline'; logger.warn(`Node ${node.name} (${nodeId}) marked offline - no heartbeat for ${Math.round(timeSinceHeartbeat / 1000)}s`); } else { try { const response = await axios.get(`http://${node.host}:${node.apiPort}/api/health`, { timeout: 5000, }); if (response.data.status === 'ok') { node.status = node.roomCount >= node.maxRooms ? 'busy' : 'online'; node.lastHeartbeat = new Date(); } } catch { if (timeSinceHeartbeat > config.heartbeatInterval * 2) { node.status = 'offline'; } } } } } getStats(): { totalNodes: number; onlineNodes: number; totalRooms: number; totalPlayers: number } { const nodes = Array.from(this.nodes.values()); return { totalNodes: nodes.length, onlineNodes: nodes.filter(n => n.status !== 'offline').length, totalRooms: nodes.reduce((sum, n) => sum + n.roomCount, 0), totalPlayers: nodes.reduce((sum, n) => sum + n.playerCount, 0), }; } }