aqualink 3.0.0 → 3.2.0

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.
@@ -5,6 +5,7 @@ const WebSocketImpl = process.isBun ? globalThis.WebSocket : require('ws')
5
5
 
6
6
  const Rest = require('./Rest')
7
7
  const { AqualinkEvents } = require('./AqualinkEvents')
8
+ const { emitOperationalError } = require('./Reporting')
8
9
 
9
10
  const privateData = new WeakMap()
10
11
 
@@ -36,6 +37,8 @@ const unrefTimer = (t) => {
36
37
  }
37
38
 
38
39
  const _functions = {
40
+ noop() {},
41
+
39
42
  buildWsUrl(host, port, ssl) {
40
43
  const needsBrackets = host.includes(':') && !host.startsWith('[')
41
44
  return `ws${ssl ? 's' : ''}://${needsBrackets ? `[${host}]` : host}:${port}${WS_PATH}`
@@ -80,6 +83,16 @@ const _functions = {
80
83
 
81
84
  errMsg(err) {
82
85
  return err?.message || String(err)
86
+ },
87
+
88
+ statusCode(err) {
89
+ return (
90
+ err?.statusCode ||
91
+ err?.status ||
92
+ err?.response?.statusCode ||
93
+ err?.response?.status ||
94
+ null
95
+ )
83
96
  }
84
97
  }
85
98
 
@@ -128,6 +141,7 @@ class Node {
128
141
  this.reconnectTimeoutId = null
129
142
  this.isDestroyed = false
130
143
  this._isConnecting = false
144
+ this._connectPromise = null
131
145
  this.isNodelink = false
132
146
 
133
147
  this._wsIsBun = !!process.isBun
@@ -152,7 +166,7 @@ class Node {
152
166
  error: this._handleError.bind(this),
153
167
  message: this._handleMessage.bind(this),
154
168
  close: this._handleClose.bind(this),
155
- connect: this.connect.bind(this)
169
+ connect: () => this.connect().catch(() => {})
156
170
  }
157
171
  })
158
172
  }
@@ -205,7 +219,15 @@ class Node {
205
219
  this.isNodelink = !!this.info?.isNodelink
206
220
  } catch (err) {
207
221
  this.info = null
208
- this._emitError(`Failed to fetch node info: ${_functions.errMsg(err)}`)
222
+ if (_functions.statusCode(err) === 404) {
223
+ this._emitDebug(
224
+ 'Node info endpoint unavailable (HTTP 404); continuing without remote info'
225
+ )
226
+ } else {
227
+ this._emitError(
228
+ `Failed to fetch node info: ${_functions.errMsg(err)}`
229
+ )
230
+ }
209
231
  } finally {
210
232
  clearTimeout(timeoutId)
211
233
  }
