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