lavalink-client 2.10.3 → 2.10.4

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.
package/dist/index.cjs CHANGED
@@ -1270,6 +1270,11 @@ function safeStringify(obj, padding = 0) {
1270
1270
  }
1271
1271
 
1272
1272
  // src/structures/Node.ts
1273
+ var normalizeInt = (value, fallback, min, max) => {
1274
+ const parsed = typeof value === "number" ? value : Number(value);
1275
+ if (!Number.isFinite(parsed)) return fallback;
1276
+ return Math.min(max, Math.max(min, Math.floor(parsed)));
1277
+ };
1273
1278
  var LavalinkNode = class _LavalinkNode {
1274
1279
  heartBeatPingTimestamp = 0;
1275
1280
  heartBeatPongTimestamp = 0;
@@ -1704,7 +1709,12 @@ var LavalinkNode = class _LavalinkNode {
1704
1709
  { headers }
1705
1710
  );
1706
1711
  this.socket.on("open", this.open.bind(this));
1707
- this.socket.on("close", (code, reason) => this.close(code, reason?.toString()));
1712
+ this.socket.on(
1713
+ "close",
1714
+ (code, reason) => void this.close(code, reason?.toString()).catch((error) => {
1715
+ this.NodeManager.emit("error", this, error);
1716
+ })
1717
+ );
1708
1718
  this.socket.on("message", this.message.bind(this));
1709
1719
  this.socket.on("error", this.error.bind(this));
1710
1720
  }
@@ -1796,43 +1806,7 @@ var LavalinkNode = class _LavalinkNode {
1796
1806
  )
1797
1807
  );
1798
1808
  }
1799
- const nodeToMove = Array.from(this.NodeManager.leastUsedNodes("playingPlayers")).find(
1800
- (n) => n.connected && n.options.id !== this.id
1801
- );
1802
- if (!nodeToMove) {
1803
- return Promise.allSettled(
1804
- Array.from(players.values()).map(
1805
- (player) => player.destroy("PlayerChangeNodeFailNoEligibleNode" /* PlayerChangeNodeFailNoEligibleNode */).catch((error) => {
1806
- this._emitDebugEvent("PlayerChangeNodeFailNoEligibleNode" /* PlayerChangeNodeFailNoEligibleNode */, {
1807
- state: "error",
1808
- message: `Failed to destroy player ${player.guildId}: ${error.message}`,
1809
- error,
1810
- functionLayer: "Node > destroy() > movePlayers"
1811
- });
1812
- })
1813
- )
1814
- );
1815
- }
1816
- return Promise.allSettled(
1817
- Array.from(players.values()).map(
1818
- (player) => player.changeNode(nodeToMove.options.id).catch((error) => {
1819
- this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
1820
- state: "error",
1821
- message: `Failed to move player ${player.guildId}: ${error.message}`,
1822
- error,
1823
- functionLayer: "Node > destroy() > movePlayers"
1824
- });
1825
- return player.destroy(error.message ?? "PlayerChangeNodeFail" /* PlayerChangeNodeFail */).catch((destroyError) => {
1826
- this._emitDebugEvent("PlayerDestroyFail" /* PlayerDestroyFail */, {
1827
- state: "error",
1828
- message: `Failed to destroy player ${player.guildId} after move failure: ${destroyError.message}`,
1829
- error: destroyError,
1830
- functionLayer: "Node > destroy() > movePlayers"
1831
- });
1832
- });
1833
- })
1834
- )
1835
- );
1809
+ return this.movePlayersToReplacementNode(Array.from(players.values()), "destroy");
1836
1810
  };
