UNPKG

plugin-manager

Version:

Plugin manager for providing a simple 'hook' framework for an application, similarly to Wordpress or Drupal

500 lines (437 loc) 18.2 kB
var path = require("path") , util = require("util") , inspect = util.inspect , fs = require("fs") /** * The Plugin class sets up a system for an application to allow for custom functionality by * exposing hooks to third-party modules. A module must export functions named the way the "host" * app's hooks are named. Hooks with periods in them are automatically namespaced when the call * is made into modules. For a module to work with namespaced hooks, it must expose objects which * implement said namespacing. For example, a hook name of "foo.bar.baz" would require something * like this in a plugin module: * * var hook = {}; * module.exports = exports = hook; * hook.foo = {}; * hook.foo.bar = {}; * hook.foo.bar.baz = function(data) {}; * * Handlers must be functions which take whatever parameters you pass when the plugin invokes the * given hook. Apps using the plugin module should document what hooks are invoked and what, * if any, parameters are sent to the handlers. * * A plugin must, at the least, include an index.js file and a package.json file. The package.json * file must be JSON-formatted and contain meta data about the plugin. The format is defined at * http://wiki.commonjs.org/wiki/Packages/1.0, but the plugin manager only requires three fields: * "name", "description", and "version". * * The meta data must contain a description of the module. The meta data must contain, at the * least, these fields: * <ul> * <li>name: The plugin's short name (this is what's used in enableModule and invoke)</li> * <li>description: A short description of what the module does</li> * <li>version: A version string using the Semantic Versioning format</li> * </ul> * * There are two built-in hooks: "plugin.enable" and "plugin.disable". If these are exposed, the * plugin will get notification when enableModule and disableModule are called. These can be useful * for plugins which start services or otherwise have resources that should only be "in use" while * the plugin is enabled. * * By default, plugins are expected to exist at a "plugins" directory residing at the same location * as the node application's main module. This may be overridden by setting basePath. * * Note: don't modify basePath after modules are loaded unless you know what you're doing! Doing * this can cause confusing behavior if, for instance, two modules have the same relative path * from two different base paths. * * A module may be loaded with a simple call to loadModule(path). This will first attempt to * load the module, which must be located in a directory named the same as the string passed * in and located at the base path. If the module is found, an entry is added to the internal * modules list, containing basic meta data about the module, and flagging it as not yet enabled. * * Enabling a module via enableModule(name, options) flags the module as enabled for use when the * host app calls invoke or invokeAll. Only already-loaded modules may be enabled. * * If desired, hook names can be validated before a module is allowed to be loaded. Set the * validHookNames property to an array of strings and set validateHookNames to true. When this * is enabled, calls to enableModule will warn on any exported functionality that isn't a valid * hook name. Note that exports prefixed with an underscore are skipped to allow for semi-private * functionality. * * Validation of hooks should be set up before loading modules, though this isn't strictly required. * * Example usage * * // SETUP - before app is listening for input * * plugin = require("plugin"); // Loads the plugin object * plugin.basePath = "/tmp/plugins"; // Modifies the default plugin path * plugin.validateHookNames = true; // Notify on handlers for undefined hook names * plugin.validHookNames = ["foo", "bar"]; // Goes with above: only "foo" and "bar" are valid hooks * plugin.loadModule("stuff"); // Loads module located in /tmp/plugins/stuff - we're aware of the module now, but not using its handlers * plugin.loadModule("things"); // Loads module located in /tmp/plugins/things * plugin.enableModule("stuff"); // The "stuff" module's handlers are now active * plugin.disableModule("stuff"); // Unloads all handlers in "stuff" module * plugin.loadDirectory("lotsastuff"); // Loads all modules found in /tmp/plugins/lotsastuff by scanning for sub-directories in that location * plugin.loadDirectory("./"); // Loads all modules found in /tmp/plugins/ (for cases where you don't need separate directories) * * // Set up custom logging API * plugin.api.logger = { * info: function(message) { console.info("[plugin] " + message}, * warn: function(message) { console.warn("Plugin Warning: " + message}, * error: function(message) { console.error("PLUGIN ERROR: " + message}, * } * * // Expose a custom API interface to the app's plugins * plugin.api.user = { * add: function(name, pass) { UserManager.addUser(name, pass) }, * delete: function(name) { UserManager.removeUser(name) } * } * * // Tell all plugins to deal with the password reset if they care about it * plugin.invokeAll("user.passwordResetRequest", someData, someOtherData); * * // Have a specific plugin run its on-enable code * plugin.invoke("my-plugin", "plugin.enable"); * * For advanced use cases, you can directly access the known modules list via the "modules" * property. This is a simple object containing key-value pairs. The key is the relative path * to a module (referred to in the documentation as the name) and the value is a module object. * Each module object contains meta-data, enabled status, etc. Make sure you look closely at this * variable if you wish to use it, as it isn't meant for general cases. * * @todo Consider aliasing hook handlers (hash by hook name lookup up array of functions) if * further optimization proves necessary * */ function Plugin() { this.modules = {}; this.pathLoaded = {}; // Parent application's exposed API, which we re-expose to plugins this.api = {} // Default logger API so all plugins can count on there being some kind of logging in place. // It is adviseable that this be replaced with a central logger the app and plugins can share. this.api.logger = { info: console.info, warn: console.warn, error: console.error } // Default the base path to the process's main directory + "/plugins" if (process.mainModule) { this.basePath = path.join(path.dirname(process.mainModule.filename), "plugins"); } // If there is no main module (run from interactive node shell, for instance), this is set to // the current working directory + "plugins/" else { this.basePath = path.join(process.cwd(), "plugins"); } // The list of allowed hook names starts off empty this.validHookNames = []; // By default we don't try to validate hook names this.validateHookNames = false; } // Constants for error messages and types // @todo: use real Error objects so this isn't necessary - we could have message strings still, // but use name and type to simplify error handling (rather than parsing the message string). // (See http://www.javascriptkit.com/javatutors/trycatch2.shtml for error handling information) Plugin.MODULE_ALREADY_LOADED = 'Module "%s" already loaded'; Plugin.MODULE_ALREADY_ENABLED = 'Module "%s" already enabled'; Plugin.UNABLE_TO_READ_MODULE = 'Unable to read module (%s)'; Plugin.MODULE_INVALID_SPEC = 'Loaded Module "%s", but it doesn\'t conform to Plugin spec'; Plugin.UNKNOWN_MODULE = 'Unknown module: "%s"'; Plugin.UNABLE_TO_LOAD_HANDLERS = 'Unable to load handlers defined in "%s": "%s"'; Plugin.INVALID_HANDLER_NAMES = 'Invalid handler names defined in "%s": %s'; Plugin.MODULE_ALREADY_DISABLED = 'Module "%s" already disabled'; Plugin.UNABLE_TO_READDIR = 'Unable to read directory "%s"'; /** * Reads a module's .info file in, validating its existence. Stores the meta data in our "known * modules" object. Sets the module's "enabled" flag to false to indicate that its code has not * yet been loaded, and handlers haven't been applied. * * @param {String} relativePath The relative path to the module being loaded * @returns {Object} The module data loaded * */ Plugin.prototype.loadModule = function(relativePath) { // Check for this module already having been loaded if (this.pathLoaded[relativePath]) { this.api.logger.error(util.format(Plugin.MODULE_ALREADY_LOADED, relativePath)) return null; } // Determine the module's full path var module = {}; module.path = path.join(this.basePath, relativePath); // Verify the module meta-data can be found and read try { module.meta = JSON.parse(fs.readFileSync(path.join(module.path, "package.json"))); } catch(err) { this.api.logger.error(util.format(Plugin.UNABLE_TO_READ_MODULE, relativePath)); return null; } // Does it have the properties we require? if (!module.meta.name || !module.meta.description || !module.meta.version) { this.api.logger.error(util.format(Plugin.MODULE_INVALID_SPEC, relativePath)); return null; } // Module is loaded, but not enabled module.enabled = false; // All is well - store the module info and return it this.modules[module.meta.name] = module; this.pathLoaded[relativePath] = true; return module; } /** * Enables the named module, setting its enabled flag to true and invoking its "plugin.enable" * handler. If the module hasn't been loaded, the enable call will fail. * * @param {String} name The name of the module to enable * @returns {Boolean} Successfully loaded the module's handlers */ Plugin.prototype.enableModule = function(name, opts) { var module = this.modules[name]; // No module, no service! if (!module) { this.api.logger.error(util.format(Plugin.UNKNOWN_MODULE, name)); return false; } // Notify on already-enabled module if (module.enabled) { this.api.logger.error(util.format(Plugin.MODULE_ALREADY_ENABLED, name)); return false; } var m; // Catch any errors trying to require the file try { m = require(module.path); } catch(err) { this.api.logger.error(util.format(Plugin.UNABLE_TO_LOAD_HANDLERS, name, err)); return false; } // Kick off the deep search for functions module.handlers = this.aliasFunctions(m); // Let module know about the plugin manager so it can access parent API, fire off its own events // that other modules can handle, etc m.pluginManager = this; // Validate handler names if flag is set. Since we alias all handlers, the calling app shouldn't // consider this a failure, just a notice that something should be examined more closely. if (this.validateHookNames) { var errors = []; for (var handlerName in module.handlers) { if (!module.handlers.hasOwnProperty(handlerName)) { continue; } if (this.validHookNames.indexOf(handlerName) == -1) { errors.push(handlerName); } } if (errors.length > 0) { this.api.logger.warn(util.format(Plugin.INVALID_HANDLER_NAMES, name, inspect(errors))); } } // Fire enable event if it exists module.enabled = true; this.invoke(name, "plugin.enable"); return true; }; /* Recursively aliases all functions in an object. For each sub-object, the namespace objects * array is updated so we can alias the functions by a nice string. * * Example: * var obj = {foo: {bar: {baz: function() {} }}}; * aliasFunctions(obj); // returns {"foo.bar.baz": obj.foo.bar.baz} */ Plugin.prototype.aliasFunctions = function(obj, aliases, namespaceObjects) { // On the first call, there's no need to send in aliases or namespaced objects if (!aliases) { aliases = {}; } if (!namespaceObjects) { namespaceObjects = []; } for (var key in obj) { if (!obj.hasOwnProperty(key)) { continue; } // Figure out the new namespace in case we need to alias a method or call aliasFunctions again var newNamespace = namespaceObjects.slice(0); newNamespace.push(key); var fullName = newNamespace.join("."); // If we have a function, just alias it if (typeof(obj[key]) == "function") { aliases[fullName] = obj[key]; } // If it's a sub-object, recurse with the new namespace if (typeof(obj[key]) == "object") { this.aliasFunctions(obj[key], aliases, newNamespace); } } return aliases; } /** * Disables the given module, removing it from lookups for invoke or invokeAll calls. * * @param {String} name The name of the module to disable * @returns {Boolean} Successfully unloaded the module's handlers */ Plugin.prototype.disableModule = function(name) { var module = this.modules[name]; // No module, no service! if (!module) { this.api.logger.error(util.format(Plugin.UNKNOWN_MODULE, name)); return false; } // Don't unload modules not already enabled if (!module.enabled) { this.api.logger.error(util.format(Plugin.MODULE_ALREADY_DISABLED, name)); return false; } // Call disable handler if present this.invoke(name, "plugin.disable"); module.enabled = false; return true; } /** * Finds all modules in the given path, loading and validating their information via loadModule() * * @param {String} moduleBasePath The path to read, relative to the plugin basePath. All * subdirectories found will be run through loadModule. * @returns {Boolean} Successfully scanned the directory and loaded modules */ Plugin.prototype.loadDirectory = function(moduleBasePath) { var fullPath = path.join(this.basePath, moduleBasePath); var files = []; try { files = fs.readdirSync(fullPath); } catch(err) { this.api.logger.error(util.format(Plugin.UNABLE_TO_READDIR, fullPath)); return false; } // Can't use for(x in y) here - that iterates over object properties quite well, but doesn't do // so well for arrays for (var x = 0, l = files.length; x < l; x++) { var file = files[x]; var modulePath = path.join(fullPath, file); var stats; try { stats = fs.statSync(modulePath); } catch(err) { stats = null; } if (stats && stats.isDirectory()) { // We don't use full path here because loadModule automatically prepends basePath for us this.loadModule(path.join(moduleBasePath, file)); } } return true; } /** * Runs the named handler for a specific module. The handler name should be broken up by periods * if namespacing by objects is desired. * * Failures are silent since invoke is a passive action and most failures are not errors - for * instance, a module not exposing a handler for the "plugin.enable" hook. */ Plugin.prototype.invoke = function(moduleName, hookName) { var module = this.modules[moduleName]; // Skip invalid or disabled modules if (!module || !module.enabled) { return false; } // Look up the handler var handler = module.handlers[hookName]; if (!handler) { return false; } // Try calling the handler as efficiently as possible switch (arguments.length) { // fast cases case 2: handler.call(this); break; case 3: handler.call(this, arguments[2]); break; case 4: handler.call(this, arguments[2], arguments[3]); break; // slower default: var len = arguments.length; var args = new Array(len - 2); for (var i = 2; i < len; i++) args[i - 2] = arguments[i]; handler.apply(this, args); } return true; } /** * Calls handlers for all modules which are both enabled and have a handler for the given hook * * Note that the `handler.call` switch block is purposely in-lined for performance - based on * EventEmitter's implementation, and the unknown use cases of this system, I felt inlining was * more important than saving a dozen lines of code. * * @todo Caching handlers would save some time here, and remove the need for the handlers method. * {hookName => [func, func, func], hookName2 => [func], ...} */ Plugin.prototype.invokeAll = function(hookName) { // Iterate over all handlers for the given hook and call them var handlers = this.handlers(hookName); var length = handlers.length; var handler; for (var index = 0; index < length; index++) { handler = handlers[index]; // Try calling the handler as efficiently as possible switch (arguments.length) { // fast cases case 1: handler.call(this); break; case 2: handler.call(this, arguments[1]); break; case 3: handler.call(this, arguments[1], arguments[2]); break; // slower default: var len = arguments.length; var args = new Array(len - 1); for (var i = 1; i < len; i++) args[i - 1] = arguments[i]; handler.apply(this, args); } } } // Returns an array of all functions handling a given hook Plugin.prototype.handlers = function(hookName) { var results = []; var handler, module, moduleName; for (moduleName in this.modules) { module = this.modules[moduleName]; // Skip inherited if (!this.modules.hasOwnProperty(moduleName)) { continue; } // Skip modules which aren't enabled if (!module.enabled) { continue; } // Get the handler or skip this module handler = module.handlers[hookName]; if (handler) { results.push(handler); } } return results; }; var plugin = new Plugin(); module.exports = exports = plugin; // It's a little hacky, attaching the Plugin object to our singleton instance, but it appears to // be the only way to preserve the magic singletonization while also exposing the class. module.exports.Plugin = Plugin;