tennu-plugins
Version:
Tennu plugin subsystem
357 lines (305 loc) • 13.2 kB
JavaScript
const format = require('util').format;
const Result = require("r-result");
const Ok = Result.Ok;
const Fail = Result.Fail;
const failures = require("./failures");
const Registry = require("./registry");
const PluginSystem = function (context) {
const plugins = Registry();
const roles = Registry();
const staticHooks = Registry();
const instanceHooks = Registry();
function addInstanceHook (hook, fn) {
if (!(typeof hook === "string" || typeof hook === "symbol")) {
throw new TypeError("Hook's name must be string or symbol");
}
if (typeof fn !== "function") {
throw new TypeError("Hook's function argument is not a function.");
}
const setInstanceHookResult = instanceHooks.set(hook, fn);
return setInstanceHookResult.mapFail(function (failure) {
return {
failureReason: failures.InstanceHookAlreadyExists,
message: format("Tried to set instance hook '%s', but a plugin already has that instance hook.", hook),
hook: hook
};
});
}
function addStaticHook (hook, fn) {
if (!(typeof hook === "string" || typeof hook === "symbol")) {
throw new TypeError("Hook's name must be string or symbol")
}
if (typeof fn !== "function") {
throw new TypeError("Hook's function argument is not a function.");
}
const setStaticHookResult = staticHooks.set(hook, fn)
return setStaticHookResult.mapFail(function (failure) {
return {
failureReason: failures.StaticHookAlreadyEixsts,
message: format("Tried to set static hook '%s', but a plugin already has that static hook.", hook),
hook: hook
};
});
}
/*
enum PluginInitializableValidation {
canInitialize: Boolean,
pluginFactory: any // but probably a PluginFactory
failureReason: Option<Symbol>,
reason: Option<String>,
help: Option<String>,
}
If canInitialize is `true`, no other fields will be set.
If canInitialize is `false`, `failureReason` and `reason` will be set. `help` may be set.
*/
// isInitializable(pluginFactory: any*)) -> IsInitializableValidation
// * probably a PluginFactory
function isInitializable(pluginFactory) {
return isInitializableWith(pluginFactory, []);
}
// isInitializableWith(pluginFactory: any*, assumedPlugins: [PluginFactory]) -> IsInitializableValidation
// * probably a PluginFactory
// Says if a plugin could be initialized if we also assumed the Plugins in
// the second list were also initialized.
// WHY(Havvy): This function is used by `use` in loader.js because we want
// to know which plugins cannot be initialized due to missing
// dependencies that aren't in the list passed to `use`.
function isInitializableWith(pluginFactory, assumedPlugins) {
if (typeof pluginFactory !== "object" || pluginFactory === null) {
const pluginType = typeof pluginFactory === "object" ? "null" : typeof pluginFactory;
return {
canInitialize: false,
pluginFactory,
failureReason: failures.validation.NotAnObject,
reason: "PluginFactory not an object.",
help: `Was given a ${pluginType} instead.`
};
}
if (typeof pluginFactory.name !== "string") {
return {
canInitialize: false,
pluginFactory,
failureReason: failures.validation.NameNotAString,
reason: "PluginFactory 'name' property not a string.",
help: `PluginFactory 'name' property is a ${typeof pluginFactory.name} instead.`
}
}
if (plugins.has(pluginFactory.name) || assumedPlugins.some(function (assumedPluginFactory) {
return assumedPluginFactory.name === pluginFactory.name;
})) {
return {
canInitialize: false,
pluginFactory,
failureReason: failures.validation.PluginAlreadyExists,
reason: `A plugin with the name '${pluginFactory.name}' has already been installed.`
}
}
if (pluginFactory.role) {
if (typeof pluginFactory.role !== "string") {
return {
canInitialize: false,
pluginFactory,
failureReason: failures.validation.RoleNotAString,
reason: "PluginFactory object's 'role' property not be a string (or undefined).",
help: `PluginFactory 'role' property is a ${typeof pluginFactory.role} instead.`
}
}
if (roles.has(pluginFactory.role)) {
return {
canInitialize: false,
pluginFactory,
failureReason: failures.validation.RoleAlreadyExists,
reason: `A plugin with the role '${pluginFactory.role}' has already been installed.`
// Can't actually get the name of the plugin at this point
// without changing the system to store that map...
}
}
const maybePlugin = assumedPlugins.find(function (assumedPluginFactory) {
return pluginFactory.role === assumedPluginFactory.role;
});
if (maybePlugin !== undefined) {
return {
canInitialize: false,
pluginFactory,
failureReason: failures.validation.RoleAlreadyExists,
reason: `A plugin with the role '${pluginFactory.role}' has already been installed.`,
help: `The plugin with this role is '${maybePlugin.name}'.`,
}
}
}
if (pluginFactory.requires) {
if (!Array.isArray(pluginFactory.requires)) {
return {
canInitialize: false,
pluginFactory,
failureReason: failures.validation.RequiresNotAnArray,
reason: "PluginFactory object's 'requires' property is not an array (or undefined).",
help: `PluginFactory 'requires' property is a ${typeof pluginFactory.requires} instead.`,
}
}
const unfulfilledRequirements = pluginFactory.requires.filter(function (requirement) {
return !plugins.has(requirement) && !assumedPlugins.some(function (pluginFactory) {
return pluginFactory.name === requirement;
});
});
if (unfulfilledRequirements.length > 0) {
return {
canInitialize: false,
pluginFactory,
failureReason: failures.validation.UnfulfilledPluginDependencies,
reason: "Unfulfilled required plugins.",
help: `Please provide these plugins first: ${unfulfilledRequirements.join(", ")}`,
unfulfilledRequirements
}
}
}
if (pluginFactory.requiresRoles) {
if (!Array.isArray(pluginFactory.requiresRoles)) {
return {
canInitialize: false,
pluginFactory,
failureReason: failures.validation.RequiresRolesNotAnArray,
reason: "PluginFactory's 'requiresRoles' property is not an array (or undefined).",
help: `PluginFactory 'requires' property is a ${typeof pluginFactory.requiresRoles} instead.`
}
}
const unfulfilledRoleRequirements = pluginFactory.requiresRoles.filter(function (requirement) {
return !roles.has(requirement)&& !assumedPlugins.some(function (assumedPluginFactory) {
return assumedPluginFactory.role === requirement;
});
});
if (unfulfilledRoleRequirements.length > 0) {
return {
canInitialize: false,
pluginFactory,
failureReason: failures.validation.UnfulfilledRoleDependencies,
reason: "Unfulfilled required roles.",
help: `Please provide these roles first: ${unfulfilledRoleRequirements.join(", ")}`,
unfulfilledRoleRequirements
}
}
}
return {
canInitialize: true,
pluginFactory
};
}
function dependencies (pluginFactory) {
var res = {};
if (pluginFactory.requires) {
pluginFactory.requires.forEach(function (dependency) {
res[dependency] = plugins.get(dependency).exports;
});
}
if (pluginFactory.requiresRoles) {
pluginFactory.requiresRoles.forEach(function (role) {
res[role] = roles.get(role).exports;
});
}
return res;
}
const isLoaded = plugins.has.bind(plugins);
function initialize(pluginFactory) {
const validation = isInitializable(pluginFactory);
if (validation.canInitialize) {
return unsafeInitialize(pluginFactory);
} else {
return Fail({
failureReason: failures.CannotInitialize,
message: "The plugin cannot be initialized. For why, check the validationFailure.",
validationFailure: validation,
validationFailureTypes: failures.validation,
pluginFactory
});
}
}
function unsafeInitialize (pluginFactory) {
// Apply static hooks.
staticHooks.forEach(function (hook, fn) {
if (pluginFactory.hasOwnProperty(hook)) {
fn(pluginFactory.name, pluginFactory[hook]);
}
});
// Create instance.
const instance = pluginFactory.init(context, dependencies(pluginFactory));
if (typeof instance !== "object") {
return Fail({
failureReason: failures.PluginNotAnObject,
message: `pluginFactory instance from init function must be an object. Got ${String(instance)}, a ${typeof instance}, instead.`
});
}
// Add instance hooks.
if (instance.hooks) {
const addInstanceHooksResult = Object.keys(instance.hooks)
.reduce(function (addInstanceHookResult, hook) {
return addInstanceHookResult.andThen(function () {
return addInstanceHook(hook, instance.hooks[hook])
});
}, Ok());
if (addInstanceHooksResult.isFail()) {
return addInstanceHooksResult;
}
}
// Add static hooks.
if (instance.staticHooks) {
const addStaticHooksResult = Object.keys(instance.staticHooks)
.reduce(function (addStaticHookResult, hook) {
return addStaticHookResult.andThen(function () {
return addStaticHook(hook, instance.staticHooks[hook])
});
}, Ok());
if (addStaticHooksResult.isFail()) {
return addStaticHooksResult;
}
}
// Apply instance hooks.
instanceHooks.forEach(function (hook, fn) {
if (instance.hasOwnProperty(hook)) {
fn(pluginFactory.name, instance[hook]);
}
});
// Register instance.
// These cannot return a Fail() because isInitialable would have
// already said it wasn't actually initializable.
plugins.set(pluginFactory.name, instance);
if (pluginFactory.role) {
roles.set(pluginFactory.role, instance);
}
return Ok();
}
function pluginExportsOf (name) {
if (plugins.has(name)) {
return Ok(plugins.get(name).exports);
} else {
return Fail({
failureReason: failures.PluginNotInstalled,
name
});
}
}
function roleExportsOf (name) {
if (roles.has(name)) {
return Ok(roles.get(name).exports);
} else {
return Fail({
failureReason: failures.RoleNotInstalled,
name
});
}
return undefined;
}
return {
initialize: initialize,
unsafeInitialize: unsafeInitialize,
isInitializable: isInitializable,
isInitializableWith: isInitializableWith,
hasPlugin: plugins.has,
hasRole: roles.has,
pluginExportsOf: pluginExportsOf,
roleExportsOf: roleExportsOf,
loadedNames: plugins.keys,
addInstanceHook: addInstanceHook,
addStaticHook: addStaticHook
};
}
module.exports = PluginSystem;