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