Plugins

BackendTooling

Plugin based architecture that supports module loading, custom types, scopes, and more.

Installation#

yarn add @boost/plugin

Events#

EventArgumentsDescription
Registry#onAfterRegisterPluginCalled after a plugin is registered.
Registry#onAfterUnregisterPluginCalled after a plugin is unregistered.
Registry#onBeforeRegisterPluginCalled before a plugin is registered and the startup life-cycle.
Registry#onBeforeUnregisterPluginCalled before a plugin is unregistered and the shutdown life-cycle.
Registry#onLoadstring, objectCalled after a plugin is loaded but before it's registered.

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, the following options can be passed, all of which are optional. For more information on life cycles, continue to the next plugins chapter.

  • beforeStartup (async (plugin: T) => void) - Callback fired before a plugin's startup life cycle is executed.
  • beforeShutdown (async (plugin: T) => void) - Callback fired before a plugin's shutdown life cycle is executed.
  • afterStartup (async (plugin: T) => void) - Callback fired after a plugin's startup life cycle is executed.
  • afterShutdown (async (plugin: T) => void) - Callback fired after a plugin's shutdown life cycle is executed.

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.

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?';
}
}

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, Predicates } 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 }: Predicates): 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.

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?';
}
}

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.

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
}
}

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.

@boost/renderer-example/src/index.ts
import { Renderable } from './registry';
import Renderer, { RendererOptions } from './Renderer';
export default async function (options: RendererOptions): Renderable {
await someProcessThatNeedsToBeAwaited();
return new Renderer(options);
}

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.

PackageName
Private/Owner@boost/renderer-example
Publicboost-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 load() method can be used to load a single plugin, while 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.

{
"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.

{
"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).

{
"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.

ProjectPlugin patternOptions patternPackage namesLifecycle events
BabelFunction that returns an objectFunction argumentbabel-plugin-foo, @babel/plugin-foopre(), post()
ESLintObjecteslint-plugin-foo
GulpFunction that returns a streamFunction argumentN/A
Parcel assetClass that extends AssetConstructorparcel-asset-foo,
@parcel/asset-foo
Parcel pluginFunction that binds eventsparcel-plugin-foo, @parcel/plugin-foo
PrettierNamed exportsprettier-plugin-foo, @prettier/plugin-foo, @scope/prettier-plugin-foo
RollupFunction that returns an objectFunction argumentrollup-plugin-fooMany
WebpackStand-alone classConstructor (implementation dependent)apply()
YarnObject with factory() that returns an objectfactory()