pdf-tei-editor / app /src /modules /plugin-manager.js
cmboulanger's picture
disaster-recovery deploy of pdf-tei-editor
6a49f21 verified
Raw
History Blame Contribute Delete
19.5 kB
/**
* 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;