Initial commit: Plugin Host system
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
plugins/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
59
README.md
Normal file
59
README.md
Normal 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
17
package.json
Normal 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
287
src/PluginHost.js
Normal 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
13
src/index.js
Normal 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));
|
||||||
Reference in New Issue
Block a user