Files
api/app/core/plugin-manager.ts
2021-09-07 13:46:23 +09:30

401 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import fs from 'fs';
import path from 'path';
import pIteration from 'p-iteration';
import glob from 'glob';
import { validate as validateArgument } from 'bycontract';
import deepMerge from 'deepmerge';
import { PackageJson } from 'type-fest';
import * as core from '.';
import { coreLogger } from './log';
import { paths } from './paths';
import { AppError } from './errors';
interface PluginModule {
/** The module's absolute file path. */
readonly filePath: string;
/** If the plugin is currently active. */
readonly isActive: boolean;
}
type PluginModuleWithName = PluginModule & {
readonly name: string;
};
export interface Plugin {
/** If the plugin is currently active. */
readonly isActive: boolean;
/** Modules belonging to the plugin. */
readonly modules: Readonly<PluginModuleWithName[] | PluginModule[]>;
/** If the plugin is currently disabled. */
readonly disabled?: string;
}
interface SetActiveStateOptions {
readonly plugin: Readonly<Plugin>;
readonly pluginName: string;
readonly moduleName: string;
readonly active: boolean;
}
/**
* Set a module to active/inactive.
*
* @param plugins All the registered plugins.
* @param plugin The plugin being modified.
* @param pluginName The name plugin to set active.
* @param moduleName The module name.
* @param activeState If the module/plugin should be set to active or inactive.
*/
const setActiveState = (
plugins: Readonly<Map<string, Readonly<Plugin>>>,
options: Readonly<SetActiveStateOptions>
): void => {
const { plugin, pluginName, moduleName, active } = options;
validateArgument(active, 'boolean');
// If we have a module let's set both the plugin and module
if (moduleName) {
validateArgument(moduleName, 'string');
const object = deepMerge(plugin, {
isActive: active,
modules: {
[moduleName]: {
isActive: active
}
}
});
plugins.set(pluginName, object);
return;
}
// Otherwise set the module as in/active
{
const object = deepMerge(plugin, {
isActive: active,
// If activeState = in-active set all modules as in-active too
modules: (active ? {} : Object.fromEntries(Object.keys(plugin.modules).map(moduleName => {
return [moduleName, {
isActive: active
}];
})))
});
plugins.set(pluginName, object);
}
};
/**
* Plugin manager
*
* @name PluginManager
* @class
* @global
* @property {Map} plugins
*/
export class PluginManager {
constructor(private readonly plugins: Map<Readonly<string>, Readonly<Plugin>> = new Map()) {}
/**
* Get plugin info
*
* @param {string} pluginName Name of the plugin.
* @param {string} moduleName Name of the module.
* @memberof PluginManager
*/
get(pluginName: string): Plugin | void;
get(pluginName: string, moduleName?: string): PluginModule | void;
get(pluginName: string, moduleName?: string): unknown {
const plugin = this.plugins.get(pluginName);
// If it's missing modules it's likely disabled
if (!plugin || !plugin.modules) {
return;
}
if (!moduleName) {
return plugin;
}
if (!Object.keys(plugin.modules).includes(moduleName)) {
return;
}
return (plugin.modules[moduleName] as PluginModule);
}
/**
* Run a plugin's init file
*
* @param pluginPath Absolute or relative(based on `path.get('plugins')`) path to the plugin's root directory.
* @async
* @memberof PluginManager
*/
async init(pluginPath: string): Promise<void> {
const isNamespaced = pluginPath.startsWith('@') && pluginPath.includes('/');
const isAbsolute = pluginPath.startsWith('/');
const absoluteName = pluginPath.slice(pluginPath.lastIndexOf('/') + 1);
const relativeName = isNamespaced ? pluginPath.split('@')[1].split('/')[1] : pluginPath;
const pluginName = isAbsolute ? absoluteName : relativeName;
const pluginCwd = isAbsolute ? pluginPath.slice(0, pluginPath.lastIndexOf('/')) : paths.get('plugins')!;
const pluginDir = path.join(pluginCwd, pluginName);
const pluginPackagePath = path.join(pluginDir, 'package.json');
const pluginHasPackage = fs.existsSync(pluginPackagePath);
// Ensure we have a package.json for the plugin
if (!pluginHasPackage) {
throw new AppError(`Plugin "${pluginName}" is missing its "package.json".`);
}
// Get the plugin's package.json
// eslint-disable-next-line @typescript-eslint/no-var-requires
const pluginPackage: PackageJson = require(pluginPackagePath);
if (!pluginPackage.main) {
throw new AppError(`Plugin "${pluginName}" is missing its "main" field in the "package.json".`);
}
// Skip any plugins without a main file
const packageMainPath = path.join(pluginDir, pluginPackage.main);
if (!pluginPackage.main) {
coreLogger.error('Plugin "%s" has no main field in its package.json', pluginName);
return;
}
if (!fs.existsSync(packageMainPath)) {
coreLogger.error('Plugin "%s" is missing its main file "%s"', pluginName, packageMainPath);
return;
}
// Create context
const context = {
params: {}
};
// Resolve plugin's main
let plugin;
try {
coreLogger.debug('Plugin "%s" loading main file.', pluginName);
plugin = require(packageMainPath);
} catch (error: unknown) {
coreLogger.error('Plugin "%s" failed to load: %s', pluginName, error);
// Disable plugin as it failed to load it's init file
this.disable(pluginName);
return;
}
// Initialize plugin
await Promise.resolve(plugin.init(context, core)).then(async () => {
coreLogger.debug('Plugin "%s" loaded successfully.', pluginName);
// Add to manager
this.add(pluginName);
// Register all modules
const modulesDir = path.join(pluginDir, 'modules');
const modulesGlob = path.join(modulesDir, '/**/*.js');
await pIteration.forEach(glob.sync(modulesGlob), async (filePath: string) => {
const moduleName = filePath.replace(modulesDir + '/', '').replace('.js', '');
this.add(pluginName, moduleName, filePath);
});
}).catch(error => {
coreLogger.error('Plugin "%s" failed to run its init function: %s', pluginName, error);
// Disable plugin as it failed to run it's init file
this.disable(pluginName);
});
}
/**
* Add a plugin to the {@link PluginManager | plugin manager}.
*
* @param pluginName Name of the plugin.
* @param moduleName Name of the module.
* @param moduleFilePath Path to the module's root directory.
* @param disabled If the plugin is disabled from core.
* @memberof PluginManager
*/
add(pluginName: string, moduleName?: string, moduleFilePath?: string, disabled = false): void {
// Get existing plugin so we don't override all opts
const plugin = this.plugins.get(pluginName)!;
// If we have a module let's set both the plugin and module
if (moduleName) {
coreLogger.debug('Plugin Manager: Adding module [%s]', moduleName);
const object = deepMerge(plugin, {
isActive: true,
disabled,
modules: {
[moduleName]: {
filePath: moduleFilePath,
isActive: true
}
}
});
this.plugins.set(pluginName, object);
return;
}
// Otherwise just set the module
coreLogger.debug('Plugin Manager: Adding plugin [%s]', pluginName);
const object = deepMerge(plugin, {
isActive: true,
disabled,
modules: {}
});
this.plugins.set(pluginName, object);
}
/**
* Disable plugin and modules.
*
* @param pluginName Name of the plugin.
* @memberof PluginManager
*/
disable(pluginName: string): void {
coreLogger.debug('Plugin Manager: Disabling plugin [%s]', pluginName);
const plugin = this.plugins.get(pluginName)!;
const object = deepMerge(plugin, {
disabled: true
});
this.plugins.set(pluginName, object);
}
/**
* Set plugin and/or module as active.
*
* @param pluginName Name of the plugin.
* @param moduleName Name of the module.
* @memberof PluginManager
*/
setActive(pluginName: string, moduleName: string): void {
const plugin = this.plugins.get(pluginName);
if (!plugin) {
throw new AppError(`Plugin ${pluginName} not found.`);
}
setActiveState(this.plugins, {
plugin,
pluginName,
moduleName,
active: true
});
}
/**
* Deactivate a plugin.
*
* @param pluginName Name of the plugin.
* @param moduleName Name of the module.
*/
setInActive(pluginName: string, moduleName: string): void {
const plugin = this.plugins.get(pluginName);
if (!plugin) {
throw new AppError(`Plugin ${pluginName} not found.`);
}
setActiveState(this.plugins, {
plugin,
pluginName,
moduleName,
active: false
});
}
/**
* Check if plugin and/or module as active.
*
* @param pluginName Name of the plugin.
* @param moduleName Name of the module.
* @returns If the plugin is active or not.
* @memberof PluginManager
*/
isActive(pluginName: string, moduleName: string): boolean {
const plugin = this.plugins.get(pluginName);
// If it's missing modules it's likely disabled
if (!plugin || !plugin.modules || plugin.disabled) {
return false;
}
if (moduleName) {
if (Object.keys(plugin.modules).includes(moduleName)) {
return (plugin.modules[moduleName] as PluginModule).isActive;
}
return false;
}
return plugin.isActive;
}
/**
* Check if plugin is installed.
*
* @param pluginName Name of the plugin.
* @param moduleName Name of the module.
* @returns `true` if the plugin is installed, otherwise `false`.
*/
isInstalled(pluginName: string, moduleName: string): boolean {
validateArgument(pluginName, 'string');
const plugin = this.plugins.get(pluginName);
if (!plugin) {
return false;
}
if (moduleName) {
validateArgument(moduleName, 'string');
return Object.keys(plugin.modules).includes(moduleName);
}
return true;
}
/**
* Get all plugins.
*
* @returns Names of all the currently registered plugins.
*/
getAllPlugins(): Plugin[] {
const keys = [...this.plugins.keys()];
return keys.map(name => {
const plugin = this.plugins.get(name)!;
return {
name,
...plugin
};
});
}
/**
* Get active plugins.
*
* @returns All the active plugins.
*/
getActivePlugins(): Plugin[] {
return this.getAllPlugins().filter(plugin => plugin.isActive);
}
/**
* Remove all plugins from manager.
*
* @memberof PluginManager
*/
reset(): void {
this.plugins.clear();
}
}
export const pluginManager = new PluginManager();