aqualink 2.18.1 → 2.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,549 +1,667 @@
1
- 'use strict'
2
-
3
- const IS_BUN = !!(process?.isBun || process?.versions?.bun || globalThis.Bun)
4
- if (process && typeof process.isBun !== 'boolean') process.isBun = IS_BUN
5
-
6
- const WebSocketImpl = process.isBun ? globalThis.WebSocket : require('ws')
7
-
8
- const Rest = require('./Rest')
9
- const { AqualinkEvents } = require('./AqualinkEvents')
10
-
11
- const privateData = new WeakMap()
12
-
13
- const NODE_STATE = Object.freeze({ IDLE: 0, CONNECTING: 1, READY: 2, DISCONNECTING: 3, RECONNECTING: 4 })
14
- const WS_STATES = Object.freeze({ CONNECTING: 0, OPEN: 1, CLOSING: 2, CLOSED: 3 })
15
- const FATAL_CLOSE_CODES = Object.freeze([4003, 4004, 4010, 4011, 4012, 4015])
16
- const WS_PATH = '/v4/websocket'
17
- const LYRICS_PREFIX = 'Lyrics'
18
- const OPS_STATS = 'stats'
19
- const OPS_READY = 'ready'
20
- const OPS_PLAYER_UPDATE = 'playerUpdate'
21
- const OPS_EVENT = 'event'
22
-
23
- const unrefTimer = (t) => { try { t?.unref?.() } catch {} }
24
-
25
- const _functions = {
26
- buildWsUrl(host, port, ssl) {
27
- const needsBrackets = host.includes(':') && !host.startsWith('[')
28
- return `ws${ssl ? 's' : ''}://${needsBrackets ? `[${host}]` : host}:${port}${WS_PATH}`
29
- },
30
-
31
- isLyricsOp(op) {
32
- return typeof op === 'string' && op.startsWith(LYRICS_PREFIX)
33
- },
34
-
35
- reasonToString(reason) {
36
- if (!reason) return 'No reason provided'
37
- if (typeof reason === 'string') return reason
38
- if (Buffer.isBuffer(reason)) {
39
- try { return reason.toString('utf8') } catch { return String(reason) }
40
- }
41
- if (reason instanceof ArrayBuffer) {
42
- try { return Buffer.from(reason).toString('utf8') } catch { return String(reason) }
43
- }
44
- if (ArrayBuffer.isView(reason)) {
45
- try { return Buffer.from(reason.buffer, reason.byteOffset, reason.byteLength).toString('utf8') } catch { return String(reason) }
46
- }
47
- if (typeof reason === 'object') return reason.message || reason.code || JSON.stringify(reason)
48
- return String(reason)
49
- },
50
-
51
- errMsg(err) {
52
- return err?.message || String(err)
53
- }
54
- }
55
-
56
- class Node {
57
- static BACKOFF_MULTIPLIER = 1.5
58
- static MAX_BACKOFF = 60000
59
- static DEFAULT_RECONNECT_TIMEOUT = 2000
60
- static DEFAULT_RESUME_TIMEOUT = 60
61
- static JITTER_MAX = 2000
62
- static JITTER_FACTOR = 0.2
63
- static WS_CLOSE_NORMAL = 1000
64
- static DEFAULT_MAX_PAYLOAD = 1048576
65
- static DEFAULT_HANDSHAKE_TIMEOUT = 15000
66
- static INFO_FETCH_TIMEOUT = 10000
67
- static INFINITE_BACKOFF = 10000
68
-
69
- constructor(aqua, connOptions, options = {}) {
70
- this.aqua = aqua
71
-
72
- this.host = connOptions.host || 'localhost'
73
- this.name = connOptions.name || this.host
74
- this.port = connOptions.port || 2333
75
- this.auth = connOptions.auth || 'youshallnotpass'
76
- this.sessionId = connOptions.sessionId || null
77
- this.regions = connOptions.regions || []
78
- this.ssl = !!connOptions.ssl
79
- this.wsUrl = _functions.buildWsUrl(this.host, this.port, this.ssl)
80
-
81
- this.rest = new Rest(aqua, this)
82
-
83
- this.resumeTimeout = options.resumeTimeout ?? Node.DEFAULT_RESUME_TIMEOUT
84
- this.autoResume = options.autoResume ?? false
85
- this.reconnectTimeout = options.reconnectTimeout ?? Node.DEFAULT_RECONNECT_TIMEOUT
86
- this.reconnectTries = options.reconnectTries ?? 3
87
- this.infiniteReconnects = options.infiniteReconnects ?? false
88
- this.timeout = options.timeout ?? Node.DEFAULT_HANDSHAKE_TIMEOUT
89
- this.maxPayload = options.maxPayload ?? Node.DEFAULT_MAX_PAYLOAD
90
- this.skipUTF8Validation = options.skipUTF8Validation ?? true
91
-
92
- this.connected = false
93
- this.state = NODE_STATE.IDLE
94
- this.info = null
95
- this.ws = null
96
- this.reconnectAttempted = 0
97
- this.reconnectTimeoutId = null
98
- this.isDestroyed = false
99
- this._isConnecting = false
100
- this.isNodelink = false
101
-
102
- this._wsIsBun = !!process.isBun
103
- this._bunCleanup = null
104
-
105
- this.stats = {
106
- players: 0,
107
- playingPlayers: 0,
108
- uptime: 0,
109
- ping: 0,
110
- memory: { free: 0, used: 0, allocated: 0, reservable: 0 },
111
- cpu: { cores: 0, systemLoad: 0, lavalinkLoad: 0 },
112
- frameStats: { sent: 0, nulled: 0, deficit: 0 }
113
- }
114
-
115
- this._clientName = `Aqua/${this.aqua.version} https://github.com/ToddyTheNoobDud/AquaLink`
116
- this._headers = this._buildHeaders()
117
-
118
- privateData.set(this, {
119
- boundHandlers: {
120
- open: this._handleOpen.bind(this),
121
- error: this._handleError.bind(this),
122
- message: this._handleMessage.bind(this),
123
- close: this._handleClose.bind(this),
124
- connect: this.connect.bind(this)
125
- }
126
- })
127
- }
128
-
129
- _buildHeaders() {
130
- const headers = {
131
- Authorization: this.auth,
132
- 'User-Id': this.aqua.clientId,
133
- 'Client-Name': this._clientName
134
- }
135
- if (this.sessionId) headers['Session-Id'] = this.sessionId
136
- return headers
137
- }
138
-
139
- get _boundHandlers() {
140
- return privateData.get(this)?.boundHandlers
141
- }
142
-
143
- _clearSession() {
144
- this.sessionId = null
145
- delete this._headers['Session-Id']
146
- this.rest?.setSessionId?.(null)
147
- }
148
-
149
- _getPlayer(guildId) {
150
- return guildId ? this.aqua?.players?.get?.(guildId) : null
151
- }
152
-
153
- async _handleOpen() {
154
- this.connected = true
155
- this.state = NODE_STATE.READY
156
- this._isConnecting = false
157
- this.reconnectAttempted = 0
158
- this._emitDebug('WebSocket connection established')
159
-
160
- if (!this.aqua?.bypassChecks?.nodeFetchInfo && !this.info) {
161
- const timeoutId = setTimeout(() => {
162
- if (!this.isDestroyed) this._emitError('Node info fetch timeout')
163
- }, Node.INFO_FETCH_TIMEOUT)
164
- unrefTimer(timeoutId)
165
-
166
- try {
167
- this.info = await this.rest.makeRequest('GET', '/v4/info')
168
- this.isNodelink = !!this.info?.isNodelink
169
- } catch (err) {
170
- this.info = null
171
- this._emitError(`Failed to fetch node info: ${_functions.errMsg(err)}`)
172
- } finally {
173
- clearTimeout(timeoutId)
174
- }
175
- }
176
-
177
- this.aqua.emit(AqualinkEvents.NodeConnect, this)
178
- }
179
-
180
- _handleError(error) {
181
- const err = error instanceof Error ? error : new Error(String(error))
182
- this.aqua.emit(AqualinkEvents.NodeError, this, err)
183
- }
184
-
185
- _handleMessage(data, isBinary) {
186
- if (isBinary) return
187
-
188
- let payload
189
- try {
190
- payload = JSON.parse(data)
191
- } catch (err) {
192
- this._emitDebug(() => `Invalid JSON from Lavalink: ${err.message}`)
193
- return
194
- }
195
-
196
- const op = payload?.op
197
- if (!op) return
198
-
199
- if (op === OPS_PLAYER_UPDATE) this._emitToPlayer(AqualinkEvents.PlayerUpdate, payload)
200
- else if (op === OPS_EVENT) this._emitToPlayer('event', payload)
201
- else if (op === OPS_STATS) this._updateStats(payload)
202
- else if (op === OPS_READY) this._handleReady(payload)
203
- else this._handleCustomStringOp(op, payload)
204
- }
205
-
206
- _emitToPlayer(eventName, payload) {
207
- const player = this._getPlayer(payload?.guildId)
208
- if (!player?.emit) return
209
- try {
210
- player.emit(eventName, payload)
211
- } catch (err) {
212
- this._emitError(`Player emit error: ${_functions.errMsg(err)}`)
213
- }
214
- }
215
-
216
- _handleCustomStringOp(op, payload) {
217
- if (_functions.isLyricsOp(op)) {
218
- this.aqua.emit(op, this._getPlayer(payload.guildId), payload.track || null, payload)
219
- return
220
- }
221
- this.aqua.emit(AqualinkEvents.NodeCustomOp, this, op, payload)
222
- this._emitDebug(() => `Unknown op from Lavalink: ${op}`)
223
- }
224
-
225
- _handleClose(code, reason) {
226
- this.connected = false
227
- const wasReady = this.state === NODE_STATE.READY
228
- this.state = this.isDestroyed ? NODE_STATE.IDLE : NODE_STATE.RECONNECTING
229
- this._isConnecting = false
230
-
231
- this.aqua.emit(AqualinkEvents.NodeDisconnect, this, {
232
- code,
233
- reason: _functions.reasonToString(reason)
234
- })
235
-
236
- if (this.isDestroyed) return
237
-
238
- const isFatal = FATAL_CLOSE_CODES.includes(code)
239
- if (code !== Node.WS_CLOSE_NORMAL && code !== 1001 && !isFatal && this.sessionId) {
240
- this._clearSession()
241
- }
242
-
243
- const shouldReconnect = (code !== Node.WS_CLOSE_NORMAL || this.infiniteReconnects) && !isFatal
244
-
245
- if (!shouldReconnect) {
246
- if (code === 4011) this._clearSession()
247
- this._emitError(new Error(`WebSocket closed (code ${code}). Not reconnecting.`))
248
- this.destroy(true)
249
- return
250
- }
251
-
252
- this.aqua.handleNodeFailover?.(this)
253
- this._scheduleReconnect()
254
- }
255
-
256
- _scheduleReconnect() {
257
- this._clearReconnectTimeout()
258
-
259
- const attempt = ++this.reconnectAttempted
260
-
261
- if (this.infiniteReconnects) {
262
- this.aqua.emit(AqualinkEvents.NodeReconnect, this, {
263
- infinite: true,
264
- attempt,
265
- backoffTime: Node.INFINITE_BACKOFF
266
- })
267
- this.reconnectTimeoutId = setTimeout(this._boundHandlers.connect, Node.INFINITE_BACKOFF)
268
- unrefTimer(this.reconnectTimeoutId)
269
- return
270
- }
271
-
272
- if (this.reconnectAttempted > this.reconnectTries) {
273
- this._emitError(new Error(`Max reconnection attempts reached (${this.reconnectTries})`))
274
- this.destroy(true)
275
- return
276
- }
277
-
278
- const backoffTime = this._calcBackoff(attempt)
279
- this.aqua.emit(AqualinkEvents.NodeReconnect, this, { infinite: false, attempt, backoffTime })
280
- this.reconnectTimeoutId = setTimeout(this._boundHandlers.connect, backoffTime)
281
- unrefTimer(this.reconnectTimeoutId)
282
- }
283
-
284
- _calcBackoff(attempt) {
285
- const baseBackoff = this.reconnectTimeout * Math.pow(Node.BACKOFF_MULTIPLIER, Math.min(attempt, 10))
286
- const maxJitter = Math.min(Node.JITTER_MAX, baseBackoff * Node.JITTER_FACTOR)
287
- return Math.min(baseBackoff + Math.random() * maxJitter, Node.MAX_BACKOFF)
288
- }
289
-
290
- _clearReconnectTimeout() {
291
- if (!this.reconnectTimeoutId) return
292
- clearTimeout(this.reconnectTimeoutId)
293
- this.reconnectTimeoutId = null
294
- }
295
-
296
- connect() {
297
- if (this.isDestroyed || this._isConnecting) return
298
-
299
- const state = this.ws?.readyState
300
- if (state === WS_STATES.OPEN) {
301
- this._emitDebug('WebSocket already connected')
302
- return
303
- }
304
- if (state === WS_STATES.CONNECTING || state === WS_STATES.CLOSING) {
305
- this._emitDebug('WebSocket is connecting/closing; skipping new connect')
306
- return
307
- }
308
-
309
- this._isConnecting = true
310
- this.state = NODE_STATE.CONNECTING
311
- this._cleanup()
312
-
313
- try {
314
- const h = this._boundHandlers
315
-
316
- if (this._wsIsBun) {
317
- const ws = new WebSocketImpl(this.wsUrl, { headers: this._headers })
318
- ws.binaryType = 'arraybuffer'
319
-
320
- const offs = []
321
- const add = (type, fn, once = false) => {
322
- const wrapped = once
323
- ? (ev) => { try { ws.removeEventListener(type, wrapped) } catch {} ; fn(ev) }
324
- : fn
325
- ws.addEventListener(type, wrapped)
326
- offs.push(() => { try { ws.removeEventListener(type, wrapped) } catch {} })
327
- }
328
-
329
- add('open', () => h.open(), true)
330
-
331
- add('error', (event) => {
332
- const err = event?.error
333
- h.error(err instanceof Error ? err : new Error('WebSocket error'))
334
- }, true)
335
-
336
- add('message', (event) => {
337
- const data = event?.data
338
- if (typeof data === 'string') h.message(data, false)
339
- else h.message(data, true)
340
- })
341
-
342
- add('close', (event) => {
343
- h.close(
344
- typeof event?.code === 'number' ? event.code : Node.WS_CLOSE_NORMAL,
345
- typeof event?.reason === 'string' ? event.reason : ''
346
- )
347
- }, true)
348
-
349
- this._bunCleanup = () => { for (let i = 0; i < offs.length; i++) offs[i]() }
350
- this.ws = ws
351
- return
352
- }
353
-
354
- const ws = new WebSocketImpl(this.wsUrl, {
355
- headers: this._headers,
356
- perMessageDeflate: true,
357
- handshakeTimeout: this.timeout,
358
- maxPayload: this.maxPayload,
359
- skipUTF8Validation: this.skipUTF8Validation
360
- })
361
-
362
- ws.binaryType = 'nodebuffer'
363
-
364
- ws.once('open', h.open)
365
- ws.once('error', h.error)
366
- ws.on('message', h.message)
367
- ws.once('close', h.close)
368
-
369
- this.ws = ws;
370
- } catch (err) {
371
- this._isConnecting = false
372
- this._emitError(`Failed to create WebSocket: ${_functions.errMsg(err)}`)
373
- this._scheduleReconnect()
374
- }
375
- }
376
-
377
- _cleanup() {
378
- const ws = this.ws
379
- if (!ws) return
380
-
381
- if (this._wsIsBun) {
382
- try { this._bunCleanup?.() } catch {}
383
- this._bunCleanup = null
384
- } else {
385
- ws.removeAllListeners?.()
386
- }
387
-
388
- try {
389
- const state = ws.readyState
390
- if (state === WS_STATES.OPEN || state === WS_STATES.CONNECTING) {
391
- ws.close(Node.WS_CLOSE_NORMAL)
392
- } else if (!this._wsIsBun && state !== WS_STATES.CLOSED) {
393
- ws.terminate?.()
394
- }
395
- } catch (err) {
396
- this._emitError(`WebSocket cleanup error: ${_functions.errMsg(err)}`)
397
- }
398
-
399
- this.ws = null
400
- }
401
-
402
- destroy(clean = false) {
403
- if (this.isDestroyed) return
404
-
405
- this.isDestroyed = true
406
- this.state = NODE_STATE.IDLE
407
- this._isConnecting = false
408
- this._clearReconnectTimeout()
409
- this._cleanup()
410
-
411
- if (!clean) this.aqua.handleNodeFailover?.(this)
412
-
413
- this.connected = false
414
- this.aqua.destroyNode?.(this.name)
415
- this.aqua.emit(AqualinkEvents.NodeDestroy, this)
416
-
417
- this.rest?.destroy?.()
418
-
419
- this.info = null
420
- this.rest = null
421
- this.aqua = null
422
- this._headers = null
423
- this.stats = null
424
-
425
- privateData.delete(this)
426
- }
427
-
428
- async getStats() {
429
- if (this.connected) return this.stats
430
-
431
- try {
432
- const newStats = await this.rest.getStats()
433
- if (newStats) this._updateStats(newStats)
434
- } catch (err) {
435
- this._emitError(`Failed to fetch node stats: ${_functions.errMsg(err)}`)
436
- }
437
-
438
- return this.stats
439
- }
440
-
441
- _updateStats(payload) {
442
- if (!payload) return
443
- const s = this.stats
444
-
445
- if (payload.players !== undefined) s.players = payload.players
446
- if (payload.playingPlayers !== undefined) s.playingPlayers = payload.playingPlayers
447
- if (payload.uptime !== undefined) s.uptime = payload.uptime
448
- if (payload.ping !== undefined) s.ping = payload.ping
449
-
450
- if (payload.memory) {
451
- const m = s.memory, pm = payload.memory
452
- if (pm.free !== undefined) m.free = pm.free
453
- if (pm.used !== undefined) m.used = pm.used
454
- if (pm.allocated !== undefined) m.allocated = pm.allocated
455
- if (pm.reservable !== undefined) m.reservable = pm.reservable
456
- }
457
-
458
- if (payload.cpu) {
459
- const c = s.cpu, pc = payload.cpu
460
- if (pc.cores !== undefined) c.cores = pc.cores
461
- if (pc.systemLoad !== undefined) c.systemLoad = pc.systemLoad
462
- if (pc.lavalinkLoad !== undefined) c.lavalinkLoad = pc.lavalinkLoad
463
- }
464
-
465
- if (payload.frameStats) {
466
- const f = s.frameStats, pf = payload.frameStats
467
- if (pf.sent !== undefined) f.sent = pf.sent
468
- if (pf.nulled !== undefined) f.nulled = pf.nulled
469
- if (pf.deficit !== undefined) f.deficit = pf.deficit
470
- }
471
- }
472
-
473
- async _handleReady(payload) {
474
- const sessionId = payload?.sessionId
475
- if (!sessionId) {
476
- this._emitError('Ready payload missing sessionId')
477
- return
478
- }
479
-
480
- const oldSessionId = this.sessionId
481
- const sessionChanged = oldSessionId && oldSessionId !== sessionId && !payload.resumed
482
-
483
- this.sessionId = sessionId
484
- this.rest.setSessionId(sessionId)
485
- this._headers['Session-Id'] = sessionId
486
-
487
- if (sessionChanged && this.aqua?.players) {
488
- this._emitDebug(`Session changed from ${oldSessionId} to ${sessionId}, invalidating stale players`)
489
- const playersToDestroy = []
490
- for (const [guildId, player] of this.aqua.players) {
491
- if (player?.nodes === this || player?.nodes?.name === this.name) {
492
- playersToDestroy.push(guildId)
493
- }
494
- }
495
- for (const guildId of playersToDestroy) {
496
- try {
497
- this._emitDebug(`Destroying stale player for guild ${guildId}`)
498
- await this.aqua.destroyPlayer(guildId)
499
- } catch (e) {
500
- this._emitDebug(`Failed to destroy stale player ${guildId}: ${e?.message || e}`)
501
- }
502
- }
503
- }
504
-
505
- this.aqua.emit(AqualinkEvents.NodeReady, this, { resumed: !!payload.resumed, sessionChanged })
506
-
507
- if (this.autoResume) {
508
- setImmediate(() => {
509
- this._resumePlayers().catch(err => {
510
- this._emitError(`_resumePlayers failed: ${_functions.errMsg(err)}`)
511
- })
512
- })
513
- }
514
- }
515
-
516
- async _resumePlayers() {
517
- if (!this.sessionId) return
518
-
519
- try {
520
- await this.rest.makeRequest('PATCH', `/v4/sessions/${this.sessionId}`, {
521
- resuming: true,
522
- timeout: this.resumeTimeout
523
- })
524
-
525
- if (this.aqua.loadPlayers) {
526
- await this.aqua.loadPlayers()
527
- }
528
- } catch (err) {
529
- this._emitError(`Failed to resume session: ${_functions.errMsg(err)}`)
530
- throw err
531
- }
532
- }
533
-
534
- _emitError(error) {
535
- const errorObj = error instanceof Error ? error : new Error(String(error))
536
- this.aqua.emit(AqualinkEvents.Error, this, errorObj)
537
- }
538
-
539
- _emitDebug(message) {
540
- if (!this.aqua?.listenerCount?.(AqualinkEvents.Debug)) return
541
- this.aqua.emit(
542
- AqualinkEvents.Debug,
543
- this.name,
544
- typeof message === 'function' ? message() : message
545
- )
546
- }
547
- }
548
-
1
+ const IS_BUN = !!(process?.isBun || process?.versions?.bun || globalThis.Bun)
2
+ if (process && typeof process.isBun !== 'boolean') process.isBun = IS_BUN
3
+
4
+ const WebSocketImpl = process.isBun ? globalThis.WebSocket : require('ws')
5
+
6
+ const Rest = require('./Rest')
7
+ const { AqualinkEvents } = require('./AqualinkEvents')
8
+
9
+ const privateData = new WeakMap()
10
+
11
+ const NODE_STATE = Object.freeze({
12
+ IDLE: 0,
13
+ CONNECTING: 1,
14
+ READY: 2,
15
+ DISCONNECTING: 3,
16
+ RECONNECTING: 4
17
+ })
18
+ const WS_STATES = Object.freeze({
19
+ CONNECTING: 0,
20
+ OPEN: 1,
21
+ CLOSING: 2,
22
+ CLOSED: 3
23
+ })
24
+ const FATAL_CLOSE_CODES = Object.freeze([4003, 4004, 4010, 4011, 4012, 4015])
25
+ const WS_PATH = '/v4/websocket'
26
+ const LYRICS_PREFIX = 'Lyrics'
27
+ const OPS_STATS = 'stats'
28
+ const OPS_READY = 'ready'
29
+ const OPS_PLAYER_UPDATE = 'playerUpdate'
30
+ const OPS_EVENT = 'event'
31
+
32
+ const unrefTimer = (t) => {
33
+ try {
34
+ t?.unref?.()
35
+ } catch {}
36
+ }
37
+
38
+ const _functions = {
39
+ buildWsUrl(host, port, ssl) {
40
+ const needsBrackets = host.includes(':') && !host.startsWith('[')
41
+ return `ws${ssl ? 's' : ''}://${needsBrackets ? `[${host}]` : host}:${port}${WS_PATH}`
42
+ },
43
+
44
+ isLyricsOp(op) {
45
+ return typeof op === 'string' && op.startsWith(LYRICS_PREFIX)
46
+ },
47
+
48
+ reasonToString(reason) {
49
+ if (!reason) return 'No reason provided'
50
+ if (typeof reason === 'string') return reason
51
+ if (Buffer.isBuffer(reason)) {
52
+ try {
53
+ return reason.toString('utf8')
54
+ } catch {
55
+ return String(reason)
56
+ }
57
+ }
58
+ if (reason instanceof ArrayBuffer) {
59
+ try {
60
+ return Buffer.from(reason).toString('utf8')
61
+ } catch {
62
+ return String(reason)
63
+ }
64
+ }
65
+ if (ArrayBuffer.isView(reason)) {
66
+ try {
67
+ return Buffer.from(
68
+ reason.buffer,
69
+ reason.byteOffset,
70
+ reason.byteLength
71
+ ).toString('utf8')
72
+ } catch {
73
+ return String(reason)
74
+ }
75
+ }
76
+ if (typeof reason === 'object')
77
+ return reason.message || reason.code || JSON.stringify(reason)
78
+ return String(reason)
79
+ },
80
+
81
+ errMsg(err) {
82
+ return err?.message || String(err)
83
+ }
84
+ }
85
+
86
+ class Node {
87
+ static BACKOFF_MULTIPLIER = 1.5
88
+ static MAX_BACKOFF = 60000
89
+ static DEFAULT_RECONNECT_TIMEOUT = 2000
90
+ static DEFAULT_RESUME_TIMEOUT = 60
91
+ static JITTER_MAX = 2000
92
+ static JITTER_FACTOR = 0.2
93
+ static WS_CLOSE_NORMAL = 1000
94
+ static DEFAULT_MAX_PAYLOAD = 1048576
95
+ static DEFAULT_HANDSHAKE_TIMEOUT = 15000
96
+ static INFO_FETCH_TIMEOUT = 10000
97
+ static INFINITE_BACKOFF = 10000
98
+
99
+ constructor(aqua, connOptions, options = {}) {
100
+ this.aqua = aqua
101
+
102
+ this.host = connOptions.host || 'localhost'
103
+ this.name = connOptions.name || this.host
104
+ this.port = connOptions.port || 2333
105
+ this.auth = connOptions.auth || 'youshallnotpass'
106
+ this.sessionId = connOptions.sessionId || null
107
+ this.regions = connOptions.regions || []
108
+ this.ssl = !!connOptions.ssl
109
+ this.wsUrl = _functions.buildWsUrl(this.host, this.port, this.ssl)
110
+
111
+ this.rest = new Rest(aqua, this)
112
+
113
+ this.resumeTimeout = options.resumeTimeout ?? Node.DEFAULT_RESUME_TIMEOUT
114
+ this.autoResume = options.autoResume ?? false
115
+ this.reconnectTimeout =
116
+ options.reconnectTimeout ?? Node.DEFAULT_RECONNECT_TIMEOUT
117
+ this.reconnectTries = options.reconnectTries ?? 3
118
+ this.infiniteReconnects = options.infiniteReconnects ?? false
119
+ this.timeout = options.timeout ?? Node.DEFAULT_HANDSHAKE_TIMEOUT
120
+ this.maxPayload = options.maxPayload ?? Node.DEFAULT_MAX_PAYLOAD
121
+ this.skipUTF8Validation = options.skipUTF8Validation ?? true
122
+
123
+ this.connected = false
124
+ this.state = NODE_STATE.IDLE
125
+ this.info = null
126
+ this.ws = null
127
+ this.reconnectAttempted = 0
128
+ this.reconnectTimeoutId = null
129
+ this.isDestroyed = false
130
+ this._isConnecting = false
131
+ this.isNodelink = false
132
+
133
+ this._wsIsBun = !!process.isBun
134
+ this._bunCleanup = null
135
+
136
+ this.stats = {
137
+ players: 0,
138
+ playingPlayers: 0,
139
+ uptime: 0,
140
+ ping: 0,
141
+ memory: { free: 0, used: 0, allocated: 0, reservable: 0 },
142
+ cpu: { cores: 0, systemLoad: 0, lavalinkLoad: 0 },
143
+ frameStats: { sent: 0, nulled: 0, deficit: 0 }
144
+ }
145
+
146
+ this._clientName = `Aqua/${this.aqua.version} https://github.com/ToddyTheNoobDud/AquaLink`
147
+ this._headers = this._buildHeaders()
148
+
149
+ privateData.set(this, {
150
+ boundHandlers: {
151
+ open: this._handleOpen.bind(this),
152
+ error: this._handleError.bind(this),
153
+ message: this._handleMessage.bind(this),
154
+ close: this._handleClose.bind(this),
155
+ connect: this.connect.bind(this)
156
+ }
157
+ })
158
+ }
159
+
160
+ _buildHeaders() {
161
+ const headers = {
162
+ Authorization: this.auth,
163
+ 'User-Id': this.aqua.clientId,
164
+ 'Client-Name': this._clientName
165
+ }
166
+ if (this.sessionId) headers['Session-Id'] = this.sessionId
167
+ return headers
168
+ }
169
+
170
+ get _boundHandlers() {
171
+ return privateData.get(this)?.boundHandlers
172
+ }
173
+
174
+ _clearSession() {
175
+ this.sessionId = null
176
+ delete this._headers['Session-Id']
177
+ this.rest?.setSessionId?.(null)
178
+ }
179
+
180
+ _getPlayer(guildId) {
181
+ return guildId ? this.aqua?.players?.get?.(guildId) : null
182
+ }
183
+
184
+ async _handleOpen() {
185
+ this.connected = true
186
+ this.state = NODE_STATE.READY
187
+ this._isConnecting = false
188
+ this.reconnectAttempted = 0
189
+ this._emitDebug('WebSocket connection established')
190
+
191
+ if (!this.aqua?.bypassChecks?.nodeFetchInfo && !this.info) {
192
+ const timeoutId = setTimeout(() => {
193
+ if (!this.isDestroyed) this._emitError('Node info fetch timeout')
194
+ }, Node.INFO_FETCH_TIMEOUT)
195
+ unrefTimer(timeoutId)
196
+
197
+ try {
198
+ this.info = await this.rest.makeRequest('GET', '/v4/info')
199
+ this.isNodelink = !!this.info?.isNodelink
200
+ } catch (err) {
201
+ this.info = null
202
+ this._emitError(`Failed to fetch node info: ${_functions.errMsg(err)}`)
203
+ } finally {
204
+ clearTimeout(timeoutId)
205
+ }
206
+ }
207
+
208
+ this.aqua.emit(AqualinkEvents.NodeConnect, this)
209
+ }
210
+
211
+ _handleError(error) {
212
+ const err = error instanceof Error ? error : new Error(String(error))
213
+ this.aqua.emit(AqualinkEvents.NodeError, this, err)
214
+ }
215
+
216
+ _handleMessage(data, isBinary) {
217
+ if (isBinary) return
218
+
219
+ let payload
220
+ try {
221
+ payload = JSON.parse(data)
222
+ } catch (err) {
223
+ this._emitDebug(() => `Invalid JSON from Lavalink: ${err.message}`)
224
+ return
225
+ }
226
+
227
+ const op = payload?.op
228
+ if (!op) return
229
+
230
+ if (op === OPS_PLAYER_UPDATE)
231
+ this._emitToPlayer(AqualinkEvents.PlayerUpdate, payload)
232
+ else if (op === OPS_EVENT) this._emitToPlayer('event', payload)
233
+ else if (op === OPS_STATS) this._updateStats(payload)
234
+ else if (op === OPS_READY) this._handleReady(payload)
235
+ else this._handleCustomStringOp(op, payload)
236
+ }
237
+
238
+ _emitToPlayer(eventName, payload) {
239
+ const player = this._getPlayer(payload?.guildId)
240
+ if (!player?.emit) return
241
+ try {
242
+ player.emit(eventName, payload)
243
+ } catch (err) {
244
+ this._emitError(`Player emit error: ${_functions.errMsg(err)}`)
245
+ }
246
+ }
247
+
248
+ _handleCustomStringOp(op, payload) {
249
+ if (_functions.isLyricsOp(op)) {
250
+ this.aqua.emit(
251
+ op,
252
+ this._getPlayer(payload.guildId),
253
+ payload.track || null,
254
+ payload
255
+ )
256
+ return
257
+ }
258
+ this.aqua.emit(AqualinkEvents.NodeCustomOp, this, op, payload)
259
+ this._emitDebug(() => `Unknown op from Lavalink: ${op}`)
260
+ }
261
+
262
+ _handleClose(code, reason) {
263
+ this.connected = false
264
+ this.state = this.isDestroyed ? NODE_STATE.IDLE : NODE_STATE.RECONNECTING
265
+ this._isConnecting = false
266
+
267
+ this.aqua.emit(AqualinkEvents.NodeDisconnect, this, {
268
+ code,
269
+ reason: _functions.reasonToString(reason)
270
+ })
271
+
272
+ if (this.isDestroyed) return
273
+
274
+ const isFatal = FATAL_CLOSE_CODES.includes(code)
275
+ if (
276
+ code !== Node.WS_CLOSE_NORMAL &&
277
+ code !== 1001 &&
278
+ !isFatal &&
279
+ this.sessionId
280
+ ) {
281
+ this._clearSession()
282
+ }
283
+
284
+ const shouldReconnect =
285
+ (code !== Node.WS_CLOSE_NORMAL || this.infiniteReconnects) && !isFatal
286
+
287
+ if (!shouldReconnect) {
288
+ if (code === 4011) this._clearSession()
289
+ this._emitError(
290
+ new Error(`WebSocket closed (code ${code}). Not reconnecting.`)
291
+ )
292
+ this.destroy(true)
293
+ return
294
+ }
295
+
296
+ this.aqua.handleNodeFailover?.(this)
297
+ this._scheduleReconnect()
298
+ }
299
+
300
+ _scheduleReconnect() {
301
+ this._clearReconnectTimeout()
302
+
303
+ const attempt = ++this.reconnectAttempted
304
+
305
+ if (this.infiniteReconnects) {
306
+ this.aqua.emit(AqualinkEvents.NodeReconnect, this, {
307
+ infinite: true,
308
+ attempt,
309
+ backoffTime: Node.INFINITE_BACKOFF
310
+ })
311
+ this.reconnectTimeoutId = setTimeout(
312
+ this._boundHandlers.connect,
313
+ Node.INFINITE_BACKOFF
314
+ )
315
+ unrefTimer(this.reconnectTimeoutId)
316
+ return
317
+ }
318
+
319
+ if (this.reconnectAttempted > this.reconnectTries) {
320
+ this._emitError(
321
+ new Error(`Max reconnection attempts reached (${this.reconnectTries})`)
322
+ )
323
+ this.destroy(true)
324
+ return
325
+ }
326
+
327
+ const backoffTime = this._calcBackoff(attempt)
328
+ this.aqua.emit(AqualinkEvents.NodeReconnect, this, {
329
+ infinite: false,
330
+ attempt,
331
+ backoffTime
332
+ })
333
+ this.reconnectTimeoutId = setTimeout(
334
+ this._boundHandlers.connect,
335
+ backoffTime
336
+ )
337
+ unrefTimer(this.reconnectTimeoutId)
338
+ }
339
+
340
+ _calcBackoff(attempt) {
341
+ const baseBackoff =
342
+ this.reconnectTimeout *
343
+ Node.BACKOFF_MULTIPLIER ** Math.min(attempt, 10)
344
+ const maxJitter = Math.min(
345
+ Node.JITTER_MAX,
346
+ baseBackoff * Node.JITTER_FACTOR
347
+ )
348
+ return Math.min(baseBackoff + Math.random() * maxJitter, Node.MAX_BACKOFF)
349
+ }
350
+
351
+ _clearReconnectTimeout() {
352
+ if (!this.reconnectTimeoutId) return
353
+ clearTimeout(this.reconnectTimeoutId)
354
+ this.reconnectTimeoutId = null
355
+ }
356
+
357
+ connect() {
358
+ if (this.isDestroyed || this._isConnecting) return
359
+
360
+ const state = this.ws?.readyState
361
+ if (state === WS_STATES.OPEN) {
362
+ this._emitDebug('WebSocket already connected')
363
+ return
364
+ }
365
+ if (state === WS_STATES.CONNECTING || state === WS_STATES.CLOSING) {
366
+ this._emitDebug('WebSocket is connecting/closing; skipping new connect')
367
+ return
368
+ }
369
+
370
+ this._isConnecting = true
371
+ this.state = NODE_STATE.CONNECTING
372
+ this._cleanup()
373
+
374
+ try {
375
+ const h = this._boundHandlers
376
+
377
+ if (this._wsIsBun) {
378
+ const ws = new WebSocketImpl(this.wsUrl, { headers: this._headers })
379
+ ws.binaryType = 'arraybuffer'
380
+
381
+ const offs = []
382
+ const add = (type, fn, once = false) => {
383
+ const wrapped = once
384
+ ? (ev) => {
385
+ try {
386
+ ws.removeEventListener(type, wrapped)
387
+ } catch {}
388
+ fn(ev)
389
+ }
390
+ : fn
391
+ ws.addEventListener(type, wrapped)
392
+ offs.push(() => {
393
+ try {
394
+ ws.removeEventListener(type, wrapped)
395
+ } catch {}
396
+ })
397
+ }
398
+
399
+ add('open', () => h.open(), true)
400
+
401
+ add(
402
+ 'error',
403
+ (event) => {
404
+ const err = event?.error
405
+ h.error(err instanceof Error ? err : new Error('WebSocket error'))
406
+ },
407
+ true
408
+ )
409
+
410
+ add('message', (event) => {
411
+ const data = event?.data
412
+ if (typeof data === 'string') h.message(data, false)
413
+ else h.message(data, true)
414
+ })
415
+
416
+ add(
417
+ 'close',
418
+ (event) => {
419
+ h.close(
420
+ typeof event?.code === 'number'
421
+ ? event.code
422
+ : Node.WS_CLOSE_NORMAL,
423
+ typeof event?.reason === 'string' ? event.reason : ''
424
+ )
425
+ },
426
+ true
427
+ )
428
+
429
+ this._bunCleanup = () => {
430
+ for (let i = 0; i < offs.length; i++) offs[i]()
431
+ }
432
+ this.ws = ws
433
+ return
434
+ }
435
+
436
+ const ws = new WebSocketImpl(this.wsUrl, {
437
+ headers: this._headers,
438
+ perMessageDeflate: true,
439
+ handshakeTimeout: this.timeout,
440
+ maxPayload: this.maxPayload,
441
+ skipUTF8Validation: this.skipUTF8Validation
442
+ })
443
+
444
+ ws.binaryType = 'nodebuffer'
445
+
446
+ ws.once('open', h.open)
447
+ ws.once('error', h.error)
448
+ ws.on('message', h.message)
449
+ ws.once('close', h.close)
450
+
451
+ this.ws = ws
452
+ } catch (err) {
453
+ this._isConnecting = false
454
+ this._emitError(`Failed to create WebSocket: ${_functions.errMsg(err)}`)
455
+ this._scheduleReconnect()
456
+ }
457
+ }
458
+
459
+ _cleanup() {
460
+ const ws = this.ws
461
+ if (!ws) return
462
+
463
+ if (this._wsIsBun) {
464
+ try {
465
+ this._bunCleanup?.()
466
+ } catch {}
467
+ this._bunCleanup = null
468
+ } else {
469
+ ws.removeAllListeners?.()
470
+ }
471
+
472
+ try {
473
+ const state = ws.readyState
474
+ if (state === WS_STATES.OPEN || state === WS_STATES.CONNECTING) {
475
+ ws.close(Node.WS_CLOSE_NORMAL)
476
+ } else if (!this._wsIsBun && state !== WS_STATES.CLOSED) {
477
+ ws.terminate?.()
478
+ }
479
+ } catch (err) {
480
+ this._emitError(`WebSocket cleanup error: ${_functions.errMsg(err)}`)
481
+ }
482
+
483
+ this.ws = null
484
+ }
485
+
486
+ destroy(clean = false) {
487
+ if (this.isDestroyed) return
488
+
489
+ this.isDestroyed = true
490
+ this.state = NODE_STATE.IDLE
491
+ this._isConnecting = false
492
+ this._clearReconnectTimeout()
493
+ this._cleanup()
494
+
495
+ if (!clean) this.aqua.handleNodeFailover?.(this)
496
+
497
+ this.connected = false
498
+ this.aqua.destroyNode?.(this.name)
499
+ this.aqua.emit(AqualinkEvents.NodeDestroy, this)
500
+
501
+ this.rest?.destroy?.()
502
+
503
+ this.info = null
504
+ this.rest = null
505
+ this.aqua = null
506
+ this._headers = null
507
+ this.stats = null
508
+
509
+ privateData.delete(this)
510
+ }
511
+
512
+ async getStats() {
513
+ if (this.connected) return this.stats
514
+
515
+ try {
516
+ const newStats = await this.rest.getStats()
517
+ if (newStats) this._updateStats(newStats)
518
+ } catch (err) {
519
+ this._emitError(`Failed to fetch node stats: ${_functions.errMsg(err)}`)
520
+ }
521
+
522
+ return this.stats
523
+ }
524
+
525
+ _updateStats(payload) {
526
+ if (!payload) return
527
+ const s = this.stats
528
+
529
+ if (payload.players !== undefined) s.players = payload.players
530
+ if (payload.playingPlayers !== undefined)
531
+ s.playingPlayers = payload.playingPlayers
532
+ if (payload.uptime !== undefined) s.uptime = payload.uptime
533
+ if (payload.ping !== undefined) s.ping = payload.ping
534
+
535
+ if (payload.memory) {
536
+ const m = s.memory,
537
+ pm = payload.memory
538
+ if (pm.free !== undefined) m.free = pm.free
539
+ if (pm.used !== undefined) m.used = pm.used
540
+ if (pm.allocated !== undefined) m.allocated = pm.allocated
541
+ if (pm.reservable !== undefined) m.reservable = pm.reservable
542
+ }
543
+
544
+ if (payload.cpu) {
545
+ const c = s.cpu,
546
+ pc = payload.cpu
547
+ if (pc.cores !== undefined) c.cores = pc.cores
548
+ if (pc.systemLoad !== undefined) c.systemLoad = pc.systemLoad
549
+ if (pc.lavalinkLoad !== undefined) c.lavalinkLoad = pc.lavalinkLoad
550
+ }
551
+
552
+ if (payload.frameStats) {
553
+ const f = s.frameStats,
554
+ pf = payload.frameStats
555
+ if (pf.sent !== undefined) f.sent = pf.sent
556
+ if (pf.nulled !== undefined) f.nulled = pf.nulled
557
+ if (pf.deficit !== undefined) f.deficit = pf.deficit
558
+ }
559
+ }
560
+
561
+ async _handleReady(payload) {
562
+ const sessionId = payload?.sessionId
563
+ if (!sessionId) {
564
+ this._emitError('Ready payload missing sessionId')
565
+ return
566
+ }
567
+
568
+ const oldSessionId = this.sessionId
569
+ const sessionChanged =
570
+ oldSessionId && oldSessionId !== sessionId && !payload.resumed
571
+
572
+ this.sessionId = sessionId
573
+ this.rest.setSessionId(sessionId)
574
+ this._headers['Session-Id'] = sessionId
575
+
576
+ if (sessionChanged && this.aqua?.players) {
577
+ this._emitDebug(
578
+ `Session changed from ${oldSessionId} to ${sessionId}, invalidating stale players`
579
+ )
580
+ const playersToDestroy = []
581
+ for (const [guildId, player] of this.aqua.players) {
582
+ if (player?.nodes === this || player?.nodes?.name === this.name) {
583
+ playersToDestroy.push(guildId)
584
+ }
585
+ }
586
+ for (const guildId of playersToDestroy) {
587
+ try {
588
+ this._emitDebug(`Destroying stale player for guild ${guildId}`)
589
+ await this.aqua.destroyPlayer(guildId)
590
+ } catch (e) {
591
+ this._emitDebug(
592
+ `Failed to destroy stale player ${guildId}: ${e?.message || e}`
593
+ )
594
+ }
595
+ }
596
+ }
597
+
598
+ this.aqua.emit(AqualinkEvents.NodeReady, this, {
599
+ resumed: !!payload.resumed,
600
+ sessionChanged
601
+ })
602
+
603
+ if (this.autoResume) {
604
+ setImmediate(() => {
605
+ this._resumePlayers().catch((err) => {
606
+ this._emitError(`_resumePlayers failed: ${_functions.errMsg(err)}`)
607
+ })
608
+ })
609
+ }
610
+ }
611
+
612
+ async _resumePlayers() {
613
+ if (!this.sessionId) return
614
+
615
+ try {
616
+ await this.rest.makeRequest('PATCH', `/v4/sessions/${this.sessionId}`, {
617
+ resuming: true,
618
+ timeout: this.resumeTimeout
619
+ })
620
+
621
+ if (this.aqua?.players) {
622
+ for (const [guildId, player] of this.aqua.players) {
623
+ if (
624
+ (player?.nodes === this || player?.nodes?.name === this.name) &&
625
+ player.voiceChannel
626
+ ) {
627
+ try {
628
+ this._emitDebug(`Rejoining voice for guild ${guildId} on resume`)
629
+ player.connect({
630
+ voiceChannel: player.voiceChannel,
631
+ deaf: player.deaf,
632
+ mute: player.mute
633
+ })
634
+ } catch (e) {
635
+ this._emitDebug(
636
+ `Failed to rejoin voice for ${guildId}: ${e?.message || e}`
637
+ )
638
+ }
639
+ }
640
+ }
641
+ }
642
+
643
+ if (this.aqua.loadPlayers && this.aqua.players.size === 0) {
644
+ await this.aqua.loadPlayers()
645
+ }
646
+ } catch (err) {
647
+ this._emitError(`Failed to resume session: ${_functions.errMsg(err)}`)
648
+ throw err
649
+ }
650
+ }
651
+
652
+ _emitError(error) {
653
+ const errorObj = error instanceof Error ? error : new Error(String(error))
654
+ this.aqua.emit(AqualinkEvents.Error, this, errorObj)
655
+ }
656
+
657
+ _emitDebug(message) {
658
+ if (!this.aqua?.listenerCount?.(AqualinkEvents.Debug)) return
659
+ this.aqua.emit(
660
+ AqualinkEvents.Debug,
661
+ this.name,
662
+ typeof message === 'function' ? message() : message
663
+ )
664
+ }
665
+ }
666
+
549
667
  module.exports = Node