353 lines
9.9 KiB
TypeScript
353 lines
9.9 KiB
TypeScript
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';
|
|
|
|
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();
|
|
}
|
|
|
|
public getAvailableByName(name: string): IPluginManifest | undefined {
|
|
return this.availablePlugins.find(p => p.name === name);
|
|
}
|
|
|
|
public getLoadedByName(name: string): IPlugin | undefined {
|
|
if (this.plugins.has(name)) {
|
|
return this.plugins.get(name) as IPlugin;
|
|
}
|
|
return;
|
|
}
|
|
|
|
public getLoaded(): IPlugin[] {
|
|
return Array.from(this.plugins.values());
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 === plugin) {
|
|
returnValue = true;
|
|
if (unload) {
|
|
this.stream.emit('pluginUnload', mf);
|
|
}
|
|
continue;
|
|
}
|
|
result.push(mf);
|
|
}
|
|
this.availablePlugins = result;
|
|
|
|
return returnValue;
|
|
}
|
|
|
|
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) {
|
|
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) {
|
|
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 (PluginModule.prototype.__defconf && config) {
|
|
config.setDefaults(PluginModule.prototype.__defconf);
|
|
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 (PluginModule.prototype.__service) {
|
|
loaded.service = new Service(PluginModule.prototype.__service);
|
|
}
|
|
|
|
// Call the initializer
|
|
if (loaded.initialize) {
|
|
loaded.initialize.call(loaded);
|
|
}
|
|
|
|
// Call methods that are supposed to be executed automatically on load.
|
|
// This is really nasty and probably shouldn't be done, but I don't care!
|
|
for (const name of Object.getOwnPropertyNames(PluginModule.prototype)) {
|
|
// Prevent double initialization
|
|
if (name === 'initialize') {
|
|
continue;
|
|
}
|
|
|
|
if (PluginModule.prototype[name] &&
|
|
PluginModule.prototype[name].prototype &&
|
|
PluginModule.prototype[name].prototype.__autoexec) {
|
|
loaded[name].call(loaded);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
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;
|
|
}
|
|
|
|
public async restart(mf: IPluginManifest | IPlugin | string): 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');
|
|
}
|
|
|
|
if (!this.getLoadedByName(manifest.name)) {
|
|
this.load(manifest);
|
|
return;
|
|
}
|
|
|
|
this.restartQueue.set(manifest.name, manifest);
|
|
this.stream.emitTo(manifest.name, 'pluginUnload', manifest.name);
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
}
|