Spaces:
Build error
Build error
| /** | |
| * 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<string, any>} 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<string, PluginConfig>} */ | |
| this.pluginsByName = new Map(); | |
| /** @type {Map<string, any>} */ | |
| this.pluginApis = new Map(); | |
| /** @type {PluginConfig[]} */ | |
| this.registeredPlugins = []; | |
| /** @type {PluginConfig[]} */ | |
| this.dependencyOrderedPlugins = []; | |
| /** @type {Map<string, PluginConfig[]>} */ | |
| 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<InvocationResult[] | any[] | any>} 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; |