Skip to content

Latest commit

 

History

51 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Rectify

Rectify is a simple but powerful structure for JavaScript applications. Using Rectify, you set up a simple configuration and tell Rectify which plugins you want to load. Each plugin registers itself with Rectify, so other plugins can use its functions. Plugins can be maintained as NPM packages so they can be dropped in to other Rectify apps.

Fork of https://github.com/c9/architect

c9/architect is no longer maintained. This version aims to be a leaner version, with the package-path loader dropped in favour of plain import/require, and with a teardown path (app.destroy()) added.

Upgrading from 1.x? See Migrating from 1.x. The 1.x line lives on the legacy branch.

Plugin Interface

A plugin is a setup function with consumes and provides arrays hung off it. Rectify reads those two arrays to work out the load order, then calls setup(imports, register).

./plugin_b/plugin.js -- provides a service:

export default function setup(imports, register) {
    register(null, {
        hello: {
            test: function () {
                console.log("Hello World");
            }
        }
    });
}

setup.consumes = [];
setup.provides = ["hello"];

./plugin_a/plugin.js -- consumes it:

export default function setup(imports, register) {
    var { hello } = imports;

    hello.test();

    register();
}

setup.consumes = ["hello"];
setup.provides = [];

index.js:

import pluginA from "./plugin_a/plugin.js";
import pluginB from "./plugin_b/plugin.js";
import rectify from "@bmatusiak/rectify";

var config = [pluginA, pluginB];

var app = rectify.build(config);

await app.start();

console.log("ready");

The order of the config array does not matter -- Rectify topologically sorts the plugins so that everything a plugin consumes is registered before its setup runs.

build() throws on a config it cannot load: a cycle, a service nobody provides, two plugins providing the same service, or a plugin trying to provide app.

See example/hello for a runnable version of the above, and example/app for a small app in the shape these are built in -- how one plugin extends another, how settings reach a plugin, and how an app like it is tested. example/README.md walks through both.

Importing plugins

Rectify reads setup, consumes and provides off whatever you put in the config array. All three of these work:

// ESM, default import
import pluginA from "./plugin_a/plugin.js";

// ESM, namespace import -- `export const consumes`, `export default function setup`
import * as pluginA from "./plugin_a/plugin.js";

// CommonJS
const pluginA = require("./plugin_a/plugin.js");   // module.exports = setup;

register(err, provided)

register must be called exactly once per plugin. Never calling it stalls the load at that plugin; calling it twice is reported as an "error" and ignored.

  • err -- pass a truthy error to abort the load.
  • provided -- one key per name in provides. Omitting one aborts the load, rather than handing the next plugin an undefined service.
  • provided.onDestroy -- optional, and not a service. Rectify stores it and calls it from app.destroy().

register returns a promise that settles once the rest of the app has loaded, so a plugin can await register(...) if it needs to do something with the finished app.

setup.allowed

Any plugin may consume any service. A plugin that should not be that open declares who is allowed to consume what it provides:

setup.consumes = ["openssl"];
setup.provides = ["keys"];
setup.allowed = ["ssh"];        // only the ssh plugin may consume "keys"

allowed holds one group per permitted plugin, and a group is everything that plugin provides. So a flat list is the one-plugin shorthand -- these both admit exactly one plugin, the one providing ssh and terminal together:

setup.allowed = ["ssh", "terminal"];
setup.allowed = [["ssh", "terminal"]];      // the same thing, written out

Two plugins taking a name each is a different arrangement, and says so:

// keys plugin                              // ssh plugin
setup.provides = ["keys"];                  setup.consumes = ["keys"];
setup.allowed = [["ssh"], ["terminal"]];    setup.provides = ["ssh"];

                                            // terminal plugin
                                            setup.consumes = ["keys"];
                                            setup.provides = ["terminal"];

A group matches a plugin whose provides is exactly that group -- order and repeats aside. Nothing else does, in either direction: a plugin providing only part of a group has not matched it, and neither has one providing all of a group plus something the group does not mention.

Plugin keys allows only [ssh] or [terminal] to consume "keys",
but ssh consumes it and provides [ssh, sftp]

That is why the match is on provides rather than on a plugin's name: a plugin cannot widen its own surface and keep its access. It follows that a plugin providing nothing is never allowed, and that allowed: [] means nobody.

Mixing the two forms -- ["ssh", ["terminal"]] -- is an error rather than a guess, as is an empty group.

