@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
JavaScript
"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;