Plugins
Plugin based architecture that supports module loading, custom types, scopes, and more.
Installation
- Yarn
- pnpm
- npm
yarn add @boost/plugin
pnpm add @boost/plugin
npm install @boost/plugin
Preface
The plugin system is comprised of 2 parts -- one for projects or libraries that want plugins, and the other for plugin authors. Project owners can integrate into the system using a registry, which is based around the idea of loading plugins from third-party packages or file system paths. Plugin authors can create and provide packages that register and hook into the project.
Our system is generic and robust enough to be integrated into any and all projects, with the ability to handle multiple plugins in parallel through configuration and setting based approaches.
Registries
For project authors, we begin by defining a unique plugin type, like "renderer", "engine", "asset",
or simply "plugin" if you're not looking to be creative. We can accomplish this with the
Registry
class, which requires a project name (used as a
package prefix and scope), plugin type name, and customizable options.
In our examples moving forward, we will use "renderer" as our plugin type.
import { Registry, Pluggable } from '@boost/plugin';
export interface Renderable<T = unknown> extends Pluggable<T> {
render(): string | Promise<string>;
}
const rendererRegistry = new Registry<Renderable>('boost', 'renderer', {
validate(plugin) {
if (typeof plugin.render !== 'function') {
throw new TypeError('Renderers require a `render()` method.');
}
},
});
export default rendererRegistry;
You may have noticed the validate
option above.
This option is required as it forces you to verify a plugin being loaded or registered abides the
contract you expect. In the example above, we expect all our renderers to have a render
method,
otherwise, what would happen if an "engine" plugin was passed instead? Nothing good.
Besides validate
, there are a handful of other options that can be passed, based on the
RegistryOptions
interface. For more information on life
cycles, continue to the next plugins chapter.
Plugins
For both project owners and plugin authors, we keep talking about plugins, but what exactly is a
plugin? In the context of this system, a plugin is either a plain object, or class instance that
extends Plugin, with both abiding a defined contract (the validate
option). A plugin
must also have a unique name
property, which is typically
the npm package name.
- Class
- Object
import { Plugin } from '@boost/plugin';
import { Renderable } from './registry';
export default class Renderer extends Plugin implements Renderable {
// Using private scope
readonly name = '@boost/renderer-example';
render() {
return 'Something rendered here?';
}
}
import { Renderable } from './registry';
const renderer: Renderable = {
// Using public scope
name: 'boost-renderer-example',
render() {
return 'Something rendered here?';
},
};
export default renderer;
Now why would we use a class instead of an object, as an object seems much simpler? For 2 reasons,
the 1st being that Plugin
extends from Contract
, which
allows the plugin to inherit options through its constructor. This automatically happens when
loading plugins from a configuration file.
import { Blueprint, Schemas } from '@boost/common';
import { Plugin } from '@boost/plugin';
import { Renderable } from './registry';
export interface RendererOptions {
async?: boolean;
}
export default class Renderer extends Plugin<unknown, RendererOptions> implements Renderable {
// ...
blueprint({ bool }: Schemas): Blueprint<RendererOptions> {
return {
async: bool(),
};
}
}
// Example
const renderer = new Renderer({ async: true });
The 2nd reason is for TypeScript, as we can type our tool that is passed to
life cycles -- more specifically, the Pluggable
type. More
information on the tool can be found in later chapters.
import { Plugin } from '@boost/plugin';
import { Renderable } from './registry';
import Tool from './Tool';
export default class Renderer extends Plugin<Tool> implements Renderable<Tool> {
// ...
}
Priority
After a plugin is loaded, the current plugins list is sorted based on priority. Priority is simply a number, in ascending order, that determines the order and precedence of plugins. Priority enables plugin authors and consumers to "mark" as high or low priority.
Plugin authors can set a priority using the priority
property.
- Class
- Object
import { Plugin } from '@boost/plugin';
import { Renderable } from './registry';
export default class Renderer extends Plugin implements Renderable {
readonly name = '@boost/renderer-example';
priority = 50;
render() {
return 'Something rendered here?';
}
}
import { Renderable } from './registry';
const renderer: Renderable = {
name: 'boost-renderer-example',
priority: 50,
render() {
return 'Something rendered here?';
},
};
export default renderer;
While consumers can override the priority using the priority
option.
rendererRegistry.load('boost-renderer-example', {}, { priority: 50 });
The default priority for all plugins is
100
.
Life cycles
A life cycle is an optional method on a plugin that is executed at specific points in the life of a
plugin. Currently, plugins support 2 life cycles,
startup
and
shutdown
. Startup is executed after a plugin is loaded
and validated, but before it's registered in the registry. Shutdown on the otherhand is executed
before a plugin is unregistered from the registry.
All life cycles are asynchronouse and receive a tool as its only argument.
- Class
- Object
import { Plugin } from '@boost/plugin';
import { Renderable } from './registry';
import Tool from './Tool';
export default class Renderer extends Plugin<Tool> implements Renderable<Tool> {
// ...
async startup(tool: Tool) {
// Do something
}
async shutdown(tool: Tool) {
// Do something
}
}
import Tool from './Tool';
const renderer = {
// ...
async startup(tool: Tool) {
// Do something
},
async shutdown(tool: Tool) {
// Do something
},
};
export default renderer;
Modules
Typically plugins are represented as an npm package or file module for easy consumption. This pattern is first class in Boost, but there are specific requirements to be followed. The 1st is that all plugin modules must return a factory function from the default index import. Using a factory function provides the following benefits:
- The return value of the factory may change without breaking the import contract.
- Option objects are passed to the factory, which allows implementors to handle it however they please.
- Runtime and boostrap based logic is encapsulated within the function.
- Multiple instances can be created from a single imported package.
- Asynchronous aware and compatible.
Using our renderer examples, we would have the following factories. One sync and the other async.
- Class (async)
- Object (sync)
import { Renderable } from './registry';
import Renderer, { RendererOptions } from './Renderer';
export default async function (options: RendererOptions): Renderable {
await someProcessThatNeedsToBeAwaited();
return new Renderer(options);
}
import { RendererOptions, Renderable } from './registry';
export default function (options: RendererOptions): Renderable {
return {
name: 'boost-renderer-example',
render() {
if (options.async) {
return Promise.resolve('Ooo, this is a fancy render.');
}
return 'Something rendered here?';
},
};
}
Naming guidelines
You may have noticed in the examples above that we've been referencing both scoped and non-scoped
package names. All plugin packages follow the format of <project>-<type>-<name>
for public
third-party packages, and @<project>/<type>-<name>
for official project owner packages. A 3rd
format exists for public third-party packages that exist within a scope,
@<scope>/<project>-<type>-<name>
.
If the plugin name is "example", and our project name is "boost", and our plugin type is "renderer", the following package names are valid. No other formats are supported.
Package | Name |
---|---|
Private/Owner | @boost/renderer-example |
Public | boost-renderer-example |
Scoped public | @scope/boost-renderer-example |
All name parts should be in kebab-case and abide the official npm package naming guidelines.
Loading plugins
Plugins are either asynchronously loaded from an npm package, a relative file system path, or
explicitly passed using the Registry
class. The
Registry#load()
method can be used to load a single plugin,
while Registry#loadMany()
will load multiple. Loading
accepts 3 different formats, which are outlined with the examples below.
Passing a string will load based on module name or file path. Names can either be short (just the
plugin name), or in the long fully qualified form (project, type, and plugin name). When using the
short form, the loader will attempt to find both the scoped (@boost/renderer-example
) and
non-scoped packages (boost-renderer-example
), with scoped taking precedence.
// Load by short module name
const renderer = await rendererRegistry.load('foo');
// Load by long module name with options
const renderer = await rendererRegistry.load('boost-renderer-foo', { async: true });
// Load by file path
const renderer = await rendererRegistry.load('./renderers/qux.js');
You may also load many plugins in parallel, by passing an array of module names or plugin instances, or by passing an object of module names that map to booleans or options.
// Load many with default options
const renderers = await rendererRegistry.loadMany([
'foo',
'@boost/renderer-bar',
'@scope/boost-renderer-baz',
]);
// Load many with custom options
const renderers = await rendererRegistry.loadMany({
foo: { async: true },
'@boost/renderer-bar': true, // Enabled
'@scope/boost-renderer-baz': false, // Disabled
});
And lastly, passing a plugin object directly is also supported.
const renderer = await rendererRegistry.load({
name: '@scope/boost-renderer-baz',
render() {
return 'Hello world';
},
});
// Or
const renderer = await rendererRegistry.load(new Renderer());
Loaded and registered plugins should then be accessed with
get()
, getMany()
, or
getAll()
, all of which check based on the plugin's name
property.
const renderer = rendererRegistry.get('boost-renderer-foo');
Tool instances
Most projects have a central object or class instance that powers their entire process, for
instance, Webpack has the Compiler
and Compilation
instances. In Boost this is called a tool (as
in developer or build tool).
Tools are optional, but when defined, they're passed to plugin life cycles, so that plugins may
interact and integrate with them. For proper type-safety, the Tool type should be passed as a
generic to Registry
, Plugin
, and Pluggable
.
import { Registry, Pluggable, Plugin } from '@boost/plugin';
import Tool from './Tool';
export interface Renderable<T> extends Pluggable<T> {
render(): string | Promise<string>;
}
class Renderer<T> extends Plugin<T> implements Renderable<T> {}
const registry = new Registry<Renderable, Tool>(/* ... */);
const renderer = new Renderer<Tool>();
If you have a tool instance, pass the tool as an option to
Registry#load()
and
Registry#loadMany()
.
import Tool from './Tool';
const tool = new Tool();
await registry.load('foo', {}, { tool });
await registry.loadMany(['foo', 'bar'], { tool });
Configuration files
The loader methods were built to support plugins defined in configuration files, as this is a common use case. Settings to configure plugins are designed with 3 different formats in mind, all of which can be used together, and will merge into a valid final result.
The first is a simple array of plugin sources (module name or relative file path). When using a module, both long and short forms are supported.
- JSON
- JS
{
"renderers": [
"foo", // @boost/renderer-foo, boost-renderer-foo
"@boost/renderer-bar",
"@scope/boost-renderer-baz",
"../custom/renderer.js"
]
}
module.exports = {
renderers: [
'foo', // @boost/renderer-foo, boost-renderer-foo
'@boost/renderer-bar',
'@scope/boost-renderer-baz',
'../custom/renderer.js',
],
};
To expand upon the previous example, an individual plugin can be configured with options by passing
a tuple alongside the source. Plugins can also be disabled by passing a false
value.
- JSON
- JS
{
"renderers": [
["foo", { "async": true }],
"@boost/renderer-bar",
["@scope/boost-renderer-baz", false], // Disabled
"../custom/renderer.js"
]
}
module.exports = {
renderers: [
['foo', { async: true }],
'@boost/renderer-bar',
['@scope/boost-renderer-baz', false], // Disabled
'../custom/renderer.js',
],
};
The final format, which is quite advanced, is using an object that maps plugin sources to configurable options or flags (enable or disable the plugin).
- JSON
- JS
{
"renderers": {
"foo": { "async": true }, // Enabled with options
"@boost/renderer-bar": {}, // Enabled with empty options
"@scope/boost-renderer-baz": false, // Disabled
"../custom/renderer.js": true // Enabled
}
}
module.exports = {
renderers: {
foo: { async: true }, // Enabled with options
'@boost/renderer-bar': {}, // Enabled with empty options
'@scope/boost-renderer-baz': false, // Disabled
'../custom/renderer.js': true, // Enabled
},
};
Configuration files are designed to be serializable, so passing class instances (Webpack/Rollup style) is not supported. It's also not necessary with our factory pattern!
Ecosystem
Below are a list of third-party projects and their current plugin implementations. These were used as a basis and reference for Boost's plugin system.
Project | Plugin pattern | Options pattern | Package names | Lifecycle events |
---|---|---|---|---|
Babel | Function that returns an object | Function argument | babel-plugin-foo, @babel/plugin-foo | pre(), post() |
ESLint | Object | eslint-plugin-foo | ||
Gulp | Function that returns a stream | Function argument | N/A | |
Parcel asset | Class that extends Asset | Constructor | parcel-asset-foo, @parcel/asset-foo | |
Parcel plugin | Function that binds events | parcel-plugin-foo, @parcel/plugin-foo | ||
Prettier | Named exports | prettier-plugin-foo, @prettier/plugin-foo, @scope/prettier-plugin-foo | ||
Rollup | Function that returns an object | Function argument | rollup-plugin-foo | Many |
Webpack | Stand-alone class | Constructor (implementation dependent) | apply() | |
Yarn | Object with factory() that returns an object | factory() |