Two things happen:

  • build() throws if any plugin in the config consumes the service without being allowed, naming the provider, the service, the consumer, and what is not listed.
  • The service is kept out of app.services and is not announced by the "service" event. It is passed to the plugins that declared it in consumes, through imports, and nowhere else -- so a plugin consuming "app" cannot reach around the declaration and take it anyway.

Leave allowed off and none of this applies. Naming a plugin that this particular config does not include is fine, so one plugin can be written for several configs.

What it does not do: an allowed plugin can hand the service on to whoever it likes. This restricts who may depend on a service, not what they do with it once they have it.

PluginBase

A service can be a plain object -- most of this README assumes it is. When you want more than that, Rectify ships a base class, as a plugin rather than as part of the container. An app that wants it puts it in the config array:

var plugins = [rectify.PluginBase, ...therest];

and the plugins built on it say so, like any other dependency:

export default function setup(imports, register) {
    var { Plugin, server } = imports;

    var plugin = new Plugin("time");

    // Waits for the whole load to finish. A plugin cannot work this out alone.
    plugin.on("ready", function () { ... });

    // Undo it where you do it, rather than keeping a teardown function in step.
    plugin.own(function () { ... });

    register(null, {
        time: plugin.api({ now: now, uptime: uptime }),
        onDestroy: plugin.unload
    });
}

setup.consumes = ["Plugin", "server"];
setup.provides = ["time"];
  • plugin.api(surface) copies the surface onto the instance and freezes it, so what a plugin registers is the plugin itself -- an emitter with a stated set of methods, rather than whatever object happened to be returned.

  • plugin.on/once/off/emit are ordinary events. plugin.announce(type, data) is the sticky kind: a listener that arrives afterwards is still called, which is the normal case here, since whoever wanted to hear may not have loaded yet.

  • "ready" is announced on every instance when the app finishes loading, before or after the listener was attached. This is the answer to "do it once everything is up" -- the server in example/app opens itself that way instead of the boot reaching in to open it.

  • plugin.own(fn) collects teardown. plugin.unload() runs it in reverse, isolating a thrower, then announces "unload", once however many times it is called. Pass it as onDestroy and app.destroy() drives it.

  • plugin.disable(reason) / enable() / disabled() are a flag and two events. Nothing enforces them -- a consumer holding the service can still call it. What "disabled" means is between the plugin and whoever watches for it.

It is deliberately not on the app service. A plugin built on this depends on it, and consumes should say so -- otherwise nothing could swap it, for a test or anything else, and Rectify would own this design permanently rather than being free to leave it in a file you can ignore, replace, or copy and change.

The "ext" service

The same plugin provides a registry, under the name c9 used for theirs:

setup.consumes = ["ext"];

ext.plugins            // every Plugin instance, in load order
ext.named              // the same, keyed by name
ext.get("time")        // one of them
ext.dependents("time") // who consumes it -- every plugin, not only these
ext.unloadAll(reason)  // unload them all, in reverse

dependents reads the graph rather than the registry, so it answers for plugins that never touched Plugin. It hands back frozen { name, provides, consumes } records -- .map(function (e) { return e.name; }) if names are all you wanted.

That graph is on the app service as app.plugins, since only the container can know it. It is the same thing Rectify works out to sort the load, kept rather than discarded, and frozen all the way down: a description of the app, not a way into it.

c9's ext could also load and unload plugins in a running editor. Rectify cannot -- build() takes a fixed array and start() runs once -- so what is here is the part that still applies: knowing what is in the app, and what is relying on it.

Config Format

The config passed to build() is a plain array of plugins. There is no package path resolution -- if you want conditional plugins, build the array in JavaScript before handing it over.

Settings live on that array, as plugins.config, keyed by the service name a plugin provides. Each plugin is handed its own entry as the third argument to setup, so a plugin reads its own settings and nobody else's:

// config.js
export default {
    log: { prefix: "[example]" },
    server: { basePath: "/api" }
};
// index.js
var plugins = [status, time, server, log];
plugins.config = config;
var app = rectify.build(plugins, { appName: "example-app" });
// plugins/server/plugin.js -- provides "server", so it receives config.server
export default function setup(imports, register, config) {
    var basePath = config.server.basePath;
    ...
}

A plugin can also carry its own defaults as setup.config, keyed the same way, which the app-wide settings are merged over. Either way the plugin gets an object per name it provides, empty if nothing is configured, so there is no need to guard the lookup.

Rectify main API

The Rectify module exposes one function as its main API.

build(config, [callback])

Sorts and validates the config and returns a Rectify instance. Nothing is loaded yet; call app.start() to load the plugins.