1837
1811
  return void handlePlayerOperations().finally(() => {
1838
1812
  this.socket?.close(1e3, "Node-Destroy");
@@ -1871,6 +1845,247 @@ var LavalinkNode = class _LavalinkNode {
1871
1845
  this.resetReconnectionAttempts();
1872
1846
  this.NodeManager.emit("disconnect", this, { code: 1e3, reason: disconnectReason });
1873
1847
  }
1848
+ /**
1849
+ * Pick the best ready node to move players to.
1850
+ * A node must be connected and have a sessionId before a player can be migrated to it.
1851
+ */
1852
+ getReadyMigrationNode(excludeNodeId) {
1853
+ return this.selectMigrationTarget(/* @__PURE__ */ new Set([excludeNodeId]), Number.MAX_SAFE_INTEGER);
1854
+ }
1855
+ getMigrationErrorMessage(error) {
1856
+ if (error instanceof Error) return error.message;
1857
+ if (typeof error === "string") return error;
1858
+ try {
1859
+ return safeStringify(error);
1860
+ } catch {
1861
+ return String(error);
1862
+ }
1863
+ }
1864
+ getMigrationConfig() {
1865
+ const migration = this._LManager.options?.advancedOptions?.playerMigration;
1866
+ return {
1867
+ concurrency: normalizeInt(migration?.concurrency, 5, 1, 32),
1868
+ perTargetConcurrency: normalizeInt(migration?.perTargetConcurrency, 2, 1, 8),
1869
+ backpressureDelayMs: normalizeInt(migration?.backpressureDelayMs, 50, 0, 1e3)
1870
+ };
1871
+ }
1872
+ getMigrationReservationCount(nodeId) {
1873
+ return this.NodeManager.playerMigration.reservations.get(nodeId) || 0;
1874
+ }
1875
+ reserveMigrationTarget(nodeId) {
1876
+ this.NodeManager.playerMigration.reservations.set(nodeId, this.getMigrationReservationCount(nodeId) + 1);
1877
+ }
1878
+ releaseMigrationTarget(nodeId) {
1879
+ const current = this.getMigrationReservationCount(nodeId);
1880
+ if (current <= 1) this.NodeManager.playerMigration.reservations.delete(nodeId);
1881
+ else this.NodeManager.playerMigration.reservations.set(nodeId, current - 1);
1882
+ this.NodeManager.signalPlayerMigrationCapacityChange();
1883
+ }
1884
+ getEffectiveMigrationLoad(node) {
1885
+ return (node.stats?.players || 0) + this.getMigrationReservationCount(node.id);
1886
+ }
1887
+ selectMigrationTarget(excludeNodeIds, perTargetConcurrency) {
1888
+ let selectedNode = null;
1889
+ let selectedEffectiveLoad = Number.POSITIVE_INFINITY;
1890
+ let selectedLiveLoad = Number.POSITIVE_INFINITY;
1891
+ for (const node of this.NodeManager.nodes.values()) {
1892
+ if (!node.connected || !node.sessionId) continue;
1893
+ if (excludeNodeIds.has(node.id)) continue;
1894
+ const reservationCount = this.getMigrationReservationCount(node.id);
1895
+ if (reservationCount >= perTargetConcurrency) continue;
1896
+ const liveLoad = node.stats?.players || 0;
1897
+ const effectiveLoad = this.getEffectiveMigrationLoad(node);
1898
+ if (!selectedNode || effectiveLoad < selectedEffectiveLoad || effectiveLoad === selectedEffectiveLoad && (liveLoad < selectedLiveLoad || liveLoad === selectedLiveLoad && node.id.localeCompare(selectedNode.id) < 0)) {
1899
+ selectedNode = node;
1900
+ selectedEffectiveLoad = effectiveLoad;
1901
+ selectedLiveLoad = liveLoad;
1902
+ }
1903
+ }
1904
+ return selectedNode;
1905
+ }
1906
+ hasAnyHealthyMigrationNode(excludeNodeIds) {
1907
+ for (const node of this.NodeManager.nodes.values()) {
1908
+ if (!node.connected || !node.sessionId) continue;
1909
+ if (excludeNodeIds.has(node.id)) continue;
1910
+ return true;
1911
+ }
1912
+ return false;
1913
+ }
1914
+ isMigrationNodeHealthy(nodeId) {
1915
+ const node = this.NodeManager.nodes.get(nodeId);
1916
+ return !!node?.connected && !!node.sessionId;
1917
+ }
1918
+ async waitForMigrationCapacity(excludeNodeIds, perTargetConcurrency, timeoutMs) {
1919
+ if (this.selectMigrationTarget(excludeNodeIds, perTargetConcurrency)) return;
1920
+ if (timeoutMs <= 0) {
1921
+ await new Promise((resolve) => setImmediate(resolve));
1922
+ return;
1923
+ }
1924
+ await new Promise((resolve) => {
1925
+ let settled = false;
1926
+ let timer;
1927
+ const onSignal = () => {
1928
+ if (settled) return;
1929
+ settled = true;
1930
+ if (timer) clearTimeout(timer);
1931
+ this.NodeManager.playerMigration.capacityWaiters.delete(onSignal);
1932
+ resolve();
1933
+ };
1934
+ timer = setTimeout(() => {
1935
+ if (settled) return;
1936
+ settled = true;
1937
+ this.NodeManager.playerMigration.capacityWaiters.delete(onSignal);
1938
+ resolve();
1939
+ }, timeoutMs);
1940
+ this.NodeManager.playerMigration.capacityWaiters.add(onSignal);
1941
+ });
1942
+ }
1943
+ /**
1944
+ * Move a batch of players to a ready node with bounded concurrency.
1945
+ * This keeps migration responsive without flooding the replacement node with requests.
1946
+ */
1947
+ async movePlayersToReplacementNode(players, onNoEligibleNode) {
1948
+ const migrationQueue = players.filter((player) => {
1949
+ if (player.getData("internal_nodeMigrating") === true) return false;
1950
+ if (player.getData("internal_destroystatus") === true) return false;
1951
+ player.setData("internal_nodeMigrating", true);
1952
+ return true;
1953
+ });
1954
+ const { concurrency, perTargetConcurrency, backpressureDelayMs } = this.getMigrationConfig();
1955
+ let nextQueueIndex = 0;
1956
+ const workerCount = Math.min(concurrency, migrationQueue.length);
1957
+ const migratePlayer = async (player) => {
1958
+ const excludedNodes = /* @__PURE__ */ new Set();
1959
+ try {
1960
+ for (let attempt = 0; attempt < 2; attempt++) {
1961
+ if (player.getData("internal_destroystatus") === true) return;
1962
+ if (player.node?.options?.id !== this.id) return;
1963
+ let targetNode = null;
1964
+ while (!(targetNode = this.selectMigrationTarget(excludedNodes, perTargetConcurrency))) {
1965
+ if (!this.hasAnyHealthyMigrationNode(excludedNodes)) {
1966
+ this._emitDebugEvent("PlayerChangeNodeFailNoEligibleNode" /* PlayerChangeNodeFailNoEligibleNode */, {
1967
+ state: "warn",
1968
+ message: `No eligible ready node was available for player ${player.guildId}`,
1969
+ functionLayer: "Node > movePlayersToReplacementNode()"
1970
+ });
1971
+ if (onNoEligibleNode === "destroy") {
1972
+ await player.destroy("PlayerChangeNodeFailNoEligibleNode" /* PlayerChangeNodeFailNoEligibleNode */).catch((destroyError) => {
1973
+ this._emitDebugEvent("PlayerDestroyFail" /* PlayerDestroyFail */, {
1974
+ state: "error",
1975
+ message: `Failed to destroy player ${player.guildId}: ${this.getMigrationErrorMessage(destroyError)}`,
1976
+ error: destroyError,
1977
+ functionLayer: "Node > movePlayersToReplacementNode()"
1978
+ });
1979
+ });
1980
+ } else {
1981
+ player.playing = false;
1982
+ }
1983
+ return;
1984
+ }
1985
+ await this.waitForMigrationCapacity(excludedNodes, perTargetConcurrency, backpressureDelayMs);
1986
+ }
1987
+ const targetNodeId = targetNode.id;
1988
+ if (player.getData("internal_destroystatus") === true) return;
1989
+ if (player.node?.options?.id !== this.id) return;
1990
+ this.reserveMigrationTarget(targetNodeId);
1991
+ try {
1992
+ await player.changeNode(targetNodeId);
1993
+ return;
1994
+ } catch (error) {
1995
+ const errorMessage = this.getMigrationErrorMessage(error);
1996
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
1997
+ state: "error",
1998
+ message: `Failed to move player ${player.guildId}: ${errorMessage}`,
1999
+ error,
2000
+ functionLayer: "Node > movePlayersToReplacementNode()"
2001
+ });
2002
+ const targetStillHealthy = this.isMigrationNodeHealthy(targetNodeId);
2003
+ excludedNodes.add(targetNodeId);
2004
+ if (attempt === 0 && !targetStillHealthy) {
2005
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
2006
+ state: "warn",
2007
+ message: `Migration target ${targetNodeId} became unavailable while moving player ${player.guildId}; retrying on another ready node`,
2008
+ error,
2009
+ functionLayer: "Node > movePlayersToReplacementNode()"
2010
+ });
2011
+ if (player.node?.options?.id === targetNodeId) player.node = this;
2012
+ continue;
2013
+ }
2014
+ if (attempt === 0 && this.hasAnyHealthyMigrationNode(excludedNodes)) {
2015
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
2016
+ state: "warn",
2017
+ message: `Retrying migration for player ${player.guildId} on a different ready node`,
2018
+ error,
2019
+ functionLayer: "Node > movePlayersToReplacementNode()"
2020
+ });
2021
+ if (player.node?.options?.id === targetNodeId) player.node = this;
2022
+ continue;
2023
+ }
2024
+ if (onNoEligibleNode === "destroy") {
2025
+ await player.destroy("PlayerChangeNodeFail" /* PlayerChangeNodeFail */).catch((destroyError) => {
2026
+ this._emitDebugEvent("PlayerDestroyFail" /* PlayerDestroyFail */, {
2027
+ state: "error",
2028
+ message: `Failed to destroy player ${player.guildId} after unexpected migration failure: ${this.getMigrationErrorMessage(destroyError)}`,
2029
+ error: destroyError,
2030
+ functionLayer: "Node > movePlayersToReplacementNode()"
2031
+ });
2032
+ });
2033
+ } else {
2034
+ player.playing = false;
2035
+ }
2036
+ return;
2037
+ } finally {
2038
+ this.releaseMigrationTarget(targetNodeId);
2039
+ }
2040
+ }
2041
+ } catch (error) {
2042
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
2043
+ state: "error",
2044
+ message: `Unexpected error while migrating player ${player.guildId}: ${this.getMigrationErrorMessage(error)}`,
2045
+ error,
2046
+ functionLayer: "Node > movePlayersToReplacementNode()"
2047
+ });
2048
+ if (onNoEligibleNode === "destroy") {
2049
+ await player.destroy("PlayerChangeNodeFail" /* PlayerChangeNodeFail */).catch((destroyError) => {
2050
+ this._emitDebugEvent("PlayerDestroyFail" /* PlayerDestroyFail */, {
2051
+ state: "error",
2052
+ message: `Failed to destroy player ${player.guildId} after unexpected migration failure: ${this.getMigrationErrorMessage(destroyError)}`,
2053
+ error: destroyError,
2054
+ functionLayer: "Node > movePlayersToReplacementNode()"
2055
+ });
2056
+ });
2057
+ } else {
2058
+ player.playing = false;
2059
+ }
2060
+ }
2061
+ };
2062
+ const workers = Array.from({ length: workerCount }, async () => {
2063
+ while (true) {
2064
+ const queueIndex = nextQueueIndex++;
2065
+ if (queueIndex >= migrationQueue.length) return;
2066
+ const player = migrationQueue[queueIndex];
2067
+ if (!player) continue;
2068
+ try {
2069
+ if (player.getData("internal_nodeMigrating") !== true) {
2070
+ player.setData("internal_nodeMigrating", true);
2071
+ }
2072
+ if (player.getData("internal_destroystatus") === true) continue;
2073
+ if (player.node?.options?.id !== this.id) continue;
2074
+ await migratePlayer(player);
2075
+ } catch (error) {
2076
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
2077
+ state: "error",
2078
+ message: `Unexpected worker error while migrating player ${player.guildId}: ${this.getMigrationErrorMessage(error)}`,
2079
+ error,
2080
+ functionLayer: "Node > movePlayersToReplacementNode()"
2081
+ });
2082
+ } finally {
2083
+ player.setData("internal_nodeMigrating", void 0);
2084
+ }
2085
+ }
2086
+ });
2087
+ await Promise.all(workers);
2088
+ }
1874
2089
  /**
1875
2090
  * Gets all Players of a Node
1876
2091
  * @returns array of players inside of lavalink
@@ -2583,9 +2798,10 @@ var LavalinkNode = class _LavalinkNode {
2583
2798
  }
2584
2799
  this.info.isNodelink = !!this.info.isNodelink;
2585
2800
  this.NodeManager.emit("connect", this);
2801
+ this.NodeManager.signalPlayerMigrationCapacityChange();
2586
2802
  }
2587
2803
  /** @private util function for handling closing events from websocket */
