Plugin Development
The definitive guide to extending NodeLink. API references, interceptors, and best practices.
Plugin Development Guide
Developer Warning
Writing plugins requires a solid understanding of JavaScript, Node.js, and asynchronous programming. You have full access to the server internals. You can crash the server. Proceed with caution and curiosity.
NodeLink's plugin system is designed to be native and low-overhead. Plugins are just JavaScript modules that NodeLink loads at startup. They have access to the internal nodelink instance, allowing you to hook into the API, the audio pipeline, and the event system.
1. Architecture & Lifecycle
NodeLink uses a Cluster Architecture. This means your plugin runs in multiple processes simultaneously, but with different roles.
Master Process
Context: master
The central brain. It handles the REST API, the WebSocket connection with Discord bots, and manages the worker processes.
Use this for:
- Custom API Endpoints (
/v4/my-route) - Intercepting Player Commands (
play,volume, etc.) - Intercepting WebSocket Packets
- Modifying Track Metadata
Worker Process(es)
Context: worker
The heavy lifters. Each worker handles a subset of players, decoding audio, applying filters, and sending UDP packets.
Use this for:
- Custom Audio Sources (Search & Resolve)
- Custom Audio Filters (DSP)
- Raw Audio Interceptors (PCM Manipulation)
Directory Structure
NodeLink/
└── plugins/
└── my-plugin/
├── package.json (Required)
└── index.js (Entry point)package.json is standard NPM format. name, version, and main are required.
2. Master Context API
These methods are available when context.type === 'master'.
Register Route
Expose new REST endpoints. Useful for dashboards, webhooks, or custom controls.
Signature: nodelink.registerRoute(method, path, handler)
Prop
Type
nodelink.registerRoute('POST', '/v4/my-plugin/trigger', async (nodelink, req, res, sendResponse) => {
const body = req.body; // JSON body is already parsed if content-type is application/json
nodelink.logger('info', 'MyPlugin', `Triggered with: ${body.action}`);
sendResponse(req, res, { success: true }, 200);
});Player Interceptor
Intercept commands sent to players before they are processed or sent to workers. This is the "Gatekeeper".
Signature: nodelink.registerPlayerInterceptor(callback)
Prop
Type
Actions: 'play', 'stop', 'pause', 'seek', 'volume', 'filters', 'updateVoice', 'destroy'.
nodelink.registerPlayerInterceptor(async (action, guildId, args) => {
// Example: Prevent volume > 100
if (action === 'volume') {
const vol = args[0];
if (vol > 100) {
// Modify the argument directly to cap it
args[0] = 100;
}
}
// Example: Block a specific song
if (action === 'play') {
const track = args[0];
if (track.info.title.includes('Baby Shark')) {
// Return an object to block execution and return error to client
return { error: 'Safety hazard detected.' };
}
}
// Return null to let the command proceed
return null;
});WebSocket Interceptor
Intercept raw WebSocket messages coming from clients (bots).
Signature: nodelink.registerWebSocketInterceptor(callback)
Prop
Type
nodelink.registerWebSocketInterceptor(async (nodelink, socket, packet, clientInfo) => {
if (packet.op === 'my-custom-op') {
socket.send(JSON.stringify({ op: 'my-reply', payload: 'Pong!' }));
return true; // Return true to STOP NodeLink from processing this packet further
}
return false; // Continue normal processing
});Track Modifier
Modify track objects right before they are sent to the client (e.g., in loadTracks response).
Signature: nodelink.registerTrackModifier(callback)
nodelink.registerTrackModifier((track) => {
// Add custom user data
track.userData = { ...track.userData, source: 'modified' };
// You can even modify the info
if (track.info.sourceName === 'youtube') {
track.info.title = `[YT] ${track.info.title}`;
}
});3. Worker Context API
These methods are available when context.type === 'worker'.
Register Source
Add a new platform support.
Signature: nodelink.registerSource(name, sourceObject)
Prop
Type
Source Class Interface:
class MySource {
constructor(nodelink) {
this.nodelink = nodelink;
this.sourceName = 'mysource';
this.searchTerms = ['mysearch']; // Enables mysearch:query
}
// Handle 'mysearch:query'
async search(query) {
// Return { loadType: 'search', data: [tracks] }
}
// Handle direct URLs
async resolve(url) {
// Return { loadType: 'track', data: track }
}
// Get playback URL/stream for a track
async getTrackUrl(trackInfo) {
return {
url: "https://stream.example.com/audio.mp3",
protocol: "https", // 'https', 'hls', 'http'
format: "mp3" // 'mp3', 'opus', 'aac', 'flac'
};
}
}Register Filter
Implement custom DSP filters. Warning: This runs on the audio thread loop.
Signature: nodelink.registerFilter(name, filterObject)
class ClipperFilter {
constructor() {
this.threshold = 1.0;
}
update(config) {
// Config comes from the 'filters' payload in Update Player
if (config.clipper) this.threshold = config.clipper.threshold;
}
process(chunk) {
// chunk is a Buffer containing 16-bit signed integer PCM
// Modify it in place or return a new buffer
return chunk;
}
}
nodelink.registerFilter('clipper', new ClipperFilter());Audio Interceptor
Inject a Transform stream into the pipeline. Use this for things like recording audio, silence detection, or complex buffering.
Signature: nodelink.registerAudioInterceptor(factory)
const { Transform } = await import('node:stream');
nodelink.registerAudioInterceptor(() => {
return new Transform({
transform(chunk, encoding, callback) {
// 'chunk' is raw PCM.
// Do whatever you want, then push it.
this.push(chunk);
callback();
}
});
});4. Communication (IPC)
Sometimes Master needs to talk to Workers or vice-versa.
Master to Worker
Use nodelink.workerManager.execute(worker, type, payload).
// Master
const worker = nodelink.workerManager.getBestWorker();
const result = await nodelink.workerManager.execute(worker, 'myPluginCommand', { foo: 'bar' });Worker Handling
Register a Worker Interceptor to handle the custom command.
// Worker
nodelink.registerWorkerInterceptor(async (type, payload) => {
if (type === 'myPluginCommand') {
// Do logic
return true; // Block default handler, although for custom types it doesn't matter
}
});(Note: Currently, direct custom IPC responses from worker to master require hooking into the internal command queue or using side-channels, but interceptors allow you to execute logic).
5. Development Checklist
- Check Context: Always wrap logic in
if (context.type === '...'). - Log Smart: Use
nodelink.logger(level, category, message)for consistent logs. - Handle Errors: A crash in a plugin crashes the server. Use
try/catch. - No Blocking: Never block the event loop in
workercontext. Audio will stutter immediately.
Need Examples?
Check out the built-in nodelink-sample-plugin in the plugins/ directory of the repository for a fully working reference implementation.