/** * PluginLoader - Dynamic plugin loading and lifecycle management. * * Handles: * - Dynamic import of plugins from file paths or npm packages * - Plugin lifecycle (load, start, stop) * - Dependency resolution via topological sort * - Hook invocation across all loaded plugins * - Error isolation (plugin crashes don't crash the loader) */ import { join, isAbsolute, resolve } from "node:path"; import { EventEmitter } from "node:events"; import type { TaskStore } from "./store.js"; import { PluginStore } from "./plugin-store.js"; import type { FusionPlugin, PluginContext, PluginLogger, PluginToolDefinition, PluginRouteDefinition, PluginState, PluginInstallation, } from "./plugin-types.js"; import { validatePluginManifest } from "./plugin-types.js"; // Minimum Fusion version for plugin compatibility checks (can be expanded later) const MINIMUM_FUSION_VERSION = "0.1.0"; export interface PluginLoaderOptions { /** Plugin store for persistence */ pluginStore: PluginStore; /** Task store for plugin context */ taskStore: TaskStore; /** Additional directories to scan for plugins */ pluginDirs?: string[]; /** npm prefix for resolving packages */ npmPrefix?: string; } /** * Event emitted when a plugin is loaded and started. */ export interface PluginLoadedEvent { pluginId: string; plugin: FusionPlugin; } /** * Event emitted when a plugin is unloaded (stopped). */ export interface PluginUnloadedEvent { pluginId: string; } /** * Event emitted when a plugin is reloaded with a new version. */ export interface PluginReloadedEvent { pluginId: string; plugin: FusionPlugin; } /** * Event emitted when a plugin encounters an error. */ export interface PluginErrorEvent { pluginId: string; error: Error; } export class PluginLoader extends EventEmitter<{ "plugin:loaded": [PluginLoadedEvent]; "plugin:unloaded": [PluginUnloadedEvent]; "plugin:reloaded": [PluginReloadedEvent]; "plugin:error": [PluginErrorEvent]; "plugin:stopped": [string]; // Kept for backward compatibility }> { /** Loaded plugin instances keyed by plugin id */ private plugins: Map = new Map(); /** Cache of dynamically imported modules */ private loadedModules: Map = new Map(); constructor(private options: PluginLoaderOptions) { super(); } // ── Context Creation ─────────────────────────────────────────────── private async createContext(plugin: FusionPlugin): Promise { return { pluginId: plugin.manifest.id, taskStore: this.options.taskStore, settings: await this.getPluginSettings(plugin.manifest.id), logger: this.createLogger(plugin.manifest.id), emitEvent: (event: string, data: unknown) => { this.emit("plugin:error", { pluginId: plugin.manifest.id, error: new Error(`Custom event: ${event}`) }); // Custom events are logged but not surfaced as errors console.log(`[plugin:${plugin.manifest.id}] Custom event: ${event}`, data); }, }; } private createLogger(pluginId: string): PluginLogger { const prefix = `[plugin:${pluginId}]`; return { info: (...args: unknown[]) => console.log(prefix, ...args), warn: (...args: unknown[]) => console.warn(prefix, ...args), error: (...args: unknown[]) => console.error(prefix, ...args), debug: (...args: unknown[]) => { if (process.env.DEBUG?.includes("plugins")) { console.log(prefix, ...args); } }, }; } private async getPluginSettings(pluginId: string): Promise> { try { const plugin = await this.options.pluginStore.getPlugin(pluginId); return plugin.settings; } catch { return {}; } } // ── Plugin Loading ───────────────────────────────────────────────── /** * Load and start a single plugin. */ async loadPlugin(pluginId: string): Promise { // Get plugin installation record let installation: PluginInstallation; try { installation = await this.options.pluginStore.getPlugin(pluginId); } catch (err) { throw new Error(`Plugin "${pluginId}" not found in store: ${(err as Error).message}`); } // Skip disabled plugins if (!installation.enabled) { console.log(`[plugin-loader] Skipping disabled plugin: ${pluginId}`); throw Object.assign(new Error(`Plugin "${pluginId}" is disabled`), { code: "PLUGIN_DISABLED", }); } // Skip already loaded plugins if (this.plugins.has(pluginId)) { console.log(`[plugin-loader] Plugin already loaded: ${pluginId}`); return this.plugins.get(pluginId)!; } // Resolve plugin path const pluginPath = this.resolvePluginPath(installation.path); try { // Dynamic import the plugin - always bypass cache to get fresh code // Our loadedModules cache is cleared on stop, but Node.js ESM cache persists const mod = await this.importPluginModule(pluginPath, true); const plugin = this.extractPluginFromModule(mod); // Validate manifest const manifestValidation = validatePluginManifest(plugin.manifest); if (!manifestValidation.valid) { throw new Error( `Invalid plugin manifest: ${manifestValidation.errors.join(", ")}`, ); } // Check version compatibility if (plugin.manifest.fusionVersion) { const compatible = this.checkVersionCompatibility( plugin.manifest.fusionVersion, ); if (!compatible) { console.warn( `[plugin-loader] Plugin ${pluginId} requires Fusion ${plugin.manifest.fusionVersion}, minimum is ${MINIMUM_FUSION_VERSION}`, ); } } // Resolve dependencies await this.resolveDependencies(plugin); // Update state to started await this.options.pluginStore.updatePluginState(pluginId, "started"); // Update plugin state locally and store plugin.state = "started"; this.plugins.set(pluginId, plugin); // Call onLoad hook const ctx = await this.createContext(plugin); try { await this.safeCallHook(plugin, "onLoad", [ctx]); } catch (loadErr) { // onLoad failed - clean up and propagate error this.plugins.delete(pluginId); const errorMsg = loadErr instanceof Error ? loadErr.message : String(loadErr); await this.options.pluginStore.updatePluginState( pluginId, "error", `onLoad failed: ${errorMsg}`, ); this.emit("plugin:error", { pluginId, error: loadErr instanceof Error ? loadErr : new Error(errorMsg), }); throw loadErr; } this.emit("plugin:loaded", { pluginId, plugin }); return plugin; } catch (err) { // Ensure plugin is removed from loaded map on any failure // (it may have been added above before the onLoad hook) this.plugins.delete(pluginId); // Error isolation: set error state but don't crash const errorMsg = err instanceof Error ? err.message : String(err); await this.options.pluginStore.updatePluginState( pluginId, "error", errorMsg, ); this.emit("plugin:error", { pluginId, error: err instanceof Error ? err : new Error(errorMsg), }); throw err; } } private resolvePluginPath(path: string): string { // If already absolute, use as-is if (isAbsolute(path)) { return path; } // Check if it's an npm package (contains / or starts with @) if (path.startsWith("@") || path.includes("/")) { // For npm packages, we'd use require.resolve in a real implementation // For now, assume it's a local path relative to project root return resolve(process.cwd(), path); } // Default: resolve relative to project root return resolve(process.cwd(), path); } private async importPluginModule(path: string, bypassCache = false): Promise { // Check cache first (unless bypassing cache for reload) if (!bypassCache && this.loadedModules.has(path)) { return this.loadedModules.get(path)!; } // Dynamic import - use cache-busting for reload scenarios let mod: unknown; if (bypassCache) { // Use cache-busting with timestamp for ESM modules // This ensures Node.js re-imports the module even if it has it cached const bustedPath = `${path}?reload=${Date.now()}`; mod = await import(bustedPath); } else { mod = await import(path); } this.loadedModules.set(path, mod); return mod; } /** * Invalidate the module cache for a plugin path. * This ensures a fresh import when the plugin is loaded again. */ private invalidateModuleCache(path: string): void { this.loadedModules.delete(path); console.log(`[plugin-loader] Module cache invalidated for: ${path}`); } /** * Reload a plugin: stop the old instance, re-import, and start the new one. * On failure, roll back to the old instance. * * @param pluginId - The plugin to reload * @param options - Options including timeout for onUnload/onLoad hooks */ async reloadPlugin( pluginId: string, options?: { timeoutMs?: number }, ): Promise { const timeoutMs = options?.timeoutMs ?? 5000; // Get existing plugin const oldPlugin = this.plugins.get(pluginId); if (!oldPlugin) { throw Object.assign(new Error(`Plugin "${pluginId}" is not loaded`), { code: "PLUGIN_NOT_LOADED", }); } // Get installation record for path const installation = await this.options.pluginStore.getPlugin(pluginId); const pluginPath = this.resolvePluginPath(installation.path); console.log(`[plugin-loader] Reloading plugin: ${pluginId}`); // Call onUnload with timeout try { await this.withTimeout( this.safeCallHook(oldPlugin, "onUnload", []), timeoutMs, `onUnload timeout for ${pluginId}`, ); } catch (err) { console.warn(`[plugin-loader] onUnload for ${pluginId} timed out or failed:`, err); // Continue with reload despite onUnload issues } // Remove old module from cache this.invalidateModuleCache(pluginPath); // Snapshot old plugin for rollback const snapshot = { ...oldPlugin }; try { // Re-import the plugin module const mod = await this.importPluginModule(pluginPath, true); const newPlugin = this.extractPluginFromModule(mod); // Validate manifest const manifestValidation = validatePluginManifest(newPlugin.manifest); if (!manifestValidation.valid) { throw new Error( `Invalid plugin manifest: ${manifestValidation.errors.join(", ")}`, ); } // Update plugin state newPlugin.state = "started"; // Replace in plugins map this.plugins.set(pluginId, newPlugin); // Create fresh context and call onLoad const ctx = await this.createContext(newPlugin); await this.withTimeout( this.safeCallHook(newPlugin, "onLoad", [ctx]), timeoutMs, `onLoad timeout for ${pluginId}`, ); // State is already "started", no need to update store // (avoiding started -> started transition which is disallowed) console.log(`[plugin-loader] Plugin ${pluginId} reloaded successfully`); this.emit("plugin:reloaded", { pluginId, plugin: newPlugin }); return newPlugin; } catch (err) { // Rollback: restore old plugin console.error(`[plugin-loader] Reload failed for ${pluginId}, rolling back:`, err); try { // Restore old plugin this.plugins.set(pluginId, snapshot); // Attempt to reactivate old plugin const ctx = await this.createContext(snapshot); await this.withTimeout( this.safeCallHook(snapshot, "onLoad", [ctx]), timeoutMs, `Rollback onLoad timeout for ${pluginId}`, ); // Update store state back to started await this.options.pluginStore.updatePluginState(pluginId, "started"); console.warn(`[plugin-loader] Rollback successful for ${pluginId}`); throw err; // Still throw the original error } catch (rollbackErr) { // Rollback also failed - remove plugin and set error state console.error( `[plugin-loader] Rollback failed for ${pluginId}, removing plugin:`, rollbackErr, ); this.plugins.delete(pluginId); const originalError = err instanceof Error ? err.message : String(err); const rollbackError = rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr); const combinedError = `Reload failed and rollback failed: ${originalError}; ${rollbackError}`; await this.options.pluginStore.updatePluginState( pluginId, "error", combinedError, ); this.emit("plugin:error", { pluginId, error: new Error(combinedError), }); throw err; // Throw original error } } } /** * Execute a promise with a timeout. */ private withTimeout( promise: Promise, ms: number, timeoutMessage: string, ): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error(timeoutMessage)); }, ms); promise .then((result) => { clearTimeout(timer); resolve(result); }) .catch((err) => { clearTimeout(timer); reject(err); }); }); } private extractPluginFromModule(mod: unknown): FusionPlugin { if (!mod || typeof mod !== "object") { throw new Error("Plugin module must export an object"); } const obj = mod as Record; // Look for default export first, then named export const pluginExport = obj.default ?? obj.plugin; if (!pluginExport || typeof pluginExport !== "object") { throw new Error( "Plugin module must export a default 'FusionPlugin' or have a 'plugin' export", ); } const plugin = pluginExport as FusionPlugin; // Basic validation if (!plugin.manifest?.id) { throw new Error("Plugin must have a manifest with id"); } return plugin; } private checkVersionCompatibility(requiredVersion: string): boolean { // Simple version comparison for now // In a real implementation, use a proper semver library const required = this.parseVersion(requiredVersion); const minimum = this.parseVersion(MINIMUM_FUSION_VERSION); if (required.major > minimum.major) return false; if (required.major < minimum.major) return true; if (required.minor > minimum.minor) return false; if (required.minor < minimum.minor) return true; return required.patch <= minimum.patch; } private parseVersion(version: string): { major: number; minor: number; patch: number } { const parts = version.split(".").map(Number); return { major: parts[0] || 0, minor: parts[1] || 0, patch: parts[2] || 0, }; } private async resolveDependencies(plugin: FusionPlugin): Promise { if (!plugin.manifest.dependencies?.length) return; for (const depId of plugin.manifest.dependencies) { if (!this.plugins.has(depId)) { throw new Error( `Plugin ${plugin.manifest.id} depends on ${depId}, which is not loaded`, ); } } } // ── Load All ────────────────────────────────────────────────────── /** * Load all enabled plugins in dependency order. */ async loadAllPlugins(): Promise<{ loaded: number; errors: number }> { const enabled = await this.options.pluginStore.listPlugins({ enabled: true }); const sorted = this.resolveLoadOrder(enabled); let loaded = 0; let errors = 0; for (const installation of sorted) { try { await this.loadPlugin(installation.id); loaded++; } catch (err) { if ((err as any).code !== "PLUGIN_DISABLED") { errors++; console.error( `[plugin-loader] Failed to load plugin ${installation.id}:`, err, ); } } } return { loaded, errors }; } /** * Topological sort for load order. */ resolveLoadOrder(plugins: PluginInstallation[]): PluginInstallation[] { const pluginMap = new Map(plugins.map((p) => [p.id, p])); const visited = new Set(); const result: PluginInstallation[] = []; const visiting = new Set(); const visit = (id: string) => { if (visited.has(id)) return; if (visiting.has(id)) { throw new Error(`Circular dependency detected: ${id}`); } const plugin = pluginMap.get(id); if (!plugin) return; // Skip plugins not in our list visiting.add(id); // Visit dependencies first for (const depId of plugin.dependencies || []) { visit(depId); } visiting.delete(id); visited.add(id); result.push(plugin); }; for (const plugin of plugins) { visit(plugin.id); } return result; } // ── Plugin Stopping ──────────────────────────────────────────────── /** * Stop and unload a single plugin. */ async stopPlugin(pluginId: string): Promise { const plugin = this.plugins.get(pluginId); if (!plugin) { console.log(`[plugin-loader] Plugin not loaded: ${pluginId}`); return; } // Get the plugin path for cache invalidation const installation = await this.options.pluginStore.getPlugin(pluginId); const pluginPath = this.resolvePluginPath(installation.path); try { // Call onUnload hook await this.safeCallHook(plugin, "onUnload", []); } catch (err) { console.error(`[plugin-loader] Error in onUnload for ${pluginId}:`, err); } // Update state await this.options.pluginStore.updatePluginState(pluginId, "stopped"); // Remove from loaded plugins this.plugins.delete(pluginId); // Invalidate module cache for clean re-import this.invalidateModuleCache(pluginPath); this.emit("plugin:unloaded", { pluginId }); this.emit("plugin:stopped", pluginId); // Backward compatibility } /** * Stop all loaded plugins in reverse dependency order. */ async stopAllPlugins(): Promise { // Get plugins in reverse topological order const loadedPlugins = Array.from(this.plugins.values()); const sorted = this.resolveLoadOrder( loadedPlugins.map((p) => ({ id: p.manifest.id, name: p.manifest.name, version: p.manifest.version, description: p.manifest.description, author: p.manifest.author, homepage: p.manifest.homepage, path: "", enabled: true, state: p.state, settings: {}, dependencies: p.manifest.dependencies, createdAt: "", updatedAt: "", })), ); // Stop in reverse order for (const plugin of sorted.reverse()) { try { await this.stopPlugin(plugin.id); } catch (err) { console.error(`[plugin-loader] Error stopping plugin ${plugin.id}:`, err); } } } // ── Hook Invocation ──────────────────────────────────────────────── /** * Invoke a hook on all loaded plugins. * Errors are isolated - one plugin's failure doesn't affect others. */ async invokeHook( hookName: keyof FusionPlugin["hooks"], ...args: unknown[] ): Promise { for (const [pluginId, plugin] of this.plugins) { const hook = plugin.hooks[hookName]; if (!hook) continue; try { await this.safeCallHook(plugin, hookName, args); } catch (err) { console.error( `[plugin-loader] Error in ${hookName} hook for ${pluginId}:`, err, ); // Update plugin state to error try { await this.options.pluginStore.updatePluginState( pluginId, "error", err instanceof Error ? err.message : String(err), ); plugin.state = "error"; } catch { // Non-fatal } // Call onError hook if available if (hookName !== "onError" && plugin.hooks.onError) { try { const ctx = await this.createContext(plugin); await plugin.hooks.onError( err instanceof Error ? err : new Error(String(err)), ctx, ); } catch { // Non-fatal } } } } } private async safeCallHook( plugin: FusionPlugin, hookName: keyof FusionPlugin["hooks"], args: unknown[], ): Promise { const hook = plugin.hooks[hookName]; if (!hook) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const fn = hook as (...args: unknown[]) => unknown; const result = fn(...args); if (result instanceof Promise) { await result; } } // ── Accessors ───────────────────────────────────────────────────── /** * Get all tools from loaded plugins. */ getPluginTools(): PluginToolDefinition[] { const tools: PluginToolDefinition[] = []; for (const plugin of this.plugins.values()) { if (plugin.tools) { tools.push(...plugin.tools); } } return tools; } /** * Get all routes from loaded plugins. */ getPluginRoutes(): Array<{ pluginId: string; route: PluginRouteDefinition }> { const routes: Array<{ pluginId: string; route: PluginRouteDefinition }> = []; for (const [pluginId, plugin] of this.plugins) { if (plugin.routes) { for (const route of plugin.routes) { routes.push({ pluginId, route }); } } } return routes; } /** * Get all loaded plugin instances. */ getLoadedPlugins(): FusionPlugin[] { return Array.from(this.plugins.values()); } /** * Get a loaded plugin by id. */ getPlugin(pluginId: string): FusionPlugin | undefined { return this.plugins.get(pluginId); } /** * Check if a plugin is loaded. */ isPluginLoaded(pluginId: string): boolean { return this.plugins.has(pluginId); } }