@@ -289,7 +311,7 @@ class Node {
289
311
  if (
290
312
  code !== Node.WS_CLOSE_NORMAL &&
291
313
  code !== 1001 &&
292
- !isFatal &&
314
+ isFatal &&
293
315
  this.sessionId
294
316
  ) {
295
317
  this._clearSession()
@@ -375,16 +397,17 @@ class Node {
375
397
  }
376
398
 
377
399
  connect() {
378
- if (this.isDestroyed || this._isConnecting) return
400
+ if (this.isDestroyed) return Promise.reject(new Error('Node is destroyed'))
401
+ if (this._connectPromise) return this._connectPromise
379
402
 
380
403
  const state = this.ws?.readyState
381
404
  if (state === WS_STATES.OPEN) {
382
405
  this._emitDebug('WebSocket already connected')
383
- return
406
+ return Promise.resolve()
384
407
  }
385
408
  if (state === WS_STATES.CONNECTING || state === WS_STATES.CLOSING) {
386
409
  this._emitDebug('WebSocket is connecting/closing; skipping new connect')
387
- return
410
+ return Promise.reject(new Error('WebSocket is closing'))
388
411
  }
389
412
 
390
413
  this._isConnecting = true
@@ -397,95 +420,165 @@ class Node {
397
420
  })
398
421
  }
399
422
 
400
- try {
401
- const h = this._boundHandlers
402
-
403
- if (this._wsIsBun) {
404
- const ws = new WebSocketImpl(this.wsUrl, { headers: this._headers })
405
- ws.binaryType = 'arraybuffer'
406
-
407
- const offs = []
408
- const add = (type, fn, once = false) => {
409
- const wrapped = once
410
- ? (ev) => {
411
- try {
412
- ws.removeEventListener(type, wrapped)
413
- } catch {}
414
- fn(ev)
415
- }
416
- : fn
417
- ws.addEventListener(type, wrapped)
418
- offs.push(() => {
419
- try {
420
- ws.removeEventListener(type, wrapped)
421
- } catch {}
422
- })
423
+ let connectPromise
424
+ connectPromise = new Promise((resolve, reject) => {
425
+ let settled = false
426
+ let opened = false
427
+ let timer = null
428
+ const settle = (ok, error) => {
429
+ if (settled) return
430
+ settled = true
431
+ if (timer) clearTimeout(timer)
432
+ queueMicrotask(() => {
433
+ if (this._connectPromise === connectPromise)
434
+ this._connectPromise = null
435
+ })
436
+ this._rejectConnect = null
437
+ ok ? resolve() : reject(error)
438
+ }
439
+ timer = setTimeout(() => {
440
+ const error = new Error(`WebSocket open timeout: ${this.timeout}ms`)
441
+ this._isConnecting = false
442
+ this._cleanup()
443
+ settle(false, error)
444
+ this._scheduleReconnect()
445
+ }, this.timeout)
446
+ unrefTimer(timer)
447
+ this._rejectConnect = (error) => settle(false, error)
448
+
449
+ try {
450
+ const h = this._boundHandlers
451
+ const onOpen = () => {
452
+ opened = true
453
+ h.open()
454
+ settle(true)
455
+ }
456
+ const onError = (error) => {
457
+ h.error(error)
458
+ if (!opened) {
459
+ this._isConnecting = false
460
+ this._cleanup()
461
+ settle(false, error)
462
+ this._scheduleReconnect()
463
+ }
464
+ }
465
+ const onClose = (code, reason) => {
466
+ if (!opened) {
467
+ settle(
468
+ false,
469
+ new Error(
470
+ `WebSocket closed before open (code ${code}): ${_functions.reasonToString(reason)}`
471
+ )
472
+ )
473
+ }
474
+ h.close(code, reason)
423
475
  }
424
476
 
425
- add('open', () => h.open(), true)
477
+ if (this._wsIsBun) {
478
+ const ws = new WebSocketImpl(this.wsUrl, { headers: this._headers })
479
+ ws.binaryType = 'arraybuffer'
480
+
481
+ const offs = []
482
+ const add = (type, fn, once = false) => {
483
+ const wrapped = once
484
+ ? (ev) => {
485
+ try {
486
+ ws.removeEventListener(type, wrapped)
487
+ } catch {}
488
+ fn(ev)
489
+ }
490
+ : fn
491
+ ws.addEventListener(type, wrapped)
492
+ offs.push(() => {
493
+ try {
494
+ ws.removeEventListener(type, wrapped)
495
+ } catch {}
496
+ })
497
+ }
426
498
 
427
- add(
428
- 'error',
429
- (event) => {
430
- const err = event?.error
431
- h.error(err instanceof Error ? err : new Error('WebSocket error'))
432
- },
433
- true
434
- )
499
+ add('open', onOpen, true)
435
500
 
436
- add('message', (event) => {
437
- const data = event?.data
438
- if (typeof data === 'string') h.message(data, false)
439
- else h.message(data, true)
440
- })
501
+ add(
502
+ 'error',
503
+ (event) => {
504
+ const err = event?.error
505
+ onError(err instanceof Error ? err : new Error('WebSocket error'))
506
+ },
507
+ false
508
+ )
441
509
 
442
- add(
443
- 'close',
444
- (event) => {
445
- h.close(
446
- typeof event?.code === 'number'
447
- ? event.code
448
- : Node.WS_CLOSE_NORMAL,
449
- typeof event?.reason === 'string' ? event.reason : ''
450
- )
451
- },
452
- true
453
- )
510
+ add('message', (event) => {
511
+ const data = event?.data
512
+ if (typeof data === 'string') h.message(data, false)
513
+ else h.message(data, true)
514
+ })
454
515
 
455
- this._bunCleanup = () => {
456
- for (let i = 0; i < offs.length; i++) offs[i]()
457
- }
458
- this.ws = ws
459
- return
460
- }
516
+ add(
517
+ 'close',
518
+ (event) => {
519
+ onClose(
520
+ typeof event?.code === 'number'
521
+ ? event.code
522
+ : Node.WS_CLOSE_NORMAL,
523
+ typeof event?.reason === 'string' ? event.reason : ''
524
+ )
525
+ },
526
+ true
527
+ )
461
528
 
462
- const ws = new WebSocketImpl(this.wsUrl, {
463
- headers: this._headers,
464
- perMessageDeflate: false,
465
- handshakeTimeout: this.timeout,
466
- maxPayload: this.maxPayload,
467
- skipUTF8Validation: this.skipUTF8Validation
468
- })
529
+ this._bunCleanup = () => {
530
+ for (let i = 0; i < offs.length; i++) offs[i]()
531
+ }
532
+ this.ws = ws
533
+ return
534
+ }
469
535
 
470
- ws.binaryType = 'nodebuffer'
536
+ const ws = new WebSocketImpl(this.wsUrl, {
537
+ headers: this._headers,
538
+ perMessageDeflate: false,
539
+ handshakeTimeout: this.timeout,
540
+ maxPayload: this.maxPayload,
541
+ skipUTF8Validation: this.skipUTF8Validation
542
+ })
471
543
 
472
- ws.once('open', h.open)
473
- ws.once('error', h.error)
474
- ws.on('message', h.message)
475
- ws.once('close', h.close)
544
+ ws.once('open', () => {
545
+ onOpen()
546
+ this._wsPingInterval = setInterval(() => {
547
+ if (ws.readyState === WS_STATES.OPEN) ws.ping?.()
548
+ }, 30000)
549
+ this._wsPingInterval.unref?.()
550
+ })
551
+ ws.on('error', onError)
552
+ ws.on('message', h.message)
553
+ ws.once('close', (...args) => {
554
+ if (this._wsPingInterval) {
555
+ clearInterval(this._wsPingInterval)
556
+ this._wsPingInterval = null
557
+ }
558
+ onClose(...args)
559
+ })
476
560
 
477
- this.ws = ws
478
- } catch (err) {
479
- this._isConnecting = false
480
- this._emitError(`Failed to create WebSocket: ${_functions.errMsg(err)}`)
481
- this._scheduleReconnect()
482
- }
561
+ this.ws = ws
562
+ } catch (err) {
563
+ this._isConnecting = false
564
+ this._emitError(`Failed to create WebSocket: ${_functions.errMsg(err)}`)
565
+ settle(false, err)
566
+ this._scheduleReconnect()
567
+ }
568
+ })
569
+ this._connectPromise = connectPromise
570
+ return connectPromise
483
571
  }
484
572
 
485
573
  _cleanup() {
486
574
  const ws = this.ws
487
575
  if (!ws) return
488
576
 
577
+ if (this._wsPingInterval) {
578
+ clearInterval(this._wsPingInterval)
579
+ this._wsPingInterval = null
580
+ }
581
+
489
582
  if (this._wsIsBun) {
490
583
  try {
491
584
  this._bunCleanup?.()
@@ -493,6 +586,7 @@ class Node {
493
586
  this._bunCleanup = null
494
587
  } else {
495
588
  ws.removeAllListeners?.()
589
+ ws.on?.('error', _functions.noop)
496
590
  }
497
591
 
498
592
  try {
@@ -513,15 +607,17 @@ class Node {
513
607
  if (this.isDestroyed) return
514
608
 
515
609
  this.isDestroyed = true
610
+ this._rejectConnect?.(new Error('Node destroyed while connecting'))
516
611
  this.state = NODE_STATE.IDLE
517
612
  this._isConnecting = false
613
+ this._connectPromise = null
518
614
  this._clearReconnectTimeout()
519
615
  this._cleanup()
520
616
 
521
617
  if (!clean) this.aqua.handleNodeFailover?.(this)
522
618
 
523
619
  this.connected = false
524
- this.aqua.destroyNode?.(this.name)
620
+ this.aqua._destroyNode?.(this.name)
525
621
  this.aqua.emit(AqualinkEvents.NodeDestroy, this)
526
622
 
527
623
  this.rest?.destroy?.()
@@ -592,8 +688,8 @@ class Node {
592
688
  }
593
689
 
594
690
  const oldSessionId = this.sessionId
595
- const sessionChanged =
596
- oldSessionId && oldSessionId !== sessionId && !payload.resumed
691
+ const sessionInvalidated = !payload.resumed && !!oldSessionId
692
+ const sessionChanged = sessionInvalidated && oldSessionId !== sessionId
597
693
 
598
694
  this.sessionId = sessionId
599
695
  if (this.aqua?.debugTrace) {
@@ -607,9 +703,9 @@ class Node {
607
703
  this.rest.setSessionId(sessionId)
608
704
  this._headers['Session-Id'] = sessionId
609
705
 
610
- if (sessionChanged && this.aqua?.players) {
706
+ if (sessionInvalidated && this.aqua?.players) {
611
707
  this._emitDebug(
612
- `Session changed from ${oldSessionId} to ${sessionId}, invalidating stale players`
708
+ `Session invalidated (resumed=${!!payload.resumed}, old=${oldSessionId}, new=${sessionId}), invalidating stale players`
613
709
  )
614
710
  try {
615
711
  await this.aqua._storeBrokenPlayers?.(this)
@@ -618,6 +714,15 @@ class Node {
618
714
  `Failed to snapshot stale players before invalidation: ${e?.message || e}`
619
715
  )
620
716
  }
717
+ for (const [, player] of this.aqua.players) {
718
+ if (player?.nodes === this || player?.nodes?.name === this.name) {
719
+ if (player.connection) {
720
+ player.connection._lastEndpoint = null
721
+ player.connection._stateFlags |= 512
722
+ }
723
+ }
724
+ }
725
+
621
726
  const playersToDestroy = []
622
727
  for (const [guildId, player] of this.aqua.players) {
623
728
  if (player?.nodes === this || player?.nodes?.name === this.name) {
@@ -650,7 +755,8 @@ class Node {
650
755
 
651
756
  this.aqua.emit(AqualinkEvents.NodeReady, this, {
652
757
  resumed: !!payload.resumed,
653
- sessionChanged
758
+ sessionChanged,
759
+ sessionInvalidated
654
760
  })
655
761
 
656
762
  if (this.autoResume) {
@@ -672,63 +778,99 @@ class Node {
672
778
  })
673
779
  }
674
780
 
781
+ let resumeSupported = true
675
782
  try {
676
783
  await this.rest.makeRequest('PATCH', `/v4/sessions/${this.sessionId}`, {
677
784
  resuming: true,
678
785
  timeout: this.resumeTimeout
679
786
  })
787
+ } catch (err) {
788
+ if (_functions.statusCode(err) === 404) {
789
+ resumeSupported = false
790
+ this._emitDebug(
791
+ 'Session resume endpoint unavailable (HTTP 404); falling back without server-side session resume'
792
+ )
793
+ } else {
794
+ if (this.aqua?.debugTrace) {
795
+ this.aqua._trace('node.resume.error', {
796
+ node: this.name,
797
+ error: _functions.errMsg(err)
798
+ })
799
+ }
800
+ this._emitError(`Failed to resume session: ${_functions.errMsg(err)}`)
801
+ throw err
802
+ }
803
+ }
680
804
 
681
- if (this.aqua?.players) {
682
- for (const [guildId, player] of this.aqua.players) {
683
- if (
684
- (player?.nodes === this || player?.nodes?.name === this.name) &&
685
- player.voiceChannel
686
- ) {
687
- try {
688
- const recoveryToken = player._claimVoiceRecovery?.(
689
- 'node_resume_rejoin'
690
- )
691
- this._emitDebug(`Rejoining voice for guild ${guildId} on resume`)
692
- if (this.aqua?.debugTrace) {
693
- this.aqua._trace('node.resume.rejoin', {
694
- node: this.name,
695
- guildId,
696
- voiceChannel: player.voiceChannel
697
- })
698
- }
699
- if (player._isVoiceRecoveryActive?.(recoveryToken))
700
- player.connect({
701
- voiceChannel: player.voiceChannel,
702
- deaf: player.deaf,
703
- mute: player.mute
704
- })
805
+ if (!resumeSupported) {
806
+ try {
807
+ const existingPlayers = await this.rest.getPlayers()
808
+ if (Array.isArray(existingPlayers) && existingPlayers.length === 0) {
809
+ this._emitDebug(
810
+ 'No players found on Lavalink for this session; will rejoin voice only'
811
+ )
812
+ }
813
+ } catch (_) {
814
+ // getPlayers may also 404 — that's fine, we already know session is invalid
815
+ }
816
+ }
817
+
818
+ if (this.aqua?.players) {
819
+ const PLAYER_BATCH_SIZE = 20
820
+ const playersToResume = []
821
+ for (const [guildId, player] of this.aqua.players) {
822
+ if (
823
+ (player?.nodes === this || player?.nodes?.name === this.name) &&
824
+ player.voiceChannel &&
825
+ !player.destroyed
826
+ ) {
827
+ playersToResume.push({ guildId, player })
828
+ }
829
+ }
830
+
831
+ for (let i = 0; i < playersToResume.length; i += PLAYER_BATCH_SIZE) {
832
+ const batch = playersToResume.slice(i, i + PLAYER_BATCH_SIZE)
833
+ await Promise.allSettled(
834
+ batch.map(async ({ guildId, player }) => {
835
+ try {
836
+ const recoveryToken = player._claimVoiceRecovery?.(
837
+ resumeSupported
838
+ ? 'node_resume_rejoin'
839
+ : 'node_rejoin_after_resume_404'
840
+ )
841
+ this._emitDebug(`Rejoining voice for guild ${guildId} on resume`)
842
+ if (this.aqua?.debugTrace) {
843
+ this.aqua._trace('node.resume.rejoin', {
844
+ node: this.name,
845
+ guildId,
846
+ voiceChannel: player.voiceChannel,
847
+ resumeSupported
848
+ })
849
+ }
850
+ if (player._isVoiceRecoveryActive?.(recoveryToken))
851
+ player.connect({
852
+ voiceChannel: player.voiceChannel,
853
+ deaf: player.deaf,
854
+ mute: player.mute
855
+ })
705
856
  } catch (e) {
706
857
  this._emitDebug(
707
858
  `Failed to rejoin voice for ${guildId}: ${e?.message || e}`
708
859
  )
709
860
  }
710
- }
711
- }
861
+ })
862
+ )
712
863
  }
864
+ }
713
865
 
714
- if (this.aqua.loadPlayers && this.aqua.players.size === 0) {
715
- await this.aqua.loadPlayers()
716
- }
717
- } catch (err) {
718
- if (this.aqua?.debugTrace) {
719
- this.aqua._trace('node.resume.error', {
720
- node: this.name,
721
- error: _functions.errMsg(err)
722
- })
723
- }
724
- this._emitError(`Failed to resume session: ${_functions.errMsg(err)}`)
725
- throw err
866
+ if (this.aqua.loadPlayers && this.aqua.players.size === 0) {
867
+ await this.aqua.loadPlayers()
726
868
  }
727
869
  }
728
870
 
729
871
  _emitError(error) {
730
872
  const errorObj = error instanceof Error ? error : new Error(String(error))
731
- this.aqua.emit(AqualinkEvents.Error, this, errorObj)
873
+ emitOperationalError(this.aqua, this, errorObj)
732
874
  }
733
875
 
734
876
  _emitDebug(message) {