commit f423c50d68ab53747cb68bd178e40d48b5e9237c Author: Max Litruv Boonzaayer Date: Fri Apr 17 01:48:22 2026 +1000 Initial commit: Plugin Host system diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e357785 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +plugins/ +*.log +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f2f37b --- /dev/null +++ b/README.md @@ -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 diff --git a/package.json b/package.json new file mode 100644 index 0000000..6b05d22 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/PluginHost.js b/src/PluginHost.js new file mode 100644 index 0000000..34723d9 --- /dev/null +++ b/src/PluginHost.js @@ -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'); + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..c4245de --- /dev/null +++ b/src/index.js @@ -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));