velink
v1.1.0
Published
A production-grade Lavalink v4 client with circuit breakers, smart load balancing, and exceptional developer experience.
Downloads
271
Maintainers
Readme
Velink
Table of Contents
- Features
- Installation
- Quick Start
- Core Concepts
- API Reference
- Error Handling
- Load Balancing
- Circuit Breaker
- Health Monitoring
- Discord.js Integration
- Advanced Usage
- Migration Guide
- Troubleshooting
Features
- Lavalink v4 Protocol -- Full support for the latest Lavalink v4 WebSocket and REST API
- Multiple Lavalink Nodes -- Connect to and manage multiple Lavalink nodes simultaneously
- Smart Load Balancing -- 4 strategies: round-robin, weighted round-robin, least players, least load
- Circuit Breaker -- Automatic failure detection and recovery to prevent cascading failures
- Health Monitoring -- Periodic liveness/readiness checks with configurable thresholds
- Stale Player Cleanup -- Automatic cleanup of disconnected/idle players to prevent memory leaks
- Resilient Reconnection -- Exponential backoff with jitter for WebSocket reconnection
- Request Deduplication -- REST client coalesces identical concurrent requests
- Lazy Track Resolution -- Unresolved tracks save memory until playback time
- Optimized Deque -- O(1) queue operations via doubly-linked list (no array shift costs)
- 22 Audio Filter Presets -- bassboost, nightcore, vaporwave, 8D, karaoke, and more
- Full Type Safety -- Written in TypeScript with strict mode and Zod schema validation
- Comprehensive Error Hierarchy -- Structured errors with codes for programmatic handling
Installation
npm install velinkPeer dependency (optional):
npm install discord.js # Only if using the DiscordJSConnectorRequirements:
- Node.js >= 18.0.0
- Lavalink server v4.x
Quick Start
import { Client, GatewayIntentBits } from "discord.js";
import { Velink } from "velink";
import { DiscordJSConnector } from "velink/connectors/discordjs";
// 1. Create Discord client
const discord = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],
});
// 2. Create Velink client
const velink = new Velink({
nodes: [
{
host: "localhost",
port: 2333,
auth: "your-lavalink-password",
secure: false,
priority: 0,
},
],
autoResume: true,
loadBalancing: {
strategy: "weightedRoundRobin",
},
});
// 3. Connect Discord and Velink
const connector = new DiscordJSConnector(discord, velink);
discord.on("ready", async () => {
console.log(`Logged in as ${discord.user?.tag}`);
await connector.init();
});
// 4. Play music on command
discord.on("messageCreate", async (message) => {
if (message.content.startsWith("!play ")) {
const query = message.content.slice(6);
const voiceChannel = message.member?.voice.channel;
if (!voiceChannel) {
return message.reply("You must be in a voice channel!");
}
// Search for tracks
const result = await velink.search(query, "youtube");
if (result.tracks.length === 0) {
return message.reply("No results found.");
}
// Create player and connect
const player = await velink.createPlayer({
guildId: message.guildId!,
voiceChannelId: voiceChannel.id,
textChannelId: message.channelId,
selfDeaf: true,
});
await player.connect();
// Add to queue and play
player.queue.addMany(result.tracks.slice(1));
await player.play(result.tracks[0]!);
message.reply(`Now playing: **${result.tracks[0]!.title}**`);
}
});
// 5. Handle events
velink.on("trackStart", (player, track) => {
console.log(`Started playing: ${track.title} by ${track.author}`);
});
velink.on("trackEnd", (player, track, reason) => {
console.log(`Finished: ${track.title} (${reason})`);
});
velink.on("queueEnd", (player) => {
console.log("Queue finished");
});
discord.login("YOUR_BOT_TOKEN");Core Concepts
Architecture
Velink (client)
|-- Node[] (Lavalink connections)
| |-- Player[] (guild players)
| |-- RestClient (HTTP API)
| |-- CircuitBreaker (failure protection)
| |-- HealthMonitor (health probes)
|
|-- LoadBalancer (node selection)
|-- StalePlayerCleanup (memory management)
|-- Queue (per-player track queue)
|-- Track (track representation)Lifecycle
- Create Velink with node configuration
- Initialize with bot user ID
- Create Player for a guild (auto-selects best node)
- Connect to voice channel
- Search for tracks via REST API
- Play track (sends WebSocket op to Lavalink)
- Events flow back via WebSocket
- Cleanup on disconnect or timeout
API Reference
Velink Client
new Velink(options)
Creates the main Velink client.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| nodes | NodeOptions[] | Yes | Lavalink node configurations |
| userId | string | No | Discord bot user ID |
| shardCount | number | No | Number of shards |
| autoResume | boolean | No | Enable automatic session resume (default: false) |
| autoResumeTimeout | number | No | Resume timeout in ms (default: 60000) |
| playerCleanupInterval | number | No | Stale player check interval (default: 300000) |
| playerCleanupThreshold | number | No | Player idle timeout (default: 300000) |
| circuitBreaker | object | No | Circuit breaker configuration |
| loadBalancing | object | No | Load balancing configuration |
Returns: Velink instance
velink.init(userId)
Initialize and connect to all configured nodes.
| Parameter | Type | Description |
|-----------|------|-------------|
| userId | string | Discord bot user ID |
Returns: Promise<void>
Throws: ClientDestroyedError, ValidationError
velink.createPlayer(options)
Create a new player for a guild.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| guildId | string | Yes | Discord guild ID |
| voiceChannelId | string | No | Voice channel ID |
| textChannelId | string | No | Text channel ID for feedback |
| selfDeaf | boolean | No | Join deafened (default: true) |
| selfMute | boolean | No | Join muted (default: false) |
| volume | number | No | Initial volume 0-1000 (default: 100) |
| nodeId | string | No | Specific node to use |
Returns: Promise<Player>
Throws: ClientDestroyedError, NoAvailableNodesError, NodeNotFoundError
velink.search(query, source?)
Search for tracks via Lavalink REST API.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| query | string | Yes | Search query |
| source | TrackSource | No | Source prefix (e.g., "youtube", "soundcloud") |
Returns: Promise<{ loadType, tracks, playlist, exception }>
Throws: NoAvailableNodesError
velink.destroy()
Gracefully shut down the client.
Returns: Promise<void>
Node
node.connect(userId)
Establish WebSocket connection to Lavalink.
Returns: Promise<void>
Throws: NodeDestroyedError, NodeConnectionError
node.destroy()
Destroy the node and all its players.
node.loadTracks(query)
Load tracks via REST API.
Returns: Promise<TrackLoadResult>
node.updatePlayer(guildId, payload)
Update a player on this node.
Returns: Promise<Record<string, unknown>>
Player
player.connect(channelId?)
Connect to a voice channel.
Returns: Promise<void>
Throws: PlayerDestroyedError, VoiceChannelRequiredError
player.play(track, options?)
Play a track.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| track | Track | Yes | Track to play |
| options.noReplace | boolean | No | Don't replace current track |
| options.startTime | number | No | Start position in ms |
| options.endTime | number | No | End position in ms |
| options.volume | number | No | Volume 0-1000 |
Returns: Promise<void>
Throws: PlayerDestroyedError
player.pause()
Pause playback.
Returns: Promise<void>
player.resume()
Resume playback.
Returns: Promise<void>
player.stop()
Stop playback and clear current track.
Returns: Promise<void>
player.seek(positionMs)
Seek to position in current track.
| Parameter | Type | Description |
|-----------|------|-------------|
| positionMs | number | Position in milliseconds |
Returns: Promise<void>
player.setVolume(volume)
Set volume (0-1000).
Returns: Promise<void>
player.setFilters(filters)
Apply Lavalink audio filters.
Returns: Promise<void>
player.skip()
Skip to next track in queue.
Returns: Promise<Track | null>
player.previous()
Go back to previous track.
Returns: Promise<Track | null>
player.setRepeatMode(mode)
Set repeat mode.
| Value | Description |
|-------|-------------|
| "off" | No repeat |
| "track" | Repeat current track |
| "queue" | Repeat entire queue |
player.destroy()
Destroy player and clean up.
Returns: Promise<void>
Queue
queue.add(track)
Add track to end. Returns boolean (false if full).
queue.addMany(tracks)
Add multiple tracks. Returns count added.
queue.insertNext(track)
Insert track at front (plays next).
queue.next()
Get next track (handles repeat modes).
Returns: Track | null
queue.previous()
Get previous track from history.
Returns: Track | null
queue.skipTo(index)
Skip to track at index.
Returns: Track | null
queue.shuffle()
Shuffle all tracks using Fisher-Yates.
queue.clear()
Remove all tracks and history.
queue.remove(predicate)
Remove first matching track.
Returns: Track | null
queue.duration
Total duration of all tracks in ms (cached).
Track
new Track(data, logger)
Create from Lavalink track data or unresolved metadata.
track.isResolved
Whether the track has full Lavalink data.
track.resolve(resolver)
Resolve an unresolved track.
| Parameter | Type | Description |
|-----------|------|-------------|
| resolver | (query: string) => Promise<LavalinkTrack> | Resolution function |
Returns: Promise<void>
Throws: TrackResolveError
track.toJSON()
Serialize to plain object.
Track.fromUnresolved(title, author, source, logger, extra?)
Create unresolved track from metadata.
Filters
new FilterBuilder()
Fluent API for building audio filters.
const filters = new FilterBuilder()
.preset("bassboost")
.timescale({ speed: 1.1, pitch: 1.05 })
.volume(1.2)
.commit();
await player.setFilters(filters);Available Methods:
| Method | Parameters | Description |
|--------|-----------|-------------|
| .volume(value) | 0-5 | Volume multiplier |
| .equalizer(bands) | LavalinkEqualizerBand[] | EQ bands |
| .equalizerFromGains(gains) | number[] (15 values) | EQ from gain array |
| .karaoke(options?) | { level?, monoLevel?, filterBand?, filterWidth? } | Vocal suppression |
| .timescale(options) | { speed?, pitch?, rate? } | Speed/pitch/rate |
| .tremolo(options) | { frequency?, depth? } | Tremolo effect |
| .vibrato(options) | { frequency?, depth? } | Vibrato effect |
| .rotation(hz) | number | 8D audio rotation |
| .distortion(options) | { sinOffset?, sinScale?, ... } | Distortion |
| .channelMix(options) | { leftToLeft?, leftToRight?, ... } | Stereo panning |
| .lowPass(smoothing) | number | Low pass filter |
| .preset(name) | FilterPresetName | Apply named preset |
| .reset() | | Clear all filters |
| .remove(filter) | keyof LavalinkFilter | Remove specific filter |
| .build() | | Get filter object |
| .commit() | | Get and mark as committed |
22 Built-in Presets: bassboost, bassboostLow, bassboostMedium, bassboostHigh, bassboostExtreme, nightcore, superNightcore, vaporwave, pop, soft, treble, trebleBass, eightD, vibrato, tremolo, karaoke, lowPass, dim, earrape, phaser, chinatown, party
Error Handling
Velink uses a comprehensive error hierarchy with machine-readable codes:
import {
VelinkError,
NodeNotFoundError,
NodeConnectionError,
CircuitBreakerOpenError,
PlayerDestroyedError,
NoAvailableNodesError,
} from "velink";
try {
const player = await velink.createPlayer({ guildId: "123" });
} catch (error) {
if (error instanceof NoAvailableNodesError) {
console.error("All Lavalink nodes are down!");
// Send alert to monitoring
} else if (error instanceof NodeNotFoundError) {
console.error(`Node not found: ${error.context.nodeId}`);
}
// All errors have codes
console.log(error.code); // "NO_AVAILABLE_NODES"
console.log(error.toDisplayMessage()); // "[NO_AVAILABLE_NODES] ..."
console.log(error.toJSON()); // { name, message, code, context, timestamp }
}Error Codes
| Code | Description |
|------|-------------|
| NODE_ERROR | Generic node error |
| NODE_NOT_FOUND | Specified node doesn't exist |
| NODE_CONNECTION_ERROR | WebSocket connection failed |
| NODE_DISCONNECTED | Node disconnected during operation |
| NODE_DESTROYED | Node has been destroyed |
| CIRCUIT_BREAKER_OPEN | Circuit breaker is rejecting requests |
| TRACK_LOAD_ERROR | Failed to load track from Lavalink |
| TRACK_RESOLVE_ERROR | Lazy track resolution failed |
| NO_TRACKS_FOUND | Search returned no results |
| PLAYER_CONNECTION_ERROR | Voice connection failed |
| PLAYER_NOT_FOUND | Player doesn't exist for guild |
| PLAYER_DESTROYED | Player has been destroyed |
| VOICE_CHANNEL_REQUIRED | Voice channel ID not provided |
| REST_ERROR | HTTP request failed |
| REST_TIMEOUT | HTTP request timed out |
| VALIDATION_ERROR | Options validation failed |
| CLIENT_DESTROYED | Velink client is destroyed |
| NO_AVAILABLE_NODES | All nodes are unavailable |
Load Balancing
4 strategies for distributing players across nodes:
| Strategy | Description | Use Case |
|----------|-------------|----------|
| roundRobin | Cycles evenly through nodes | Balanced distribution |
| weightedRoundRobin | Probability-based by node priority | Prioritize powerful nodes |
| leastPlayers | Picks node with fewest players | Even player distribution |
| leastLoad | Picks node with lowest CPU+memory | Resource optimization |
const velink = new Velink({
nodes: [
{ host: "node1", port: 2333, auth: "pw", priority: 10 },
{ host: "node2", port: 2333, auth: "pw", priority: 5 },
],
loadBalancing: {
strategy: "weightedRoundRobin",
healthCheckInterval: 30000,
readinessThreshold: {
maxCpuLoad: 0.8,
maxMemoryUsage: 0.9,
},
},
});Circuit Breaker
Prevents cascading failures by temporarily rejecting requests to failing nodes.
State Machine:
CLOSED (normal) --failures>=threshold--> OPEN (rejecting)
^ |
|--successes>=halfOpenMaxCalls-- HALF_OPEN (testing)
^------------------resetTimeout--|// Configure in Velink options
const velink = new Velink({
nodes: [...],
circuitBreaker: {
failureThreshold: 5, // Open after 5 consecutive failures
resetTimeout: 30000, // Try recovery after 30 seconds
halfOpenMaxCalls: 3, // Allow 3 test requests in half-open
},
});Health Monitoring
Periodic HTTP probes to each Lavalink node:
- Liveness (every 15s): Quick
/infocheck to confirm reachability - Readiness (every 30s): Full
/statsevaluation against CPU/memory thresholds
Nodes exceeding thresholds are excluded from load balancing until they recover.
Discord.js Integration
The DiscordJSConnector bridges Discord.js and Velink:
import { DiscordJSConnector } from "velink/connectors/discordjs";
const connector = new DiscordJSConnector(discordClient, velinkClient);
await connector.init(); // Call after discord client is readyHandles:
VOICE_SERVER_UPDATEevents -> VelinkVOICE_STATE_UPDATEevents -> Velink- Voice gateway payload sending
- Automatic shard calculation
Advanced Usage
Custom Logger
import { setLogger } from "velink";
import pino from "pino";
setLogger(pino({ level: "info" }));The logger interface:
interface Logger {
trace(msg: string, ...args: unknown[]): void;
debug(msg: string, ...args: unknown[]): void;
info(msg: string, ...args: unknown[]): void;
warn(msg: string, ...args: unknown[]): void;
error(msg: string, ...args: unknown[]): void;
fatal(msg: string, ...args: unknown[]): void;
child(bindings: Record<string, unknown>): Logger;
}Direct Voice State Management
If not using DiscordJSConnector, handle events manually:
// Forward Discord voice events to Velink
discord.ws.on("VOICE_SERVER_UPDATE", (data) => {
velink.voiceServerUpdate(data);
});
discord.ws.on("VOICE_STATE_UPDATE", (data) => {
velink.voiceStateUpdate(data);
});
// Set payload sender
velink.setSend((guildId, payload) => {
discord.guilds.cache.get(guildId)?.shard?.send(payload);
});Queue Management
// Add to end of queue
player.queue.add(track);
// Play next (inserts at front)
player.queue.insertNext(track);
// Shuffle
player.queue.shuffle();
// Get duration
console.log(`Queue duration: ${player.queue.duration}ms`);
// Clear
player.queue.clear();Custom Load Balancer
velink.on("nodeConnect", () => {
// Access the internal load balancer
// (advanced: extend Velink to expose the balancer directly)
});Migration Guide
From v1.0.x to v1.1.0
- Error handling: All errors now extend
VelinkErrorwith.codeproperty - Options validation: Constructor now validates options with Zod schemas
- Player options:
voiceChannelIdandtextChannelIdacceptundefinedexplicitly - Circuit breaker: Metrics available via
node.getCircuitMetrics() - Health monitoring: Now built-in with configurable thresholds
Troubleshooting
"No available Lavalink nodes"
- Check that Lavalink server is running and accessible
- Verify
host,port, andauthcredentials - Check firewall rules between your bot and Lavalink
"Circuit breaker is OPEN"
- The node has failed too many times; it will auto-recover
- Check Lavalink logs for errors
- Manually reset:
node.resetCircuitBreaker()
"Voice connection failed"
- Ensure the bot has permission to join the voice channel
- Check that gateway intents include
GuildVoiceStates - Verify you're forwarding
VOICE_SERVER_UPDATEandVOICE_STATE_UPDATEevents
High memory usage
- Reduce
playerCleanupThresholdfor faster stale player cleanup - Limit queue size:
new Queue({ maxSize: 1000 }) - Use unresolved tracks for large playlists:
Track.fromUnresolved(title, author, source, logger)
License
MIT (c) Velink Contributors
