@moduul/core
v0.1.4
Published
Core plugin host system for loading and managing plugins
Maintainers
Readme
@moduul/core
Core plugin host system for dynamically loading and managing plugins at runtime.
Features
- 🔌 Dynamic Plugin Loading - Load plugins from directories or ZIP archives at runtime
- � Single-Plugin Mode - Point directly at one plugin directory instead of scanning a folder
- �🔥 Hot Reload - Cache-busting support for plugin reloading during development
- 📦 Multiple Formats - Support for both ESM and CommonJS plugins
- ✅ Validation - Built-in manifest validation with optional custom validators
- 🎯 Type-Safe - Full TypeScript support with comprehensive type definitions
- 🚀 Zero Config - Works out of the box with sensible defaults
Installation
npm install @moduul/coreQuick Start
Folder mode (scan a directory of plugins)
import { PluginHost } from '@moduul/core';
// Define your plugin interface for type safety
interface MyPlugin {
execute(): Promise<string>;
}
// Create a typed plugin host pointing at a folder of plugin subdirectories
const host = new PluginHost<MyPlugin>({
folder: './plugins'
});
// Load all plugins found in the folder
await host.reload();
// Get all loaded plugins (typed!)
const plugins = host.getAll();
console.log(`Loaded ${plugins.length} plugins`);
// Find a specific plugin
const plugin = host.find('my-plugin');
if (plugin) {
// Full type safety and autocomplete
const result = await plugin.plugin.execute();
console.log(result);
}Single-plugin mode (load one plugin directly)
import { PluginHost } from '@moduul/core';
interface MyPlugin {
execute(): Promise<string>;
}
// Point directly at a single plugin directory
const host = new PluginHost<MyPlugin>({
pluginPath: '/absolute/path/to/my-plugin'
});
await host.reload();
const plugin = host.find('my-plugin');
if (plugin) {
const result = await plugin.plugin.execute();
console.log(result);
}API Reference
PluginHost<T>
The main class for managing plugins. Supports a generic type parameter T to define the expected plugin interface, enabling full type safety and IDE autocomplete.
Generic Type Parameter
The PluginHost class accepts a generic type parameter that defines the shape of your plugins:
// Define your plugin interface
interface MyPlugin {
name: string;
version: string;
execute(input: string): Promise<string>;
}
// Create a typed host
const host = new PluginHost<MyPlugin>({
folder: './plugins'
});
// Type-safe access to plugins
await host.reload();
const plugin = host.find('my-plugin');
if (plugin) {
// TypeScript knows plugin.plugin is MyPlugin
const result = await plugin.plugin.execute('hello'); // ✓ Type-checked
console.log(plugin.plugin.name); // ✓ Autocomplete works
}Benefits of using the generic type:
- Type Safety: Catch errors at compile time instead of runtime
- IDE Support: Full autocomplete and IntelliSense for plugin methods
- Documentation: Self-documenting code with clear contracts
- Refactoring: Safe refactoring with TypeScript's rename/find references
Without generic type (untyped):
const host = new PluginHost({ folder: './plugins' });
const plugin = host.find('my-plugin');
if (plugin) {
// plugin.plugin is `unknown` - no type safety
const result = await (plugin.plugin as any).execute('hello'); // No autocomplete
}Constructor
new PluginHost<T>(options: PluginHostOptions<T>)PluginHostOptions<T> is a union type with two mutually exclusive modes:
Folder mode — scan a directory for plugin subdirectories (or .zip archives):
| Option | Type | Required | Description |
|---|---|---|---|
| folder | string | ✓ | Path to the directory that contains plugin subdirectories or .zip archives |
| validator | (plugin: unknown) => plugin is T | | Type guard to validate each loaded plugin |
Single-plugin mode — load exactly one plugin from a specific directory:
| Option | Type | Required | Description |
|---|---|---|---|
| pluginPath | string | ✓ | Path to a single plugin directory (must contain plugin.manifest.json) |
| validator | (plugin: unknown) => plugin is T | | Type guard to validate the loaded plugin |
The two modes are mutually exclusive: use either
folderorpluginPath, never both.
Example — folder mode with validator:
interface MyPlugin {
execute(): string;
version: string;
}
const host = new PluginHost<MyPlugin>({
folder: './plugins',
// Type guard that validates plugin structure
validator: (plugin): plugin is MyPlugin => {
return (
typeof plugin === 'object' &&
plugin !== null &&
typeof (plugin as any).execute === 'function' &&
typeof (plugin as any).version === 'string'
);
}
});Example — single-plugin mode with validator:
const host = new PluginHost<MyPlugin>({
pluginPath: '/opt/plugins/my-plugin',
validator: (plugin): plugin is MyPlugin => {
return (
typeof plugin === 'object' &&
plugin !== null &&
typeof (plugin as any).execute === 'function' &&
typeof (plugin as any).version === 'string'
);
}
});The validator acts as a TypeScript type guard, narrowing unknown to T.
Methods
reload(): Promise<void>
Scans the plugin folder and loads all valid plugins. Clears previously loaded plugins.
await host.reload();getAll(): LoadedPlugin<T>[]
Returns an array of all loaded plugins with full type information.
interface MyPlugin {
execute(): string;
}
const host = new PluginHost<MyPlugin>({ folder: './plugins' });
await host.reload();
const plugins = host.getAll();
plugins.forEach(({ manifest, plugin }) => {
console.log(`${manifest.name} v${manifest.version}`);
// plugin is typed as MyPlugin
const result = plugin.execute(); // ✓ Type-safe
});find(name: string): LoadedPlugin<T> | undefined
Finds a plugin by name with full type information.
const plugin = host.find('my-plugin');
if (plugin) {
// plugin is typed as LoadedPlugin<MyPlugin>
console.log('Found:', plugin.manifest.name);
const result = await plugin.plugin.execute(); // ✓ Autocomplete works
}Types
PluginManifest
The required structure for plugin.manifest.json:
interface PluginManifest {
name: string; // Unique plugin identifier
version: string; // Semantic version
entryPoint: string; // Relative path to main file (e.g., "./dist/index.js")
meta?: { // Optional metadata
description?: string;
author?: string;
[key: string]: unknown;
};
}LoadedPlugin<T>
The structure returned by getAll() and find():
interface LoadedPlugin<T> {
manifest: PluginManifest; // Parsed manifest
plugin: T; // The loaded module (typed)
}Example usage:
interface MyPlugin {
execute(): string;
}
const host = new PluginHost<MyPlugin>({ folder: './plugins' });
const plugin: LoadedPlugin<MyPlugin> | undefined = host.find('test');PluginHostOptions<T>
Union of PluginHostFolderOptions<T> and PluginHostSingleOptions<T>.
// Folder scan mode
interface PluginHostFolderOptions<T> {
folder: string; // Directory containing plugin subdirectories / ZIPs
validator?: (plugin: unknown) => plugin is T; // Optional type guard
}
// Single-plugin mode
interface PluginHostSingleOptions<T> {
pluginPath: string; // Path to one plugin directory
validator?: (plugin: unknown) => plugin is T; // Optional type guard
}
type PluginHostOptions<T> = PluginHostFolderOptions<T> | PluginHostSingleOptions<T>;Both constituent types are exported and can be used for narrowing or explicit annotation.
Plugin Structure
A valid plugin must have:
- plugin.manifest.json - Manifest file at the root
- Entry point - The JavaScript file specified in
entryPoint
Directory Plugin Example
my-plugin/
├── plugin.manifest.json
└── dist/
└── index.jsplugin.manifest.json:
{
"name": "my-plugin",
"version": "1.0.0",
"entryPoint": "./dist/index.js",
"meta": {
"description": "My awesome plugin",
"author": "Your Name"
}
}dist/index.js:
export default {
name: 'my-plugin',
execute() {
console.log('Hello from plugin!');
}
};ZIP Archive Support
Plugins can be distributed as .zip files. The host automatically extracts them to a temporary directory before loading.
plugins/
├── my-plugin/ # Directory plugin
└── another-plugin.zip # ZIP archive pluginModule Formats
ESM Plugins (Recommended)
package.json:
{
"type": "module"
}index.js:
export default {
execute() {
return 'result';
}
};CommonJS Plugins
index.js:
module.exports = {
execute() {
return 'result';
}
};Custom Validation
Add a validator to enforce plugin structure at runtime and enable type narrowing:
interface MyPlugin {
name: string;
execute(): Promise<string>;
version: string;
}
const host = new PluginHost<MyPlugin>({
folder: './plugins',
// Type guard validates and narrows type from unknown to MyPlugin
validator: (plugin): plugin is MyPlugin => {
return (
typeof plugin === 'object' &&
plugin !== null &&
typeof (plugin as any).name === 'string' &&
typeof (plugin as any).execute === 'function' &&
typeof (plugin as any).version === 'string'
);
}
});
await host.reload();
// All plugins are guaranteed to match MyPlugin interface
const plugins = host.getAll();
plugins.forEach(({ plugin }) => {
// TypeScript knows these properties exist
console.log(`${plugin.name} v${plugin.version}`);
});Pro Tip: Use a validation library like zod for more robust validation:
import { z } from 'zod';
const PluginSchema = z.object({
name: z.string(),
version: z.string(),
execute: z.function().returns(z.promise(z.string())),
});
type MyPlugin = z.infer<typeof PluginSchema>;
const host = new PluginHost<MyPlugin>({
folder: './plugins',
validator: (plugin): plugin is MyPlugin => {
return PluginSchema.safeParse(plugin).success;
}
});Error Handling
The plugin host gracefully handles errors:
- Invalid manifests - Logged and skipped
- Missing entry points - Logged and skipped
- Import errors - Logged and skipped
- Validation failures - Logged and skipped
Errors are logged to console.warn and don't crash the host.
Hot Reload
Cache busting is automatic. Each reload() call uses a new timestamp:
// Development workflow
while (developing) {
// Make changes to plugin...
await host.reload(); // Loads fresh version
const plugin = host.find('my-plugin');
await plugin.plugin.execute();
}Dual-Format Package
@moduul/core is published in both ESM and CommonJS formats:
// ESM
import { PluginHost } from '@moduul/core';
// CommonJS
const { PluginHost } = require('@moduul/core');TypeScript Support
Full type definitions included with generic type support. All option types are exported:
import { PluginHost, PluginManifest, LoadedPlugin, PluginHostFolderOptions, PluginHostSingleOptions } from '@moduul/core';
// Define your plugin interface
interface MyPlugin {
execute(input: string): Promise<string>;
cleanup?(): Promise<void>;
}
// Create typed host
const host: PluginHost<MyPlugin> = new PluginHost<MyPlugin>({
folder: './plugins'
});
// Type-safe plugin access
const plugins: LoadedPlugin<MyPlugin>[] = host.getAll();
// Type guard validator
const validator = (plugin: unknown): plugin is MyPlugin => {
return (
typeof plugin === 'object' &&
plugin !== null &&
typeof (plugin as any).execute === 'function'
);
};
// Manifest typing
const manifest: PluginManifest = {
name: 'my-plugin',
version: '1.0.0',
entryPoint: './dist/index.js'
};Advanced: Multiple Plugin Types
// Define base interface
interface BasePlugin {
name: string;
version: string;
}
// Define specific plugin types
interface DataPlugin extends BasePlugin {
processData(data: unknown): Promise<unknown>;
}
interface UIPlugin extends BasePlugin {
render(): HTMLElement;
}
// Create separate hosts for different plugin types
const dataHost = new PluginHost<DataPlugin>({
folder: './plugins/data',
validator: (p): p is DataPlugin =>
typeof (p as any)?.processData === 'function'
});
const uiHost = new PluginHost<UIPlugin>({
folder: './plugins/ui',
validator: (p): p is UIPlugin =>
typeof (p as any)?.render === 'function'
});Requirements
- Node.js 20 or higher
- ESM support (built-in)
Related Packages
- @moduul/builder - CLI tool for building plugins
- @moduul/boilerplate - Official plugin template
License
MIT
