aqualink
Version:
An Lavalink/Nodelink client, focused in pure performance and features
908 lines (806 loc) • 25.7 kB
JavaScript
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