155 lines
4.4 KiB
TypeScript
155 lines
4.4 KiB
TypeScript
|
|
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<string, RelayNode> = new Map();
|
||
|
|
private heartbeatTimers: Map<string, NodeJS.Timeout> = 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<void> {
|
||
|
|
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),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|