2588
- close(code, reason) {
2804
+ async close(code, reason) {
2589
2805
  this.resetAckTimeouts(true, true);
2590
2806
  try {
2591
2807
  if (this.socket) {
@@ -2604,19 +2820,22 @@ var LavalinkNode = class _LavalinkNode {
2604
2820
  this.isAlive = false;
2605
2821
  if (code === 1006 && !reason) reason = "Socket got terminated due to no ping connection";
2606
2822
  if (code === 1e3 && reason === "Node-Disconnect") return;
2823
+ const players = Array.from(
2824
+ this._LManager.players.filter((p) => p?.node?.options?.id === this?.options?.id).values()
2825
+ );
2607
2826
  this.NodeManager.emit("disconnect", this, { code, reason });
2608
2827
  if (code !== 1e3 || reason !== "Node-Destroy") {
2609
2828
  if (this.NodeManager.nodes.has(this.id)) {
2610
2829
  this.reconnect();
2611
2830
  }
2612
2831
  }
2613
- this._LManager.players.filter((p) => p?.node?.options?.id === this?.options?.id).forEach((p) => {
2614
- if (!this._LManager.options.autoMove) return p.playing = false;
2615
- if (this._LManager.options.autoMove) {
2616
- if (this.NodeManager.nodes.filter((n) => n.connected).size === 0) return p.playing = false;
2617
- p.moveNode();
2618
- }
2619
- });
2832
+ if (this._LManager.options.autoMove && players.length) {
2833
+ await this.movePlayersToReplacementNode(players, "freeze");
2834
+ } else if (!this._LManager.options.autoMove) {
2835
+ players.forEach((p) => p.playing = false);
2836
+ } else if (this.NodeManager.nodes.filter((n) => n.connected && !!n.sessionId).size === 0) {
2837
+ players.forEach((p) => p.playing = false);
2838
+ }
2620
2839
  }
2621
2840
  /** @private util function for handling error events from websocket */
2622
2841
  error(error) {
@@ -3694,6 +3913,16 @@ var NodeManager = class extends import_node_events.EventEmitter {
3694
3913
  * A map of all nodes in the nodeManager
3695
3914
  */
3696
3915
  nodes = new MiniMap();
3916
+ /** @internal Coordinates player migrations between nodes owned by this manager. */
3917
+ playerMigration = {
3918
+ reservations: /* @__PURE__ */ new Map(),
3919
+ capacityWaiters: /* @__PURE__ */ new Set()
3920
+ };
3921
+ /** @internal Wakes migrations waiting for capacity on one of this manager's nodes. */
3922
+ signalPlayerMigrationCapacityChange() {
3923
+ for (const resolve of this.playerMigration.capacityWaiters) resolve();
3924
+ this.playerMigration.capacityWaiters.clear();
3925
+ }
3697
3926
  /**
3698
3927
  * @param LavalinkManager The LavalinkManager that created this NodeManager
3699
3928
  */
@@ -6345,8 +6574,8 @@ var Player = class {
6345
6574
  try {
6346
6575
  if (!node)
6347
6576
  node = Array.from(this.LavalinkManager.nodeManager.leastUsedNodes("playingPlayers")).find(
6348
- (n) => n.connected && n.options.id !== this.node.options.id
6349
- ).id;
6577
+ (n) => n.connected && !!n.sessionId && n.options.id !== this.node.options.id
6578
+ )?.id;
6350
6579
  if (!node || !this.LavalinkManager.nodeManager.nodes.get(node))
6351
6580
  throw new RangeError("No nodes are available.");
6352
6581
  if (this.node.options.id === node) return this;
@@ -6501,6 +6730,11 @@ var LavalinkManager = class _LavalinkManager extends import_node_events2.EventEm
6501
6730
  advancedOptions: {
6502
6731
  enableDebugEvents: options?.advancedOptions?.enableDebugEvents ?? false,
6503
6732
  maxFilterFixDuration: options?.advancedOptions?.maxFilterFixDuration ?? 6e5,
6733
+ playerMigration: {
6734
+ concurrency: options?.advancedOptions?.playerMigration?.concurrency ?? 5,
6735
+ perTargetConcurrency: options?.advancedOptions?.playerMigration?.perTargetConcurrency ?? 2,
6736
+ backpressureDelayMs: options?.advancedOptions?.playerMigration?.backpressureDelayMs ?? 50
6737
+ },
6504
6738
  debugOptions: {
6505
6739
  logCustomSearches: options?.advancedOptions?.debugOptions?.logCustomSearches ?? false,
6506
6740
  noAudio: options?.advancedOptions?.debugOptions?.noAudio ?? false,
package/dist/index.d.cts CHANGED
@@ -3306,6 +3306,26 @@ declare class LavalinkNode {
3306
3306
  * ```
3307
3307
  */
3308
3308
  disconnect(disconnectReason?: DisconnectReasonsType): void;
3309
+ /**
3310
+ * Pick the best ready node to move players to.
3311
+ * A node must be connected and have a sessionId before a player can be migrated to it.
3312
+ */
3313
+ private getReadyMigrationNode;
3314
+ private getMigrationErrorMessage;
3315
+ private getMigrationConfig;
3316
+ private getMigrationReservationCount;
3317
+ private reserveMigrationTarget;
3318
+ private releaseMigrationTarget;
3319
+ private getEffectiveMigrationLoad;
3320
+ private selectMigrationTarget;
3321
+ private hasAnyHealthyMigrationNode;
3322
+ private isMigrationNodeHealthy;
3323
+ private waitForMigrationCapacity;
3324
+ /**
3325
+ * Move a batch of players to a ready node with bounded concurrency.
3326
+ * This keeps migration responsive without flooding the replacement node with requests.
3327
+ */
3328
+ private movePlayersToReplacementNode;
3309
3329
  /**
3310
3330
  * Gets all Players of a Node
3311
3331
  * @returns array of players inside of lavalink
@@ -3741,6 +3761,13 @@ declare class NodeManager extends EventEmitter {
3741
3761
  * A map of all nodes in the nodeManager
3742
3762
  */
3743
3763
  nodes: MiniMap<string, LavalinkNode | NodeLinkNode>;
3764
+ /** @internal Coordinates player migrations between nodes owned by this manager. */
3765
+ readonly playerMigration: {
3766
+ reservations: Map<string, number>;
3767
+ capacityWaiters: Set<() => void>;
3768
+ };
3769
+ /** @internal Wakes migrations waiting for capacity on one of this manager's nodes. */
3770
+ signalPlayerMigrationCapacityChange(): void;
3744
3771
  /**
3745
3772
  * @param LavalinkManager The LavalinkManager that created this NodeManager
3746
3773
  */
@@ -4103,6 +4130,15 @@ interface ManagerOptions<CustomPlayerT extends Player = Player> {
4103
4130
  dontThrowError?: boolean;
4104
4131
  };
4105
4132
  };
4133
+ /** Player migration configuration */
4134
+ playerMigration?: {
4135
+ /** Maximum number of players migrated concurrently. Default: 5 */
4136
+ concurrency?: number;
4137
+ /** Maximum number of concurrent migrations to a single target node. Default: 2 */
4138
+ perTargetConcurrency?: number;
4139
+ /** Maximum time to wait for migration capacity before retrying. Default: 50ms */
4140
+ backpressureDelayMs?: number;
4141
+ };
4106
4142
  };
4107
4143
  }
4108
4144
 
package/dist/index.d.ts CHANGED
@@ -3306,6 +3306,26 @@ declare class LavalinkNode {
3306
3306
  * ```
3307
3307
  */
3308
3308
  disconnect(disconnectReason?: DisconnectReasonsType): void;
3309
+ /**
3310
+ * Pick the best ready node to move players to.
3311
+ * A node must be connected and have a sessionId before a player can be migrated to it.
3312
+ */
3313
+ private getReadyMigrationNode;
3314
+ private getMigrationErrorMessage;
3315
+ private getMigrationConfig;
3316
+ private getMigrationReservationCount;
3317
+ private reserveMigrationTarget;
3318
+ private releaseMigrationTarget;
3319
+ private getEffectiveMigrationLoad;
3320
+ private selectMigrationTarget;
3321
+ private hasAnyHealthyMigrationNode;
3322
+ private isMigrationNodeHealthy;
3323
+ private waitForMigrationCapacity;
3324
+ /**
3325
+ * Move a batch of players to a ready node with bounded concurrency.
3326
+ * This keeps migration responsive without flooding the replacement node with requests.
3327
+ */
3328
+ private movePlayersToReplacementNode;
3309
3329
  /**
3310
3330
  * Gets all Players of a Node
3311
3331
  * @returns array of players inside of lavalink
@@ -3741,6 +3761,13 @@ declare class NodeManager extends EventEmitter {
3741
3761
  * A map of all nodes in the nodeManager
3742
3762
  */
3743
3763
  nodes: MiniMap<string, LavalinkNode | NodeLinkNode>;
3764
+ /** @internal Coordinates player migrations between nodes owned by this manager. */
3765
+ readonly playerMigration: {
3766
+ reservations: Map<string, number>;
3767
+ capacityWaiters: Set<() => void>;
3768
+ };
3769
+ /** @internal Wakes migrations waiting for capacity on one of this manager's nodes. */
3770
+ signalPlayerMigrationCapacityChange(): void;
3744
3771
  /**
3745
3772
  * @param LavalinkManager The LavalinkManager that created this NodeManager
3746
3773
  */
@@ -4103,6 +4130,15 @@ interface ManagerOptions<CustomPlayerT extends Player = Player> {
4103
4130
  dontThrowError?: boolean;
4104
4131
  };
4105
4132
  };
4133
+ /** Player migration configuration */
4134
+ playerMigration?: {
4135
+ /** Maximum number of players migrated concurrently. Default: 5 */
4136
+ concurrency?: number;
4137
+ /** Maximum number of concurrent migrations to a single target node. Default: 2 */
4138
+ perTargetConcurrency?: number;
4139
+ /** Maximum time to wait for migration capacity before retrying. Default: 50ms */
4140
+ backpressureDelayMs?: number;
4141
+ };
4106
4142
  };
4107
4143
  }
4108
4144
 
package/dist/index.js CHANGED
@@ -1204,6 +1204,11 @@ function safeStringify(obj, padding = 0) {
1204
1204
  }
1205
1205
 
1206
1206
  // src/structures/Node.ts
1207
+ var normalizeInt = (value, fallback, min, max) => {
1208
+ const parsed = typeof value === "number" ? value : Number(value);
1209
+ if (!Number.isFinite(parsed)) return fallback;
1210
+ return Math.min(max, Math.max(min, Math.floor(parsed)));
1211
+ };
1207
1212
  var LavalinkNode = class _LavalinkNode {
1208
1213
  heartBeatPingTimestamp = 0;
1209
1214
  heartBeatPongTimestamp = 0;
@@ -1638,7 +1643,12 @@ var LavalinkNode = class _LavalinkNode {
1638
1643
  { headers }
1639
1644
  );
1640
1645
  this.socket.on("open", this.open.bind(this));
1641
- this.socket.on("close", (code, reason) => this.close(code, reason?.toString()));
1646
+ this.socket.on(
1647
+ "close",
1648
+ (code, reason) => void this.close(code, reason?.toString()).catch((error) => {
1649
+ this.NodeManager.emit("error", this, error);
1650
+ })
1651
+ );
1642
1652
  this.socket.on("message", this.message.bind(this));
1643
1653
  this.socket.on("error", this.error.bind(this));
1644
1654
  }
@@ -1730,43 +1740,7 @@ var LavalinkNode = class _LavalinkNode {
1730
1740
  )
1731
1741
  );
1732
1742
  }
1733
- const nodeToMove = Array.from(this.NodeManager.leastUsedNodes("playingPlayers")).find(
1734
- (n) => n.connected && n.options.id !== this.id
1735
- );
1736
- if (!nodeToMove) {
1737
- return Promise.allSettled(
1738
- Array.from(players.values()).map(
1739
- (player) => player.destroy("PlayerChangeNodeFailNoEligibleNode" /* PlayerChangeNodeFailNoEligibleNode */).catch((error) => {
1740
- this._emitDebugEvent("PlayerChangeNodeFailNoEligibleNode" /* PlayerChangeNodeFailNoEligibleNode */, {
1741
- state: "error",
1742
- message: `Failed to destroy player ${player.guildId}: ${error.message}`,
1743
- error,
1744
- functionLayer: "Node > destroy() > movePlayers"
1745
- });
1746
- })
1747
- )
1748
- );
1749
- }
1750
- return Promise.allSettled(
1751
- Array.from(players.values()).map(
1752
- (player) => player.changeNode(nodeToMove.options.id).catch((error) => {
1753
- this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
1754
- state: "error",
1755
- message: `Failed to move player ${player.guildId}: ${error.message}`,
1756
- error,
1757
- functionLayer: "Node > destroy() > movePlayers"
1758
- });
1759
- return player.destroy(error.message ?? "PlayerChangeNodeFail" /* PlayerChangeNodeFail */).catch((destroyError) => {
1760
- this._emitDebugEvent("PlayerDestroyFail" /* PlayerDestroyFail */, {
1761
- state: "error",
1762
- message: `Failed to destroy player ${player.guildId} after move failure: ${destroyError.message}`,
1763
- error: destroyError,
1764
- functionLayer: "Node > destroy() > movePlayers"
1765
- });
1766
- });
1767
- })
1768
- )
1769
- );
1743
+ return this.movePlayersToReplacementNode(Array.from(players.values()), "destroy");
1770
1744
  };
1771
1745
  return void handlePlayerOperations().finally(() => {
1772
1746
  this.socket?.close(1e3, "Node-Destroy");
@@ -1805,6 +1779,247 @@ var LavalinkNode = class _LavalinkNode {
1805
1779
  this.resetReconnectionAttempts();
1806
1780
  this.NodeManager.emit("disconnect", this, { code: 1e3, reason: disconnectReason });
1807
1781
  }
1782
+ /**
1783
+ * Pick the best ready node to move players to.
1784
+ * A node must be connected and have a sessionId before a player can be migrated to it.
1785
+ */
1786
+ getReadyMigrationNode(excludeNodeId) {
1787
+ return this.selectMigrationTarget(/* @__PURE__ */ new Set([excludeNodeId]), Number.MAX_SAFE_INTEGER);
1788
+ }
1789
+ getMigrationErrorMessage(error) {
1790
+ if (error instanceof Error) return error.message;
1791
+ if (typeof error === "string") return error;
1792
+ try {
1793
+ return safeStringify(error);
1794
+ } catch {
1795
+ return String(error);
1796
+ }
1797
+ }
1798
+ getMigrationConfig() {
1799
+ const migration = this._LManager.options?.advancedOptions?.playerMigration;
1800
+ return {
1801
+ concurrency: normalizeInt(migration?.concurrency, 5, 1, 32),
1802
+ perTargetConcurrency: normalizeInt(migration?.perTargetConcurrency, 2, 1, 8),
1803
+ backpressureDelayMs: normalizeInt(migration?.backpressureDelayMs, 50, 0, 1e3)
1804
+ };
1805
+ }
1806
+ getMigrationReservationCount(nodeId) {
1807
+ return this.NodeManager.playerMigration.reservations.get(nodeId) || 0;
1808
+ }
1809
+ reserveMigrationTarget(nodeId) {
1810
+ this.NodeManager.playerMigration.reservations.set(nodeId, this.getMigrationReservationCount(nodeId) + 1);
1811
+ }
1812
+ releaseMigrationTarget(nodeId) {
1813
+ const current = this.getMigrationReservationCount(nodeId);
1814
+ if (current <= 1) this.NodeManager.playerMigration.reservations.delete(nodeId);
1815
+ else this.NodeManager.playerMigration.reservations.set(nodeId, current - 1);
1816
+ this.NodeManager.signalPlayerMigrationCapacityChange();
1817
+ }
1818
+ getEffectiveMigrationLoad(node) {
1819
+ return (node.stats?.players || 0) + this.getMigrationReservationCount(node.id);
1820
+ }
1821
+ selectMigrationTarget(excludeNodeIds, perTargetConcurrency) {
1822
+ let selectedNode = null;
1823
+ let selectedEffectiveLoad = Number.POSITIVE_INFINITY;
1824
+ let selectedLiveLoad = Number.POSITIVE_INFINITY;
1825
+ for (const node of this.NodeManager.nodes.values()) {
1826
+ if (!node.connected || !node.sessionId) continue;
1827
+ if (excludeNodeIds.has(node.id)) continue;
1828
+ const reservationCount = this.getMigrationReservationCount(node.id);
1829
+ if (reservationCount >= perTargetConcurrency) continue;
1830
+ const liveLoad = node.stats?.players || 0;
1831
+ const effectiveLoad = this.getEffectiveMigrationLoad(node);
1832
+ if (!selectedNode || effectiveLoad < selectedEffectiveLoad || effectiveLoad === selectedEffectiveLoad && (liveLoad < selectedLiveLoad || liveLoad === selectedLiveLoad && node.id.localeCompare(selectedNode.id) < 0)) {
1833
+ selectedNode = node;
1834
+ selectedEffectiveLoad = effectiveLoad;
1835
+ selectedLiveLoad = liveLoad;
1836
+ }
1837
+ }
1838
+ return selectedNode;
1839
+ }
1840
+ hasAnyHealthyMigrationNode(excludeNodeIds) {
1841
+ for (const node of this.NodeManager.nodes.values()) {
1842
+ if (!node.connected || !node.sessionId) continue;
1843
+ if (excludeNodeIds.has(node.id)) continue;
1844
+ return true;
1845
+ }
1846
+ return false;
1847
+ }
1848
+ isMigrationNodeHealthy(nodeId) {
1849
+ const node = this.NodeManager.nodes.get(nodeId);
1850
+ return !!node?.connected && !!node.sessionId;
1851
+ }
1852
+ async waitForMigrationCapacity(excludeNodeIds, perTargetConcurrency, timeoutMs) {
1853
+ if (this.selectMigrationTarget(excludeNodeIds, perTargetConcurrency)) return;
1854
+ if (timeoutMs <= 0) {
1855
+ await new Promise((resolve) => setImmediate(resolve));
1856
+ return;
1857
+ }
1858
+ await new Promise((resolve) => {
1859
+ let settled = false;
1860
+ let timer;
1861
+ const onSignal = () => {
1862
+ if (settled) return;
1863
+ settled = true;
1864
+ if (timer) clearTimeout(timer);
1865
+ this.NodeManager.playerMigration.capacityWaiters.delete(onSignal);
1866
+ resolve();
1867
+ };
1868
+ timer = setTimeout(() => {
1869
+ if (settled) return;
1870
+ settled = true;
1871
+ this.NodeManager.playerMigration.capacityWaiters.delete(onSignal);
1872
+ resolve();
1873
+ }, timeoutMs);
1874
+ this.NodeManager.playerMigration.capacityWaiters.add(onSignal);
1875
+ });
1876
+ }
1877
+ /**
1878
+ * Move a batch of players to a ready node with bounded concurrency.
1879
+ * This keeps migration responsive without flooding the replacement node with requests.
1880
+ */
1881
+ async movePlayersToReplacementNode(players, onNoEligibleNode) {
1882
+ const migrationQueue = players.filter((player) => {
1883
+ if (player.getData("internal_nodeMigrating") === true) return false;
1884
+ if (player.getData("internal_destroystatus") === true) return false;
1885
+ player.setData("internal_nodeMigrating", true);
1886
+ return true;
1887
+ });
1888
+ const { concurrency, perTargetConcurrency, backpressureDelayMs } = this.getMigrationConfig();
1889
+ let nextQueueIndex = 0;
1890
+ const workerCount = Math.min(concurrency, migrationQueue.length);
1891
+ const migratePlayer = async (player) => {
1892
+ const excludedNodes = /* @__PURE__ */ new Set();
1893
+ try {
1894
+ for (let attempt = 0; attempt < 2; attempt++) {
1895
+ if (player.getData("internal_destroystatus") === true) return;
1896
+ if (player.node?.options?.id !== this.id) return;
1897
+ let targetNode = null;
1898
+ while (!(targetNode = this.selectMigrationTarget(excludedNodes, perTargetConcurrency))) {
1899
+ if (!this.hasAnyHealthyMigrationNode(excludedNodes)) {
1900
+ this._emitDebugEvent("PlayerChangeNodeFailNoEligibleNode" /* PlayerChangeNodeFailNoEligibleNode */, {
1901
+ state: "warn",
1902
+ message: `No eligible ready node was available for player ${player.guildId}`,
1903
+ functionLayer: "Node > movePlayersToReplacementNode()"
1904
+ });
1905
+ if (onNoEligibleNode === "destroy") {
1906
+ await player.destroy("PlayerChangeNodeFailNoEligibleNode" /* PlayerChangeNodeFailNoEligibleNode */).catch((destroyError) => {
1907
+ this._emitDebugEvent("PlayerDestroyFail" /* PlayerDestroyFail */, {
1908
+ state: "error",
1909
+ message: `Failed to destroy player ${player.guildId}: ${this.getMigrationErrorMessage(destroyError)}`,
1910
+ error: destroyError,
1911
+ functionLayer: "Node > movePlayersToReplacementNode()"
1912
+ });
1913
+ });
1914
+ } else {
1915
+ player.playing = false;
1916
+ }
1917
+ return;
1918
+ }
1919
+ await this.waitForMigrationCapacity(excludedNodes, perTargetConcurrency, backpressureDelayMs);
1920
+ }
1921
+ const targetNodeId = targetNode.id;
1922
+ if (player.getData("internal_destroystatus") === true) return;
1923
+ if (player.node?.options?.id !== this.id) return;
1924
+ this.reserveMigrationTarget(targetNodeId);
1925
+ try {
1926
+ await player.changeNode(targetNodeId);
1927
+ return;
1928
+ } catch (error) {
1929
+ const errorMessage = this.getMigrationErrorMessage(error);
1930
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
1931
+ state: "error",
1932
+ message: `Failed to move player ${player.guildId}: ${errorMessage}`,
1933
+ error,
1934
+ functionLayer: "Node > movePlayersToReplacementNode()"
1935
+ });
1936
+ const targetStillHealthy = this.isMigrationNodeHealthy(targetNodeId);
1937
+ excludedNodes.add(targetNodeId);
1938
+ if (attempt === 0 && !targetStillHealthy) {
1939
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
1940
+ state: "warn",
1941
+ message: `Migration target ${targetNodeId} became unavailable while moving player ${player.guildId}; retrying on another ready node`,
1942
+ error,
1943
+ functionLayer: "Node > movePlayersToReplacementNode()"
1944
+ });
1945
+ if (player.node?.options?.id === targetNodeId) player.node = this;
1946
+ continue;
1947
+ }
1948
+ if (attempt === 0 && this.hasAnyHealthyMigrationNode(excludedNodes)) {
1949
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
1950
+ state: "warn",
1951
+ message: `Retrying migration for player ${player.guildId} on a different ready node`,
1952
+ error,
1953
+ functionLayer: "Node > movePlayersToReplacementNode()"
1954
+ });
1955
+ if (player.node?.options?.id === targetNodeId) player.node = this;
1956
+ continue;
1957
+ }
1958
+ if (onNoEligibleNode === "destroy") {
1959
+ await player.destroy("PlayerChangeNodeFail" /* PlayerChangeNodeFail */).catch((destroyError) => {
1960
+ this._emitDebugEvent("PlayerDestroyFail" /* PlayerDestroyFail */, {
1961
+ state: "error",
1962
+ message: `Failed to destroy player ${player.guildId} after unexpected migration failure: ${this.getMigrationErrorMessage(destroyError)}`,
1963
+ error: destroyError,
1964
+ functionLayer: "Node > movePlayersToReplacementNode()"
1965
+ });
1966
+ });
1967
+ } else {
1968
+ player.playing = false;
1969
+ }
1970
+ return;
1971
+ } finally {
1972
+ this.releaseMigrationTarget(targetNodeId);
1973
+ }
1974
+ }
1975
+ } catch (error) {
1976
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
1977
+ state: "error",
1978
+ message: `Unexpected error while migrating player ${player.guildId}: ${this.getMigrationErrorMessage(error)}`,
1979
+ error,
1980
+ functionLayer: "Node > movePlayersToReplacementNode()"
1981
+ });
1982
+ if (onNoEligibleNode === "destroy") {
1983
+ await player.destroy("PlayerChangeNodeFail" /* PlayerChangeNodeFail */).catch((destroyError) => {
1984
+ this._emitDebugEvent("PlayerDestroyFail" /* PlayerDestroyFail */, {
1985
+ state: "error",
1986
+ message: `Failed to destroy player ${player.guildId} after unexpected migration failure: ${this.getMigrationErrorMessage(destroyError)}`,
1987
+ error: destroyError,
1988
+ functionLayer: "Node > movePlayersToReplacementNode()"
1989
+ });
1990
+ });
1991
+ } else {
1992
+ player.playing = false;
1993
+ }
1994
+ }
1995
+ };
1996
+ const workers = Array.from({ length: workerCount }, async () => {
1997
+ while (true) {
1998
+ const queueIndex = nextQueueIndex++;
1999
+ if (queueIndex >= migrationQueue.length) return;
2000
+ const player = migrationQueue[queueIndex];
2001
+ if (!player) continue;
2002
+ try {
2003
+ if (player.getData("internal_nodeMigrating") !== true) {
2004
+ player.setData("internal_nodeMigrating", true);
2005
+ }
2006
+ if (player.getData("internal_destroystatus") === true) continue;
2007
+ if (player.node?.options?.id !== this.id) continue;
2008
+ await migratePlayer(player);
2009
+ } catch (error) {
2010
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
2011
+ state: "error",
2012
+ message: `Unexpected worker error while migrating player ${player.guildId}: ${this.getMigrationErrorMessage(error)}`,
2013
+ error,
2014
+ functionLayer: "Node > movePlayersToReplacementNode()"
2015
+ });
2016
+ } finally {
2017
+ player.setData("internal_nodeMigrating", void 0);
2018
+ }
2019
+ }
2020
+ });
2021
+ await Promise.all(workers);
2022
+ }
1808
2023
  /**
1809
2024
  * Gets all Players of a Node
1810
2025
  * @returns array of players inside of lavalink
@@ -2517,9 +2732,10 @@ var LavalinkNode = class _LavalinkNode {
2517
2732
  }
2518
2733
  this.info.isNodelink = !!this.info.isNodelink;
2519
2734
  this.NodeManager.emit("connect", this);
2735
+ this.NodeManager.signalPlayerMigrationCapacityChange();
2520
2736
  }
2521
2737
  /** @private util function for handling closing events from websocket */
