UNPKG

aqualink

Version:

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

908 lines (806 loc) 25.7 kB
const { Buffer } = require('node:buffer') const { Agent: HttpsAgent, request: httpsRequest } = require('node:https') const { Agent: HttpAgent, request: httpRequest } = require('node:http') const http2 = require('node:http2') const { createBrotliDecompress, createUnzip, brotliDecompressSync, unzipSync, createZstdDecompress, zstdDecompressSync } = require('node:zlib') let autoplayModule = null try { autoplayModule = require('../handlers/autoplay') } catch {} const unrefTimer = (t) => { try { t?.unref?.() } catch {} } const HAS_ZSTD = typeof createZstdDecompress === 'function' && typeof zstdDecompressSync === 'function' const BASE64_LOOKUP = new Uint8Array(256) for (let i = 65; i <= 90; i++) BASE64_LOOKUP[i] = 1 for (let i = 97; i <= 122; i++) BASE64_LOOKUP[i] = 1 for (let i = 48; i <= 57; i++) BASE64_LOOKUP[i] = 1 BASE64_LOOKUP[43] = BASE64_LOOKUP[47] = BASE64_LOOKUP[61] = BASE64_LOOKUP[95] = BASE64_LOOKUP[45] = 1 const ENCODING_NONE = 0, ENCODING_BR = 1, ENCODING_GZIP = 2, ENCODING_DEFLATE = 3, ENCODING_ZSTD = 4 const MAX_RESPONSE_SIZE = 10485760 const COMPRESSION_MIN_SIZE = 1024 const API_VERSION = 'v4' const UTF8 = 'utf8' const JSON_CT = 'application/json' const HTTP2_THRESHOLD = 1024 const MAX_HEADER_POOL = 10 const H2_TIMEOUT = 60000 const ERRORS = Object.freeze({ NO_SESSION: new Error('Session ID required'), INVALID_TRACK: new Error('Invalid encoded track format'), INVALID_TRACKS: new Error('One or more tracks have invalid format'), RESPONSE_TOO_LARGE: new Error('Response too large'), RESPONSE_ABORTED: new Error('Response aborted') }) const _functions = { isValidBase64(str) { if (typeof str !== 'string' || !str) return false const len = str.length if (len % 4 === 1) return false for (let i = 0; i < len; i++) { if (!BASE64_LOOKUP[str.charCodeAt(i)]) return false } return true }, getEncodingType(header) { if (!header) return ENCODING_NONE const c = header.charCodeAt(0) if (c === 122 && header.startsWith('zstd')) return ENCODING_ZSTD if (c === 120 && header.startsWith('x-zstd')) return ENCODING_ZSTD if (c === 98 && header.startsWith('br')) return ENCODING_BR if (c === 103 && header.startsWith('gzip')) return ENCODING_GZIP if (c === 100 && header.startsWith('deflate')) return ENCODING_DEFLATE return ENCODING_NONE }, isJsonContent(ct) { return ct && ct.charCodeAt(0) === 97 && ct.includes(JSON_CT) }, parseBody(data, contentType, forceJson) { const isJson = forceJson || this.isJsonContent(contentType) if (isJson) { if (typeof data === 'string') return JSON.parse(data) return JSON.parse(data) } return typeof data === 'string' ? data : data.toString(UTF8) }, createHttpError(status, method, url, headers, body, statusMessage) { const err = new Error(`HTTP ${status} ${method} ${url}`) err.statusCode = status err.headers = headers err.body = body err.url = url if (statusMessage !== undefined) err.statusMessage = statusMessage return err }, createDecompressor(type) { if (type === ENCODING_ZSTD) { if (!HAS_ZSTD) throw new Error( 'Unsupported content-encoding: zstd (zlib zstd APIs not available in this Node runtime)' ) return createZstdDecompress() } return type === ENCODING_BR ? createBrotliDecompress() : createUnzip() }, decompressSync(buf, type) { if (type === ENCODING_ZSTD) { if (!HAS_ZSTD) throw new Error( 'Unsupported content-encoding: zstd (zlib zstd APIs not available in this Node runtime)' ) return zstdDecompressSync(buf) } return type === ENCODING_BR ? brotliDecompressSync(buf) : unzipSync(buf) } } class Rest { constructor(aqua, node) { this.aqua = aqua this.node = node this.sessionId = node.sessionId this._sessionGeneration = 0 this.timeout = node.timeout || 30000 const protocol = node.ssl ? 'https:' : 'http:' const host = node.host.includes(':') && !node.host.startsWith('[') ? `[${node.host}]` : node.host this.baseUrl = `${protocol}//${host}:${node.port}` this._apiBase = `/${API_VERSION}` this._endpoints = Object.freeze({ loadtracks: `${this._apiBase}/loadtracks?identifier=`, decodetrack: `${this._apiBase}/decodetrack?encodedTrack=`, decodetracks: `${this._apiBase}/decodetracks`, stats: `${this._apiBase}/stats`, info: `${this._apiBase}/info`, version: `/version`, routeplanner: Object.freeze({ status: `${this._apiBase}/routeplanner/status`, freeAddress: `${this._apiBase}/routeplanner/free/address`, freeAll: `${this._apiBase}/routeplanner/free/all` }), lyrics: `${this._apiBase}/lyrics` }) const acceptEncoding = HAS_ZSTD ? 'zstd, br, gzip, deflate' : 'br, gzip, deflate' this.defaultHeaders = Object.freeze({ Authorization: String(node.auth || node.password || ''), Accept: 'application/json, */*;q=0.5', 'Accept-Encoding': acceptEncoding, 'User-Agent': `Aqualink/${aqua?.version || '1.0'} (Node.js ${process.version})` }) this._headerPool = [] this._tlsOptions = null this._autoplayAgent = null this._setupAgent(node) this.useHttp2 = !!aqua?.options?.useHttp2 this._h2 = null this._h2Timer = null this.calls = 0 } _setupAgent(node) { const opts = { keepAlive: true, maxSockets: node.maxSockets || 128, maxFreeSockets: node.maxFreeSockets || 64, freeSocketTimeout: node.freeSocketTimeout || 15000, keepAliveMsecs: node.keepAliveMsecs || 500, scheduling: 'lifo', timeout: this.timeout } if (node.ssl) { opts.maxCachedSessions = node.maxCachedSessions || 200 this._tlsOptions = Object.create(null) if (node.rejectUnauthorized !== undefined) this._tlsOptions.rejectUnauthorized = opts.rejectUnauthorized = node.rejectUnauthorized if (node.ca) this._tlsOptions.ca = opts.ca = node.ca if (node.cert) this._tlsOptions.cert = opts.cert = node.cert if (node.key) this._tlsOptions.key = opts.key = node.key if (node.passphrase) this._tlsOptions.passphrase = opts.passphrase = node.passphrase if (node.servername) this._tlsOptions.servername = node.servername } this.agent = new (node.ssl ? HttpsAgent : HttpAgent)(opts) this.request = node.ssl ? httpsRequest : httpRequest if (autoplayModule?.setSharedAgent) { if (node.ssl) { this._autoplayAgent = this.agent } else { this._autoplayAgent = new HttpsAgent({ keepAlive: true, maxSockets: node.maxSockets || 128, maxFreeSockets: node.maxFreeSockets || 64, freeSocketTimeout: node.freeSocketTimeout || 15000, keepAliveMsecs: node.keepAliveMsecs || 500, scheduling: 'lifo', timeout: this.timeout }) } autoplayModule.setSharedAgent(this._autoplayAgent) } const origCreate = this.agent.createConnection.bind(this.agent) this.agent.createConnection = (options, cb) => { const socket = origCreate(options, cb) socket.setNoDelay(true) socket.setKeepAlive(true, 500) return socket } } setSessionId(sessionId) { this.sessionId = sessionId this._sessionGeneration++ } _getSessionPath(generation) { if (!this.sessionId) throw ERRORS.NO_SESSION if (generation != null && generation !== this._sessionGeneration) { const staleErr = new Error( `Stale session: sessionId was updated (expected gen ${generation}, current ${this._sessionGeneration}) for session ${this.sessionId}` ) staleErr.statusCode = 404 throw staleErr } return `${this._apiBase}/sessions/${this.sessionId}` } _buildHeaders(hasPayload, payloadLength) { if (!hasPayload) return this.defaultHeaders const h = this._headerPool.pop() || Object.create(null) h.Authorization = this.defaultHeaders.Authorization h.Accept = this.defaultHeaders.Accept h['Accept-Encoding'] = this.defaultHeaders['Accept-Encoding'] h['User-Agent'] = this.defaultHeaders['User-Agent'] h['Content-Type'] = JSON_CT h['Content-Length'] = payloadLength return h } _returnHeaders(h) { if ( h !== this.defaultHeaders && this._headerPool.length < MAX_HEADER_POOL ) { h.Authorization = h.Accept = h['Accept-Encoding'] = h['User-Agent'] = h['Content-Type'] = h['Content-Length'] = null this._headerPool.push(h) } } _collectBody(stream, preallocSize = 0) { return new Promise((resolve, reject) => { let done = false let size = 0 let prealloc = preallocSize > 0 && preallocSize <= MAX_RESPONSE_SIZE ? Buffer.allocUnsafe(preallocSize) : null let chunks = prealloc ? null : [] const finish = (ok, val) => { if (done) return done = true prealloc = null chunks = null ok ? resolve(val) : reject(val) } stream.on('data', (chunk) => { if (done) return if (prealloc) { if (size + chunk.length <= prealloc.length) { chunk.copy(prealloc, size) size += chunk.length } else { chunks = [prealloc.slice(0, size), chunk] size += chunk.length prealloc = null } } else { size += chunk.length chunks.push(chunk) } if (size > MAX_RESPONSE_SIZE) finish(false, ERRORS.RESPONSE_TOO_LARGE) }) stream.once('error', (e) => finish(false, e)) stream.once('end', () => { if (done) return if (size === 0) return finish(true, null) finish( true, prealloc ? prealloc.slice(0, size) : chunks.length === 1 ? chunks[0] : Buffer.concat(chunks, size) ) }) }) } _parseResponseBuffer( buffer, status, method, url, headers, contentType, statusMessage ) { if (!buffer) return null if (buffer.length > MAX_RESPONSE_SIZE) throw ERRORS.RESPONSE_TOO_LARGE let result try { result = _functions.parseBody(buffer, contentType, false) } catch (e) { throw new Error(`JSON parse error: ${e.message}`) } if (status >= 400) { throw _functions.createHttpError( status, method, url, headers, result, statusMessage ) } return result } async makeRequest(method, endpoint, body) { const url = `${this.baseUrl}${endpoint}` const payload = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body) const payloadLen = payload ? Buffer.byteLength(payload, UTF8) : 0 const headers = this._buildHeaders(!!payload, payloadLen) this.calls++ try { const resp = this.useHttp2 && payloadLen >= HTTP2_THRESHOLD ? await this._h2Request(method, endpoint, headers, payload) : await this._h1Request(method, url, headers, payload) return resp } finally { if (this.calls > 0) this.calls-- this._returnHeaders(headers) } } _h1Request(method, url, headers, payload) { return new Promise((resolve, reject) => { let req, timer, done = false const finish = (ok, val) => { if (done) return done = true if (timer) { clearTimeout(timer) timer = null } if (req && !ok) req.destroy() ok ? resolve(val) : reject(val) } req = this.request( url, { method, headers, agent: this.agent, timeout: this.timeout }, (res) => { if (timer) { clearTimeout(timer) timer = null } const status = res.statusCode || 0 const cl = res.headers['content-length'] const contentType = res.headers['content-type'] || '' if (status === 204 || cl === '0') { res.resume() return finish(true, null) } const clInt = cl ? parseInt(cl, 10) : 0 if (clInt > MAX_RESPONSE_SIZE) { res.resume() return finish(false, ERRORS.RESPONSE_TOO_LARGE) } const encoding = _functions.getEncodingType( res.headers['content-encoding'] ) const finalize = (buffer) => { try { const result = this._parseResponseBuffer( buffer, status, method, url, res.headers, contentType, res.statusMessage ) finish(true, result) } catch (e) { finish(false, e) } } res.once('aborted', () => finish(false, ERRORS.RESPONSE_ABORTED)) res.once('error', (e) => finish(false, e)) if ( encoding !== ENCODING_NONE && clInt > 0 && clInt < COMPRESSION_MIN_SIZE ) { this._collectBody(res) .then((compressed) => { if (!compressed) return finish(true, null) const decompressed = _functions.decompressSync( compressed, encoding ) finalize(decompressed) }) .catch((e) => { finish(false, e) }) return } let stream = res let preallocSize = 0 if (encoding !== ENCODING_NONE) { const decomp = _functions.createDecompressor(encoding) decomp.once('error', (e) => finish(false, e)) res.pipe(decomp) stream = decomp } else if (clInt > 0 && clInt <= MAX_RESPONSE_SIZE) { preallocSize = clInt } this._collectBody(stream, preallocSize) .then((buffer) => { if (!buffer) return finish(true, null) finalize(buffer) }) .catch((e) => finish(false, e)) } ) req.once('error', (e) => finish(false, e)) timer = setTimeout( () => finish(false, new Error(`Request timeout: ${this.timeout}ms`)), this.timeout ) unrefTimer(timer) payload ? req.end(payload) : req.end() }) } _getH2Session() { if (!this._h2 || this._h2.closed || this._h2.destroyed) { this._clearH2() this._h2 = http2.connect(this.baseUrl, this._tlsOptions || undefined) this._resetH2Timer() const onEnd = () => this._clearH2() this._h2.once('error', onEnd) this._h2.once('close', onEnd) this._h2.socket?.unref?.() } return this._h2 } _resetH2Timer() { if (this._h2Timer) { clearTimeout(this._h2Timer) this._h2Timer = null } if (this._h2 && !this._h2.closed && !this._h2.destroyed) { this._h2Timer = setTimeout(() => this._closeH2(), H2_TIMEOUT) unrefTimer(this._h2Timer) } } _clearH2() { if (this._h2Timer) { clearTimeout(this._h2Timer) this._h2Timer = null } this._h2 = null } _closeH2() { if (this._h2Timer) { clearTimeout(this._h2Timer) this._h2Timer = null } if (this._h2) { try { this._h2.close() } catch {} this._h2 = null } } _h2Request(method, path, headers, payload) { const session = this._getH2Session() return new Promise((resolve, reject) => { let req, timer, done = false const finish = (ok, val) => { if (done) return done = true if (timer) { clearTimeout(timer) timer = null } if (req && !ok) req.close(http2.constants.NGHTTP2_CANCEL) ok ? resolve(val) : reject(val) } const h2h = { ':method': method, ':path': path, Authorization: headers.Authorization, Accept: headers.Accept, 'Accept-Encoding': headers['Accept-Encoding'], 'User-Agent': headers['User-Agent'] } if (headers['Content-Type']) h2h['Content-Type'] = headers['Content-Type'] if (headers['Content-Length']) h2h['Content-Length'] = headers['Content-Length'] req = session.request(h2h) this._resetH2Timer() req.once('response', (rh) => { if (timer) { clearTimeout(timer) timer = null } const status = rh[':status'] || 0 const cl = rh['content-length'] const contentType = rh['content-type'] || '' if (status === 204 || cl === '0') { req.resume() return finish(true, null) } const clInt = cl ? parseInt(cl, 10) : 0 if (clInt > MAX_RESPONSE_SIZE) { req.resume() return finish(false, ERRORS.RESPONSE_TOO_LARGE) } const encoding = _functions.getEncodingType(rh['content-encoding']) const finalize = (buffer) => { try { const result = this._parseResponseBuffer( buffer, status, method, this.baseUrl + path, rh, contentType ) finish(true, result) } catch (e) { finish(false, e) } } const decomp = encoding !== ENCODING_NONE ? _functions.createDecompressor(encoding) : null const stream = decomp ? req.pipe(decomp) : req const preallocSize = encoding === ENCODING_NONE && clInt > 0 && clInt <= MAX_RESPONSE_SIZE ? clInt : 0 if (decomp) decomp.once('error', (e) => finish(false, e)) req.once('error', (e) => finish(false, e)) this._collectBody(stream, preallocSize) .then((buffer) => { if (!buffer) return finish(true, null) finalize(buffer) }) .catch((e) => finish(false, e)) }) timer = setTimeout( () => finish(false, new Error(`Request timeout: ${this.timeout}ms`)), this.timeout ) unrefTimer(timer) payload ? req.end(payload) : req.end() }) } async updatePlayer({ guildId, data, noReplace = false }) { const gen = this._sessionGeneration return this.makeRequest( 'PATCH', `${this._getSessionPath(gen)}/players/${guildId}?noReplace=${noReplace}`, data ) } async getPlayer(guildId) { const gen = this._sessionGeneration return this.makeRequest( 'GET', `${this._getSessionPath(gen)}/players/${guildId}` ) } async getPlayers() { const gen = this._sessionGeneration return this.makeRequest('GET', `${this._getSessionPath(gen)}/players`) } async destroyPlayer(guildId, abortSignal) { const gen = this._sessionGeneration if (abortSignal?.aborted) return null return this.makeRequest( 'DELETE', `${this._getSessionPath(gen)}/players/${guildId}` ) } async loadTracks(identifier) { return this.makeRequest( 'GET', `${this._endpoints.loadtracks}${encodeURIComponent(identifier)}` ) } async decodeTrack(encodedTrack) { if (!_functions.isValidBase64(encodedTrack)) throw ERRORS.INVALID_TRACK return this.makeRequest( 'GET', `${this._endpoints.decodetrack}${encodeURIComponent(encodedTrack)}` ) } async decodeTracks(encodedTracks) { if (!Array.isArray(encodedTracks) || !encodedTracks.length) throw ERRORS.INVALID_TRACKS for (let i = 0; i < encodedTracks.length; i++) { if (!_functions.isValidBase64(encodedTracks[i])) throw ERRORS.INVALID_TRACKS } return this.makeRequest('POST', this._endpoints.decodetracks, encodedTracks) } async getStats() { return this.makeRequest('GET', this._endpoints.stats) } async getInfo() { return this.makeRequest('GET', this._endpoints.info) } async getVersion() { return this.makeRequest('GET', this._endpoints.version) } async getRoutePlannerStatus() { return this.makeRequest('GET', this._endpoints.routeplanner.status) } async freeRoutePlannerAddress(address) { return this.makeRequest('POST', this._endpoints.routeplanner.freeAddress, { address }) } async freeAllRoutePlannerAddresses() { return this.makeRequest('POST', this._endpoints.routeplanner.freeAll) } async getLyrics({ track, skipTrackSource = false }) { const guildId = track?.guild_id ?? track?.guildId const encoded = track?.encoded const hasEncoded = typeof encoded === 'string' && encoded.length > 0 && _functions.isValidBase64(encoded) const title = track?.info?.title if (!track || (!guildId && !hasEncoded && !title)) { this.aqua?.emit?.('error', '[Aqua/Lyrics] Invalid track object') return null } const skip = skipTrackSource ? 'true' : 'false' if (guildId) { try { const gen = this._sessionGeneration const lyrics = await this.makeRequest( 'GET', `${this._getSessionPath(gen)}/players/${guildId}/track/lyrics?skipTrackSource=${skip}` ) if (this._validLyrics(lyrics)) return lyrics } catch {} } if (hasEncoded) { try { const lyrics = await this.makeRequest( 'GET', `${this._endpoints.lyrics}?track=${encodeURIComponent(encoded)}&skipTrackSource=${skip}` ) if (this._validLyrics(lyrics)) return lyrics } catch {} } if (title) { const info = track.info || {} const query = info.author ? `${title} ${info.author}` : title try { const lyrics = await this.makeRequest( 'GET', `${this._endpoints.lyrics}/search?query=${encodeURIComponent(query)}` ) if (this._validLyrics(lyrics)) return lyrics } catch {} } return null } _validLyrics(r) { if (!r) return false if (typeof r === 'string') return r.length > 0 if (typeof r === 'object') return Array.isArray(r) ? r.length > 0 : Object.keys(r).length > 0 return false } async subscribeLiveLyrics(guildId, skipTrackSource = false) { try { const gen = this._sessionGeneration return ( (await this.makeRequest( 'POST', `${this._getSessionPath(gen)}/players/${guildId}/lyrics/subscribe?skipTrackSource=${skipTrackSource ? 'true' : 'false'}` )) === null ) } catch { return false } } async unsubscribeLiveLyrics(guildId) { try { const gen = this._sessionGeneration return ( (await this.makeRequest( 'DELETE', `${this._getSessionPath(gen)}/players/${guildId}/lyrics/subscribe` )) === null ) } catch { return false } } async addMixer(guildId, options) { if (!this.node.isNodelink) throw new Error('Mixer endpoints are only available on Nodelink nodes') if (!options?.encoded && !options?.identifier) throw new Error('You must provide either encoded or identifier') const track = {} if (options.encoded) track.encoded = options.encoded if (options.identifier) track.identifier = options.identifier if (options.userData) track.userData = options.userData const payload = { track, volume: options.volume !== undefined ? options.volume : 0.8 } return this.makeRequest( 'POST', `/v4/sessions/${this.sessionId}/players/${guildId}/mix`, payload ) } async getActiveMixer(guildId) { if (!this.node.isNodelink) throw new Error('Mixer endpoints are only available on Nodelink nodes') const response = await this.makeRequest( 'GET', `/v4/sessions/${this.sessionId}/players/${guildId}/mix` ) return response?.mixes || [] } async updateMixerVolume(guildId, mix, volume) { if (!this.node.isNodelink) throw new Error('Mixer endpoints are only available on Nodelink nodes') if (!guildId || !mix || typeof volume !== 'number') throw new Error('You forget to set the guild_id, mix or volume options') return this.makeRequest( 'PATCH', `/v4/sessions/${this.sessionId}/players/${guildId}/mix/${mix}`, { volume } ) } async removeMixer(guildId, mix) { if (!this.node.isNodelink) throw new Error('Mixer endpoints are only available on Nodelink nodes') if (!guildId || !mix) throw new Error('You forget to set the guild_id and/or mix options') return this.makeRequest( 'DELETE', `/v4/sessions/${this.sessionId}/players/${guildId}/mix/${mix}` ) } destroy() { const autoplayAgent = this._autoplayAgent const primaryAgent = this.agent if (this.agent) { this.agent.destroy() this.agent = null } if (autoplayAgent && autoplayAgent !== primaryAgent) { autoplayAgent.destroy?.() } if (autoplayModule?.setSharedAgent && autoplayAgent) { autoplayModule.setSharedAgent(null) } this._closeH2() if (this._headerPool) { this._headerPool.length = 0 this._headerPool = null } this.aqua = this.node = this.request = this.defaultHeaders = this._endpoints = this._autoplayAgent = null this.calls = 0 } } module.exports = Rest