UNPKG

@storm-stack/plugin-system

Version:

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

321 lines (320 loc) 12.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.PluginManager = void 0; var _dateTime = require("@storm-stack/date-time"); var _errors = require("@storm-stack/errors"); var _fileSystem = require("@storm-stack/file-system"); var _serialization = require("@storm-stack/serialization"); var _stringFns = require("@storm-stack/string-fns"); var _types = require("@storm-stack/types"); var _utilities = require("@storm-stack/utilities"); var _glob = require("glob"); var _md = _interopRequireDefault(require("md5")); var _promises = require("node:fs/promises"); var _toposort = _interopRequireDefault(require("toposort")); var _errors2 = require("../errors.cjs"); var _types2 = require("../types.cjs"); var _createResolver = require("../utilities/create-resolver.cjs"); function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } const PLUGIN_CONFIG_JSON = "plugin.json"; 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 === _types2.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: _types2.PluginDiscoveryMode.FALLBACK }; this._options = (0, _utilities.deepMerge)(defaults, options); if (!this._options.tsconfig || !(0, _fileSystem.exists)(this._options.tsconfig)) { this._options.tsconfig = (0, _fileSystem.joinPaths)(this._options.rootPath, "tsconfig.json"); if (!(0, _fileSystem.exists)(this._options.tsconfig)) { this._options.tsconfig = (0, _fileSystem.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 = (0, _createResolver.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 (0, _glob.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 (!(0, _types.isSetObject)(instance)) { throw new _errors.StormError(_errors2.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 = _dateTime.StormDateTime.current()) => { const instance = await this.instantiate(provider, options); if (!instance) { return { [provider]: new _errors.StormError(_errors2.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] = _errors.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.default.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 ((0, _types.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 === _types2.PluginDiscoveryMode.AUTO || this._options.discoveryMode === _types2.PluginDiscoveryMode.FALLBACK)) { await this.discover(); if (this._registry.has(provider)) { definition = this._registry.get(provider); } } if (!definition) { throw new _errors.StormError(_errors2.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 (0, _md.default)(`${provider}::${_serialization.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 (!(0, _types.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 _errors.StormError(_errors2.PluginSystemErrorCode.module_not_found, { message: `Cannot find plugin loader ${loader}` }); } module = await Promise.resolve(`${resolved}`).then(s => require(s)); } catch (origError) { this._logger.error(`Unable to initialize loader module ${loader}: ${origError}`); throw new _errors.StormError(_errors2.PluginSystemErrorCode.module_not_found, { message: (0, _types.isSet)(origError) ? `Error: ${_serialization.StormParser.stringify(origError)}` : void 0 }); } if (!module) { this._logger.error(`Plugin provider ${loader} cannot be found`); throw new _errors.StormError(_errors2.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 = (0, _fileSystem.findFilePath)(configPath); } else { configPath = (0, _fileSystem.joinPaths)(configPath, PLUGIN_CONFIG_JSON); packagePath = configPath; } if (!(0, _fileSystem.exists)(configPath)) { return void 0; } const fileContent = await (0, _promises.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 ((0, _fileSystem.exists)((0, _fileSystem.joinPaths)(configPath, "package.json"))) { const packageContent = await (0, _promises.readFile)((0, _fileSystem.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("@", _types.EMPTY_STRING).replaceAll("/", "-").replaceAll("\\", "-").replaceAll(" ", "-"); provider ??= packageJson.name; name ??= (0, _stringFns.titleCase)(packageJson.name); version ??= packageJson.version; description ??= packageJson.description; } name ??= (0, _fileSystem.findContainingFolder)(provider); return { id: id ?? (0, _stringFns.kebabCase)(name.trim().replaceAll("@", _types.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 }; }; } exports.PluginManager = PluginManager;