2522
- close(code, reason) {
2738
+ async close(code, reason) {
2523
2739
  this.resetAckTimeouts(true, true);
2524
2740
  try {
2525
2741
  if (this.socket) {
@@ -2538,19 +2754,22 @@ var LavalinkNode = class _LavalinkNode {
2538
2754
  this.isAlive = false;
2539
2755
  if (code === 1006 && !reason) reason = "Socket got terminated due to no ping connection";
2540
2756
  if (code === 1e3 && reason === "Node-Disconnect") return;
2757
+ const players = Array.from(
2758
+ this._LManager.players.filter((p) => p?.node?.options?.id === this?.options?.id).values()
2759
+ );
2541
2760
  this.NodeManager.emit("disconnect", this, { code, reason });
2542
2761
  if (code !== 1e3 || reason !== "Node-Destroy") {
2543
2762
  if (this.NodeManager.nodes.has(this.id)) {
2544
2763
  this.reconnect();
2545
2764
  }
2546
2765
  }
2547
- this._LManager.players.filter((p) => p?.node?.options?.id === this?.options?.id).forEach((p) => {
2548
- if (!this._LManager.options.autoMove) return p.playing = false;
2549
- if (this._LManager.options.autoMove) {
2550
- if (this.NodeManager.nodes.filter((n) => n.connected).size === 0) return p.playing = false;
2551
- p.moveNode();
2552
- }
2553
- });
2766
+ if (this._LManager.options.autoMove && players.length) {
2767
+ await this.movePlayersToReplacementNode(players, "freeze");
2768
+ } else if (!this._LManager.options.autoMove) {
2769
+ players.forEach((p) => p.playing = false);
2770
+ } else if (this.NodeManager.nodes.filter((n) => n.connected && !!n.sessionId).size === 0) {
2771
+ players.forEach((p) => p.playing = false);
2772
+ }
2554
2773
  }
2555
2774
  /** @private util function for handling error events from websocket */
2556
2775
  error(error) {
@@ -3628,6 +3847,16 @@ var NodeManager = class extends EventEmitter {
3628
3847
  * A map of all nodes in the nodeManager
3629
3848
  */
3630
3849
  nodes = new MiniMap();
3850
+ /** @internal Coordinates player migrations between nodes owned by this manager. */
3851
+ playerMigration = {
3852
+ reservations: /* @__PURE__ */ new Map(),
3853
+ capacityWaiters: /* @__PURE__ */ new Set()
3854
+ };
3855
+ /** @internal Wakes migrations waiting for capacity on one of this manager's nodes. */
3856
+ signalPlayerMigrationCapacityChange() {
3857
+ for (const resolve of this.playerMigration.capacityWaiters) resolve();
3858
+ this.playerMigration.capacityWaiters.clear();
3859
+ }
3631
3860
  /**
3632
3861
  * @param LavalinkManager The LavalinkManager that created this NodeManager
3633
3862
  */
@@ -6279,8 +6508,8 @@ var Player = class {
6279
6508
  try {
6280
6509
  if (!node)
6281
6510
  node = Array.from(this.LavalinkManager.nodeManager.leastUsedNodes("playingPlayers")).find(
6282
- (n) => n.connected && n.options.id !== this.node.options.id
6283
- ).id;
6511
+ (n) => n.connected && !!n.sessionId && n.options.id !== this.node.options.id
6512
+ )?.id;
6284
6513
  if (!node || !this.LavalinkManager.nodeManager.nodes.get(node))
6285
6514
  throw new RangeError("No nodes are available.");
6286
6515
  if (this.node.options.id === node) return this;
@@ -6435,6 +6664,11 @@ var LavalinkManager = class _LavalinkManager extends EventEmitter2 {
6435
6664
  advancedOptions: {
6436
6665
  enableDebugEvents: options?.advancedOptions?.enableDebugEvents ?? false,
6437
6666
  maxFilterFixDuration: options?.advancedOptions?.maxFilterFixDuration ?? 6e5,
6667
+ playerMigration: {
6668
+ concurrency: options?.advancedOptions?.playerMigration?.concurrency ?? 5,
6669
+ perTargetConcurrency: options?.advancedOptions?.playerMigration?.perTargetConcurrency ?? 2,
6670
+ backpressureDelayMs: options?.advancedOptions?.playerMigration?.backpressureDelayMs ?? 50
6671
+ },
6438
6672
  debugOptions: {
6439
6673
  logCustomSearches: options?.advancedOptions?.debugOptions?.logCustomSearches ?? false,
6440
6674
  noAudio: options?.advancedOptions?.debugOptions?.noAudio ?? false,
package/dist/index.mjs CHANGED
@@ -1204,6 +1204,11 @@ function safeStringify(obj, padding = 0) {
1204
1204
  }
1205
1205
 
1206
1206
  // src/structures/Node.ts
1207
+ var normalizeInt = (value, fallback, min, max) => {
1208
+ const parsed = typeof value === "number" ? value : Number(value);
1209
+ if (!Number.isFinite(parsed)) return fallback;
1210
+ return Math.min(max, Math.max(min, Math.floor(parsed)));
1211
+ };
1207
1212
  var LavalinkNode = class _LavalinkNode {
1208
1213
  heartBeatPingTimestamp = 0;
1209
1214
  heartBeatPongTimestamp = 0;
@@ -1638,7 +1643,12 @@ var LavalinkNode = class _LavalinkNode {
1638
1643
  { headers }
1639
1644
  );
1640
1645
  this.socket.on("open", this.open.bind(this));
1641
- this.socket.on("close", (code, reason) => this.close(code, reason?.toString()));
1646
+ this.socket.on(
1647
+ "close",
1648
+ (code, reason) => void this.close(code, reason?.toString()).catch((error) => {
1649
+ this.NodeManager.emit("error", this, error);
1650
+ })
1651
+ );
1642
1652
  this.socket.on("message", this.message.bind(this));
1643
1653
  this.socket.on("error", this.error.bind(this));
1644
1654
  }
@@ -1730,43 +1740,7 @@ var LavalinkNode = class _LavalinkNode {
1730
1740
  )
1731
1741
  );
1732
1742
  }
1733
- const nodeToMove = Array.from(this.NodeManager.leastUsedNodes("playingPlayers")).find(
1734
- (n) => n.connected && n.options.id !== this.id
1735
- );
1736
- if (!nodeToMove) {
1737
- return Promise.allSettled(
1738
- Array.from(players.values()).map(
1739
- (player) => player.destroy("PlayerChangeNodeFailNoEligibleNode" /* PlayerChangeNodeFailNoEligibleNode */).catch((error) => {
1740
- this._emitDebugEvent("PlayerChangeNodeFailNoEligibleNode" /* PlayerChangeNodeFailNoEligibleNode */, {
1741
- state: "error",
1742
- message: `Failed to destroy player ${player.guildId}: ${error.message}`,
1743
- error,
1744
- functionLayer: "Node > destroy() > movePlayers"
1745
- });
1746
- })
1747
- )
1748
- );
1749
- }
1750
- return Promise.allSettled(
1751
- Array.from(players.values()).map(
1752
- (player) => player.changeNode(nodeToMove.options.id).catch((error) => {
1753
- this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
1754
- state: "error",
1755
- message: `Failed to move player ${player.guildId}: ${error.message}`,
1756
- error,
1757
- functionLayer: "Node > destroy() > movePlayers"
1758
- });
1759
- return player.destroy(error.message ?? "PlayerChangeNodeFail" /* PlayerChangeNodeFail */).catch((destroyError) => {
1760
- this._emitDebugEvent("PlayerDestroyFail" /* PlayerDestroyFail */, {
1761
- state: "error",
1762
- message: `Failed to destroy player ${player.guildId} after move failure: ${destroyError.message}`,
1763
- error: destroyError,
1764
- functionLayer: "Node > destroy() > movePlayers"
1765
- });
1766
- });
1767
- })
1768
- )
1769
- );
1743
+ return this.movePlayersToReplacementNode(Array.from(players.values()), "destroy");
1770
1744
  };
1771
1745
  return void handlePlayerOperations().finally(() => {
1772
1746
  this.socket?.close(1e3, "Node-Destroy");
@@ -1805,6 +1779,247 @@ var LavalinkNode = class _LavalinkNode {
1805
1779
  this.resetReconnectionAttempts();
1806
1780
  this.NodeManager.emit("disconnect", this, { code: 1e3, reason: disconnectReason });
1807
1781
  }
1782
+ /**
1783
+ * Pick the best ready node to move players to.
1784
+ * A node must be connected and have a sessionId before a player can be migrated to it.
1785
+ */
1786
+ getReadyMigrationNode(excludeNodeId) {
1787
+ return this.selectMigrationTarget(/* @__PURE__ */ new Set([excludeNodeId]), Number.MAX_SAFE_INTEGER);
1788
+ }
1789
+ getMigrationErrorMessage(error) {
1790
+ if (error instanceof Error) return error.message;
1791
+ if (typeof error === "string") return error;
1792
+ try {
1793
+ return safeStringify(error);
1794
+ } catch {
1795
+ return String(error);
1796
+ }
1797
+ }
1798
+ getMigrationConfig() {
1799
+ const migration = this._LManager.options?.advancedOptions?.playerMigration;
1800
+ return {
1801
+ concurrency: normalizeInt(migration?.concurrency, 5, 1, 32),
1802
+ perTargetConcurrency: normalizeInt(migration?.perTargetConcurrency, 2, 1, 8),
1803
+ backpressureDelayMs: normalizeInt(migration?.backpressureDelayMs, 50, 0, 1e3)
1804
+ };
1805
+ }
1806
+ getMigrationReservationCount(nodeId) {
1807
+ return this.NodeManager.playerMigration.reservations.get(nodeId) || 0;
1808
+ }
1809
+ reserveMigrationTarget(nodeId) {
1810
+ this.NodeManager.playerMigration.reservations.set(nodeId, this.getMigrationReservationCount(nodeId) + 1);
1811
+ }
1812
+ releaseMigrationTarget(nodeId) {
1813
+ const current = this.getMigrationReservationCount(nodeId);
1814
+ if (current <= 1) this.NodeManager.playerMigration.reservations.delete(nodeId);
1815
+ else this.NodeManager.playerMigration.reservations.set(nodeId, current - 1);
1816
+ this.NodeManager.signalPlayerMigrationCapacityChange();
1817
+ }
1818
+ getEffectiveMigrationLoad(node) {
1819
+ return (node.stats?.players || 0) + this.getMigrationReservationCount(node.id);
1820
+ }
1821
+ selectMigrationTarget(excludeNodeIds, perTargetConcurrency) {
1822
+ let selectedNode = null;
1823
+ let selectedEffectiveLoad = Number.POSITIVE_INFINITY;
1824
+ let selectedLiveLoad = Number.POSITIVE_INFINITY;
1825
+ for (const node of this.NodeManager.nodes.values()) {
1826
+ if (!node.connected || !node.sessionId) continue;
1827
+ if (excludeNodeIds.has(node.id)) continue;
1828
+ const reservationCount = this.getMigrationReservationCount(node.id);
1829
+ if (reservationCount >= perTargetConcurrency) continue;
1830
+ const liveLoad = node.stats?.players || 0;
1831
+ const effectiveLoad = this.getEffectiveMigrationLoad(node);
1832
+ if (!selectedNode || effectiveLoad < selectedEffectiveLoad || effectiveLoad === selectedEffectiveLoad && (liveLoad < selectedLiveLoad || liveLoad === selectedLiveLoad && node.id.localeCompare(selectedNode.id) < 0)) {
1833
+ selectedNode = node;
1834
+ selectedEffectiveLoad = effectiveLoad;
1835
+ selectedLiveLoad = liveLoad;
1836
+ }
1837
+ }
1838
+ return selectedNode;
1839
+ }
1840
+ hasAnyHealthyMigrationNode(excludeNodeIds) {
1841
+ for (const node of this.NodeManager.nodes.values()) {
1842
+ if (!node.connected || !node.sessionId) continue;
1843
+ if (excludeNodeIds.has(node.id)) continue;
1844
+ return true;
1845
+ }
1846
+ return false;
1847
+ }
1848
+ isMigrationNodeHealthy(nodeId) {
1849
+ const node = this.NodeManager.nodes.get(nodeId);
1850
+ return !!node?.connected && !!node.sessionId;
1851
+ }
1852
+ async waitForMigrationCapacity(excludeNodeIds, perTargetConcurrency, timeoutMs) {
1853
+ if (this.selectMigrationTarget(excludeNodeIds, perTargetConcurrency)) return;
1854
+ if (timeoutMs <= 0) {
1855
+ await new Promise((resolve) => setImmediate(resolve));
1856
+ return;
1857
+ }
1858
+ await new Promise((resolve) => {
1859
+ let settled = false;
1860
+ let timer;
1861
+ const onSignal = () => {
1862
+ if (settled) return;
1863
+ settled = true;
1864
+ if (timer) clearTimeout(timer);
1865
+ this.NodeManager.playerMigration.capacityWaiters.delete(onSignal);
1866
+ resolve();
1867
+ };
1868
+ timer = setTimeout(() => {
1869
+ if (settled) return;
1870
+ settled = true;
1871
+ this.NodeManager.playerMigration.capacityWaiters.delete(onSignal);
1872
+ resolve();
1873
+ }, timeoutMs);
1874
+ this.NodeManager.playerMigration.capacityWaiters.add(onSignal);
1875
+ });
1876
+ }
1877
+ /**
1878
+ * Move a batch of players to a ready node with bounded concurrency.
1879
+ * This keeps migration responsive without flooding the replacement node with requests.
1880
+ */
1881
+ async movePlayersToReplacementNode(players, onNoEligibleNode) {
1882
+ const migrationQueue = players.filter((player) => {
1883
+ if (player.getData("internal_nodeMigrating") === true) return false;
1884
+ if (player.getData("internal_destroystatus") === true) return false;
1885
+ player.setData("internal_nodeMigrating", true);
1886
+ return true;
1887
+ });
1888
+ const { concurrency, perTargetConcurrency, backpressureDelayMs } = this.getMigrationConfig();
1889
+ let nextQueueIndex = 0;
1890
+ const workerCount = Math.min(concurrency, migrationQueue.length);
1891
+ const migratePlayer = async (player) => {
1892
+ const excludedNodes = /* @__PURE__ */ new Set();
1893
+ try {
1894
+ for (let attempt = 0; attempt < 2; attempt++) {
1895
+ if (player.getData("internal_destroystatus") === true) return;
1896
+ if (player.node?.options?.id !== this.id) return;
1897
+ let targetNode = null;
1898
+ while (!(targetNode = this.selectMigrationTarget(excludedNodes, perTargetConcurrency))) {
1899
+ if (!this.hasAnyHealthyMigrationNode(excludedNodes)) {
1900
+ this._emitDebugEvent("PlayerChangeNodeFailNoEligibleNode" /* PlayerChangeNodeFailNoEligibleNode */, {
1901
+ state: "warn",
1902
+ message: `No eligible ready node was available for player ${player.guildId}`,
1903
+ functionLayer: "Node > movePlayersToReplacementNode()"
1904
+ });
1905
+ if (onNoEligibleNode === "destroy") {
1906
+ await player.destroy("PlayerChangeNodeFailNoEligibleNode" /* PlayerChangeNodeFailNoEligibleNode */).catch((destroyError) => {
1907
+ this._emitDebugEvent("PlayerDestroyFail" /* PlayerDestroyFail */, {
1908
+ state: "error",
1909
+ message: `Failed to destroy player ${player.guildId}: ${this.getMigrationErrorMessage(destroyError)}`,
1910
+ error: destroyError,
1911
+ functionLayer: "Node > movePlayersToReplacementNode()"
1912
+ });
1913
+ });
1914
+ } else {
1915
+ player.playing = false;
1916
+ }
1917
+ return;
1918
+ }
1919
+ await this.waitForMigrationCapacity(excludedNodes, perTargetConcurrency, backpressureDelayMs);
1920
+ }
1921
+ const targetNodeId = targetNode.id;
1922
+ if (player.getData("internal_destroystatus") === true) return;
1923
+ if (player.node?.options?.id !== this.id) return;
1924
+ this.reserveMigrationTarget(targetNodeId);
1925
+ try {
1926
+ await player.changeNode(targetNodeId);
1927
+ return;
1928
+ } catch (error) {
1929
+ const errorMessage = this.getMigrationErrorMessage(error);
1930
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
1931
+ state: "error",
1932
+ message: `Failed to move player ${player.guildId}: ${errorMessage}`,
1933
+ error,
1934
+ functionLayer: "Node > movePlayersToReplacementNode()"
1935
+ });
1936
+ const targetStillHealthy = this.isMigrationNodeHealthy(targetNodeId);
1937
+ excludedNodes.add(targetNodeId);
1938
+ if (attempt === 0 && !targetStillHealthy) {
1939
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
1940
+ state: "warn",
1941
+ message: `Migration target ${targetNodeId} became unavailable while moving player ${player.guildId}; retrying on another ready node`,
1942
+ error,
1943
+ functionLayer: "Node > movePlayersToReplacementNode()"
1944
+ });
1945
+ if (player.node?.options?.id === targetNodeId) player.node = this;
1946
+ continue;
1947
+ }
1948
+ if (attempt === 0 && this.hasAnyHealthyMigrationNode(excludedNodes)) {
1949
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
1950
+ state: "warn",
1951
+ message: `Retrying migration for player ${player.guildId} on a different ready node`,
1952
+ error,
1953
+ functionLayer: "Node > movePlayersToReplacementNode()"
1954
+ });
1955
+ if (player.node?.options?.id === targetNodeId) player.node = this;
1956
+ continue;
1957
+ }
1958
+ if (onNoEligibleNode === "destroy") {
1959
+ await player.destroy("PlayerChangeNodeFail" /* PlayerChangeNodeFail */).catch((destroyError) => {
1960
+ this._emitDebugEvent("PlayerDestroyFail" /* PlayerDestroyFail */, {
1961
+ state: "error",
1962
+ message: `Failed to destroy player ${player.guildId} after unexpected migration failure: ${this.getMigrationErrorMessage(destroyError)}`,
1963
+ error: destroyError,
1964
+ functionLayer: "Node > movePlayersToReplacementNode()"
1965
+ });
1966
+ });
1967
+ } else {
1968
+ player.playing = false;
1969
+ }
1970
+ return;
1971
+ } finally {
1972
+ this.releaseMigrationTarget(targetNodeId);
1973
+ }
1974
+ }
1975
+ } catch (error) {
1976
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
1977
+ state: "error",
1978
+ message: `Unexpected error while migrating player ${player.guildId}: ${this.getMigrationErrorMessage(error)}`,
1979
+ error,
1980
+ functionLayer: "Node > movePlayersToReplacementNode()"
1981
+ });
1982
+ if (onNoEligibleNode === "destroy") {
1983
+ await player.destroy("PlayerChangeNodeFail" /* PlayerChangeNodeFail */).catch((destroyError) => {
1984
+ this._emitDebugEvent("PlayerDestroyFail" /* PlayerDestroyFail */, {
1985
+ state: "error",
1986
+ message: `Failed to destroy player ${player.guildId} after unexpected migration failure: ${this.getMigrationErrorMessage(destroyError)}`,
1987
+ error: destroyError,
1988
+ functionLayer: "Node > movePlayersToReplacementNode()"
1989
+ });
1990
+ });
1991
+ } else {
1992
+ player.playing = false;
1993
+ }
1994
+ }
1995
+ };
1996
+ const workers = Array.from({ length: workerCount }, async () => {
1997
+ while (true) {
1998
+ const queueIndex = nextQueueIndex++;
1999
+ if (queueIndex >= migrationQueue.length) return;
2000
+ const player = migrationQueue[queueIndex];
2001
+ if (!player) continue;
2002
+ try {
2003
+ if (player.getData("internal_nodeMigrating") !== true) {
2004
+ player.setData("internal_nodeMigrating", true);
2005
+ }
2006
+ if (player.getData("internal_destroystatus") === true) continue;
2007
+ if (player.node?.options?.id !== this.id) continue;
2008
+ await migratePlayer(player);
2009
+ } catch (error) {
2010
+ this._emitDebugEvent("PlayerChangeNodeFail" /* PlayerChangeNodeFail */, {
2011
+ state: "error",
2012
+ message: `Unexpected worker error while migrating player ${player.guildId}: ${this.getMigrationErrorMessage(error)}`,
2013
+ error,
2014
+ functionLayer: "Node > movePlayersToReplacementNode()"
2015
+ });
2016
+ } finally {
2017
+ player.setData("internal_nodeMigrating", void 0);
2018
+ }
2019
+ }
2020
+ });
2021
+ await Promise.all(workers);
2022
+ }
1808
2023
  /**
1809
2024
  * Gets all Players of a Node
1810
2025
  * @returns array of players inside of lavalink
@@ -2517,9 +2732,10 @@ var LavalinkNode = class _LavalinkNode {
2517
2732
  }
2518
2733
  this.info.isNodelink = !!this.info.isNodelink;
2519
2734
  this.NodeManager.emit("connect", this);
2735
+ this.NodeManager.signalPlayerMigrationCapacityChange();
2520
2736
  }
2521
2737
  /** @private util function for handling closing events from websocket */
