teralink
Version:
A next-generation, super-performant, v4-only Lavalink manager for Node.js with advanced features, smart search, enhanced error handling, performance monitoring, and optimized resource usage.
405 lines (379 loc) • 16.9 kB
JavaScript
const Websocket = require("ws");
const { Rest } = require("./Rest");
const { Track } = require("./Track");
class Node {
/**
* @param {import("./Tera").Teralink} tera
* @param {} node
*/
constructor(tera, node, options) {
this.tera = tera;
this.node = node;
this.options = options;
this.name = node.name || node.host;
this.host = node.host || "localhost";
this.port = node.port || 2333;
this.password = node.password || "youshallnotpass";
this.restVersion = (options.rest?.version || options.restVersion);
this.secure = node.secure || false;
this.sessionId = node.sessionId || null;
this.rest = new Rest(tera, this);
// Improved URL construction with HTTP2 support
this.wsUrl = this.constructWsUrl();
this.restUrl = this.constructRestUrl();
this.ws = null;
this.regions = node.regions;
/**
* Lavalink Info fetched While/After connecting.
* @type {import("..\").NodeInfo | null}
*/
this.info = null;
this.stats = {
players: 0,
playingPlayers: 0,
uptime: 0,
memory: {
free: 0,
used: 0,
allocated: 0,
reservable: 0,
},
cpu: {
cores: 0,
systemLoad: 0,
lavalinkLoad: 0,
},
frameStats: {
sent: 0,
nulled: 0,
deficit: 0,
},
};
this.connected = false;
// Improved autoResume configuration
this.resumeKey = (options.resume?.key || options.resumeKey || null);
this.resumeTimeout = (options.resume?.timeout || options.resumeTimeout || 60);
this.autoResume = (options.resume?.enabled || options.autoResume || false);
this.autoResumePlayers = new Map(); // Track players for autoResume
this.reconnectTimeout = (options.node?.ws?.reconnectInterval || options.reconnectTimeout || 5000);
this.reconnectTries = (options.node?.ws?.reconnectTries || options.reconnectTries || 3);
this.reconnectAttempt = null;
this.reconnectAttempted = 1;
this.lastStats = Date.now();
// Performance tracking
this.connectionStartTime = null;
this.lastPing = 0;
this.pingHistory = [];
this.maxPingHistory = 10;
// Enhanced error tracking and health monitoring
this.errorCount = 0;
this.lastErrorTime = null;
this.healthMetrics = {
successfulConnections: 0,
failedConnections: 0,
disconnections: 0,
averageResponseTime: 0,
lastHealthCheck: Date.now()
};
// Connection stability tracking
this.connectionStability = {
consecutiveFailures: 0,
maxConsecutiveFailures: 5,
backoffMultiplier: 1,
maxBackoffMultiplier: 8
};
}
constructWsUrl() {
const protocol = this.secure ? "wss" : "ws";
const version = this.restVersion === "v4" ? "/v4/websocket" : "";
return `${protocol}://${this.host}:${this.port}${version}`;
}
constructRestUrl() {
const protocol = this.secure ? "https" : "http";
return `${protocol}://${this.host}:${this.port}`;
}
lyrics = {
checkAvailable: async (eitherOne=true,...plugins) => {
if (!this.sessionId) throw new Error(`Node (${this.name}) is not Ready/Connected.`)
if (!plugins.length) plugins = ["lavalyrics-plugin", "java-lyrics-plugin", "lyrics"];
const missingPlugins = [];
plugins.forEach((plugin) => {
const p = this.info.plugins.find((p) => p.name === plugin)
if (!p) {
missingPlugins.push(plugin)
return false;
}
return true;
});
const AllPluginsMissing = missingPlugins.length === plugins.length;
if (eitherOne && AllPluginsMissing) {
throw new RangeError(`Node (${this.name}) is missing plugins: ${missingPlugins.join(", ")} (required for Lyrics)`)
} else if (!eitherOne && missingPlugins.length) {
throw new RangeError(`Node (${this.name}) is missing plugins: ${missingPlugins.join(", ")} (required for Lyrics)`)
}
return true
},
get: async (trackOrEncodedTrackStr, skipTrackSource=false) => {
if (!(await this.lyrics.checkAvailable(false, "lavalyrics-plugin"))) return null;
if(!(trackOrEncodedTrackStr instanceof Track) && typeof trackOrEncodedTrackStr !== "string") throw new TypeError(`Expected \`Track\` or \`string\` for \`trackOrEncodedTrackStr\` in "lyrics.get" but got \`${typeof trackOrEncodedTrackStr}\``)
let encodedTrackStr = typeof trackOrEncodedTrackStr === "string" ? trackOrEncodedTrackStr : trackOrEncodedTrackStr.track;
return await this.rest.makeRequest("GET",`/v4/lyrics?skipTrackSource=${skipTrackSource}&track=${encodedTrackStr}`);
},
getCurrentTrack: async (guildId, skipTrackSource=false, plugin) => {
const DEFAULT_PLUGIN = "lavalyrics-plugin"
if (!(await this.lyrics.checkAvailable())) return null;
const nodePlugins = this.info?.plugins;
let requestURL = `/v4/sessions/${this.sessionId}/players/${guildId}/track/lyrics?skipTrackSource=${skipTrackSource}&plugin=${plugin}`
if(!plugin && (nodePlugins.find((p) => p.name === "java-lyrics-plugin") || nodePlugins.find((p) => p.name === "lyrics")) && !(nodePlugins.find((p) => p.name === DEFAULT_PLUGIN))) {
requestURL = `/v4/sessions/${this.sessionId}/players/${guildId}/lyrics?skipTrackSource=${skipTrackSource}`
} else if(plugin && ["java-lyrics-plugin", "lyrics"].includes(plugin)) {
requestURL = `/v4/sessions/${this.sessionId}/players/${guildId}/lyrics?skipTrackSource=${skipTrackSource}`
}
return await this.rest.makeRequest("GET", `${requestURL}`)
}
}
async fetchInfo(options = { restVersion: this.restVersion, includeHeaders: false }) {
try {
return await this.rest.makeRequest("GET", `/${options.restVersion || this.restVersion}/info`, null, options.includeHeaders);
} catch (error) {
this.tera.emit('debug', this.name, `Failed to fetch node info: ${error.message}`);
throw error;
}
}
async connect() {
if (this.connected) return;
this.connectionStartTime = Date.now();
this.tera.emit('debug', this.name, `Connecting to node...`);
const headers = {
"Authorization": this.password,
"User-Id": this.tera.clientId,
"Client-Name": `Teralink/${this.tera.version}`,
};
if (this.restVersion === "v4") {
if (this.sessionId) headers["Session-Id"] = this.sessionId;
} else {
if (this.resumeKey) headers["Resume-Key"] = this.resumeKey;
}
if (this.secure) {
headers["Accept"] = "application/json";
headers["Accept-Encoding"] = "gzip, deflate, br";
}
this.ws = new Websocket(this.wsUrl, { headers });
this.ws.on("open", this.open.bind(this));
this.ws.on("error", this.error.bind(this));
this.ws.on("message", this.message.bind(this));
this.ws.on("close", this.close.bind(this));
}
async open() {
if (this.reconnectTimeout) clearTimeout(this.reconnectTimeout);
this.connected = true;
const connectionTime = Date.now() - this.connectionStartTime;
this.tera.emit('debug', this.name, `Connection established in ${connectionTime}ms`);
// Track successful connection
this.healthMetrics.successfulConnections++;
this.connectionStability.consecutiveFailures = 0;
this.connectionStability.backoffMultiplier = 1;
try {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Node info fetch timeout')), 5000)
);
const infoPromise = this.fetchInfo();
this.info = await Promise.race([infoPromise, timeoutPromise]);
this.tera.emit('debug', this.name, `Node info fetched successfully`);
} catch (error) {
this.tera.emit('debug', this.name, `Failed to fetch node info: ${error.message}`);
this.info = null;
}
if (!this.info && this.options?.bypassChecks?.nodeFetchInfo === false) {
throw new Error(`Node (${this.name} - URL: ${this.restUrl}) Failed to fetch info on WS-OPEN`);
}
if (this.autoResume) {
await this.performAutoResume();
}
}
async performAutoResume() {
try {
const playersToResume = [];
for (const player of this.tera.players.values()) {
if (player.node === this && player.current) {
playersToResume.push(player);
}
}
if (playersToResume.length === 0) {
return;
}
const resumePromises = playersToResume.map(async (player) => {
try {
if (player.track && player.track.encoded) {
await this.rest.updatePlayer({
guildId: player.guildId,
playerOptions: {
track: { encoded: player.track.encoded },
position: player.position || 0,
volume: player.volume || 100,
filters: player.filters || {},
paused: player.paused || false
}
});
}
} catch (error) {
this.tera.emit("debug", this.name, `Failed to resume player ${player.guildId}: ${error.message}`);
}
});
await Promise.all(resumePromises);
} catch (error) {
this.tera.emit("debug", this.name, `AutoResume failed: ${error.message}`);
}
}
error(event) {
if (!event) return;
// Track error for health monitoring
this.errorCount++;
this.lastErrorTime = Date.now();
this.connectionStability.consecutiveFailures++;
this.tera.emit("nodeError", this, event);
}
/**
* Get node health status
* @returns {Object} Health status object
*/
getHealthStatus() {
const now = Date.now();
const uptime = this.connected ? now - (this.connectionStartTime || now) : 0;
const avgPing = this.pingHistory.length > 0 ?
this.pingHistory.reduce((a, b) => a + b, 0) / this.pingHistory.length : 0;
return {
name: this.name,
connected: this.connected,
uptime,
ping: this.lastPing,
averagePing: Math.round(avgPing),
errorCount: this.errorCount,
lastError: this.lastErrorTime,
consecutiveFailures: this.connectionStability.consecutiveFailures,
connectionStability: this.connectionStability.consecutiveFailures < this.connectionStability.maxConsecutiveFailures ? 'stable' : 'unstable',
healthMetrics: this.healthMetrics,
stats: this.stats
};
}
/**
* Check if node is healthy and ready for use
* @returns {boolean} Whether node is healthy
*/
isHealthy() {
return this.connected &&
this.connectionStability.consecutiveFailures < this.connectionStability.maxConsecutiveFailures &&
this.lastPing < 1000; // Less than 1 second ping
}
async message(msg) {
if (Array.isArray(msg)) msg = Buffer.concat(msg);
else if (msg instanceof ArrayBuffer) msg = Buffer.from(msg);
const payload = JSON.parse(msg.toString());
if (!payload.op) return;
this.tera.emit("raw", "Node", payload);
if (payload.op === "stats") {
this.stats = { ...payload };
this.lastStats = Date.now();
if (payload.ping) {
this.lastPing = payload.ping;
this.pingHistory.push(payload.ping);
if (this.pingHistory.length > this.maxPingHistory) {
this.pingHistory.shift();
}
}
}
if (payload.op === "ready") {
if (this.sessionId !== payload.sessionId) {
this.rest.setSessionId(payload.sessionId);
this.sessionId = payload.sessionId;
}
this.tera.emit("nodeConnect", this);
if (this.restVersion === "v4") {
if (this.sessionId) {
try {
await this.rest.makeRequest(`PATCH`, `/${this.rest.version}/sessions/${this.sessionId}`, {
resuming: true,
timeout: this.resumeTimeout
});
} catch (error) {
this.tera.emit("debug", this.name, `Failed to configure resuming: ${error.message}`);
}
}
} else {
if (this.resumeKey) {
try {
await this.rest.makeRequest(`PATCH`, `/${this.rest.version}/sessions/${this.sessionId}`, {
resumingKey: this.resumeKey,
timeout: this.resumeTimeout
});
} catch (error) {
this.tera.emit("debug", this.name, `Failed to configure resuming: ${error.message}`);
}
}
}
}
const player = this.tera.players.get(payload.guildId);
if (payload.guildId && player) player.emit(payload.op, payload);
}
close(event, reason) {
this.connected = false;
this.tera.emit("nodeDisconnect", this, event, reason);
if (this.reconnectAttempted <= this.reconnectTries) {
this.tera.emit("debug", this.name, `Connection closed, attempting to reconnect... (${this.reconnectAttempted}/${this.reconnectTries})`);
this.reconnect();
} else {
this.tera.emit("debug", this.name, `Connection closed, max reconnection attempts reached`);
}
}
reconnect() {
if (this.reconnectAttempt) clearTimeout(this.reconnectAttempt);
this.reconnectAttempt = setTimeout(() => {
this.reconnectAttempted++;
this.connect();
}, this.reconnectTimeout);
}
destroy(clean = false) {
// Remove all event listeners and clear caches
this.removeAllListeners && this.removeAllListeners();
if (this.autoResumePlayers) this.autoResumePlayers.clear();
if (this.pingHistory) this.pingHistory.length = 0;
if (this.rest && typeof this.rest.destroy === 'function') this.rest.destroy();
this.connected = false;
this.tera.emit("nodeDestroy", this);
}
disconnect() {
this.connected = false;
if (this.ws) this.ws.close();
if (this.reconnectAttempt) clearTimeout(this.reconnectAttempt);
}
get penalties() {
let penalties = 0;
if (this.stats) {
penalties += this.stats.playingPlayers * 1;
penalties += (this.stats.cpu.systemLoad / this.stats.cpu.cores) * 10;
if (this.stats.frameStats && this.stats.frameStats.deficit) {
penalties += Math.round(this.stats.frameStats.deficit * 2.5);
}
penalties += this.stats.players;
}
return penalties;
}
getHealthStatus() {
const now = Date.now();
const uptime = now - this.lastStats;
return {
connected: this.connected,
uptime,
ping: this.lastPing,
averagePing: this.pingHistory.length > 0 ?
this.pingHistory.reduce((a, b) => a + b, 0) / this.pingHistory.length : 0,
penalties: this.penalties,
players: this.stats.players,
playingPlayers: this.stats.playingPlayers,
cpuLoad: this.stats.cpu ? this.stats.cpu.systemLoad / this.stats.cpu.cores : 0,
memoryUsage: this.stats.memory ?
(this.stats.memory.used / this.stats.memory.allocated) * 100 : 0
};
}
}
module.exports = { Node };