core/src/plugin/manager.ts

414 lines
12 KiB
TypeScript

import 'reflect-metadata';
import * as path from 'path';
import { IEnvironment, Service } from '../types';
import { IPlugin, IPluginManifest } from './plugin';
import { PluginConfiguration } from '../types';
import { PluginConfigurator } from './config';
import { ScopedEventEmitter, requireNoCache } from '../util';
import { NPMExecutor } from '../npm/executor';
import { logger } from '../core/logger';
/**
* Plugin management and execution system
*/
export class PluginManager {
private plugins: Map<string, IPlugin> = new Map();
private configs: PluginConfigurator = new PluginConfigurator(this.environment);
private restartQueue: Map<string, IPluginManifest> = new Map();
private stopping = false;
constructor(
public availablePlugins: IPluginManifest[],
private stream: ScopedEventEmitter,
private environment: IEnvironment,
private npm: NPMExecutor
) {
this.addEvents();
}
/**
* Get a plugin manifest by plugin name, if it is available.
* @param name Plugin name
* @returns Plugin manifest
*/
public getAvailableByName(name: string): IPluginManifest | undefined {
return this.availablePlugins.find(p => p.name === name);
}
/**
* Get a loaded plugin by name.
* @param name Plugin name
* @returns Plugin instance
*/
public getLoadedByName(name: string): IPlugin | undefined {
if (this.plugins.has(name)) {
return this.plugins.get(name) as IPlugin;
}
return;
}
/**
* Get a list of all loaded plugins.
* @returns List of loaded plugins
*/
public getLoaded(): IPlugin[] {
return Array.from(this.plugins.values());
}
/**
* Add plugin manifests as available (ready to load).
*
* Also used to replace existing manifest after installing a plugin update.
* @param manifest Plugin manifest or list of
* @returns true on success
*/
public addAvailable(manifest: IPluginManifest | IPluginManifest[]): boolean {
// Automatically add arrays of manifests
if (Array.isArray(manifest)) {
let returnValue = true;
for (const mf of manifest) {
if (!this.addAvailable(mf)) {
returnValue = false;
}
}
return returnValue;
}
// Replace the manifest
if (this.getAvailableByName(manifest.name)) {
this.removeAvailable(manifest, false);
this.availablePlugins.push(manifest);
return true;
}
if (!(/^[a-zA-Z0-9_-]+$/.test(manifest.name))) {
throw new Error('Illegal name for a plugin!');
}
this.availablePlugins.push(manifest);
return true;
}
/**
* Remove a plugin from available list, usually called after uninstalling a plugin.
* @param plugin Plugin name, plugin manifest or list of
* @param unload If they're loaded, unload them
* @returns true on success
*/
public removeAvailable(
plugin: IPluginManifest | IPluginManifest[] | string | string[],
unload = true
): boolean {
let returnValue = false;
if (Array.isArray(plugin)) {
returnValue = true;
for (const mf of plugin) {
if (!this.removeAvailable(mf)) {
returnValue = false;
}
}
return returnValue;
}
// By name
if (typeof plugin === 'string') {
const p = this.getAvailableByName(plugin);
if (!p) {
return false;
}
plugin = p;
}
const result: IPluginManifest[] = [];
for (const mf of this.availablePlugins) {
if (mf.name === plugin.name) {
returnValue = true;
if (unload) {
this.stream.emit('pluginUnload', mf);
}
continue;
}
result.push(mf);
}
this.availablePlugins = result;
return returnValue;
}
/**
* Load a plugin into memory and start executing it.
* @param plugin Plugin manifest
* @returns Plugin instance
*/
public async load(plugin: IPluginManifest): Promise<IPlugin> {
// Ignore loading when we're shutting down
if (this.stopping) {
throw new Error('Squeebot is shutting down');
}
// Don't load plugins twice
const ready = this.getLoadedByName(plugin.name);
if (ready) {
return ready;
}
// Dependencies required to load
const requires = [];
// Dependencies available
const available = [];
logger.debug('Loading plugin', plugin.name);
// Check dependencies
for (let dep of plugin.dependencies) {
let optional = false;
if (dep.indexOf('?') !== -1) {
dep = dep.replace('?', '');
optional = true;
}
if (dep === plugin.name) {
throw new Error(`Plugin "${plugin.name}" cannot depend on itself.`);
}
const existing = this.getLoadedByName(dep);
if (existing) {
available.push(existing.manifest);
continue;
}
const isLoaded = this.getAvailableByName(dep);
if (!isLoaded) {
if (optional) {
continue;
}
throw new Error(`Plugin dependency "${dep}" resolution failed for "${plugin.name}"`);
}
requires.push(isLoaded);
}
// Load dependencies
logger.debug('Loading plugin %s dependencies', plugin.name);
for (const manifest of requires) {
try {
await this.load(manifest);
available.push(manifest);
} catch (e: any) {
logger.error(e.stack);
throw new Error(`Plugin dependency "${manifest.name}" loading failed for "${plugin.name}"`);
}
}
// Load npm modules
logger.debug('Loading plugin %s npm modules', plugin.name);
for (const depm of plugin.npmDependencies) {
try {
await this.npm.installPackage(depm);
} catch (e: any) {
logger.error(e.stack);
throw new Error(`Plugin dependency "${depm}" installation failed for "${plugin.name}"`);
}
}
// Load the configuration
const config: PluginConfiguration = await this.configs.loadConfig(plugin);
// Ensure plugin has a full path
if (!plugin.fullPath) {
plugin.fullPath = path.join(this.environment.pluginsPath, plugin.name);
}
// Load the module
logger.debug('Loading plugin %s module', plugin.name);
const PluginModule = requireNoCache(path.resolve(plugin.fullPath, plugin.main)) as any;
if (!PluginModule) {
throw new Error(`Plugin "${plugin.name}" loading failed.`);
}
// Find default configuration, if it's configured, load the configuration
if (Reflect.hasOwnMetadata('sb:defconf', PluginModule) && config) {
config.setDefaults(Reflect.getOwnMetadata('sb:defconf', PluginModule));
await config.load();
}
// Construct an instance of the module
logger.debug('Instancing plugin %s', plugin.name);
const loaded = new PluginModule(plugin, this.stream, config);
try {
// Give the plugin a service
if (Reflect.hasOwnMetadata('sb:service', PluginModule)) {
loaded.service = new Service(Reflect.getOwnMetadata('sb:service', PluginModule));
}
// Call the initializer
if (loaded.initialize) {
loaded.initialize.call(loaded);
}
// Call methods that are supposed to be executed automatically on load.
const autorunFunctions: Array<string> = Reflect.getOwnMetadata('sb:autorunners', PluginModule.prototype);
if (autorunFunctions?.length) {
for (const autoFunction of autorunFunctions) {
if (!loaded[autoFunction]) continue;
if (autoFunction === 'initialize') continue;
loaded[autoFunction].call(loaded);
}
}
} catch (e: any) {
logger.error(e.stack);
throw new Error(`Plugin "${plugin.name}" initialization failed.`);
}
this.plugins.set(plugin.name, loaded);
this.stream.emit('pluginLoaded', loaded);
// Inform the new plugin that it's dependencies are available
for (const depn of available) {
this.stream.emitTo(plugin.name, 'pluginLoaded', this.plugins.get(depn.name));
}
return loaded;
}
/**
* Restart a loaded plugin.
* @param mf Plugin instance, plugin manifest or plugin name
* @param wait Waits for the plugin to be available before resolving
*/
public async restart(mf: IPluginManifest | IPlugin | string, wait = false): Promise<void> {
let manifest;
if (typeof mf === 'string') {
manifest = this.getAvailableByName(mf);
} else if ('manifest' in mf) {
manifest = mf.manifest;
} else {
manifest = mf;
}
if (!manifest) {
throw new Error('Plugin not found');
}
const pluginName = manifest.name;
if (!this.getLoadedByName(pluginName)) {
this.load(manifest);
return;
}
this.restartQueue.set(pluginName, manifest);
this.stream.emitTo(pluginName, 'pluginUnload', pluginName);
if (wait) {
return new Promise((resolve, reject) => {
let retries = 0;
const checkInterval = setInterval(() => {
// Plugin has been loaded, resolve the promise
if (this.getLoadedByName(pluginName)) {
clearInterval(checkInterval);
return resolve();
}
// Increment retry count and wait for next iteration
retries++;
if (retries >= 20) {
// Give up after 10 seconds
clearInterval(checkInterval);
reject(new Error('Could not determine loaded status within a reasonable time frame.'));
}
}, 500);
});
}
}
/**
* Listen for plugin status events.
*/
private addEvents(): void {
this.stream.on('core', 'pluginLoad', (mf: IPluginManifest | string) => {
if (typeof mf === 'string') {
const manifest = this.getAvailableByName(mf);
if (manifest) {
return this.load(manifest).catch((e) => logger.error(e.stack));
}
}
});
this.stream.on('core', 'pluginUnloaded', (mf: IPlugin | string) => {
if (typeof mf !== 'string') {
if (mf.manifest && mf.service != null) {
mf.service.die();
}
mf = mf.manifest.name;
} else {
const st = this.getLoadedByName(mf);
if (st && st.manifest && st.service != null) {
st.service.die();
}
}
logger.debug('%s has unloaded.', mf);
// Delete plugin from the list of loaded plugins
this.plugins.delete(mf);
// Remove all listeners created by the plugin
this.stream.removeName(mf);
// Restart, if applicable
if (this.restartQueue.has(mf) && !this.stopping) {
const manifest = this.restartQueue.get(mf) as IPluginManifest;
this.restartQueue.delete(mf);
this.load(manifest).catch(e => console.error(e));
}
});
this.stream.on('core', 'pluginKill', (mf: IPlugin | string) => {
const pluginName = (typeof mf === 'string' ? mf : mf.manifest.name);
logger.debug('Killing plugin %s', pluginName);
this.stream.emitTo(pluginName, 'pluginUnload', pluginName);
this.stream.emit('pluginUnloaded', mf);
});
this.stream.on('core', 'shutdown', (state: number) => {
// When the shutdown is initiated, this state will be zero.
// We will be re-emitting this event with a higher state when
// all the plugins have finished shutting down.
if (state !== 0) {
return;
}
// Prevent loading of new plugins
this.stopping = true;
logger.debug('Shutdown has been received by plugin manager');
// Shutting down all the plugins
for (const [name, plugin] of this.plugins) {
this.stream.emitTo(name, 'pluginUnload', name);
}
// Every second check for plugins
let testCount = 0;
const testInterval = setInterval(() => {
// There's still plugins loaded..
if (this.plugins.size > 0 && testCount < 5) {
testCount++;
return;
}
// Shut down when there are no more plugins active or
// after 5 seconds we just force shutdown
clearInterval(testInterval);
this.stream.emitTo('core', 'shutdown', 1);
}, 1000);
});
}
}