/** * Plugin Manager with sophisticated dependency resolution using topological sorting * * This is a reimplementation of https://www.npmjs.com/package/js-plugin with additional features, * designed as a superset that maintains compatibility with code written for js-plugin. * * ## Basic Usage Example * * ```javascript * import { PluginManager } from './plugin-manager.js'; * * const manager = new PluginManager(); * * // Register plugins * manager.register({ * name: 'logger', * config: { level: 'info' }, * install() { console.log('Logger installed'); } * }); * * manager.register({ * name: 'database', * deps: ['logger'], * config: { timeout: 5000 }, * install() { console.log('Database installed'); } * }); * * // Invoke extension points (functions) * await manager.invoke('install'); // Calls install() on all plugins in dependency order * * // Collect configuration values (no-call) * const timeouts = await manager.invoke('!config.timeout'); // Returns [undefined, 5000] * const levels = await manager.invoke('!config.level'); // Returns ['info', undefined] * * // Error handling * await manager.invoke('install!'); // Throws on first plugin error * ``` * * ## Endpoint Invocation Flags * * Endpoint names support special flag prefixes and suffixes to control invocation behavior: * * ### No-Call Flag (`!` prefix) * - **Usage**: `manager.invoke("!config.timeout")` * - **Purpose**: Retrieve property values without calling functions * - **Behavior**: Returns the value at the endpoint path from each plugin * - **Example**: Collect configuration values or status data across plugins * * ### Throw Flag (`!` suffix) * - **Usage**: `manager.invoke("install!")` * - **Purpose**: Throw errors immediately instead of collecting them * - **Behavior**: First plugin error terminates execution and rethrows * - **Example**: Critical operations where failures should halt processing * * Flags can be combined. * * The no-call flag (`!`) converts function invocation into value retrieval, enabling * data collection workflows using the same plugin traversal and dependency ordering. */ import Plugin from './plugin-base.js'; /** * The minimal plugin configuration object * @typedef {object & Record} PluginConfig * @property {string} name - The name of the plugin * @property {string[]} [deps] - The names of the plugins this plugin depends on * @property {any} [initialize] - Optional initialization function */ /** * Options for plugin endpoint invocation * @typedef {Object} InvokeOptions * @property {boolean} [throws] - If true, an error in one of the endpoints throws the invocation * @property {boolean} [nocall] - If true, get the extension points value rather than trying to call it as a function * @property {number} [timeout] - Timeout override for this invocation (milliseconds) * @property {'parallel'|'sequential'} [mode] - Execution mode: 'parallel' (default) or 'sequential' * @property {'first'|'values'|'full'} [result] - Form of result: * 'first' (default) returns the first fulfilled response, * 'values' returns the values of all fulfilled responses, * 'full' returns an array of {status:string, value:any} objects * @property {boolean} [debug] - If true, output verbose debug messages */ /** * @typedef {({status: 'fulfilled', value: any} | {status: 'rejected', reason: Error})} InvocationResult */ /** * Plugin Manager with dependency resolution */ export class PluginManager { /** * @param {InvokeOptions} options */ constructor(options = {}) { /** @type {Map} */ this.pluginsByName = new Map(); /** @type {Map} */ this.pluginApis = new Map(); /** @type {PluginConfig[]} */ this.registeredPlugins = []; /** @type {PluginConfig[]} */ this.dependencyOrderedPlugins = []; /** @type {Map} */ this.endpointCache = new Map(); /** @type {InvokeOptions} */ this.config = { timeout: options.timeout || 2000, throws: options.throws || false, mode: options.mode || "parallel", result: options.result || "first" }; /** @type {boolean} */ this.debug = options.debug || false; } /** * Register a plugin with dependency resolution * @param {PluginConfig|Plugin} plugin - Plugin to register (can be Plugin instance or config object) * @throws {Error} If plugin is invalid or creates circular dependencies */ register(plugin) { // Validate plugin if (!plugin || typeof plugin !== 'object') { throw new Error('Plugin must be an object'); } // Handle Plugin class instances - convert to plugin object let pluginConfig; if (plugin instanceof Plugin) { if (this.debug) { console.log(`Converting Plugin instance '${plugin.name}' to plugin object`); } pluginConfig = this.convertPluginInstance(plugin); } else { pluginConfig = plugin; } if (!pluginConfig.name || typeof pluginConfig.name !== 'string') { console.error('Invalid plugin:', pluginConfig); throw new Error('Every plugin must have a name property'); } if (this.pluginsByName.has(pluginConfig.name)) { throw new Error(`Plugin "${pluginConfig.name}" is already registered`); } // Normalize dependencies const normalizedPlugin = { ...pluginConfig, deps: Array.isArray(pluginConfig.deps) ? pluginConfig.deps : [] }; // Register plugin this.pluginsByName.set(pluginConfig.name, normalizedPlugin); this.registeredPlugins.push(normalizedPlugin); // Store public API: Plugin instances use getApi() (defaults to the instance itself); // object plugins use .api if present if (plugin instanceof Plugin) { this.pluginApis.set(pluginConfig.name, plugin.getApi()); } else if (pluginConfig.api !== undefined) { this.pluginApis.set(pluginConfig.name, pluginConfig.api); } else { this.pluginApis.set(pluginConfig.name, pluginConfig); } // Clear caches this.endpointCache.clear(); this.dependencyOrderedPlugins = []; // Recompute dependency order this.computeDependencyOrder(); // Call initialize function if present if (plugin instanceof Plugin) { plugin.initialize(); } if (this.debug) { console.log(`Registered plugin '${plugin.name}' with dependencies: [${normalizedPlugin.deps.join(', ') || 'none'}]`); } } /** * Unregister a plugin * @param {string} pluginName - Name of plugin to unregister * @throws {Error} If plugin doesn't exist */ unregister(pluginName) { const plugin = this.pluginsByName.get(pluginName); if (!plugin) { throw new Error(`Plugin "${pluginName}" doesn't exist`); } // Remove from all collections this.pluginsByName.delete(pluginName); this.pluginApis.delete(pluginName); this.registeredPlugins = this.registeredPlugins.filter(p => p.name !== pluginName); // Clear caches and recompute order this.endpointCache.clear(); this.dependencyOrderedPlugins = []; this.computeDependencyOrder(); } /** * Get a specific plugin by name * @param {string} pluginName - Name of plugin to retrieve * @returns {PluginConfig|undefined} Plugin definition or undefined if not found */ getPlugin(pluginName) { return this.pluginsByName.get(pluginName); } /** * Get the public API for a registered plugin by name. * For class-based plugins this is the Plugin instance; for object plugins it is the * `api` field of the plugin descriptor (or the descriptor itself as a fallback). * @param {string} name - Plugin name * @returns {any} The plugin's public API, or undefined if not registered */ getDependency(name) { return this.pluginApis.get(name); } /** * Get plugins that implement a specific endpoint in dependency order * @param {string} endpoint - Endpoint path (e.g., 'install', 'state.update') * @returns {PluginConfig[]} Array of plugins that implement the endpoint */ getPlugins(endpoint = '.') { // Check cache first if (this.endpointCache.has(endpoint)) { return this.endpointCache.get(endpoint) || []; } // Filter plugins that have the endpoint and all their dependencies are satisfied const filteredPlugins = this.dependencyOrderedPlugins.filter(plugin => { // Check if any dependencies are missing if (plugin.deps && plugin.deps.length > 0) { const missingDependencies = plugin.deps.filter(depName => !this.pluginsByName.has(depName)); if (missingDependencies.length > 0) { console.warn(`Plugin ${plugin.name} is not loaded because its dependencies do not exist: ${missingDependencies.join(', ')}`); return false; } } // Check if plugin has the requested endpoint if (endpoint === '.') { return true; // All plugins } return this.hasEndpoint(plugin, endpoint); }); // Cache the result this.endpointCache.set(endpoint, filteredPlugins); return filteredPlugins; } /** * Check if a plugin has a specific endpoint * @param {PluginConfig} plugin - Plugin to check * @param {string} endpoint - Endpoint path to check * @returns {boolean} True if plugin has the endpoint * @private */ hasEndpoint(plugin, endpoint) { const pathParts = endpoint.split('.'); let current = plugin; for (const part of pathParts) { if (current == null || !(part in current)) { return false; } current = current[part]; } return true; } /** * Get value at endpoint path in plugin * @param {PluginConfig} plugin - Plugin object * @param {string} endpoint - Endpoint path * @returns {*} Value at endpoint or undefined * @private */ getEndpointValue(plugin, endpoint) { const pathParts = endpoint.split('.'); let current = plugin; for (const part of pathParts) { if (current == null || !(part in current)) { return undefined; } current = current[part]; } return current; } /** * Invoke an endpoint on all plugins that implement it, in dependency order. The invocation can be done in parallel or sequentially. * @param {string} endpoint - Endpoint to invoke * @param {*|Array} [args] - Arguments to pass to endpoint functions. If array, spread as parameters; if not array, pass as single parameter * @param {InvokeOptions} [options] - Optional configuration for this invocation * @returns {Promise} Result formatted depending on options.result */ async invoke(endpoint, args = [], options = {}) { if (!endpoint) { throw new Error('Invoke requires an endpoint argument'); } // Convert args to array for apply() - if it's already an array, use it; otherwise wrap in array const invokeArgs = Array.isArray(args) ? args : [args]; // Parse endpoint flags const isNoCall = options.nocall || /^!/.test(endpoint); const shouldThrow = options.throws || /!$/.test(endpoint) || this.config.throws; const cleanEndpoint = endpoint.replace(/^!|!$/g, ''); // Get the parent object path for method context const endpointParts = cleanEndpoint.split('.'); endpointParts.pop(); // Remove the method name const contextPath = endpointParts.join('.'); // Get plugins that implement this endpoint const plugins = this.getPlugins(cleanEndpoint); // invocation mode const mode = options.mode || this.config.mode; switch (mode) { case 'sequential': // Sequential execution - respects dependency order /** @type {InvocationResult[]} */ const results = []; for (const plugin of plugins) { const method = this.getEndpointValue(plugin, cleanEndpoint); if (typeof method !== 'function' || isNoCall) { results.push({ status: 'fulfilled', value: method }); continue; } try { if (this.debug) { console.log('Before', plugin.name, cleanEndpoint, invokeArgs); } // Get the context object for the method const context = contextPath ? this.getEndpointValue(plugin, contextPath) : plugin; // Invoke the method with proper context const result = await method.apply(context, invokeArgs); if (this.debug) { console.log('After', plugin.name, cleanEndpoint, 'completed'); } results.push({ status: 'fulfilled', value: result }); } catch (error) { const errorMessage = `Failed to invoke plugin: ${plugin.name}!${cleanEndpoint}`; console.error(errorMessage, error); if (shouldThrow) { throw error; } results.push({ status: 'rejected', reason: error }); } } return this._processResult(results, options); case 'parallel': default: // Parallel execution (default behavior) const promises = plugins.map(async plugin => { const method = this.getEndpointValue(plugin, cleanEndpoint); if (typeof method !== 'function' || isNoCall) { return method; } if (this.debug) { console.log('Before', plugin.name, cleanEndpoint, invokeArgs); } // Get the context object for the method const context = contextPath ? this.getEndpointValue(plugin, contextPath) : plugin; // Invoke the method with proper context const result = method.apply(context, invokeArgs); if (this.debug) { console.log('After', plugin.name, cleanEndpoint, 'completed'); } return result; }); // Set up timeout mechanism const timeout = options.timeout !== undefined ? options.timeout : this.config.timeout; const controller = new AbortController(); const timeoutId = setTimeout(() => { controller.abort(); }, timeout); try { /** @type {InvocationResult[]} */ const result = await Promise.allSettled(promises); // Handle throwing behavior before result processing if (shouldThrow) { const firstRejected = result.find(({ status }) => status === "rejected"); if (firstRejected ) { // @ts-ignore throw firstRejected.reason; } } return this._processResult(result, options); } finally { clearTimeout(timeoutId); } } //throw new Error(`Invalid option.result: '${options.result}'`) } /** * Processes the results of the endpoint invocations * @param {InvocationResult[]} result The result of the invocation * @param {InvokeOptions} options * @return {any} {@see {InvocationOptions}} */ _processResult(result, options = {}) { const resultMode = options.result || this.config.result || 'full'; switch (resultMode) { case 'first': const firstFulfilled = result.find(({ status }) => status === "fulfilled"); // @ts-ignore return firstFulfilled ? firstFulfilled.value : undefined; case 'values': // Return only the values from fulfilled results return result .filter(({ status }) => status === "fulfilled") // @ts-ignore .map(({ value }) => value); case 'full': default: return result; } } /** * Compute dependency-resolved plugin order using topological sort * @throws {Error} If circular dependencies are detected * @private */ computeDependencyOrder() { const plugins = [...this.registeredPlugins]; const resolved = []; const visiting = new Set(); const visited = new Set(); /** * Depth-first search for topological sorting * @param {PluginConfig} plugin - Plugin to visit * @param {string[]} path - Current dependency path (for circular dependency detection) */ const visit = (plugin, path = []) => { if (visiting.has(plugin.name)) { const cycle = [...path, plugin.name].join(' → '); throw new Error(`Circular dependency detected: ${cycle}`); } if (visited.has(plugin.name)) { return; // Already processed } visiting.add(plugin.name); // Visit all dependencies first for (const depName of plugin.deps || []) { const dependency = this.pluginsByName.get(depName); if (dependency) { visit(dependency, [...path, plugin.name]); } // Missing dependencies are handled in getPlugins() } visiting.delete(plugin.name); visited.add(plugin.name); resolved.push(plugin); }; // Visit all plugins for (const plugin of plugins) { if (!visited.has(plugin.name)) { visit(plugin); } } this.dependencyOrderedPlugins = resolved; // Debug output const pluginNames = resolved.map(p => p.name); if (this.debug) { console.log(`🔗 Plugin dependency order: ${pluginNames.join(' → ')}`); } } /** * Sort an array by a property (utility method) * @param {Array} array - Array to sort * @param {string} [sortProperty='order'] - Property to sort by */ sort(array, sortProperty = 'order') { array.sort((a, b) => { const orderA = a.hasOwnProperty(sortProperty) ? a[sortProperty] : 1000000; const orderB = b.hasOwnProperty(sortProperty) ? b[sortProperty] : 1000000; return orderA - orderB; }); } /** * Process raw plugins array (utility method for pre-processing) * @param {Function} callback - Function to process the plugins array */ processRawPlugins(callback) { callback(this.registeredPlugins); this.endpointCache.clear(); this.computeDependencyOrder(); } /** * Convert Plugin instance to plugin object using getExtensionPoints() method * @param {Plugin} pluginInstance - Plugin instance to convert * @returns {Object} Plugin configuration object * @private */ convertPluginInstance(pluginInstance) { const pluginObject = { name: pluginInstance.name, deps: [...pluginInstance.deps], // Create a copy to avoid reference issues }; const getPointsFn = pluginInstance.getExtensionPoints; if (typeof getPointsFn === 'function') { const endpoints = getPointsFn.call(pluginInstance); // Apply endpoint mappings to plugin object using dot notation for (const [endpointPath, method] of Object.entries(endpoints)) { this.setNestedProperty(pluginObject, endpointPath, method); } } return pluginObject; } /** * Set nested property in object using dot notation path * @param {Object} obj - Object to set property on * @param {string} path - Dot notation path * @param {*} value - Value to set * @private */ setNestedProperty(obj, path, value) { const pathParts = path.split('.'); let current = obj; for (let i = 0; i < pathParts.length - 1; i++) { const part = pathParts[i]; if (!(part in current)) { current[part] = {}; } current = current[part]; } current[pathParts[pathParts.length - 1]] = value; } } // Export the class export default PluginManager;