2522
- close(code, reason) {
2738
+ async close(code, reason) {
2523
2739
  this.resetAckTimeouts(true, true);
2524
2740
  try {
2525
2741
  if (this.socket) {
@@ -2538,19 +2754,22 @@ var LavalinkNode = class _LavalinkNode {
2538
2754
  this.isAlive = false;
2539
2755
  if (code === 1006 && !reason) reason = "Socket got terminated due to no ping connection";
2540
2756
  if (code === 1e3 && reason === "Node-Disconnect") return;
2757
+ const players = Array.from(
2758
+ this._LManager.players.filter((p) => p?.node?.options?.id === this?.options?.id).values()
2759
+ );
2541
2760
  this.NodeManager.emit("disconnect", this, { code, reason });
2542
2761
  if (code !== 1e3 || reason !== "Node-Destroy") {
2543
2762
  if (this.NodeManager.nodes.has(this.id)) {
2544
2763
  this.reconnect();
2545
2764
  }
2546
2765
  }
2547
- this._LManager.players.filter((p) => p?.node?.options?.id === this?.options?.id).forEach((p) => {
2548
- if (!this._LManager.options.autoMove) return p.playing = false;
2549
- if (this._LManager.options.autoMove) {
2550
- if (this.NodeManager.nodes.filter((n) => n.connected).size === 0) return p.playing = false;
2551
- p.moveNode();
2552
- }
2553
- });
2766
+ if (this._LManager.options.autoMove && players.length) {
2767
+ await this.movePlayersToReplacementNode(players, "freeze");
2768
+ } else if (!this._LManager.options.autoMove) {
2769
+ players.forEach((p) => p.playing = false);
2770
+ } else if (this.NodeManager.nodes.filter((n) => n.connected && !!n.sessionId).size === 0) {
2771
+ players.forEach((p) => p.playing = false);
2772
+ }
2554
2773
  }
2555
2774
  /** @private util function for handling error events from websocket */
2556
2775
  error(error) {
@@ -3628,6 +3847,16 @@ var NodeManager = class extends EventEmitter {
3628
3847
  * A map of all nodes in the nodeManager
3629
3848
  */
3630
3849
  nodes = new MiniMap();
3850
+ /** @internal Coordinates player migrations between nodes owned by this manager. */
3851
+ playerMigration = {
3852
+ reservations: /* @__PURE__ */ new Map(),
3853
+ capacityWaiters: /* @__PURE__ */ new Set()
3854
+ };
3855
+ /** @internal Wakes migrations waiting for capacity on one of this manager's nodes. */
3856
+ signalPlayerMigrationCapacityChange() {
3857
+ for (const resolve of this.playerMigration.capacityWaiters) resolve();
3858
+ this.playerMigration.capacityWaiters.clear();
3859
+ }
3631
3860
  /**
3632
3861
  * @param LavalinkManager The LavalinkManager that created this NodeManager
3633
3862
  */
@@ -6279,8 +6508,8 @@ var Player = class {
6279
6508
  try {
6280
6509
  if (!node)
6281
6510
  node = Array.from(this.LavalinkManager.nodeManager.leastUsedNodes("playingPlayers")).find(
6282
- (n) => n.connected && n.options.id !== this.node.options.id
6283
- ).id;
6511
+ (n) => n.connected && !!n.sessionId && n.options.id !== this.node.options.id
6512
+ )?.id;
6284
6513
  if (!node || !this.LavalinkManager.nodeManager.nodes.get(node))
6285
6514
  throw new RangeError("No nodes are available.");
6286
6515
  if (this.node.options.id === node) return this;
@@ -6435,6 +6664,11 @@ var LavalinkManager = class _LavalinkManager extends EventEmitter2 {
6435
6664
  advancedOptions: {
6436
6665
  enableDebugEvents: options?.advancedOptions?.enableDebugEvents ?? false,
6437
6666
  maxFilterFixDuration: options?.advancedOptions?.maxFilterFixDuration ?? 6e5,
6667
+ playerMigration: {
6668
+ concurrency: options?.advancedOptions?.playerMigration?.concurrency ?? 5,
6669
+ perTargetConcurrency: options?.advancedOptions?.playerMigration?.perTargetConcurrency ?? 2,
6670
+ backpressureDelayMs: options?.advancedOptions?.playerMigration?.backpressureDelayMs ?? 50
6671
+ },
6438
6672
  debugOptions: {
6439
6673
  logCustomSearches: options?.advancedOptions?.debugOptions?.logCustomSearches ?? false,
6440
6674
  noAudio: options?.advancedOptions?.debugOptions?.noAudio ?? false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lavalink-client",
3
- "version": "2.10.3",
3
+ "version": "2.10.4",
4
4
  "description": "Easy, flexible and feature-rich lavalink@v4 Client. Both for Beginners and Proficients. - Supports NodeLink@v3 too.",
5
5
  "keywords": [
6
6
  "advanced",