Initial commit: Plugin Host system

This commit is contained in:
2026-04-17 01:48:22 +10:00
commit f423c50d68
5 changed files with 380 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
plugins/
*.log
.env

59
README.md Normal file
View File

@@ -0,0 +1,59 @@
# Plugin Host
A lightweight, event-driven plugin host system for Node.js applications.
## Features
- **Dynamic Plugin Loading**: Load plugins at runtime from a local directory
- **Plugin Registry**: Automatic fetching of available plugins from a remote directory
- **Hook System**: Plugins can register hooks for extensibility
- **Event System**: EventEmitter-based communication between host and plugins
- **Hot Reload**: Support for unloading and reloading plugins
- **Auto-Update**: Periodic registry updates from the plugin directory
## Installation
```bash
npm install
```
## Usage
```javascript
import { PluginHost } from './src/PluginHost.js';
const host = new PluginHost({
directoryUrl: 'http://your-plugin-directory/plugins',
pluginsDir: './plugins',
autoUpdate: true,
updateInterval: 300000 // 5 minutes
});
await host.initialize();
await host.loadPlugins();
```
## Plugin Context API
Plugins receive a context object with the following API:
- `registerHook(hookName, callback)` - Register a hook
- `unregisterHook(hookName, callback)` - Unregister a hook
- `runHook(hookName, ...args)` - Execute a hook
- `getConfig(key)` - Get plugin configuration
- `log(...args)` - Log with plugin prefix
- `on(event, listener)` - Listen to host events
- `emit(event, ...args)` - Emit events
## Events
- `initialized` - Host initialized
- `registry-updated` - Plugin registry updated
- `plugin-loaded` - Plugin loaded
- `plugin-unloaded` - Plugin unloaded
- `plugin-error` - Plugin error occurred
- `shutdown` - Host shutting down
## License
MIT

17
package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "@pluginhost/core",
"version": "1.0.0",
"description": "Plugin host system for loading and managing plugins",
"main": "src/index.js",
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
},
"keywords": ["plugin", "host", "extensibility"],
"author": "",
"license": "MIT",
"dependencies": {
"node-fetch": "^3.3.2"
}
}

287
src/PluginHost.js Normal file
View File

