use.js
/* Copyright © 2014-2022 Richard Rodger and other contributors, MIT License. */
'use strict'
const Path = require('path')
const Module = require('module')
// Generic plugin loader functionality for Node.js frameworks.
// #### External modules
const Nid = require('nid')
const Norma = require('norma')
const Eraro = require('eraro')
const DefaultsDeep = require('lodash.defaultsdeep')
// const Optioner = require('optioner')
// #### Exports
module.exports = make
// module.exports.Joi = Optioner.Joi
// module.exports.Optioner = Optioner
// #### Create a _use_ function
// Parameters:
//
// * _useopts_ : (optional) Object; options, which are:
// * _prefix_ : (optional) String or Array[String]; prepended to plugin names when searching, allows abbreviation of plugin names
// * _builtin_ : (optional) String or Array[String]; prepend to plugin names when searching, only applies to base module, used for frameworks with builtin plugins
// * _module_ : (optional, defaults to parent) Object; Node.js API module object, this should be the module of the framework, search will ascend from this module via the parent property
// * _errmsgprefix_ : (optional, default: true) String or Boolean; error message prefix for [eraro](http://github.com/rjrodger/eraro) module, used by this module to generate error messages
function make(useopts) {
// Default options, overidden by caller supplied options.
useopts = Object.assign(
{
prefix: 'plugin-',
builtin: '../plugin/',
module: module.parent,
errmsgprefix: true,
system_modules: intern.make_system_modules(),
merge_defaults: true,
gubu: true,
},
useopts,
)
// Setup error messages, see msgmap function below for text.
const eraro = Eraro({
package: 'use-plugin',
msgmap: msgmap(),
module: module,
prefix: useopts.errmsgprefix,
})
// This is the function that loads plugins.
// It is returned for use by the framework calling code.
// Parameters:
//
// * _plugin_ : (Object or Function or String); plugin definition
// * if Object: provide a partial or complete definition with same properties as return value
// * if Function: assumed to be plugin _init_ function; plugin name taken from function name, if defined
// * if String: base for _require_ search; assumes module defines an _init_ function
// * _options_ : (Object, ...); plugin options, if not an object, constructs an object of form {value$:options}
// * _callback_ : (Function); callback function, possibly to be called by framework after init function completes
//
// Returns: A plugin description object is returned, with properties:
//
// * _name_ : String; the plugin name, either supplied by calling code, or derived from definition
// * _init_ : Function; the plugin init function, the resolution of which is the point of this module!
// * _options_ : Object; plugin options, if supplied
// * _search_ : Array[{type,name}]; list of require search paths; applied to each module up the parent chain until something is found
// * _found_ : Object{type,name}; search entry that found something
// * _requirepath_ : String; the argument to require that found something
// * _modulepath_ : String; the Node.js API module.id whose require found something
// * _tag_ : String; the tag value of the plugin name (format: name$tag), if any, allows loading of same plugin multiple times
// * _err_ : Error; plugin load error, if any
function use() {
const args = Norma(
'{plugin:o|f|s, options:o|s|n|b?, callback:f?}',
arguments,
)
return use_plugin_desc(
build_plugin_desc(args, useopts, eraro),
useopts,
eraro,
)
}
// use.Optioner = Optioner
// use.Joi = Optioner.Joi
use.use_plugin_desc = function (plugin_desc) {
return use_plugin_desc(plugin_desc, useopts, eraro)
}
use.build_plugin_desc = function () {
const args = Norma(
'{plugin:o|f|s, options:o|s|n|b?, callback:f?}',
arguments,
)
return build_plugin_desc(args, useopts, eraro)
}
return use
}
function use_plugin_desc(plugin_desc, useopts, eraro) {
plugin_desc.search = build_plugin_names(
plugin_desc.name,
useopts.builtin,
useopts.prefix,
useopts.system_modules,
)
// The init function may already be defined.
// If it isn't, try to load it using _require_ over
// the search paths and module ancestry.
if ('function' !== typeof plugin_desc.init) {
load_plugin(plugin_desc, useopts.module, eraro)
}
let defaults = null
if (
plugin_desc.init &&
plugin_desc.init.defaults &&
// (Joi.isSchema(plugin_desc.init.defaults, { legacy: true }) ||
// TODO: use Gubu.isShape
((plugin_desc.init.defaults.gubu && plugin_desc.init.defaults.gubu.gubu$) ||
'function' === typeof plugin_desc.init.defaults)
) {
defaults = plugin_desc.init.defaults
} else if (
(plugin_desc.defaults &&
// (Joi.isSchema(plugin_desc.defaults, { legacy: true }) ||
// TODO: use Gubu.isShape
plugin_desc.defaults.gubu &&
plugin_desc.defaults.gubu.gubu$) ||
'function' === typeof plugin_desc.defaults
) {
defaults = plugin_desc.defaults
} else {
defaults = Object.assign(
{},
plugin_desc.defaults,
plugin_desc.init && plugin_desc.init.defaults,
)
}
plugin_desc.defaults = defaults
if (useopts.merge_defaults && 'object' === typeof defaults) {
plugin_desc.options = DefaultsDeep({}, plugin_desc.options, defaults)
// try {
// // plugin_desc.options = Optioner(defaults, { allow_unknown: true }).check(
// // plugin_desc.options
// // )
// } catch (e) {
// throw eraro('invalid_option', {
// name: plugin_desc.name,
// err_msg: e.message,
// options: plugin_desc.options,
// })
// }
}
// No init function found, require found nothing, so throw error.
if ('function' !== typeof plugin_desc.init) {
if (null == plugin_desc.found) {
const foldermap = {}
for (let i = 0; i < plugin_desc.history.length; i++) {
const item = plugin_desc.history[i]
const folder = Path.dirname(item.module)
foldermap[folder] = foldermap[folder] || []
foldermap[folder].push(item.path)
}
const b = []
Object.keys(foldermap).forEach(function (folder) {
b.push('\n' + Path.resolve(folder) + ':')
foldermap[folder].forEach(function (path) {
b.push('\n ' + path)
})
b.push('\n')
})
plugin_desc.searchlist = b.join('')
throw eraro('not_found', plugin_desc)
} else {
throw eraro('invalid_definition', plugin_desc)
}
}
return plugin_desc
}
// #### Create description object for the plugin
function build_plugin_desc(spec, useopts, eraro) {
const plugin = spec.plugin
// Don't do much with plugin options, just ensure they are an object.
let options =
null == spec.options
? null == plugin.options
? {}
: plugin.options
: spec.options
options = 'object' === typeof options ? options : { value$: options }
// Start building the return value.
let plugin_desc = {
options: options,
callback: spec.callback,
history: [],
}
// The most common case, where the plugin is
// specified as a string name to be required in.
if ('string' === typeof plugin) {
plugin_desc.name = plugin
}
// Define the plugin with a function, most often used for small,
// on-the-fly plugins.
else if ('function' === typeof plugin) {
if ('' !== plugin.name) {
plugin_desc.name = plugin.name
}
// The function has no name, so generate a name for the plugin
else {
const prefix = Array.isArray(useopts.prefix)
? useopts.prefix[0]
: useopts.prefix
plugin_desc.name = prefix + Nid()
}
plugin_desc.init = plugin
}
// Provide some or all of plugin definition directly.
else if ('object' === typeof plugin) {
plugin_desc = Object.assign({}, plugin, plugin_desc)
let name = plugin_desc.name
if ('string' !== typeof name) {
name = null != plugin_desc.init ? plugin_desc.init.name : null
}
if (null == name) {
throw eraro('no_name', { plugin: plugin })
} else {
plugin_desc.name = name
}
if (null != plugin_desc.init && 'function' !== typeof plugin_desc.init) {
throw eraro('no_init_function', {
name: name,
plugin: plugin,
})
}
}
// Options as an argument to the _use_ function override options
// in the plugin description object.
plugin_desc.options = Object.assign(
{},
plugin_desc.options || {},
options || {},
)
// Plugins can be tagged.
// The tag can be embedded inside the name using a $ separator: _name$tag_.
// Note: the $tag suffix is NOT considered part of the file name!
const m = /^(.+)\$(.+)$/.exec(plugin_desc.name)
if (m) {
plugin_desc.name = m[1]
plugin_desc.tag = m[2]
}
plugin_desc.full =
plugin_desc.name +
(null == plugin_desc.tag || '' == plugin_desc.tag
? ''
: '$' + plugin_desc.tag)
// Plugins must have a name.
if (!plugin_desc.name) {
throw eraro('no_name', plugin_desc)
}
return plugin_desc
}
// #### Attempt to load the plugin
// The following algorithm is used:
// 0. WHILE module defined
// 1. FOR EACH search-entry
// 2. IF NOT first module IGNORE builtins
// 3. PERFORM require ON search-entry.name
// 4. IF FOUND BREAK
// 5. IF ERROR THROW # construct contextual info
// 6. IF FOUND update plugin_desc, BREAK
// 7. IF NOT FOUND module = module.parent
function load_plugin(plugin_desc, start_module, eraro) {
let current_module = start_module
let builtin = true
let level = 0
let funcdesc = {}
let reqfunc
// Each loop ascends the module.parent hierarchy
while (
null == funcdesc.initfunc &&
(reqfunc = make_reqfunc(current_module))
) {
funcdesc = perform_require(reqfunc, plugin_desc, builtin, level)
if (funcdesc.error) {
throw handle_load_error(
funcdesc.error,
funcdesc.found,
plugin_desc,
eraro,
)
}
builtin = false
level++
current_module = current_module.parent
}
// Record the details of where we found the plugin.
// This is useful for debugging, especially if the "wrong" plugin is loaded.
plugin_desc.modulepath = funcdesc.module
plugin_desc.requirepath = funcdesc.require
plugin_desc.found = funcdesc.found
// Handle TypeScript default export shenanigans
if (
null != funcdesc.initfunc &&
'object' === typeof funcdesc.initfunc &&
'function' === typeof funcdesc.initfunc.default
) {
funcdesc.initfunc = funcdesc.initfunc.default
}
// The function name of the initfunc, if defined,
// sets the final name of the plugin.
// This replaces relative path references (like "../myplugins/foo")
// with a clean name ("foo").
if (
funcdesc.initfunc &&
null != funcdesc.initfunc.name &&
'' !== funcdesc.initfunc.name
) {
plugin_desc.name = funcdesc.initfunc.name
}
plugin_desc.init = funcdesc.initfunc
// Init function can also provide options
if (plugin_desc.init && 'object' === typeof plugin_desc.init.defaults) {
plugin_desc.defaults = Object.assign({}, plugin_desc.init.defaults)
}
}
// #### The require that loads a plugin can fail
// This code deals with the known failure cases.
function handle_load_error(err, found, plugin_desc, eraro) {
plugin_desc.err = err
plugin_desc.found = found
plugin_desc.found_name = plugin_desc.found.name
plugin_desc.err_msg = err.message
// Syntax error inside the plugin code.
// Unfortunately V8 does not give us location info.
// It does print a complaint to STDERR, so need to tell user to look there.
if (err instanceof SyntaxError) {
return eraro('syntax_error', plugin_desc)
}
// Not what you think!
// This covers the case where the plugin contains
// _require_ calls that themselves fail.
else if ('MODULE_NOT_FOUND' == err.code) {
plugin_desc.err_msg = err.stack.replace(/\n.*\(module\.js:.*/g, '')
plugin_desc.err_msg = plugin_desc.err_msg.replace(/\s+/g, ' ')
return eraro('require_failed', plugin_desc)
}
// The require call failed for some other reason.
else {
return eraro('load_failed', plugin_desc)
}
}
// #### Create a _require_ call bound to the correct module
function make_reqfunc(module) {
if (null == module) return null
const reqfunc = module.require.bind(module)
reqfunc.module = module.id
return reqfunc
}
// #### Iterate over all the search items using the provided require function
function perform_require(reqfunc, plugin_desc, builtin, level) {
const search_list = plugin_desc.search
let initfunc
let search
let found
next_search_entry: for (let i = 0; i < search_list.length; i++) {
search = search_list[i]
// only load builtins if builtin flag true
if (!builtin && 'builtin' == search.type) continue
if (0 === level && 'builtin' != search.type && search.name.match(/^[./]/))
continue
try {
// NOTE: Unfortunately module.require does not expose require.resolve
if (reqfunc.resolve) {
search.path = reqfunc.resolve(search.name)
} else {
search.path = search.path || search.name
}
const history_entry = {
module: reqfunc.module,
path: search.path,
name: search.name,
}
plugin_desc.history.push(history_entry)
initfunc = reqfunc(search.name)
found = search
// Found it!
break
} catch (e) {
if ('MODULE_NOT_FOUND' == e.code) {
// TODO: this fails if a sub file of the plugin module fails,
// as the module name is within the file path
// E.g. seneca-entity/lib/common.js
// A require failed inside the plugin.
if (-1 == e.message.indexOf(search.name)) {
return { error: e, found: search }
}
// Plain old not found, so continue searching.
continue next_search_entry
} else {
// The require failed for some other reason.
return { error: e, found: search }
}
}
}
// Return the init function, and a description of where we found it.
return {
initfunc: initfunc,
module: reqfunc.module,
require: search.name,
path: search.path,
// found: search,
found,
}
}
// #### Create the list of require search locations
// Searches are performed without the prefix first
function build_plugin_names() {
const args = Norma(
'{name:s, builtin:s|a?, prefix:s|a?, system:a?}',
arguments,
)
const name = args.name
const isRelative = name.match(/^[./]/)
const builtin_list = args.builtin
? Array.isArray(args.builtin)
? args.builtin
: [args.builtin]
: []
const prefix_list = args.prefix
? Array.isArray(args.prefix)
? args.prefix
: [args.prefix]
: []
const system_modules = args.system || []
const plugin_names = []
// Do the builtins first! But only for the framework module, see above.
if (!isRelative) {
builtin_list.forEach(function (builtin) {
plugin_names.push({ type: 'builtin', name: builtin + name })
prefix_list.forEach(function (prefix) {
plugin_names.push({ type: 'builtin', name: builtin + prefix + name })
})
})
// Try the prefix first - this ensures something like seneca-joi works
// where there is also a joi module
prefix_list.forEach(function (prefix) {
plugin_names.push({ type: 'normal', name: prefix + name })
})
}
// Vanilla require on the plugin name.
// Common case: the require succeeds on first module parent,
// because the plugin is an npm module
// in the code calling the framework.
// You can't load node system modules as plugins, however.
if (-1 == system_modules.indexOf(name)) {
plugin_names.push({ type: 'normal', name: name })
}
if (!isRelative) {
// OK, probably not an npm module, try locally.
plugin_names.push({ type: 'normal', name: './' + name })
prefix_list.forEach(function (prefix) {
plugin_names.push({ type: 'normal', name: './' + prefix + name })
})
}
const orig_plugin_names = plugin_names.slice(0)
orig_plugin_names.forEach((n) => {
if (n.name.match(/[a-z][A-Z]/)) {
plugin_names.push({
...n,
name: n.name
.replace(
/([a-z])([A-Z])/g,
(m, p1, p2) => p1 + '-' + p2.toLowerCase(),
)
.replace(/([A-Z])/g, (m, p1) => p1.toLowerCase()),
})
} else if (n.name.match(/[a-z]-[a-z]/)) {
plugin_names.push({
...n,
name: n.name
.replace(/([a-z])-([a-z])/g, (m, p1, p2) => p1 + p2.toUpperCase())
.replace(/([^\w])([a-z])/g, (m, p1, p2) => p1 + p2.toUpperCase())
.replace(/^([a-z])/g, (m, p1) => p1.toUpperCase()),
})
}
})
// console.log('PLUGIN_NAMES', plugin_names)
return plugin_names
}
// #### Define the error messages for this module
function msgmap() {
return {
syntax_error:
'Could not load plugin <%=name%> defined in <%=found_name%> due to syntax error: <%=err_msg%>. See STDERR for details.',
not_found:
'Could not load plugin <%=name%>; searched the following folder and file paths: <%=searchlist%>.',
require_failed:
'Could not load plugin <%=name%> defined in <%=found_name%> as a require call inside the plugin (or a module required by the plugin) failed: <%=err_msg%>.',
no_name: 'No name property found for plugin defined by Object <%=plugin%>.',
no_init_function:
'The init property is not a function for plugin <%=name%> defined by Object <%=plugin%>.',
load_failed:
'Could not load plugin <%=name%> defined in <%=found_name%> due to error: <%=err_msg%>.',
invalid_option:
'Plugin <%=name%>: option value is not valid: <%=err_msg%> in options <%=options%>',
invalid_definition: 'Plugin <%=name%>: no definition function found.',
}
}
const intern = (module.exports.intern = {
make_system_modules: function () {
return Module.builtinModules
},
})