UNPKG

aqualink

Version:

An Lavalink/Nodelink client, focused in pure performance and features

809 lines (709 loc) 22.6 kB
const IS_BUN = !!(process?.isBun || process?.versions?.bun || globalThis.Bun) if (process && typeof process.isBun !== 'boolean') process.isBun = IS_BUN const WebSocketImpl = process.isBun ? globalThis.WebSocket : require('ws') const Rest = require('./Rest') const { AqualinkEvents } = require('./AqualinkEvents') const privateData = new WeakMap() const NODE_STATE = Object.freeze({ IDLE: 0, CONNECTING: 1, READY: 2, DISCONNECTING: 3, RECONNECTING: 4 }) const WS_STATES = Object.freeze({ CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 }) const FATAL_CLOSE_CODES = Object.freeze([4003, 4004, 4010, 4011, 4012, 4015]) const WS_PATH = '/v4/websocket' const LYRICS_PREFIX = 'Lyrics' const OPS_STATS = 'stats' const OPS_READY = 'ready' const OPS_PLAYER_UPDATE = 'playerUpdate' const OPS_EVENT = 'event' const unrefTimer = (t) => { try { t?.unref?.() } catch {} } const _functions = { buildWsUrl(host, port, ssl) { const needsBrackets = host.includes(':') && !host.startsWith('[') return `ws${ssl ? 's' : ''}://${needsBrackets ? `[${host}]` : host}:${port}${WS_PATH}` }, isLyricsOp(op) { return typeof op === 'string' && op.startsWith(LYRICS_PREFIX) }, reasonToString(reason) { if (!reason) return 'No reason provided' if (typeof reason === 'string') return reason if (Buffer.isBuffer(reason)) { try { return reason.toString('utf8') } catch { return String(reason) } } if (reason instanceof ArrayBuffer) { try { return Buffer.from(reason).toString('utf8') } catch { return String(reason) } } if (ArrayBuffer.isView(reason)) { try { return Buffer.from( reason.buffer, reason.byteOffset, reason.byteLength ).toString('utf8') } catch { return String(reason) } } if (typeof reason === 'object') return reason.message || reason.code || JSON.stringify(reason) return String(reason) }, errMsg(err) { return err?.message || String(err) }, statusCode(err) { return ( err?.statusCode || err?.status || err?.response?.statusCode || err?.response?.status || null ) } } class Node { static BACKOFF_MULTIPLIER = 1.5 static MAX_BACKOFF = 60000 static DEFAULT_RECONNECT_TIMEOUT = 2000 static DEFAULT_RESUME_TIMEOUT = 60 static JITTER_MAX = 2000 static JITTER_FACTOR = 0.2 static WS_CLOSE_NORMAL = 1000 static DEFAULT_MAX_PAYLOAD = 1048576 static DEFAULT_HANDSHAKE_TIMEOUT = 15000 static INFO_FETCH_TIMEOUT = 10000 static INFINITE_BACKOFF = 10000 constructor(aqua, connOptions, options = {}) { this.aqua = aqua this.host = connOptions.host || 'localhost' this.name = connOptions.name || this.host this.port = connOptions.port || 2333 this.auth = connOptions.auth || connOptions.password || 'youshallnotpass' this.sessionId = connOptions.sessionId || null this.regions = connOptions.regions || [] this.ssl = !!connOptions.ssl || !!connOptions.secure || false this.wsUrl = _functions.buildWsUrl(this.host, this.port, this.ssl) this.rest = new Rest(aqua, this) this.resumeTimeout = options.resumeTimeout ?? Node.DEFAULT_RESUME_TIMEOUT this.autoResume = options.autoResume ?? false this.reconnectTimeout = options.reconnectTimeout ?? Node.DEFAULT_RECONNECT_TIMEOUT this.reconnectTries = options.reconnectTries ?? 3 this.infiniteReconnects = options.infiniteReconnects ?? false this.timeout = options.timeout ?? Node.DEFAULT_HANDSHAKE_TIMEOUT this.maxPayload = options.maxPayload ?? Node.DEFAULT_MAX_PAYLOAD this.skipUTF8Validation = options.skipUTF8Validation ?? true this.connected = false this.state = NODE_STATE.IDLE this.info = null this.ws = null this.reconnectAttempted = 0 this.reconnectTimeoutId = null this.isDestroyed = false this._isConnecting = false this.isNodelink = false this._wsIsBun = !!process.isBun this._bunCleanup = null this.stats = { players: 0, playingPlayers: 0, uptime: 0, ping: 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._clientName = `Aqua/${this.aqua.version} https://github.com/ToddyTheNoobDud/AquaLink` this._headers = this._buildHeaders() privateData.set(this, { boundHandlers: { open: this._handleOpen.bind(this), error: this._handleError.bind(this), message: this._handleMessage.bind(this), close: this._handleClose.bind(this), connect: this.connect.bind(this) } }) } _buildHeaders() { const headers = { Authorization: this.auth, 'User-Id': this.aqua.clientId, 'Client-Name': this._clientName } if (this.sessionId) headers['Session-Id'] = this.sessionId return headers } get _boundHandlers() { return privateData.get(this)?.boundHandlers } _clearSession() { this.sessionId = null delete this._headers['Session-Id'] this.rest?.setSessionId?.(null) } _getPlayer(guildId) { return guildId ? this.aqua?.players?.get?.(guildId) : null } async _handleOpen() { this.connected = true this.state = NODE_STATE.READY this._isConnecting = false this.reconnectAttempted = 0 this._emitDebug('WebSocket connection established') if (this.aqua?.debugTrace) { this.aqua._trace('node.ws.open', { node: this.name, reconnectAttempted: this.reconnectAttempted }) } if (!this.aqua?.bypassChecks?.nodeFetchInfo && !this.info) { const timeoutId = setTimeout(() => { if (!this.isDestroyed) this._emitError('Node info fetch timeout') }, Node.INFO_FETCH_TIMEOUT) unrefTimer(timeoutId) try { this.info = await this.rest.makeRequest('GET', '/v4/info') this.isNodelink = !!this.info?.isNodelink } catch (err) { this.info = null if (_functions.statusCode(err) === 404) { this._emitDebug( 'Node info endpoint unavailable (HTTP 404); continuing without remote info' ) } else { this._emitError( `Failed to fetch node info: ${_functions.errMsg(err)}` ) } } finally { clearTimeout(timeoutId) } } this.aqua.emit(AqualinkEvents.NodeConnect, this) } _handleError(error) { const err = error instanceof Error ? error : new Error(String(error)) this.aqua.emit(AqualinkEvents.NodeError, this, err) } _handleMessage(data, isBinary) { if (isBinary) return let payload try { payload = JSON.parse(data) } catch (err) { this._emitDebug(() => `Invalid JSON from Lavalink: ${err.message}`) return } const op = payload?.op if (!op) return if (op === OPS_PLAYER_UPDATE) this._emitToPlayer(AqualinkEvents.PlayerUpdate, payload) else if (op === OPS_EVENT) this._emitToPlayer('event', payload) else if (op === OPS_STATS) this._updateStats(payload) else if (op === OPS_READY) this._handleReady(payload) else this._handleCustomStringOp(op, payload) } _emitToPlayer(eventName, payload) { const player = this._getPlayer(payload?.guildId) if (!player?.emit) return try { player.emit(eventName, payload) } catch (err) { this._emitError(`Player emit error: ${_functions.errMsg(err)}`) } } _handleCustomStringOp(op, payload) { if (_functions.isLyricsOp(op)) { this.aqua.emit( op, this._getPlayer(payload.guildId), payload.track || null, payload ) return } this.aqua.emit(AqualinkEvents.NodeCustomOp, this, op, payload) this._emitDebug(() => `Unknown op from Lavalink: ${op}`) } _handleClose(code, reason) { this.connected = false this.state = this.isDestroyed ? NODE_STATE.IDLE : NODE_STATE.RECONNECTING this._isConnecting = false this.aqua.emit(AqualinkEvents.NodeDisconnect, this, { code, reason: _functions.reasonToString(reason) }) if (this.aqua?.debugTrace) { this.aqua._trace('node.ws.close', { node: this.name, code, reason: _functions.reasonToString(reason), hasSessionId: !!this.sessionId }) } if (this.isDestroyed) return const isFatal = FATAL_CLOSE_CODES.includes(code) if ( code !== Node.WS_CLOSE_NORMAL && code !== 1001 && !isFatal && this.sessionId ) { this._clearSession() } const shouldReconnect = (code !== Node.WS_CLOSE_NORMAL || this.infiniteReconnects) && !isFatal if (!shouldReconnect) { if (code === 4011) this._clearSession() this._emitError( new Error(`WebSocket closed (code ${code}). Not reconnecting.`) ) this.destroy(true) return } this.aqua.handleNodeFailover?.(this) this._scheduleReconnect() } _scheduleReconnect() { this._clearReconnectTimeout() const attempt = ++this.reconnectAttempted if (this.aqua?.debugTrace) { this.aqua._trace('node.ws.reconnect.scheduled', { node: this.name, attempt, infinite: !!this.infiniteReconnects }) } if (this.infiniteReconnects) { this.aqua.emit(AqualinkEvents.NodeReconnect, this, { infinite: true, attempt, backoffTime: Node.INFINITE_BACKOFF }) this.reconnectTimeoutId = setTimeout( this._boundHandlers.connect, Node.INFINITE_BACKOFF ) unrefTimer(this.reconnectTimeoutId) return } if (this.reconnectAttempted > this.reconnectTries) { this._emitError( new Error(`Max reconnection attempts reached (${this.reconnectTries})`) ) this.destroy(true) return } const backoffTime = this._calcBackoff(attempt) this.aqua.emit(AqualinkEvents.NodeReconnect, this, { infinite: false, attempt, backoffTime }) this.reconnectTimeoutId = setTimeout( this._boundHandlers.connect, backoffTime ) unrefTimer(this.reconnectTimeoutId) } _calcBackoff(attempt) { const baseBackoff = this.reconnectTimeout * Node.BACKOFF_MULTIPLIER ** Math.min(attempt, 10) const maxJitter = Math.min( Node.JITTER_MAX, baseBackoff * Node.JITTER_FACTOR ) return Math.min(baseBackoff + Math.random() * maxJitter, Node.MAX_BACKOFF) } _clearReconnectTimeout() { if (!this.reconnectTimeoutId) return clearTimeout(this.reconnectTimeoutId) this.reconnectTimeoutId = null } connect() { if (this.isDestroyed || this._isConnecting) return const state = this.ws?.readyState if (state === WS_STATES.OPEN) { this._emitDebug('WebSocket already connected') return } if (state === WS_STATES.CONNECTING || state === WS_STATES.CLOSING) { this._emitDebug('WebSocket is connecting/closing; skipping new connect') return } this._isConnecting = true this.state = NODE_STATE.CONNECTING this._cleanup() if (this.aqua?.debugTrace) { this.aqua._trace('node.ws.connect', { node: this.name, url: this.wsUrl }) } try { const h = this._boundHandlers if (this._wsIsBun) { const ws = new WebSocketImpl(this.wsUrl, { headers: this._headers }) ws.binaryType = 'arraybuffer' const offs = [] const add = (type, fn, once = false) => { const wrapped = once ? (ev) => { try { ws.removeEventListener(type, wrapped) } catch {} fn(ev) } : fn ws.addEventListener(type, wrapped) offs.push(() => { try { ws.removeEventListener(type, wrapped) } catch {} }) } add('open', () => h.open(), true) add( 'error', (event) => { const err = event?.error h.error(err instanceof Error ? err : new Error('WebSocket error')) }, true ) add('message', (event) => { const data = event?.data if (typeof data === 'string') h.message(data, false) else h.message(data, true) }) add( 'close', (event) => { h.close( typeof event?.code === 'number' ? event.code : Node.WS_CLOSE_NORMAL, typeof event?.reason === 'string' ? event.reason : '' ) }, true ) this._bunCleanup = () => { for (let i = 0; i < offs.length; i++) offs[i]() } this.ws = ws return } const ws = new WebSocketImpl(this.wsUrl, { headers: this._headers, perMessageDeflate: false, handshakeTimeout: this.timeout, maxPayload: this.maxPayload, skipUTF8Validation: this.skipUTF8Validation }) ws.binaryType = 'nodebuffer' ws.once('open', h.open) ws.once('error', h.error) ws.on('message', h.message) ws.once('close', h.close) this.ws = ws } catch (err) { this._isConnecting = false this._emitError(`Failed to create WebSocket: ${_functions.errMsg(err)}`) this._scheduleReconnect() } } _cleanup() { const ws = this.ws if (!ws) return if (this._wsIsBun) { try { this._bunCleanup?.() } catch {} this._bunCleanup = null } else { ws.removeAllListeners?.() } try { const state = ws.readyState if (state === WS_STATES.OPEN || state === WS_STATES.CONNECTING) { ws.close(Node.WS_CLOSE_NORMAL) } else if (!this._wsIsBun && state !== WS_STATES.CLOSED) { ws.terminate?.() } } catch (err) { this._emitError(`WebSocket cleanup error: ${_functions.errMsg(err)}`) } this.ws = null } destroy(clean = false) { if (this.isDestroyed) return this.isDestroyed = true this.state = NODE_STATE.IDLE this._isConnecting = false this._clearReconnectTimeout() this._cleanup() if (!clean) this.aqua.handleNodeFailover?.(this) this.connected = false this.aqua.destroyNode?.(this.name) this.aqua.emit(AqualinkEvents.NodeDestroy, this) this.rest?.destroy?.() this.info = null this.rest = null this.aqua = null this._headers = null this.stats = null privateData.delete(this) } async getStats() { if (this.connected) return this.stats try { const newStats = await this.rest.getStats() if (newStats) this._updateStats(newStats) } catch (err) { this._emitError(`Failed to fetch node stats: ${_functions.errMsg(err)}`) } return this.stats } _updateStats(payload) { if (!payload) return const s = this.stats if (payload.players !== undefined) s.players = payload.players if (payload.playingPlayers !== undefined) s.playingPlayers = payload.playingPlayers if (payload.uptime !== undefined) s.uptime = payload.uptime if (payload.ping !== undefined) s.ping = payload.ping if (payload.memory) { const m = s.memory, pm = payload.memory if (pm.free !== undefined) m.free = pm.free if (pm.used !== undefined) m.used = pm.used if (pm.allocated !== undefined) m.allocated = pm.allocated if (pm.reservable !== undefined) m.reservable = pm.reservable } if (payload.cpu) { const c = s.cpu, pc = payload.cpu if (pc.cores !== undefined) c.cores = pc.cores if (pc.systemLoad !== undefined) c.systemLoad = pc.systemLoad if (pc.lavalinkLoad !== undefined) c.lavalinkLoad = pc.lavalinkLoad } if (payload.frameStats) { const f = s.frameStats, pf = payload.frameStats if (pf.sent !== undefined) f.sent = pf.sent if (pf.nulled !== undefined) f.nulled = pf.nulled if (pf.deficit !== undefined) f.deficit = pf.deficit } } async _handleReady(payload) { const sessionId = payload?.sessionId if (!sessionId) { this._emitError('Ready payload missing sessionId') return } const oldSessionId = this.sessionId const sessionInvalidated = !payload.resumed && !!oldSessionId const sessionChanged = sessionInvalidated && oldSessionId !== sessionId this.sessionId = sessionId if (this.aqua?.debugTrace) { this.aqua._trace('node.ready.packet', { node: this.name, resumed: !!payload.resumed, oldSessionId, newSessionId: sessionId }) } this.rest.setSessionId(sessionId) this._headers['Session-Id'] = sessionId if (sessionInvalidated && this.aqua?.players) { this._emitDebug( `Session invalidated (resumed=${!!payload.resumed}, old=${oldSessionId}, new=${sessionId}), invalidating stale players` ) try { await this.aqua._storeBrokenPlayers?.(this) } catch (e) { this._emitDebug( `Failed to snapshot stale players before invalidation: ${e?.message || e}` ) } for (const [, player] of this.aqua.players) { if (player?.nodes === this || player?.nodes?.name === this.name) { if (player.connection) { player.connection._lastEndpoint = null player.connection._stateFlags |= 512 } } } const playersToDestroy = [] for (const [guildId, player] of this.aqua.players) { if (player?.nodes === this || player?.nodes?.name === this.name) { playersToDestroy.push({ guildId, player }) } } for (const { guildId, player } of playersToDestroy) { try { this._emitDebug( `Invalidating stale player for guild ${guildId} without voice disconnect` ) player?.destroy?.({ preserveClient: true, skipRemote: true, preserveMessage: true, preserveTracks: true, preserveReconnecting: true }) if (this.aqua.players.get(String(guildId)) === player) { this.aqua.players.delete(String(guildId)) } this.players?.delete?.(player) } catch (e) { this._emitDebug( `Failed to invalidate stale player ${guildId}: ${e?.message || e}` ) } } } this.aqua.emit(AqualinkEvents.NodeReady, this, { resumed: !!payload.resumed, sessionChanged, sessionInvalidated }) if (this.autoResume) { setImmediate(() => { this._resumePlayers().catch((err) => { this._emitError(`_resumePlayers failed: ${_functions.errMsg(err)}`) }) }) } } async _resumePlayers() { if (!this.sessionId) return if (this.aqua?.debugTrace) { this.aqua._trace('node.resume.begin', { node: this.name, sessionId: this.sessionId, players: this.aqua?.players?.size || 0 }) } let resumeSupported = true try { await this.rest.makeRequest('PATCH', `/v4/sessions/${this.sessionId}`, { resuming: true, timeout: this.resumeTimeout }) } catch (err) { if (_functions.statusCode(err) === 404) { resumeSupported = false this._emitDebug( 'Session resume endpoint unavailable (HTTP 404); falling back without server-side session resume' ) } else { if (this.aqua?.debugTrace) { this.aqua._trace('node.resume.error', { node: this.name, error: _functions.errMsg(err) }) } this._emitError(`Failed to resume session: ${_functions.errMsg(err)}`) throw err } } if (!resumeSupported) { try { const existingPlayers = await this.rest.getPlayers() if (Array.isArray(existingPlayers) && existingPlayers.length === 0) { this._emitDebug( 'No players found on Lavalink for this session; will rejoin voice only' ) } } catch (_) { // getPlayers may also 404 — that's fine, we already know session is invalid } } if (this.aqua?.players) { const PLAYER_BATCH_SIZE = 20 const playersToResume = [] for (const [guildId, player] of this.aqua.players) { if ( (player?.nodes === this || player?.nodes?.name === this.name) && player.voiceChannel && !player.destroyed ) { playersToResume.push({ guildId, player }) } } for (let i = 0; i < playersToResume.length; i += PLAYER_BATCH_SIZE) { const batch = playersToResume.slice(i, i + PLAYER_BATCH_SIZE) await Promise.allSettled( batch.map(async ({ guildId, player }) => { try { const recoveryToken = player._claimVoiceRecovery?.( resumeSupported ? 'node_resume_rejoin' : 'node_rejoin_after_resume_404' ) this._emitDebug(`Rejoining voice for guild ${guildId} on resume`) if (this.aqua?.debugTrace) { this.aqua._trace('node.resume.rejoin', { node: this.name, guildId, voiceChannel: player.voiceChannel, resumeSupported }) } if (player._isVoiceRecoveryActive?.(recoveryToken)) player.connect({ voiceChannel: player.voiceChannel, deaf: player.deaf, mute: player.mute }) } catch (e) { this._emitDebug( `Failed to rejoin voice for ${guildId}: ${e?.message || e}` ) } }) ) } } if (this.aqua.loadPlayers && this.aqua.players.size === 0) { await this.aqua.loadPlayers() } } _emitError(error) { const errorObj = error instanceof Error ? error : new Error(String(error)) this.aqua.emit(AqualinkEvents.Error, this, errorObj) } _emitDebug(message) { if (!this.aqua?.listenerCount?.(AqualinkEvents.Debug)) return this.aqua.emit( AqualinkEvents.Debug, this.name, typeof message === 'function' ? message() : message ) } } module.exports = Node