@@ -0,0 +1,287 @@
import { EventEmitter } from 'events';
import { readdir, readFile, mkdir } from 'fs/promises';
import { join, resolve } from 'path';
import { existsSync } from 'fs';
import fetch from 'node-fetch';
/**
* Plugin Host - Manages plugin lifecycle and provides plugin API
*/
export class PluginHost extends EventEmitter {
constructor(options = {}) {
super();
this.directoryUrl = options.directoryUrl;
this.pluginsDir = resolve(options.pluginsDir || './plugins');
this.autoUpdate = options.autoUpdate ?? true;
this.updateInterval = options.updateInterval || 300000;
this.plugins = new Map();
this.pluginRegistry = new Map();
this.hooks = new Map();
this._updateTimer = null;
}
/**
* Initialize the plugin host
*/
async initialize() {
if (!existsSync(this.pluginsDir)) {
await mkdir(this.pluginsDir, { recursive: true });
}
if (this.autoUpdate && this.directoryUrl) {
await this.updateRegistry();
this._updateTimer = setInterval(() => this.updateRegistry(), this.updateInterval);
}
this.emit('initialized');
}
/**
* Fetch plugin registry from the directory
*/
async updateRegistry() {
try {
const response = await fetch(this.directoryUrl);
if (!response.ok) {
console.warn('Failed to fetch plugin registry:', response.statusText);
return;
}
const html = await response.text();
const pluginFiles = this._parseDirectoryListing(html);
for (const file of pluginFiles) {
if (file.endsWith('.json')) {
await this._fetchPluginDefinition(file);
}
}
this.emit('registry-updated', Array.from(this.pluginRegistry.values()));
} catch (error) {
console.error('Error updating plugin registry:', error);
}
}
/**
* Parse HTML directory listing to find JSON files
*/
_parseDirectoryListing(html) {
const files = [];
const regex = /href="([^"]+\.json)"/g;
let match;
while ((match = regex.exec(html)) !== null) {
files.push(match[1]);
}
return files;
}
/**
* Fetch individual plugin definition
*/
async _fetchPluginDefinition(filename) {
try {
const url = `${this.directoryUrl}/${filename}`;
const response = await fetch(url);
if (!response.ok) return;
const definition = await response.json();
this.pluginRegistry.set(definition.id, definition);
} catch (error) {
console.error(`Error fetching plugin definition ${filename}:`, error);
}
}
/**
* Load all plugins from the plugins directory
*/
async loadPlugins() {
try {
const entries = await readdir(this.pluginsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
await this.loadPlugin(entry.name);
}
}
} catch (error) {
console.error('Error loading plugins:', error);
}
}
/**
* Load a specific plugin
*/
async loadPlugin(pluginName) {
const pluginPath = join(this.pluginsDir, pluginName);
const manifestPath = join(pluginPath, 'plugin.json');
try {
if (!existsSync(manifestPath)) {
throw new Error(`Plugin manifest not found: ${manifestPath}`);
}
const manifestData = await readFile(manifestPath, 'utf-8');
const manifest = JSON.parse(manifestData);
const entryPoint = join(pluginPath, manifest.main || 'index.js');
if (!existsSync(entryPoint)) {
throw new Error(`Plugin entry point not found: ${entryPoint}`);
}
const pluginModule = await import(`file:///${entryPoint.replace(/\\/g, '/')}`);
const pluginInstance = {
id: manifest.id,
name: manifest.name,
version: manifest.version,
manifest,
module: pluginModule,
context: this._createPluginContext(manifest.id)
};
if (typeof pluginModule.activate === 'function') {
await pluginModule.activate(pluginInstance.context);
}
this.plugins.set(manifest.id, pluginInstance);
this.emit('plugin-loaded', pluginInstance);
console.log(`✓ Loaded plugin: ${manifest.name} v${manifest.version}`);
return pluginInstance;
} catch (error) {
console.error(`Failed to load plugin ${pluginName}:`, error);
this.emit('plugin-error', { pluginName, error });
return null;
}
}
/**
* Create plugin context API
*/
_createPluginContext(pluginId) {
return {
pluginId,
registerHook: (hookName, callback) => {
if (!this.hooks.has(hookName)) {
this.hooks.set(hookName, []);
}
this.hooks.get(hookName).push({ pluginId, callback });
},
unregisterHook: (hookName, callback) => {
const hooks = this.hooks.get(hookName);
if (hooks) {
const index = hooks.findIndex(h => h.pluginId === pluginId && h.callback === callback);
if (index !== -1) {
hooks.splice(index, 1);
}
}
},
runHook: async (hookName, ...args) => {
return this.runHook(hookName, ...args);
},
getConfig: (key) => {
const plugin = this.plugins.get(pluginId);
return plugin?.manifest?.config?.[key];
},
log: (...args) => {
console.log(`[${pluginId}]`, ...args);
},
on: (event, listener) => {
this.on(event, listener);
},
emit: (event, ...args) => {
this.emit(event, ...args);
}
};
}
/**
* Run all hooks registered for a specific hook name
*/
async runHook(hookName, ...args) {
const hooks = this.hooks.get(hookName) || [];
const results = [];
for (const { pluginId, callback } of hooks) {
try {
const result = await callback(...args);
results.push({ pluginId, result });
} catch (error) {
console.error(`Error running hook ${hookName} in plugin ${pluginId}:`, error);
results.push({ pluginId, error });
}
}
return results;
}
/**
* Unload a plugin
*/
async unloadPlugin(pluginId) {
const plugin = this.plugins.get(pluginId);
if (!plugin) return false;
try {
if (typeof plugin.module.deactivate === 'function') {
await plugin.module.deactivate(plugin.context);
}
// Remove all hooks from this plugin
for (const [hookName, hooks] of this.hooks.entries()) {
this.hooks.set(hookName, hooks.filter(h => h.pluginId !== pluginId));
}
this.plugins.delete(pluginId);
this.emit('plugin-unloaded', plugin);
console.log(`✓ Unloaded plugin: ${plugin.name}`);
return true;
} catch (error) {
console.error(`Error unloading plugin ${pluginId}:`, error);
return false;
}
}
/**
* Get all loaded plugins
*/
getLoadedPlugins() {
return Array.from(this.plugins.values()).map(p => ({
id: p.id,
name: p.name,
version: p.version,
manifest: p.manifest
}));
}
/**
* Get plugin registry
*/
getRegistry() {
return Array.from(this.pluginRegistry.values());
}
/**
* Shutdown the plugin host
*/
async shutdown() {
if (this._updateTimer) {
clearInterval(this._updateTimer);
}
for (const pluginId of this.plugins.keys()) {
await this.unloadPlugin(pluginId);
}
this.emit('shutdown');
}
}

13
src/index.js Normal file
View File

@@ -0,0 +1,13 @@
import { PluginHost } from './PluginHost.js';
const host = new PluginHost({
directoryUrl: 'http://synbox.ruv.wtf:8418/litruv/Plugin-Directory/raw/branch/main/plugins',
pluginsDir: './plugins',
autoUpdate: true,
updateInterval: 300000 // 5 minutes
});
await host.initialize();
await host.loadPlugins();
console.log('Plugin Host initialized with plugins:', host.getLoadedPlugins().map(p => p.name));