Initial commit: Plugin Host system
This commit is contained in:
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user