UNPKG

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.

953 lines (887 loc) 30.8 kB
// Tera.js - Next Generation Lavalink v4+ Super Performant Wrapper const { EventEmitter } = require("tseep"); const fs = require("fs/promises"); const { Node } = require("./Node"); const { Player } = require("./Player"); const { Plugin } = require("./Plugin"); const { Status } = require("./Status"); // Add Status import const { version: pkgVersion } = require("../../package.json"); // Expose package version const SUPPORTED_VERSION = "v4"; /** * Teralink - Next Generation Lavalink v4+ Super Performant Wrapper * @class * @extends EventEmitter * @param {Client} client - Your Discord.js client * @param {Array} nodes - Lavalink node configs * @param {Object} options - Teralink options * @param {Function} options.send - Function to send payloads to Discord * @param {boolean|Object} [options.sync] - Enable voice status updates (default: false) or pass { template: '...' } * @param {boolean|Object} [options.setActivityStatus] - Enable bot activity status updates (default: false) or pass { template: '...' } * @param {boolean} [options.autoResume] - Enable autoResume functionality (default: false) * @param {number} [options.multipleTrackHistory] - Number of previous tracks to remember (default: 1) * @param {boolean} [options.lazyLoad] - Enable lazy loading for better performance (default: false) * @param {number} [options.lazyLoadTimeout] - Timeout for lazy loading in ms (default: 5000) * @param {string} [options.defaultSearchPlatform] - Default search platform (default: "ytmsearch") * @param {string} [options.restVersion] - Lavalink REST API version (default: "v4") * @param {Array} [options.plugins] - Array of Teralink plugins */ class Teralink extends EventEmitter { constructor(client, nodes, options = {}) { super(); if (!client) throw new Error("Client is required to initialize Teralink"); if (!Array.isArray(nodes)) throw new Error("Nodes must be an array"); if (!options.send || typeof options.send !== "function") throw new Error("Send function is required"); this.client = client; this.nodes = nodes; this.nodeMap = new Map(); this.players = new Map(); this.options = options; this.clientId = null; this.initiated = false; this.send = options.send; // --- Begin: New Options Structure Migration --- // Source this.source = options.source || { default: options.defaultSearchPlatform || "ytmsearch", }; // REST this.rest = options.rest || { version: options.restVersion || SUPPORTED_VERSION, retryCount: options.restRetryCount || 3, timeout: options.restTimeout || 5000, }; // Plugins this.plugins = options.plugins || []; // Resume this.resume = options.resume || { key: options.resumeKey || "teralink-resume", timeout: options.resumeTimeout || 60_000, }; // Node this.node = options.node || { dynamicSwitching: options.dynamicNodeSwitching !== undefined ? options.dynamicNodeSwitching : true, autoReconnect: options.autoReconnectNodes !== undefined ? options.autoReconnectNodes : true, ws: { reconnectTries: options.wsReconnectTries || 5, reconnectInterval: options.wsReconnectInterval || 5000, }, }; // Autopause this.autopauseOnEmpty = options.autopauseOnEmpty !== undefined ? options.autopauseOnEmpty : true; // Lazy Load this.lazyLoad = options.lazyLoad && typeof options.lazyLoad === "object" ? options.lazyLoad.enabled : options.lazyLoad || false; this.lazyLoadTimeout = options.lazyLoad && typeof options.lazyLoad === "object" ? options.lazyLoad.timeout : options.lazyLoadTimeout || 5000; // Sync this.sync = options.sync; // --- End: New Options Structure Migration --- this.defaultSearchPlatform = this.source.default; this.restVersion = this.rest.version; this.tracks = []; this.resumeKey = this.resume.key; this.resumeTimeout = this.resume.timeout; this.dynamicNodeSwitching = this.node.dynamicSwitching; this.autoReconnectNodes = this.node.autoReconnect; this.wsReconnectTries = this.node.ws.reconnectTries; this.wsReconnectInterval = this.node.ws.reconnectInterval; this.restRetryCount = this.rest.retryCount; this.restTimeout = this.rest.timeout; this.regionCache = new Map(); this.nodeHealthCache = new Map(); this.cacheTimeout = 30_000; this.resolveCache = new Map(); // { identifier: { result, timestamp } } this.resolveCacheMaxSize = 500; // Increased cache size this.resolveCacheTTL = 10 * 60 * 1000; // Increased to 10 minutes // Performance monitoring this.performanceMetrics = { searchRequests: 0, cacheHits: 0, cacheMisses: 0, playerCreations: 0, nodeReconnections: 0, lastResetTime: Date.now(), }; // Memory management this.memoryOptimization = { enabled: true, cleanupInterval: 15 * 60 * 1000, // 15 minutes maxInactiveTime: 30 * 60 * 1000, // 30 minutes memoryThreshold: 85, // percentage }; // Enhanced connection pooling this.connectionPool = { maxConcurrentRequests: 50, requestQueue: [], activeRequests: 0, rateLimitedNodes: new Set(), }; // Only statusSync (voice channel status) if (this.sync === true) { this.statusSync = new Status(this.client); } else if (typeof this.sync === "object" && this.sync !== null) { this.statusSync = new Status(this.client, this.sync); } else { this.statusSync = null; } this.pluginInstances = []; this.version = pkgVersion; // Expose package version } // Helper to prune resolve cache pruneResolveCache() { const now = Date.now(); for (const [key, value] of this.resolveCache.entries()) { if (now - value.timestamp > this.resolveCacheTTL) { this.resolveCache.delete(key); } } // Limit cache size while (this.resolveCache.size > this.resolveCacheMaxSize) { const firstKey = this.resolveCache.keys().next().value; this.resolveCache.delete(firstKey); } } /** * Get performance metrics and statistics * @returns {Object} Performance metrics object */ getPerformanceMetrics() { const uptime = Date.now() - this.performanceMetrics.lastResetTime; const cacheHitRate = (this.performanceMetrics.cacheHits / (this.performanceMetrics.cacheHits + this.performanceMetrics.cacheMisses)) * 100; return { ...this.performanceMetrics, uptime, cacheHitRate: isNaN(cacheHitRate) ? 0 : Math.round(cacheHitRate * 100) / 100, averageSearchesPerMinute: Math.round( (this.performanceMetrics.searchRequests / uptime) * 60000 ), memoryUsage: this.getMemoryUsage(), activeConnections: this.connectionPool.activeRequests, queuedRequests: this.connectionPool.requestQueue.length, }; } /** * Reset performance metrics */ resetPerformanceMetrics() { this.performanceMetrics = { searchRequests: 0, cacheHits: 0, cacheMisses: 0, playerCreations: 0, nodeReconnections: 0, lastResetTime: Date.now(), }; this.emit("debug", "Performance metrics reset"); } /** * Get memory usage information * @returns {Object} Memory usage data */ getMemoryUsage() { const memUsage = process.memoryUsage(); return { heapUsed: Math.round((memUsage.heapUsed / 1024 / 1024) * 100) / 100, heapTotal: Math.round((memUsage.heapTotal / 1024 / 1024) * 100) / 100, external: Math.round((memUsage.external / 1024 / 1024) * 100) / 100, rss: Math.round((memUsage.rss / 1024 / 1024) * 100) / 100, heapUsagePercentage: Math.round( (memUsage.heapUsed / memUsage.heapTotal) * 100 ), }; } /** * Perform memory cleanup and optimization */ performMemoryCleanup() { if (!this.memoryOptimization.enabled) return; const now = Date.now(); let cleanedPlayers = 0; // Clean up inactive players for (const [guildId, player] of this.players.entries()) { const inactiveTime = now - player.timestamp; if ( inactiveTime > this.memoryOptimization.maxInactiveTime && !player.playing && player.queue.length === 0 ) { this.emit("debug", `Cleaning up inactive player for guild ${guildId}`); player.destroy(); cleanedPlayers++; } } // Prune caches this.pruneResolveCache(); this.regionCache.clear(); this.nodeHealthCache.clear(); // Force garbage collection if available if (global.gc) { global.gc(); } this.emit( "debug", `Memory cleanup completed. Cleaned ${cleanedPlayers} inactive players` ); } /** * Start automatic memory management */ startMemoryManagement() { if (this.memoryCleanupInterval) return; this.memoryCleanupInterval = setInterval(() => { const memUsage = this.getMemoryUsage(); if ( memUsage.heapUsagePercentage > this.memoryOptimization.memoryThreshold ) { this.emit( "debug", `Memory threshold exceeded (${memUsage.heapUsagePercentage}%), performing cleanup` ); this.performMemoryCleanup(); } }, this.memoryOptimization.cleanupInterval); this.emit("debug", "Automatic memory management started"); } /** * Stop automatic memory management */ stopMemoryManagement() { if (this.memoryCleanupInterval) { clearInterval(this.memoryCleanupInterval); this.memoryCleanupInterval = null; this.emit("debug", "Automatic memory management stopped"); } } /** * Initialize Teralink and connect to all nodes * @param {string} clientId - Discord client user ID * @returns {Teralink} */ init(clientId) { if (this.initiated) return this; this.clientId = clientId; this.nodes.forEach((node) => this.createNode(node)); this.initiated = true; this.emit( "debug", `Teralink initialized, connecting to ${this.nodes.length} node(s)` ); // Start performance monitoring and memory management this.startMemoryManagement(); if (this.plugins) { this.emit("debug", `Loading ${this.plugins.length} Teralink plugin(s)`); this.plugins.forEach((plugin) => { if (plugin instanceof Plugin && typeof plugin.load === "function") { plugin.load(this); this.pluginInstances.push(plugin); } }); } // Status sync integration if (this.statusSync) { this.on("playerCreate", (player) => { player.on("trackStart", (track) => { this.statusSync.setVoiceStatus(player.voiceChannel, track.info); }); player.on("trackEnd", () => { this.statusSync.clearVoiceStatus(player.voiceChannel); }); player.on("destroy", () => { this.statusSync.clearVoiceStatus(player.voiceChannel); }); }); } // --- Begin: Dynamic Node Switching (Failover) --- this.on("nodeDisconnect", (node, event, reason) => { if (!this.dynamicNodeSwitching) return; for (const [guildId, player] of this.players) { if (player.node === node) { const playerData = player.toJSON(); this.destroyPlayer(guildId); const newNode = this.leastUsedNodes.find((n) => n !== node); if (!newNode) { this.emit( "debug", `No healthy node available for failover for guild ${guildId}` ); continue; } const newPlayer = require("./Player").Player.fromJSON( this, newNode, playerData ); this.players.set(guildId, newPlayer); // Resume playback if there was a current track if (newPlayer.current) { newPlayer.play(newPlayer.current, { position: newPlayer.position }); } this.emit( "debug", `Migrated player for guild ${guildId} to node ${newNode.name}` ); this.emit("playerCreate", newPlayer); } } }); // --- End: Dynamic Node Switching (Failover) --- } createNode(options) { const node = new Node(this, options, this.options); this.nodeMap.set(options.name || options.host, node); node.connect(); this.emit("nodeCreate", node); return node; } destroyNode(identifier) { const node = this.nodeMap.get(identifier); if (!node) return; node.disconnect(); this.nodeMap.delete(identifier); this.nodeHealthCache.delete(identifier); this.emit("nodeDestroy", node); } get leastUsedNodes() { return [...this.nodeMap.values()] .filter((node) => node.connected) .sort((a, b) => { const aHealth = this.getNodeHealth(a); const bHealth = this.getNodeHealth(b); return aHealth.score - bHealth.score; }); } // Add cache pruning for regionCache and nodeHealthCache pruneCaches() { const now = Date.now(); // Prune regionCache for (const [key, value] of this.regionCache.entries()) { if (now - value.timestamp > this.cacheTimeout) { this.regionCache.delete(key); } } // Prune nodeHealthCache for (const [key, value] of this.nodeHealthCache.entries()) { if (now - value.timestamp > this.cacheTimeout) { this.nodeHealthCache.delete(key); } } } getNodeHealth(node) { this.pruneCaches(); const now = Date.now(); const cached = this.nodeHealthCache.get(node.name); if (cached && now - cached.timestamp < this.cacheTimeout) { return cached.health; } const health = node.getHealthStatus(); const score = this.calculateNodeScore(health); this.nodeHealthCache.set(node.name, { health: { ...health, score }, timestamp: now, }); return { ...health, score }; } calculateNodeScore(health) { let score = 0; score += health.penalties * 10; score += health.cpuLoad * 100; score += health.memoryUsage * 0.5; score += health.ping * 0.1; score += health.players * 2; score += health.playingPlayers * 5; return score; } fetchRegion(region) { this.pruneCaches(); const now = Date.now(); const cacheKey = `region_${region}`; const cached = this.regionCache.get(cacheKey); if (cached && now - cached.timestamp < this.cacheTimeout) { return cached.nodes; } const nodesByRegion = [...this.nodeMap.values()] .filter( (node) => node.connected && node.regions?.includes(region?.toLowerCase()) ) .sort((a, b) => { const aHealth = this.getNodeHealth(a); const bHealth = this.getNodeHealth(b); return aHealth.score - bHealth.score; }); this.regionCache.set(cacheKey, { nodes: nodesByRegion, timestamp: now, }); return nodesByRegion; } getBestNodeForRegion(region) { const regionNodes = this.fetchRegion(region); return regionNodes.length > 0 ? regionNodes[0] : this.leastUsedNodes[0]; } createConnection(options) { if (!this.initiated) throw new Error("You have to initialize Teralink in your ready event"); const player = this.players.get(options.guildId); if (player) return player; if (this.leastUsedNodes.length === 0) throw new Error("No nodes are available"); let node; if (options.region) { node = this.getBestNodeForRegion(options.region); } else { node = this.leastUsedNodes[0]; } if (!node) throw new Error("No nodes are available"); return this.createPlayer(node, options); } createPlayer(node, options) { this.performanceMetrics.playerCreations++; const player = new Player(this, node, options); this.players.set(options.guildId, player); player.connect(options); this.emit( "debug", `Created a player (${options.guildId}) on node ${node.name}` ); this.emit("playerCreate", player); return player; } destroyPlayer(guildId) { const player = this.players.get(guildId); if (!player) return; player.destroy(); this.players.delete(guildId); this.emit("playerDestroy", player); } removeConnection(guildId) { this.players.get(guildId)?.destroy(); this.players.delete(guildId); } async loadPlayersState(filePath) { try { const data = await fs.readFile(filePath, "utf8"); const playersData = JSON.parse(data); let loadedCount = 0; for (const [guildId, playerData] of Object.entries(playersData)) { try { const node = this.leastUsedNodes[0]; if (!node) { this.emit( "debug", `No available nodes to restore player for guild ${guildId}` ); continue; } const player = Player.fromJSON(this, node, playerData); this.players.set(guildId, player); if (player.autoResumeState.enabled) { player.saveAutoResumeState(); } loadedCount++; this.emit("playerCreate", player); this.emit("debug", `Restored player for guild ${guildId}`); } catch (error) { this.emit( "debug", `Failed to restore player for guild ${guildId}: ${error.message}` ); } } this.emit( "debug", `Loaded ${loadedCount} player states from ${filePath}` ); return loadedCount; } catch (error) { if (error.code === "ENOENT") { this.emit("debug", `No player state file found at ${filePath}`); return 0; } this.emit("debug", `Failed to load player states: ${error.message}`); throw error; } } get(guildId) { return this.players.get(guildId); } /** * Resolve a query to tracks using the best available node. * @param {Object} params * @param {string} params.query - The search query or URL * @param {string} [params.source] - The search source (e.g., ytmsearch, spsearch) * @param {*} params.requester - The requester (user) * @param {string|Node} [params.node] - Node identifier or Node instance * @returns {Promise<{ loadType: string, tracks: any[], playlistInfo: any }>} - Lavalink response */ async resolve({ query, source, requester, node }) { const startTime = Date.now(); this.performanceMetrics.searchRequests++; if (!this.initiated) throw new Error("You have to initialize Teralink in your ready event"); if (node && typeof node !== "string" && !(node instanceof Node)) throw new Error( `'node' property must either be a node identifier/name (string) or a Node instance, but received: ${typeof node}` ); const querySource = source || this.defaultSearchPlatform; const requestNode = (node && typeof node === "string" ? this.nodeMap.get(node) : node) || this.leastUsedNodes[0]; if (!requestNode) throw new Error("No nodes are available."); const regex = /^https?:\/\//; const identifier = regex.test(query) ? query : `${querySource}:${query}`; // --- Begin: Caching logic --- this.pruneResolveCache(); const cacheKey = identifier; const cached = this.resolveCache.get(cacheKey); if (cached && Date.now() - cached.timestamp < this.resolveCacheTTL) { this.performanceMetrics.cacheHits++; this.emit( "debug", `Cache hit for resolve: ${identifier} (${Date.now() - startTime}ms)` ); return cached.result; } this.performanceMetrics.cacheMisses++; // --- End: Caching logic --- this.emit( "debug", `Searching for ${query} on node \"${requestNode.name}\"` ); let response = await requestNode.rest.makeRequest( "GET", `/${requestNode.rest.version}/loadtracks?identifier=${encodeURIComponent( identifier )}` ); // Debug: Log raw Lavalink response for Spotify URLs if (identifier.includes("spotify.com")) { this.emit( "debug", `Raw Lavalink response for Spotify: ${JSON.stringify( response, null, 2 )}` ); } // Handle failed requests (like 500 errors) if (!response || response.loadType === "error") { this.emit( "debug", `Search failed for \"${query}\" on node \"${requestNode.name}\": ${ response?.data?.message || "Unknown error" }` ); // Try fallback search if it's a URL if (regex.test(query)) { this.emit("debug", `Attempting fallback search for \"${query}\"`); const fallbackIdentifier = `${querySource}:${query}`; response = await requestNode.rest.makeRequest( "GET", `/${ requestNode.rest.version }/loadtracks?identifier=${encodeURIComponent(fallbackIdentifier)}` ); } if (!response || response.loadType === "error") { throw new Error(response?.data?.message || "Failed to load tracks"); } } // Try to resolve identifiers for Spotify/YouTube if no matches if (response.loadType === "empty" || response.loadType === "NO_MATCHES") { response = await requestNode.rest.makeRequest( "GET", `/${requestNode.rest.version}/loadtracks?identifier=https://open.spotify.com/track/${query}` ); if (response.loadType === "empty" || response.loadType === "NO_MATCHES") { response = await requestNode.rest.makeRequest( "GET", `/${requestNode.rest.version}/loadtracks?identifier=https://www.youtube.com/watch?v=${query}` ); } } let tracks = []; let playlistInfo = null; if (requestNode.rest.version === "v4") { if (response.loadType === "track") { tracks = response.data ? [ new (require("./Track").Track)( response.data, requester, requestNode ), ] : []; this.emit( "debug", `Search Success for \"${query}\" on node \"${requestNode.name}\", loadType: ${response.loadType}, Resulted track Title: ${tracks[0]?.info?.title}` ); } else if (response.loadType === "playlist") { const trackData = response.data?.tracks || []; tracks = await Promise.all( trackData.map((track) => Promise.resolve( new (require("./Track").Track)(track, requester, requestNode) ) ) ); playlistInfo = response.data?.info || null; this.emit( "debug", `Search Success for \"${query}\" on node \"${requestNode.name}\", loadType: ${response.loadType} tracks: ${tracks.length}` ); } else { const trackData = response.loadType === "search" && response.data ? response.data : []; tracks = await Promise.all( trackData.map((track) => Promise.resolve( new (require("./Track").Track)(track, requester, requestNode) ) ) ); this.emit( "debug", `Search ${ response.loadType !== "error" ? "Success" : "Failed" } for \"${query}\" on node \"${requestNode.name}\", loadType: ${ response.loadType } tracks: ${tracks.length}` ); } } else { // v3 (legacy) const trackData = response?.tracks || []; tracks = await Promise.all( trackData.map((track) => Promise.resolve( new (require("./Track").Track)(track, requester, requestNode) ) ) ); this.emit( "debug", `Search ${ response.loadType !== "error" && response.loadType !== "LOAD_FAILED" ? "Success" : "Failed" } for \"${query}\" on node \"${requestNode.name}\", loadType: ${ response.loadType } tracks: ${tracks.length}` ); } const result = { loadType: response.loadType, tracks, playlistInfo, }; // --- Store in cache --- this.resolveCache.set(cacheKey, { result, timestamp: Date.now() }); this.pruneResolveCache(); // --- End cache store --- // Log performance metrics const totalTime = Date.now() - startTime; this.emit( "debug", `Search completed for "${query}" in ${totalTime}ms (${tracks.length} tracks)` ); return result; } /** * Smart search with enhanced query processing and source detection * @param {string} query - Search query * @param {*} requester - User making the request * @param {Object} options - Search options * @param {string} [options.source] - Preferred source * @param {boolean} [options.smartSearch=true] - Enable smart search features * @param {number} [options.limit=10] - Maximum results * @returns {Promise<Object>} Search results */ async smartSearch(query, requester, options = {}) { const { source, smartSearch = true, limit = 10 } = options; if (!smartSearch) { return this.resolve({ query, source, requester }); } // Detect URL patterns and optimize source selection const urlPatterns = { youtube: /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/playlist\?list=)/i, spotify: /(?:open\.spotify\.com|spotify:)/i, soundcloud: /soundcloud\.com/i, bandcamp: /bandcamp\.com/i, twitch: /twitch\.tv/i, }; let detectedSource = source; // Auto-detect source from URL if (!detectedSource) { for (const [platform, pattern] of Object.entries(urlPatterns)) { if (pattern.test(query)) { detectedSource = platform === "youtube" ? "ytsearch" : `${platform}search`; break; } } } // Enhance query for better results let enhancedQuery = query; if (!urlPatterns.youtube.test(query) && !urlPatterns.spotify.test(query)) { // Clean up common phrases that might interfere with search enhancedQuery = query .replace(/\b(official|music|video|audio|lyrics|hd|hq)\b/gi, "") .replace(/\s+/g, " ") .trim(); } const searchResults = await this.resolve({ query: enhancedQuery, source: detectedSource || this.defaultSearchPlatform, requester, }); // Limit results if requested if (limit && searchResults.tracks && searchResults.tracks.length > limit) { searchResults.tracks = searchResults.tracks.slice(0, limit); } return searchResults; } /** * Batch search multiple queries efficiently * @param {Array<string>} queries - Array of search queries * @param {*} requester - User making the request * @param {Object} options - Search options * @returns {Promise<Array>} Array of search results */ async batchSearch(queries, requester, options = {}) { const batchSize = 5; // Process 5 at a time to avoid overwhelming nodes const results = []; for (let i = 0; i < queries.length; i += batchSize) { const batch = queries.slice(i, i + batchSize); const batchPromises = batch.map((query) => this.smartSearch(query, requester, options).catch((error) => ({ error: error.message, query, })) ); const batchResults = await Promise.all(batchPromises); results.push(...batchResults); } return results; } /** * Handle Discord voice state updates for a guild/player. * @param {Object} packet - Discord voice state/voice server update packet */ updateVoiceState(packet) { if (!["VOICE_STATE_UPDATE", "VOICE_SERVER_UPDATE"].includes(packet.t)) return; const player = this.players.get(packet.d.guild_id); if (!player) return; if (packet.t === "VOICE_SERVER_UPDATE") { player.connection.setServerUpdate(packet.d); } else if (packet.t === "VOICE_STATE_UPDATE") { if (packet.d.user_id !== this.clientId) return; player.connection.setStateUpdate(packet.d); } } async search(query, requester, source = this.defaultSearchPlatform) { return this.resolve({ query, source, requester }); } getNodesHealth() { const health = {}; for (const [name, node] of this.nodeMap) { health[name] = this.getNodeHealth(node); } return health; } getSystemHealth() { const nodesHealth = this.getNodesHealth(); const connectedNodes = Object.values(nodesHealth).filter( (h) => h.connected ); const totalPlayers = Object.values(nodesHealth).reduce( (sum, h) => sum + h.players, 0 ); const totalPlayingPlayers = Object.values(nodesHealth).reduce( (sum, h) => sum + h.playingPlayers, 0 ); return { totalNodes: Object.keys(nodesHealth).length, connectedNodes: connectedNodes.length, totalPlayers, totalPlayingPlayers, averagePing: connectedNodes.length > 0 ? connectedNodes.reduce((sum, h) => sum + h.averagePing, 0) / connectedNodes.length : 0, nodesHealth, }; } clearCaches() { this.regionCache.clear(); this.nodeHealthCache.clear(); this.resolveCache.clear(); this.emit("debug", "All caches cleared"); } async savePlayersState(filePath) { try { const playersData = {}; for (const [guildId, player] of this.players) { if (player.current || player.queue.length > 0) { playersData[guildId] = player.toJSON(); } } await fs.writeFile(filePath, JSON.stringify(playersData, null, 2)); this.emit( "debug", `Saved ${Object.keys(playersData).length} player states to ${filePath}` ); return playersData; } catch (error) { this.emit("debug", `Failed to save player states: ${error.message}`); throw error; } } async destroy() { // Remove all event listeners and clear caches this.removeAllListeners(); for (const player of this.players.values()) { player.removeAllListeners && player.removeAllListeners(); if (typeof player.destroy === "function") player.destroy(); } for (const node of this.nodeMap.values()) { node.removeAllListeners && node.removeAllListeners(); if (typeof node.destroy === "function") node.destroy(); } this.players.clear(); this.nodeMap.clear(); this.regionCache.clear(); this.nodeHealthCache.clear(); this.resolveCache.clear(); this.pluginInstances = []; this.statusSync = null; this.initiated = false; this.emit("destroy"); } } module.exports = { Teralink };