@libria/plugin-loader
A TypeScript-first plugin system for Node.js applications with dependency resolution, lifecycle hooks, and hot-reloading.
Features
- Dependency Resolution - Plugins can depend on other plugins with semver version requirements
- Circular Dependency Detection - Throws clear errors when circular dependencies are found
- Async Plugin Initialization - Factories can be async for loading configs, connecting to databases, etc.
- Lifecycle Hooks -
onLoad and onUnload hooks for setup and cleanup
- Hot-Reloading - Watch for file changes and reload plugins on the fly
- Plugin Queries - Find plugins by type, get metadata, list all loaded plugins
- TypeScript First - Full type safety with generics for plugin APIs
- ESM & CJS Support - Load plugins in either module format
Installation
npm install @libria/plugin-loader
Quick Start
1. Define a Plugin
Create a plugin with definePlugin:
import { definePlugin } from '@libria/plugin-loader';
interface GreeterAPI {
greet(name: string): string;
}
export default definePlugin<GreeterAPI>({
id: 'greeter',
pluginType: 'util',
create(ctx) {
return {
api: {
greet(name) {
return `Hello, ${name}!`;
}
}
};
}
});
2. Create a Plugin Manifest
Each plugin needs a plugin.json in its directory:
{
"id": "greeter",
"name": "Greeter Plugin",
"pluginType": "util",
"version": "1.0.0",
"module": "./dist/index.mjs"
}
3. Load and Use Plugins
import { PluginManager } from '@libria/plugin-loader';
const manager = new PluginManager();
await manager.loadPlugins(['./plugins/*']);
const greeter = manager.getPlugin<GreeterAPI>('greeter');
console.log(greeter.greet('World'));
Plugin Manifest
The plugin.json file defines your plugin's metadata:
{
"id": "my-plugin",
"name": "My Plugin",
"pluginType": "feature",
"version": "1.0.0",
"description": "Optional description",
"module": "./dist/index.mjs",
"main": "./dist/index.cjs",
"dependencies": [
{ "id": "other-plugin", "version": "^1.0.0" }
]
}
id | Yes | Unique identifier for the plugin |
pluginType | Yes | Category/type of the plugin (for queries) |
version | Yes | Semver version string |
name | No | Human-readable name |
description | No | Plugin description |
module | No* | Path to ESM entry point |
main | No* | Path to CJS entry point |
dependencies | No | Array of plugin dependencies |
*At least one of module or main is required.
Plugin Dependencies
Plugins can depend on other plugins. Dependencies are loaded first, and the loading order is determined by topological sort.
{
"id": "plugin-a",
"pluginType": "feature",
"version": "1.0.0",
"module": "./dist/index.mjs",
"dependencies": [
{ "id": "plugin-b", "version": "^1.0.0" },
{ "id": "plugin-c", "version": ">=2.0.0" }
]
}
Access dependencies in your plugin via the context:
export default definePlugin<MyAPI>({
id: 'plugin-a',
pluginType: 'feature',
create(ctx) {
const pluginB = ctx.getPlugin<PluginBAPI>('plugin-b');
const pluginC = ctx.getPlugin<PluginCAPI>('plugin-c');
return {
api: {
doSomething() {
return pluginB.getValue() + pluginC.calculate();
}
}
};
}
});
Version Requirements
Dependencies use semver ranges:
"1.0.0" - Exact version
"^1.0.0" - Compatible with 1.x.x
"~1.0.0" - Compatible with 1.0.x
">=1.0.0 <2.0.0" - Range
"*" - Any version
Async Initialization
Plugin factories can be async for loading configs, connecting to services, etc:
export default definePlugin<DatabaseAPI>({
id: 'database',
pluginType: 'service',
async create(ctx) {
const config = await loadConfig();
const connection = await connectToDatabase(config);
return {
api: {
query: (sql) => connection.query(sql),
close: () => connection.close()
}
};
}
});
Lifecycle Hooks
Plugins can implement lifecycle hooks for setup and cleanup:
export default definePlugin<MyAPI>({
id: 'my-plugin',
pluginType: 'feature',
create(ctx) {
let intervalId: NodeJS.Timeout;
return {
api: {
},
onLoad() {
console.log('Plugin loaded!');
intervalId = setInterval(() => {
console.log('heartbeat');
}, 1000);
},
async onUnload() {
clearInterval(intervalId);
await saveState();
console.log('Plugin unloaded!');
}
};
}
});
Hot-Reloading
Reload plugins without restarting your application:
const manager = new PluginManager();
await manager.loadPlugins(['./plugins/*']);
await manager.reloadPlugin('my-plugin');
await manager.watch(['./plugins/*'], (id, event, error) => {
if (event === 'reload') {
console.log(`Plugin ${id} reloaded successfully`);
}
if (event === 'error') {
console.error(`Failed to reload ${id}:`, error);
}
});
await manager.stopWatching();
Plugin Queries
Query loaded plugins by type or get metadata:
const ids = manager.getPluginIds();
if (manager.hasPlugin('greeter')) {
}
const meta = manager.getPluginMetadata('greeter');
const services = manager.getPluginsByType('service');
const allMeta = manager.getAllMetadata();
Graceful Shutdown
Properly unload all plugins (calls onUnload hooks in reverse order):
process.on('SIGTERM', async () => {
await manager.shutdown();
process.exit(0);
});
Error Handling
The library provides typed errors for common scenarios:
import {
PluginLoadError,
PluginInvalidExportError,
PluginNotFoundError,
ManifestNotFoundError,
DuplicatePluginError,
CircularDependencyError,
DependencyNotFoundError,
VersionMismatchError,
} from '@libria/plugin-loader';
try {
await manager.loadPlugins(['./plugins/*']);
} catch (err) {
if (err instanceof CircularDependencyError) {
console.error('Circular dependency:', err.cycle.join(' -> '));
}
if (err instanceof VersionMismatchError) {
console.error(
`${err.packageId}@${err.actualVersion} doesn't satisfy ${err.requiredVersion}`
);
}
}
try {
await manager.reloadPlugin('my-plugin');
} catch (err) {
if (err instanceof PluginNotFoundError) {
console.error(`Plugin ${err.id} is not loaded`);
}
if (err instanceof ManifestNotFoundError) {
console.error(`Manifest missing for ${err.pluginId} in ${err.dir}`);
}
}
API Reference
PluginManager
loadPlugins(patterns: string[]) | Load plugins from glob patterns |
getPlugin<T>(id: string): T | Get a plugin's API by ID |
hasPlugin(id: string): boolean | Check if a plugin is loaded |
getPluginIds(): string[] | Get all loaded plugin IDs |
getPluginMetadata(id: string) | Get metadata for a plugin |
getPluginsByType(type: string) | Get all plugins of a type |
getAllMetadata() | Get all plugin metadata |
reloadPlugin(id: string) | Hot-reload a specific plugin |
unloadPlugin(id: string) | Unload a specific plugin |
watch(patterns, callback) | Watch for file changes |
stopWatching() | Stop watching for changes |
shutdown() | Unload all plugins and cleanup |
getContext() | Get the internal plugin context |
definePlugin<T>(factory: PluginFactory<T>)
Helper function for defining plugins with proper typing.
PluginContext
Passed to the create function:
getPlugin<T>(id: string): T | Get another plugin's API |
hasPlugin(id: string): boolean | Check if a plugin is loaded |
LibriaPlugin<T>
The return type of create:
interface LibriaPlugin<T> {
api: T;
onLoad?(): void | Promise<void>;
onUnload?(): void | Promise<void>;
}
Directory Structure
Recommended project structure:
my-app/
├── src/
│ └── index.ts
├── plugins/
│ ├── greeter/
│ │ ├── plugin.json
│ │ ├── src/
│ │ │ └── index.ts
│ │ └── dist/
│ │ └── index.mjs
│ └── database/
│ ├── plugin.json
│ ├── src/
│ │ └── index.ts
│ └── dist/
│ └── index.mjs
└── package.json
License
MIT