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.
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.
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 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 inprovides. 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 fromapp.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.
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 outTwo 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.servicesand is not announced by the"service"event. It is passed to the plugins that declared it inconsumes, throughimports, 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.
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/emitare 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 inexample/appopens 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 asonDestroyandapp.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 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 reversedependents 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.
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.
The Rectify module exposes one function as its main API.
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
appservice (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.
Inherits from EventEmitter.
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.
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.
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.
When a plugin registers, this event is emitted.
When all plugins are done, the "ready" event is emitted. The value is the Rectify
instance itself.
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.
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.
npm test # node test/run.js
node test/run.js register # only cases whose name contains "register"
npm run lintThose cover Rectify itself. For how an app built on it is tested -- where a test is
simply another plugin -- see example/README.md.
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'ssetupreturned, soawait 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 throwingsetup, 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 andstart()rejects. An app that used to limp to"ready"in that state will now fail atstart()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 pluginworks. 1.x reported it as a plugin with no setup function.- Errors name the plugin they came from, instead of the
packagePaththe 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
setupis a copy, so a plugin writing to it no longer edits its ownplugin.config. - A failed load unwinds before
start()rejects. 1.x only did this for thebuild(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 providingapp(which quietly replaced the container's own service for every plugin after it). Both throw naming the plugins.- New:
setup.allowedrestricts 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.