The optional second argument is either:

  • a function -- listens for both "error" and "ready" on the app and is called with (err, app) for whichever happens first, or
  • an object -- its properties are merged into the app service (see below), so plugins can read values passed in from the host application.

If config is invalid, build() throws -- unless a function callback was given, in which case the error goes to the callback instead.

Class: Rectify

Inherits from EventEmitter.

start([event], [callback])

Loads the plugins in dependency order and returns a promise that resolves with the app once every plugin has registered, or rejects with the first failure. The optional callback is added as a "ready" listener. The optional event name is emitted on the app service when the load finishes.

Loading is one-shot: calling start() again hands back the same promise rather than loading anything a second time.

If you do not await it, listen for "error" (or use the build() callback) before calling it -- otherwise a failed load surfaces as an unhandled rejection.

A failed load unwinds itself: the onDestroy hooks of the plugins that did start have already run by the time start() rejects, so a half-built app is not left holding a port or a file handle open.

While a load is running, app.loading is the name of the plugin whose setup has not called register yet, which is what to look at if a start never finishes. On node, a process that is about to exit with a load still pending prints that name.

destroy()

Calls each registered onDestroy in reverse order, so a consumer is torn down before what it consumed, then emits "destroy". An onDestroy that throws is reported as an "error" and does not stop the rest. Calling it more than once returns the same promise rather than tearing down twice.

Event: "service" (name, service)

When a new service is registered, this event is emitted on the app. name is the short name for the service, and service is the actual object with functions.

Event: "plugin" (plugin)

When a plugin registers, this event is emitted.

Event: "ready" (app)

When all plugins are done, the "ready" event is emitted. The value is the Rectify instance itself.

Event: "error" (err)

Emitted on a plugin throwing, a register(err) call, or a plugin failing to provide a declared service. Any of these stops the load, and start() rejects with the same error; "ready" does not follow.

The "app" service

Rectify registers one service itself, called app. Consume it to hook into build events -- it is the hub of the running application:

export default function setup(imports, register) {
    const { app } = imports;

    app.on("ready", (app) => { });
    app.on("service", (name, service) => { });
    app.on("plugin", (plugin) => { });
    app.on("error", (error) => { });

    register(null, {});
}

setup.consumes = ["app"];
setup.provides = [];

Because a plugin only sees events emitted after its own setup runs, a plugin that wants to watch the whole load should consume nothing else, so it sorts to the front.

The app service also carries environment flags -- isBrowser, isNode, isElectron, isNWJS, isFork, isWorker (each 1 or 0) -- along with window (or global), the EventEmitter class, and a live services getter for the full service registry.

Tests

npm test                     # node test/run.js
node test/run.js register    # only cases whose name contains "register"
npm run lint

Those cover Rectify itself. For how an app built on it is tested -- where a test is simply another plugin -- see example/README.md.

Migrating from 1.x

The plugin interface is unchanged -- setup, consumes, provides, register, onDestroy and build(config, host) all behave as they did. What changed is when the load gives up, and what start() promises:

  • start() resolves on ready. In 1.x it resolved as soon as the first plugin's setup returned, so await app.start() could hand back an app whose services were still registering. Code that awaited it and then reached for a service was relying on every plugin registering synchronously.
  • A failing plugin stops the load. A register(err), a throwing setup, or a plugin that does not provide what it declared used to emit "error" and carry on to "ready", leaving the next plugin with an undefined service. Now the load stops and start() rejects. An app that used to limp to "ready" in that state will now fail at start() instead of somewhere later.
  • start() is one-shot. A second call returns the same promise instead of emitting a second "ready" over a running app.
  • register() twice is reported as an "error" instead of silently re-running the rest of the load.
  • import * as plugin works. 1.x reported it as a plugin with no setup function.
  • Errors name the plugin they came from, instead of the packagePath the fork no longer has.
  • A failed build(config, callback) runs the destructors of the plugins that did start, instead of only emitting "destroy".
  • The config handed to setup is a copy, so a plugin writing to it no longer edits its own plugin.config.
  • A failed load unwinds before start() rejects. 1.x only did this for the build(config, callback) form, and only after reaching "ready".
  • build() rejects two configs it used to accept: two plugins providing the same service (last one silently won), and a plugin providing app (which quietly replaced the container's own service for every plugin after it). Both throw naming the plugins.
  • New: setup.allowed restricts who may consume what a plugin provides. Plugins that do not declare it are unaffected.

If you need the old behaviour, 1.x remains on the legacy branch.

Releases

Packages

Used by

Contributors

Languages