A production-grade, developer-first Lavalink v4 client for Node.js & TypeScript.
Elegant audio orchestration for Discord bots.
Junie wraps the Lavalink v4 protocol — WebSocket sessions, REST player control, voice credential routing, session resuming, multi-node load balancing — in a small, type-safe, thoroughly tested library with excellent developer experience as its first design goal.
Discord libraries come and go; Junie is library-agnostic. It talks to Lavalink and to your
shards through one small sendToShard callback, and consumes raw gateway packets through one
sendRawData method. Wire it to discord.js, Eris, DJS proxy bots, or a hand-rolled sharder —
Junie doesn't care.
import { Junie } from 'junie';
const junie = new Junie({
nodes: [{ id: 'main', host: 'localhost', authorization: 'youshallnotpass' }],
sendToShard: (guildId, payload) => client.guilds.cache.get(guildId)?.shard.send(payload),
});
junie.init(client.user.id);
client.on('raw', (packet) => junie.sendRawData(packet));
client.on('interactionCreate', async (interaction) => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName !== 'play') return;
const player = junie.createPlayer({
guildId: interaction.guildId,
voiceChannelId: interaction.member.voice.channelId,
textChannelId: interaction.channelId,
});
await player.connect();
const result = await junie.search(interaction.options.getString('query'), interaction.user);
player.queue.add(result.tracks);
if (!player.playing) await player.play();
await interaction.reply(`Now playing **${result.tracks[0]!.title}** 🎶`);
});That's a working music command. Everything below is what makes Junie production-grade.
| Junie | |
|---|---|
| Protocol | Complete Lavalink v4: REST player control, WebSocket events, session resuming, DAVE-ready voice payloads (channelId always forwarded) |
| Reliability | Exponential backoff with jitter, session-404 self-healing, voice self-rejoin, zombie-proof player destruction (a dead node can never wedge a guild) |
| Scale | Penalty-based load balancing (players · CPU · frame loss · region), region-aware node placement, parallel search fan-out to defeat upstream rate limits |
| DX | Fully typed events, requester typing that flows through tracks → queues → players, fluent filter chains, one-call presets |
| Queue | Repeat modes, bounded history, shuffle/move/jump, autoplay, pluggable persistence (Redis/Postgres/...) with UnresolvedTrack lazy resolution |
| Ops | Leveled structured logging, raw-payload event for telemetry, per-node stats, custom WebSocket transport for proxies |
- Node management — connect many Lavalink nodes; automatic selection via
PenaltyStrategy(default) or round-robin / least-players / least-load / your own. Region-aware placement from Discord voice endpoints. - Session resuming — configured on connect, verified on reconnect; buffered events replay automatically. Fresh sessions transparently rebuild remote players (voice + track + position).
- Players — full lifecycle with auto-advance, repeat modes, autoplay, pause/seek/volume,
live node migration (
player.setNode('eu-2')), and deterministic force-cleanup on destroy. - Queues — rich operations, bounded history, JSON serialization, pluggable
QueueStoreadapters, andUnresolvedTracks that resolve right before they play (perfect for restart persistence). - Filters — every Lavalink v4 filter with validation and defaults, one-round-trip applies,
plus
nightcore(),vaporwave(),bassboost(),eightD(),karaoke()presets. See filters. - Search — sources & plugin prefixes (LavaSrc's
spsearch:etc. work out of the box), requester attachment, playlist flattening, and optional parallel fan-out across all nodes. - Events — 20+ typed events at client, node and player level. See the event reference.
- Errors — a small, documented hierarchy with stable codes and Lavalink's structured error bodies attached. See errors.
npm install junie
# you also need a Lavalink v4 server:
docker run -d -p 2333:2333 ghcr.io/lavalink-devs/lavalink:4 --port 2333 --password youshallnotpassJunie targets Node.js ≥ 18.17, ships CommonJS and ESM builds, and has exactly one runtime
dependency (ws) — native fetch handles the rest.
Full walkthrough (including a Lavalink config file): docs/getting-started.md
- Create the client and connect nodes when your bot is ready:
const junie = new Junie({
nodes: [
{ id: 'eu-1', host: 'eu.example.com', authorization: 'secret', regions: ['europe'] },
{ id: 'us-1', host: 'us.example.com', authorization: 'secret', regions: ['north-america'] },
],
sendToShard: (guildId, payload) => shardFor(guildId).send(payload),
});
client.once('ready', () => junie.init(client.user.id));- Forward raw Discord voice packets (discord.js shown; any library that exposes raw dispatches works):
client.on('raw', (packet) => junie.sendRawData(packet));- Play music:
const player = junie.createPlayer({ guildId, voiceChannelId, textChannelId });
await player.connect();
const { tracks } = await junie.search('never gonna give you up', interaction.user);
player.queue.add(tracks);
await player.play();- React to the queue:
junie.on('trackStart', (player, track) => {
const channel = client.channels.cache.get(player.textChannelId!);
channel?.send(`🎶 Now playing: **${track.title}** (${track.requester})`);
});
junie.on('queueEnd', (player) => {
player.setAutoplay(true); // or destroy, or announce
});The client — Junie
junie.init(userId) // connect all nodes (call when your bot is ready)
junie.search(query, requester) // search; URLs, 'ytsearch:' etc. pass through
junie.createPlayer(options) // create (or fetch) a guild player
junie.getPlayer(guildId) // undefined-safe fetch
junie.destroyPlayer(guildId) // graceful teardown
junie.sendRawData(packet) // feed VOICE_* gateway packets
junie.destroy() // players + nodes + listeners, in order
junie.utils.buildTrack(apiTrack) // rebuild a Track (e.g. from your database)
junie.logger.child('MyBot') // structured, leveled loggingThe player — Player
await player.connect() // join voice; resolves when Lavalink is wired up
await player.play(track?) // play a track or the next queued one
await player.play('some query') // strings resolve lazily via search
await player.pause() / .resume()
await player.seek(90_000) // throws on live streams — by design
await player.setVolume(150) // 0–1000, clamped
await player.skip(2) // skip N tracks
await player.stop() // stop (advances); stop(false) keeps the track replayable
await player.setNode('us-1') // migrate a live player between nodes
await player.destroy() // zombie-proof, timeout-bounded teardown
player.queue / player.filters // see below
player.playing / paused / connected / position / ping / volume
player.setTextChannel(id) / setRepeatMode('queue') / setAutoplay(true)The queue — player.queue
queue.add(trackOrTracks, position?) // Track | UnresolvedTrack | raw Lavalink tracks
queue.remove(3) / queue.removeRange(0, 5)
queue.shuffle(seed?) / queue.reverse() / queue.move(4, 0)
queue.clear() / queue.clearHistory()
queue.size / totalSize / duration / isEmpty
queue.repeatMode = 'track' // 'off' | 'track' | 'queue'
queue.previous / queue.lastTrack // bounded, restart-friendly history
await queue.restore() // hydrate from a QueueStoreFilters — player.filters
await player.filters
.setVolume(1.2)
.setTimescale({ speed: 1.2, pitch: 1.05 })
.bassboost(0.7)
.apply(); // ONE PATCH — one round trip
player.filters.nightcore(); // ...vaporwave(), karaoke(), eightD()
await player.filters.clear(); // reset + apply
await player.setFilters({ volume: 2 }); // raw merge-and-applyNodes & stats — junie.nodes
junie.nodes.best({ voiceEndpoint }) // strategy selection (region-aware)
junie.nodes.get('eu-1') // Node: .stats, .sessionId, .resumed, .penalty()
await node.getInfo() // version + installed plugins
await node.getPluginNames()
await junie.nodes.fanOutSearch('q') // parallel search across all nodes
node.on('stats', (node, stats) => dashboard.update(node.id, stats)); ┌───────────────┐ op 4 (voice join) ┌──────────────────┐
│ Discord │ ───────────────────► │ Your bot / shards │
│ Gateway │ ◄────VOICE_STATE──── │ (any library) │
│ │ _UPDATE, └────────┬─────────┘
│ │ VOICE_SERVER_ │ raw packets
│ │ UPDATE ▼
└───────▲───────┘ ┌──────────────────┐
│ WebRTC audio (Opus) │ Junie │
│ │ ┌──────────────┐ │
┌───────┴───────┐ REST /v4 │ │ NodeManager │ │ WSS /v4/websocket
│ Lavalink │ ◄──────────────────── │ │ ├ Node … │ │ ─────────────────►
│ node(s) │ ─────────events────► │ │ PlayerManager│ │
└───────────────┘ playerUpdate, │ │ └ Player(s) │ │
track events, stats │ │ Queues │ │
│ └──────────────┘ │
└──────────────────┘
Deep dive: docs/architecture.md — the dual control loop, event routing, state synchronization and the failure modes Junie defends against.
| Guide | Contents |
|---|---|
| Getting started | Installing, Lavalink config, wiring, first commands |
| Architecture | Components, protocol flows, resilience design |
| Players | Player lifecycle & complete method reference |
| Queue & autoplay | Queue operations, persistence, autoplay |
| Filters | Every filter, every range, presets, plugin filters |
| Nodes & load balancing | Strategies, the penalty formula, resuming, failover |
| Events | Full typed event reference |
| REST & plugins | REST API, plugin endpoints (LavaLyrics, LavaSrc, …) |
| Errors | Error hierarchy, codes, recovery guidance |
| Troubleshooting | Common pitfalls and their fixes |
Runnable examples: discord.js music bot · custom-shard bot
npm test # 111 unit & behavioural tests — no Lavalink server required
npm run build # dual CJS + ESM outputThe suite covers queue semantics, filter validation, penalty math, REST retry/timeout/404 behaviour, WebSocket reconnection with backoff, session-loss rebuilding, voice self-healing, autoplay, node migration and zombie-proof destruction.
- Correctness under failure first. Every network path has a timeout, every teardown has a
finally, every reconnect has a backoff with jitter. - Small, honest API. No magic defaults that surprise you; no methods that quietly hit the network twice.
- Types are documentation. The requester generic flows through the entire library, and listening to the wrong event shape is a compile error.
- Zero lock-in. Library-agnostic voice wiring, pluggable transports, stores, strategies, resolvers.
MIT — © Junie Labs