UNPKG

@storm-stack/plugin-system

Version:

A library used to create and manage a plugin-styled architecture in a TypeScript application.

388 lines (387 loc) 12.4 kB
import { StormDateTime } from "@storm-stack/date-time"; import { StormError } from "@storm-stack/errors"; import { exists, findContainingFolder, findFilePath, joinPaths } from "@storm-stack/file-system"; import { StormParser } from "@storm-stack/serialization"; import { kebabCase, titleCase } from "@storm-stack/string-fns"; import { EMPTY_STRING, isFunction, isSet, isSetObject, isString } from "@storm-stack/types"; import { deepMerge } from "@storm-stack/utilities"; import { glob } from "glob"; import md5 from "md5"; import { readFile } from "node:fs/promises"; import toposort from "toposort"; import { PluginSystemErrorCode } from "../errors.mjs"; import { PluginDiscoveryMode } from "../types.mjs"; import { createResolver } from "../utilities/create-resolver.mjs"; const PLUGIN_CONFIG_JSON = "plugin.json"; export class PluginManager { _options; _hasDiscovered = false; _registry; _store; _hooks; _logger; _loaders; _loaderResolver; static create = async (logger, options) => { const pluginManager = new PluginManager( logger, options ); await pluginManager._getLoader(pluginManager._options.defaultLoader); if (pluginManager._options.discoveryMode === PluginDiscoveryMode.AUTO) { await pluginManager.discover(); } return pluginManager; }; /** * Creates a new plugin manager object. * * @param config - The base storm workspace configuration. * @param options - The plugin configuration options. */ constructor(logger, options) { const defaults = { rootPath: process.env.STORM_WORKSPACE_ROOT || process.cwd() || ".", useNodeModules: true, autoInstall: true, discoveryMode: PluginDiscoveryMode.FALLBACK }; this._options = deepMerge(defaults, options); if (!this._options.tsconfig || !exists(this._options.tsconfig)) { this._options.tsconfig = joinPaths( this._options.rootPath, "tsconfig.json" ); if (!exists(this._options.tsconfig)) { this._options.tsconfig = joinPaths( this._options.rootPath, "tsconfig.base.json" ); } } this._logger = logger; this._registry = /* @__PURE__ */ new Map(); this._store = /* @__PURE__ */ new Map(); this._hooks = /* @__PURE__ */ new Map(); this._loaders = /* @__PURE__ */ new Map(); this._loaderResolver = createResolver( options.rootPath, this._options.tsconfig, this._options.autoInstall ); } discover = async () => { if (this._hasDiscovered) { return this._registry; } const fileGlob = this._globExpression(); this._logger.info(`Discovering plugins using glob ${fileGlob}`); const paths = await glob(fileGlob); await Promise.all(paths.map((element) => this.register(element)) ?? []); this._hasDiscovered = true; return this._registry; }; getInstance = (provider, options = {}) => { return this._store.get(this._getCacheId(provider, options)); }; instantiate = async (provider, options = {}) => { let instance = this.getInstance(provider, options); if (instance) { return instance; } const definition = await this.register(provider); const loader = await this._getLoader( definition.loader ?? this._options.defaultLoader ); instance = await loader.load(definition, options); if (!isSetObject(instance)) { throw new StormError(PluginSystemErrorCode.plugin_loading_failure, { message: `The plugin "${provider}" did not return an object after loading.` }); } this._store.set( this._getCacheId(instance.definition.provider, options), instance ); await Promise.all( instance.definition.dependencies.map( (dependency) => this.instantiate(dependency, options) ) ); return instance; }; execute = async (provider, context, options = {}, executionDateTime = StormDateTime.current()) => { const instance = await this.instantiate(provider, options); if (!instance) { return { [provider]: new StormError( PluginSystemErrorCode.plugin_loading_failure, { message: `The plugin "${provider}" could not be loaded prior to execution.` } ) }; } instance.executionDateTime = executionDateTime; this._store.set(this._getCacheId(provider, options), instance); const dependenciesResults = await Promise.all( instance.definition.dependencies.map( (dependency) => this.execute(dependency, context, options, executionDateTime) ) ); const result = dependenciesResults.reduce( (ret, dependenciesResult) => { for (const key of Object.keys(dependenciesResult)) { if (!ret[key]) { ret[key] = dependenciesResult[key]; } } return ret; }, {} ); try { instance.loader.process(context, instance, options); } catch (error_) { result[provider] = StormError.create(error_); } return result; }; invokeHook = async (name, context, handler) => { let listeners = []; if (this._hooks.has(name)) { listeners = this._hooks.get(name); } else { const hooks = []; for (const [provider, value] of this._store.entries()) { if (value.module.hooks?.[name]) { const plugin = this._store.get(provider); hooks.push({ provider, listener: value.module.hooks[name], dependencies: plugin?.definition?.dependencies ?? [] }); } } const edges = hooks.reduce( (ret, hook) => { hook.dependencies.filter( (dependency) => hooks.some((depHook) => depHook.provider === dependency) ).map((dependency) => ret.push([hook.provider, dependency])); return ret; }, [] ); listeners = toposort.array( hooks.map((hook) => hook.provider), edges ).map((hook) => hooks.find((h) => h.provider === hook).listener); } let nextContext = context; const callbacks = []; for (const listener of listeners) { const result = await Promise.resolve(listener(nextContext)); if (isFunction(result)) { callbacks.push(result); } else { nextContext = result ?? nextContext; } } if (handler) { nextContext = await Promise.resolve(handler(nextContext)); } for (const callback of callbacks) { nextContext = await Promise.resolve(callback(nextContext)); } return nextContext; }; register = async (provider) => { let definition = this._registry.get(provider); if (definition) { return definition; } definition = await this._getDefinition(provider); if (!definition && (this._options.discoveryMode === PluginDiscoveryMode.AUTO || this._options.discoveryMode === PluginDiscoveryMode.FALLBACK)) { await this.discover(); if (this._registry.has(provider)) { definition = this._registry.get(provider); } } if (!definition) { throw new StormError(PluginSystemErrorCode.plugin_not_found, { message: `Could not find plugin provider ${provider}. Discovered plugins: ${Object.keys( this._registry ).map((key) => { const found = this._registry.get(key); return `${found?.name} v${found?.version} - ${found?.configPath}`; }).join("\n")}` }); } this._registry.set(provider, definition); await Promise.all( definition.dependencies.map((element) => this.register(element)) ); return definition; }; getRegistry() { return this._registry; } getLoaders() { return this._loaders; } getStore() { return this._store; } /** * Generates a cache ID for the plugin and the config. * * @param provider - The plugin provider. * @param options - The options for the plugin. * @returns The cache ID. */ // eslint-disable-next-line class-methods-use-this _getCacheId(provider, options) { return md5(`${provider}::${StormParser.stringify(options)}`); } /** * Builds the globbing expression based on the configuration options. * * @returns The globbing expression. */ _globExpression() { return this._options.useNodeModules ? `${this._options.rootPath}/**/${PLUGIN_CONFIG_JSON}` : `${this._options.rootPath}/!(node_modules)/**/${PLUGIN_CONFIG_JSON}`; } /** * Gets the loader module. * * @param loader - The loader module to retrieve. * @returns The loader module. */ _getLoader = async (loader) => { if (!isString(loader)) { const instance2 = new loader.loader( this._options.rootPath, this._options.tsconfig, this._options.autoInstall ); this._loaders.set(loader.provider, instance2); return instance2; } if (this._loaders.has(loader)) { return this._loaders.get(loader); } let module; try { const resolved = await this._loaderResolver(loader); if (!resolved) { throw new StormError(PluginSystemErrorCode.module_not_found, { message: `Cannot find plugin loader ${loader}` }); } module = await import(resolved); } catch (origError) { this._logger.error( `Unable to initialize loader module ${loader}: ${origError}` ); throw new StormError(PluginSystemErrorCode.module_not_found, { message: isSet(origError) ? `Error: ${StormParser.stringify(origError)}` : void 0 }); } if (!module) { this._logger.error(`Plugin provider ${loader} cannot be found`); throw new StormError(PluginSystemErrorCode.module_not_found, { message: `Plugin provider ${loader} cannot be found` }); } const instance = new module.PluginLoader( this._options.rootPath, this._options.tsconfig, this._options.autoInstall ); this._loaders.set(loader, instance); return instance; }; /** * Gets the plugin definition from the plugin configuration file. * * @param _configPath - The path to the plugin configuration file. * @returns The plugin definition. */ _getDefinition = async (_configPath) => { let configPath = _configPath; let packagePath; if (configPath.includes(PLUGIN_CONFIG_JSON)) { packagePath = findFilePath(configPath); } else { configPath = joinPaths(configPath, PLUGIN_CONFIG_JSON); packagePath = configPath; } if (!exists(configPath)) { return void 0; } const fileContent = await readFile(configPath); const configJson = JSON.parse(fileContent.toString()); let id = configJson?.id; let name = configJson?.name; let description = configJson?.description; let provider = configJson?.provider; let version = configJson?.version; let dependencies = configJson?.dependencies ?? []; const imagePath = configJson?.imagePath; const options = configJson?.options ?? {}; const tags = configJson?.tags ?? []; if (exists(joinPaths(configPath, "package.json"))) { const packageContent = await readFile( joinPaths(configPath, "package.json") ); const packageJson = JSON.parse(packageContent.toString()); if (packageJson.peerDependencies) { dependencies = Object.keys(packageJson.peerDependencies).reduce( (ret, key) => { if (!ret.includes(key)) { ret.push(key); } return ret; }, dependencies ); } id ??= packageJson.name.trim().replaceAll("@", EMPTY_STRING).replaceAll("/", "-").replaceAll("\\", "-").replaceAll(" ", "-"); provider ??= packageJson.name; name ??= titleCase(packageJson.name); version ??= packageJson.version; description ??= packageJson.description; } name ??= findContainingFolder(provider); return { id: id ?? kebabCase( name.trim().replaceAll("@", EMPTY_STRING).replaceAll("/", "-").replaceAll("\\", "-").replaceAll(" ", "-") ), provider: provider ?? packagePath ?? configPath, name, version: version ?? "0.0.0", description, dependencies, packagePath, configPath, imagePath, options, tags, loader: configJson.loader ?? this._options.defaultLoader }; }; }