tennu-plugins
v4.0.0-b4
Published
Tennu plugin subsystem
Downloads
7
Readme
This is the Plugins subsystem of the Tennu IRC Bot Framework for Node.
This module is a plugin system that handles loading, dependencies, and cross-plugin hooks.
The main facets of this plugin system are:
- Loading of dependencies
- Initialization of plugins
- Plugin roles, where plugins of that role expose a common interface.
- Plugin hooks, where plugins can hook into each initializing plugin.
While this plugin system was designed for Tennu, you can repurpose this system for your own plugin architecture framework.
When do I want to use this?
Use this if you want to create a plugin based framework in Node.js with a common context for the plugins (e.g. IRC for Tennu).
If you are using a framework that uses this, it's useful to read the sections on PluginFactorys, Plugins, Hooks, and Exports.
Installation
This plugin system targets Node.js v4.x to v6.x, and probably works on later versions of Node.js.
npm install tennu-plugins
Creating a Plugin System
require("tennu-plugins")(systemname: string, context: any)
The systemname is the name of the plugin system,
and declares the directory names the plugin loader's use
method
will look for plugins in. See PluginLoader#use/1
for more.
The context is the first argument passed the plugin initialization functions.
For example, in Tennu, the IRC client
is passed to each plugin
and plugins are stored in tennu_plugins
directories. It is initialized as
follows.
const plugins = require("tennu-plugins")("tennu", client)
Initialization and Loading of Plugins
A plugin system comes with two ways to initialize a plugin,
PluginSystem#initialize/1
and PluginSystem#use/2
.
initialize/1
takes a PluginFactory while use
takes an array of strings and
a path that the system uses to locate and then load PluginFactorys.
These functions are described in more detail later on in this document.
const channelsPluginFactory = require("./tennu-plugins/channels");
plugins.initialize(channelsPluginFactory).ok();
## What's a PluginFactory?
A PluginFactory is an object that describes how to create a Plugin. It contains
*static* properties that don't change. PluginFactorys have the following
properties.
```javascript
{
init: Fn(context, imports) -> Plugin,
name: string
role: RoleName?,
requires: [string]?,
requiresRoles: [RoleName]?,
// Other properties depending on static hooks
// See the section on Hooks below for how those work.
}
The name is there so that other plugins can reference the plugin the factory creates. This also means that each plugin must have a unique name, so you cannot have multiple instances of the same plugin.
The init function must return a Plugin. All properties on a Plugin are actually optional, so a plugin could just be an empty object. The context parameter is the context passed in during the creation of the plugin system. The imports parameter is discussed later.
The following is the minimally viable PluginFactory:
{
name: "bare",
init (_context, _imports) {
return {};
}
}
The requires
and requiresRoles
properties take a list of plugin names or
roles that the plugin requires to be loaded to function properly.
What's a Plugin?
At it's most simple, a plugin is an object that exports properties or defines hooks. A Plugin has the following structure.
{
hooks: Object<Fn(String, Any)>?,
staticHooks: Object<Fn(String, Any)>?,
exports: Any?,
// Other properties depending on instance hooks
// See the section on Hooks below for how those work.
}
Plugins are created in the `init` function of the PluginFactory, and as such,
have access to the `context` which is basically a free dependency chosen by
the creator of the plugin system. They also have access to imported plugins.
The plugin instance can export values for other plugins to use. To do so, these
values must be located on the exports property.
The plugin instance can also hook into plugins that require it. To do so, the
properties to hook onto must be defined on the hooks property.
## Cross-Plugin Interaction
Plugins have two main ways of interacting with each other. The first way, called
`direct dependencies`, allows plugins to export values that are later imported
by other plugins.
The second way, called `hooks`, allows a plugin to define
new properties for PluginFactorys and Plugins that have their values passed
back into the defining plugin.
### Direct Dependencies
The exports property is given to plugins that depend on your plugin through
the init function. For example, let's say we have two plugins, A and B.
Plugin B depends on Plugin A. Plugin A exports a property `exists: true`.
Plugin B loads plugin A and then logs A's `exist` property to the console.
```javascript
const pluginSystem = require("tennu-plugins")({}, "example");
const PluginA = {
name: "A",
init: function (_context, _imports) {
return {
exports: {exists: true}
};
}
}
const PluginB = {
name: "B"
requires: ["A"],
init: function (_context, {B}) {
console.log(B.exists);
return {
exports: {}
};
},
}
pluginSystem.initialize(PluginA);
pluginSystem.initialize(PluginB);
// console will log "true" here.
This is very similar to how Node.js's module system works, except object based instead of file based.
Hooks
At times, a plugin will want to extend the functionality of PluginFactorys and Plugin objects by defining new properties for these objects. Hooks allow for plugins to do so.
A hook is a function that is given the plugin's name and the value on the hook's property.
A plugin defines a hook by creating an object on either its hooks
or
staticHooks
properties where the key is the property to hook into and the
value is a function that takes two parameters, the first being the name of the
plugin and the second the value that plugin provides for that property. The
plugin defining the hook can do whatever it wants with that value. For
Plugin hooks, use hooks
and for PluginFactory hooks, use staticHooks
.
For example, Tennu has a help plugin that hooks into the 'help'
property.
So, let's say there's a time plugin that wants to use this hook.
The time plugin puts "help"
in the dependencies list, and adds a help
property to the plugin instance with the help message.
// Plugin "help"
{
name: "help",
init (client, imports) {
// ... initialization code.
return {
// ... other properties
hooks: {
help (pluginName, helpobj) {
// Does stuff with helpobj
}
}
};
}
}
// Plugin "time"
{
name: "time",
init: function (client, imports) {
// ... initialization code.
return {
// ... other properties
help: "Stuff about time."
}
}
}
Note: In this example, the plugin name and hook name are the same. This is not a requirement. You can name your hooks whatever you want. You can also have multiple hooks.
Global Hooks
The creator of the plugin system can add hooks that apply to all plugins.
Global hooks should be added before loading any plugins.
To do so, use one of the following functions:
addHook(hook: String, fn: Fn(String, Any)) -> Result<(), InstanceHookAlreadyExistsFailure>
addStaticHook(hook: String, fn: Fn(String, Any)) -> Result<(), StaticHookAlreadyExistsFailure>
For example, Tennu used to add a 'handlers' global hook.
var plugins = require("tennu-plugins")("tennu", client);
plugins.addHook("handlers", function (pluginName, handlers) {
client.on(handlers);
});
Nowadays, Tennu uses a plugin for this behaviour.
Loading Plugins
var plugins = require("tennu-plugins")("tennu", client);
var builtins = ["server", "actions", "help", "user", "channel"];
plugins.use(builtins, __);
Create a list of plugins that you want to use, and then pass them to plugins.use(names: [String], path: String).
The plugin system will then locate and load the plugins in an order such that all dependencies are properly met.
This function returns a Result<undefined, UseFailure>
. A UseFailure is a
[Failure](#Failure Handling) with one of the following failure types:
- UnmetDependency
- NoSuchPlugin
- NoSuchRole (Not yet used)
- CyclicicDependency (Not yet used)
- PluginInitializationError
- RegistryKeyAlreadySet
- HookAlreadyExists
Locating Plugins
The second parameter to use()
is a path. The plugin system will look for the following
places for your plugin:
- %path%/%systemname%_plugins/%pluginname%.js
- %path%/%systemname%_plugins/%pluginname%/index.js
- %path%/node_plugins/%systemname%-%pluginname%/
If it cannot find the plugin there, it will then go up the parent directory, and repeat, until it either finds the plugin or is at the root.
If the plugin cannot be found, a NoSuchPlugin failure will be returned.
For example, say you want the plugin "config" for "tennu" starting from "/projects/tennu". The plugin locator will look at the following places until it finds it.
- /projects/tennu/tennu_plugins/config.js
- /projects/tennu/tennu_plugins/config/index.js
- /projects/tennu/node_plugins/tennu-config/
- /projects/tennu_plugins/config.js
- /projects/tennu_plugins/config/index.js
- /projects/node_plugins/tennu-config/
- /tennu_plugins/config.js
- /tennu_plugins/config/index.js
- /node_modules/tennu-config/
Initializing Plugins
Full PluginSystem API
These are the method signatures that a PluginSystem has.
- addHook(name: string, hook: Fn(String, Any)) -> Result<undefined, InstanceHookAlreadyEixstsFailure>
- addInstanceHook(name: string, hook: Fn(String, Any) -> Result<undefined, InstanceHookAlreadyExistsFailure>
- addStaticHook(name: string, hook: Fn(String, Any)) -> Result<undefined, StaticHookAlreadyExistsFailure>
- isInitializable(plugin: Plugin): InitializationValidation
- initialize(plugin: Plugin): Result<undefined, InitializeFailure>
- use(plugins: [String]: Result<undefined, UseFailure)
- hasPlugin(name: string): boolean
- hasRole(name: string): boolean
- getPlugin(name: string): Result<exports, NoSuchPluginFailure>
- getRole(name: string): Result<exports, NoSuchRoleFailure>
Failure Handling
Any function that has a failure condition returns a Result
from
r-result
.
The failure type can be determined by checking the failureType
property of
the object. This property contains a symbol accessible from the failures
property of this module. For example, the InstanceHookAlreadySet
symbol can
be found at require("tennu-plugins").failures.InstanceHookAlreadySet
.
The failure also has a message
property describing the issue
Failures may also have other properties, depending on what failure type it is.
The following subsections are the types of failures.
CannotInitialize
Returned by initialize
and use
.
Returned when trying to initialize a plugin, but after looking at it's factory object, it was determined not to be possible. The system will not have initialized any plugins if it returns this failure.
The reason for why it cannot be initialized is on the validationFailure
property.
The PluginFactory is on the pluginFactory
property.
PluginNotAnObject
Returned by initalize
and use
.
Returned when the init
function of a PluginFactory returns something that is
not an object.
You should consider the plugin system to be in an indeterminate state at this
point, since the init
function for a plugin was ran, but it was not fully
initialized.
InstanceHookAlreadySet
Returned by initialize
, use
, addHook
and addInstanceHook
.
Returned when trying to set an instance hook after calling the init
function
on the plugin, and the plugin system already has that hook registered to another
plugin.
You should consider the plugin system to be in an indeterminate state at this
point, since the init
function for a plugin was ran, but it was not fully
initialized.
The name of the hook is stored on the hook
property.
To solve this, figure out which plugin has the hook installed already, and change one of the plugins to use a different hook.
Ideally, no two plugins would ever share the same instance hook (without sharing the same role) though, since that makes them incompatible, both with each other, but also any plugin that depends on the other.
StaticHookAlreadySet
Returned by initialize
, use
, and addStaticeHook
.
Same as InstanceHookAlreadySet
, but for static hooks.
// NoSuchPlugin: Symbol("NoSuchPlugin"), // UnmetDependency: Symbol("UnmetDependency"), // InconsistentlyNamedPlugin: Symbol("InconsistentlyNamedPlugin"), // CyclicicDependency: Symbol("CyclicicDependency"),