lib/plugin.ts
/* Copyright © 2020 Richard Rodger and other contributors, MIT License. */
/* $lab:coverage:off$ */
'use strict'
const Uniq: any = require('lodash.uniq')
const Eraro: any = require('eraro')
import Nua from 'nua'
import { Ordu, TaskSpec } from 'ordu'
// TODO: refactor: use.js->plugin.js and contain *_plugin api methods too
const Common: any = require('./common')
const { Print } = require('./print')
/* $lab:coverage:on$ */
const intern = make_intern()
function api_use(callpoint: any, opts: any) {
const tasks = make_tasks()
const ordu = new Ordu({ debug: opts.debug })
ordu.operator('seneca_plugin', intern.op.seneca_plugin)
ordu.operator('seneca_export', intern.op.seneca_export)
ordu.operator('seneca_options', intern.op.seneca_options)
ordu.operator('seneca_complete', intern.op.seneca_complete)
ordu.add([
tasks.args,
tasks.load,
tasks.normalize,
tasks.preload,
{ name: 'pre_meta', exec: tasks.meta },
{ name: 'pre_legacy_extend', exec: tasks.legacy_extend },
tasks.delegate,
tasks.call_define,
tasks.options,
tasks.define,
{ name: 'post_meta', exec: tasks.meta },
{ name: 'post_legacy_extend', exec: tasks.legacy_extend },
tasks.call_prepare,
tasks.complete,
])
return {
use: make_use(ordu, callpoint),
ordu,
tasks,
}
}
interface UseCtx {
seq: { index: number }
args: string[]
seneca: any,
callpoint: any
}
// TODO: not satisfactory
interface UseData {
seq: number
args: string[]
plugin: any
meta: any
delegate: any
plugin_done: any
exports: any
prepare: any
}
function make_use(ordu: any, callpoint: any) {
let seq = { index: 0 }
return function use(this: any) {
let self = this
let args = [...arguments]
if (0 === args.length) {
throw self.error('use_no_args')
}
let ctx: UseCtx = {
seq: seq,
args: args,
seneca: this,
callpoint: callpoint(true)
}
let data: UseData = {
seq: -1,
args: [],
plugin: null,
meta: null,
delegate: null,
plugin_done: null,
exports: {},
prepare: {},
}
async function run() {
await ordu.exec(ctx, data, {
done: function(res: any) {
if (res.err) {
var err = res.err.seneca ? res.err :
self.private$.error(res.err, res.err.code)
err.plugin = err.plugin ||
(data.plugin ? (data.plugin.fullname || data.plugin.name) :
args.join(' '))
err.plugin_callpoint = err.plugin_callpoint || ctx.callpoint
self.die(err)
}
}
})
}
// NOTE: don't wait for result!
run()
return self
}
}
function make_tasks(): any {
return {
// TODO: args validation?
args: (spec: TaskSpec) => {
let args: any[] = [...spec.ctx.args]
// DEPRECATED: Remove when Seneca >= 4.x
// Allow chaining with seneca.use('options', {...})
// see https://github.com/rjrodger/seneca/issues/80
if ('options' === args[0]) {
spec.ctx.seneca.options(args[1])
return {
op: 'stop',
why: 'legacy-options'
}
}
// Plugin definition function is under property `define`.
// `init` is deprecated from 4.x
// TODO: use-plugin expects `init` - update use-plugin to make this customizable
if (null != args[0] && 'object' === typeof args[0]) {
args[0].init = args[0].define || args[0].init
}
return {
op: 'merge',
out: { plugin: { args } }
}
},
load: (spec: TaskSpec) => {
let args: string[] = spec.data.plugin.args
let seneca: any = spec.ctx.seneca
let private$: any = seneca.private$
// TODO: use-plugin needs better error message for malformed plugin desc
let desc = private$.use.build_plugin_desc(...args)
desc.callpoint = spec.ctx.callpoint
if (private$.ignore_plugins[desc.full]) {
seneca.log.info({
kind: 'plugin',
case: 'ignore',
plugin_full: desc.full,
plugin_name: desc.name,
plugin_tag: desc.tag,
})
return {
op: 'stop',
why: 'ignore'
}
}
else {
let plugin: any = private$.use.use_plugin_desc(desc)
return {
op: 'merge',
out: {
plugin
}
}
}
},
normalize: (spec: TaskSpec) => {
let plugin: any = spec.data.plugin
let modify: any = {}
// NOTE: `define` is the property for the plugin definition action.
// The property `init` will be deprecated in 4.x
modify.define = plugin.define || plugin.init
modify.fullname = Common.make_plugin_key(plugin)
modify.loading = true
return {
op: 'merge',
out: { plugin: modify }
}
},
preload: (spec: TaskSpec) => {
let seneca: any = spec.ctx.seneca
let plugin: any = spec.data.plugin
let so: any = seneca.options()
// Don't reload plugins if load_once true.
if (so.system.plugin.load_once) {
if (seneca.has_plugin(plugin)) {
return {
op: 'stop',
why: 'already-loaded',
out: {
plugin: {
loading: false
}
}
}
}
}
let meta: any = {}
if ('function' === typeof plugin.define.preload) {
// TODO: need to capture errors
meta = plugin.define.preload.call(seneca, plugin) || {}
}
let name = meta.name || plugin.name
let fullname = Common.make_plugin_key(name, plugin.tag)
return {
op: 'seneca_plugin',
out: {
merge: {
meta,
plugin: {
name,
fullname
}
},
plugin
}
}
},
// Handle plugin meta data returned by plugin define function
meta: (spec: TaskSpec) => {
let seneca: any = spec.ctx.seneca
let plugin: any = spec.data.plugin
let meta: any = spec.data.meta
let exports: any = {}
exports[plugin.name] = meta.export || plugin
exports[plugin.fullname] = meta.export || plugin
let exportmap: any = meta.exportmap || meta.exports || {}
Object.keys(exportmap).forEach(k => {
let v: any = exportmap[k]
if (void 0 !== v) {
let exportfullname = plugin.fullname + '/' + k
exports[exportfullname] = v
// Also provide exports on untagged plugin name. This is the
// standard name that other plugins use
let exportname = plugin.name + '/' + k
exports[exportname] = v
}
})
if (meta.order) {
if (meta.order.plugin) {
let tasks: any[] =
Array.isArray(meta.order.plugin) ? meta.order.plugin :
[meta.order.plugin]
seneca.order.plugin.add(tasks)
delete meta.order.plugin
}
}
return {
op: 'seneca_export',
out: {
exports
}
}
},
// NOTE: mutates spec.ctx.seneca
legacy_extend: (spec: TaskSpec) => {
let seneca: any = spec.ctx.seneca
// let plugin: any = spec.data.plugin
let meta: any = spec.data.meta
if (meta.extend && 'object' === typeof meta.extend) {
if ('function' === typeof meta.extend.action_modifier) {
seneca.private$.action_modifiers.push(meta.extend.action_modifier)
}
// FIX: needs to use logging.load_logger
if ('function' === typeof meta.extend.logger) {
if (
!meta.extend.logger.replace &&
'function' === typeof seneca.private$.logger.add
) {
seneca.private$.logger.add(meta.extend.logger)
} else {
seneca.private$.logger = meta.extend.logger
}
}
}
//seneca.register(plugin, meta)
},
delegate: (spec: TaskSpec) => {
let seneca: any = spec.ctx.seneca
let plugin: any = spec.data.plugin
// Adjust Seneca API to be plugin specific.
let delegate = seneca.delegate({
plugin$: {
name: plugin.name,
tag: plugin.tag,
},
fatal$: true,
})
// Shared plugin resources.
plugin.shared = Object.create(null)
delegate.shared = plugin.shared
delegate.plugin = plugin
delegate.private$ = Object.create(seneca.private$)
delegate.private$.ge = delegate.private$.ge.gate()
delegate.die = Common.makedie(delegate, {
type: 'plugin',
plugin: plugin.name,
})
let actdeflist: any = []
delegate.add = function plugin_add() {
let argsarr = [...arguments]
let actdef = argsarr[argsarr.length - 1] || {}
if ('function' === typeof actdef) {
actdef = {}
argsarr.push(actdef)
}
actdef.plugin_name = plugin.name || '-'
actdef.plugin_tag = plugin.tag || '-'
actdef.plugin_fullname = plugin.fullname
// TODO: is this necessary?
actdef.log = delegate.log
actdeflist.push(actdef)
seneca.add.apply(delegate, argsarr)
return this
}
delegate.__update_plugin__ = function(plugin: any) {
delegate.context.name = plugin.name || '-'
delegate.context.tag = plugin.tag || '-'
delegate.context.full = plugin.fullname || '-'
actdeflist.forEach(function(actdef: any) {
actdef.plugin_name = plugin.name || actdef.plugin_name || '-'
actdef.plugin_tag = plugin.tag || actdef.plugin_tag || '-'
actdef.plugin_fullname = plugin.fullname || actdef.plugin_fullname || '-'
})
}
delegate.init = function(init: any) {
// TODO: validate init_action is function
let pat: any = {
role: 'seneca',
plugin: 'init',
init: plugin.name,
}
if (null != plugin.tag && '-' != plugin.tag) {
pat.tag = plugin.tag
}
delegate.add(pat, function(this: any, _: any, reply: any): any {
init.call(this, reply)
})
}
delegate.context.plugin = plugin
delegate.context.plugin.mark = Math.random()
return {
op: 'merge',
out: {
delegate
}
}
},
call_define: (spec: TaskSpec) => {
let plugin: any = spec.data.plugin
let delegate: any = spec.data.delegate
// FIX: mutating context!!!
let seq: number = spec.ctx.seq.index++
let plugin_define_pattern: any = {
role: 'seneca',
plugin: 'define',
name: plugin.name,
seq: seq,
}
if (plugin.tag !== null) {
plugin_define_pattern.tag = plugin.tag
}
return new Promise(resolve => {
// seneca
delegate.add(plugin_define_pattern, (_: any, reply: any) => {
resolve({
op: 'merge',
out: { seq, plugin_done: reply }
})
})
delegate.act({
role: 'seneca',
plugin: 'define',
name: plugin.name,
tag: plugin.tag,
seq: seq,
default$: {},
fatal$: true,
local$: true,
})
})
},
options: (spec: TaskSpec) => {
let plugin: any = spec.data.plugin
let delegate: any = spec.data.delegate
let so = delegate.options()
let fullname = plugin.fullname
let defaults = plugin.defaults
let fullname_options = Object.assign(
{},
// DEPRECATED: remove in 4
so[fullname],
so.plugin[fullname],
// DEPRECATED: remove in 4
so[fullname + '$' + plugin.tag],
so.plugin[fullname + '$' + plugin.tag]
)
let shortname = fullname !== plugin.name ? plugin.name : null
if (!shortname && fullname.indexOf('seneca-') === 0) {
shortname = fullname.substring('seneca-'.length)
}
let shortname_options = Object.assign(
{},
// DEPRECATED: remove in 4
so[shortname],
so.plugin[shortname],
// DEPRECATED: remove in 4
so[shortname + '$' + plugin.tag],
so.plugin[shortname + '$' + plugin.tag]
)
let base: any = {}
// NOTE: plugin error codes are in their own namespaces
// TODO: test this!!!
let errors = plugin.errors || (plugin.define && plugin.define.errors)
if (errors) {
base.errors = errors
}
// TODO: these should deep merge
let fullopts = Object.assign(
base,
shortname_options,
fullname_options,
plugin.options || {}
)
let resolved_options: any = {}
let valid = delegate.valid // Gubu validator: https://github.com/rjrodger/gubu
let err: Error | undefined = void 0
let joi_schema: any = null
let Joi = delegate.util.Joi
let defaults_values =
('function' === typeof (defaults) && !defaults.gubu) ?
defaults({ valid, Joi }) : defaults
if (null == defaults_values ||
0 === Object.keys(defaults_values).length ||
!so.valid.active ||
!so.valid.plugin
) {
resolved_options = fullopts
}
else {
if (
!so.legacy.options &&
!defaults_values.$_root // check for legacy Joi schema
) { // !Joi.isSchema(defaults_values, { legacy: true })) {
// TODO: use Gubu.isShape
let isShape = defaults_values.gubu && defaults_values.gubu.gubu$
// TODO: when Gubu supports merge, also merge if isShape
if (!isShape && null == defaults_values.errors && null != errors) {
defaults_values.errors = {}
}
let optionShape =
isShape ? defaults_values : delegate.valid(defaults_values)
let shapeErrors: any[] = []
resolved_options = optionShape(fullopts, { err: shapeErrors })
if (0 < shapeErrors.length) {
err = delegate.error('invalid_plugin_option', {
name: fullname,
err_msg: shapeErrors.map((se: any) => se.t).join('; '),
options: fullopts,
})
}
}
else {
resolved_options = delegate.util.deep(defaults_values, fullopts)
/* TODO: move to seneca-joi
let joi_schema: any = intern.prepare_spec(
Joi,
defaults_values,
{ allow_unknown: true },
{}
)
let joi_out = joi_schema.validate(fullopts)
if (joi_out.error) {
err = delegate.error('invalid_plugin_option', {
name: fullname,
err_msg: joi_out.error.message,
options: fullopts,
})
}
else {
resolved_options = joi_out.value
}
*/
}
}
return {
op: 'seneca_options',
err: err,
out: {
plugin: {
options: resolved_options,
options_schema: joi_schema
}
}
}
},
// TODO: move data modification to returned operation
define: (spec: TaskSpec) => {
let seneca: any = spec.ctx.seneca
let plugin: any = spec.data.plugin
let delegate: any = spec.data.delegate
let plugin_options: any = spec.data.plugin.options
delegate.log.debug({
kind: 'plugin',
case: 'DEFINE',
name: plugin.name,
tag: plugin.tag,
options: plugin_options,
callpoint: spec.ctx.callpoint,
})
let meta
meta = intern.define_plugin(
delegate,
plugin,
seneca.util.clean(plugin_options)
)
if (meta instanceof Promise) {
return meta.then(finalize_meta)
}
return finalize_meta(meta)
function finalize_meta(meta: any) {
plugin.meta = meta
// legacy api for service function
if ('function' === typeof meta) {
meta = { service: meta }
}
// Plugin may have changed its own name dynamically
plugin.name = meta.name || plugin.name
plugin.tag =
meta.tag || plugin.tag || (plugin.options && plugin.options.tag$)
plugin.fullname = Common.make_plugin_key(plugin)
plugin.service = meta.service || plugin.service
delegate.__update_plugin__(plugin)
seneca.private$.plugins[plugin.fullname] = plugin
seneca.private$.plugin_order.byname.push(plugin.name)
seneca.private$.plugin_order.byname = Uniq(
seneca.private$.plugin_order.byname
)
seneca.private$.plugin_order.byref.push(plugin.fullname)
// 3.x Backwards compatibility - REMOVE in 4.x
if ('amqp-transport' === plugin.name) {
seneca.options({ legacy: { meta: true } })
}
if ('function' === typeof plugin_options.defined$) {
plugin_options.defined$(plugin)
}
// TODO: test this, with preload, explicitly
return {
op: 'merge',
out: {
meta,
}
}
}
},
call_prepare: (spec: TaskSpec) => {
let plugin: any = spec.data.plugin
let plugin_options: any = spec.data.plugin.options
let delegate: any = spec.data.delegate
// If init$ option false, do not execute init action.
if (false === plugin_options.init$) {
return
}
let exports = (spec.data as any).exports
delegate.log.debug({
kind: 'plugin',
case: 'INIT',
name: plugin.name,
tag: plugin.tag,
exports: exports,
})
return new Promise(resolve => {
delegate.act(
{
role: 'seneca',
plugin: 'init',
seq: spec.data.seq,
init: plugin.name,
tag: plugin.tag,
default$: {},
fatal$: true,
local$: true,
},
function(err: Error, res: any) {
resolve({
op: 'merge',
out: {
prepare: {
err,
res
}
}
})
}
)
})
},
complete: (spec: TaskSpec) => {
let prepare: any = spec.data.prepare
let plugin: any = spec.data.plugin
let plugin_done: any = spec.data.plugin_done
let plugin_options: any = spec.data.plugin.options
let delegate: any = spec.data.delegate
let so = delegate.options()
if (prepare) {
if (prepare.err) {
let plugin_out: any = {}
plugin_out.err_code = 'plugin_init'
plugin_out.plugin_error = prepare.err.message
if (prepare.err.code === 'action-timeout') {
plugin_out.err_code = 'plugin_init_timeout'
plugin_out.timeout = so.timeout
}
return {
op: 'seneca_complete',
out: {
plugin: plugin_out
}
}
}
let fullname = plugin.name + (plugin.tag ? '$' + plugin.tag : '')
if (so.debug.print && so.debug.print.options) {
Print.plugin_options(delegate, fullname, plugin_options)
}
delegate.log.info({
kind: 'plugin',
case: 'READY',
name: plugin.name,
tag: plugin.tag,
})
if ('function' === typeof plugin_options.inited$) {
plugin_options.inited$(plugin)
}
}
plugin_done()
return {
op: 'seneca_complete',
out: {
plugin: {
loading: false
}
}
}
}
}
}
function make_intern() {
return {
// TODO: explicit tests for these operators
op: {
seneca_plugin: (tr: any, ctx: any, data: any): any => {
Nua(data, tr.out.merge, { preserve: true })
ctx.seneca.private$.plugins[data.plugin.fullname] = tr.out.plugin
return { stop: false }
},
seneca_export: (tr: any, ctx: any, data: any): any => {
// NOTE/plugin/774a: when loading multiple tagged plugins,
// last plugin wins the plugin name on the exports. This is
// consistent with general Seneca principal that plugin load
// order is significant, as later plugins override earlier
// action patterns. Thus later plugins override exports too.
Object.assign(data.exports, tr.out.exports)
Object.assign(ctx.seneca.private$.exports, tr.out.exports)
return { stop: false }
},
seneca_options: (tr: any, ctx: any, data: any): any => {
Nua(data.plugin, tr.out.plugin, { preserve: true })
let plugin_fullname: string = data.plugin.fullname
let plugin_options = data.plugin.options
let plugin_options_update: any = { plugin: {} }
plugin_options_update.plugin[plugin_fullname] = plugin_options
ctx.seneca.options(plugin_options_update)
return { stop: false }
},
seneca_complete: (tr: any, _ctx: any, data: any): any => {
Nua(data.plugin, tr.out.plugin, { preserve: true })
if (data.prepare.err) {
data.delegate.die(
data.delegate.error(data.prepare.err, data.plugin.err_code, data.plugin))
}
return { stop: true }
},
},
define_plugin: function(delegate: any, plugin: any, options: any): any {
// legacy plugins
if (plugin.define.length > 1) {
let fnstr = plugin.define.toString()
plugin.init_func_sig = (fnstr.match(/^(.*)\r*\n/) || [])[1]
let ex = delegate.error('unsupported_legacy_plugin', plugin)
throw ex
}
if (options.errors) {
plugin.eraro = Eraro({
package: 'seneca',
msgmap: options.errors,
override: true,
})
}
let meta
try {
meta = plugin.define.call(delegate, options) || {}
} catch (e: any) {
Common.wrap_error(e, 'plugin_define_failed', {
fullname: plugin.fullname,
message: (
e.message + (' (' + e.stack.match(/\n.*?\n/)).replace(/\n.*\//g, '')
).replace(/\n/g, ''),
options: options,
repo: plugin.repo ? ' ' + plugin.repo + '/issues' : '',
})
}
if (meta instanceof Promise) {
return meta.then(finalize_meta)
}
return finalize_meta(meta)
function finalize_meta(base_meta: any) {
const meta = 'string' === typeof base_meta
? { name: base_meta }
: base_meta
meta.options = meta.options || options
return meta
}
},
/* TODO: move to seneca-joi
// copied from https://github.com/rjrodger/optioner
// TODO: remove unnecessary vars+code
prepare_spec: function(Joi: any, spec: any, opts: any, ctxt: any) {
if (Joi.isSchema(spec, { legacy: true })) {
return spec
}
let joiobj = Joi.object()
if (opts.allow_unknown) {
joiobj = joiobj.unknown()
}
let joi = intern.walk(
Joi,
joiobj,
spec,
'',
opts,
ctxt,
function(valspec: any) {
if (valspec && Joi.isSchema(valspec, { legacy: true })) {
return valspec
} else {
let typecheck = typeof valspec
//typecheck = 'function' === typecheck ? 'func' : typecheck
if (opts.must_match_literals) {
return Joi.any()
.required()
.valid(valspec)
} else {
if (void 0 === valspec) {
return Joi.any().optional()
} else if (null == valspec) {
return Joi.any().default(null)
} else if ('number' === typecheck && Number.isInteger(valspec)) {
return Joi.number()
.integer()
.default(valspec)
} else if ('string' === typecheck) {
return Joi.string()
.empty('')
.default(() => valspec)
} else {
return Joi[typecheck]().default(() => valspec)
}
}
}
})
return joi
},
// copied from https://github.com/rjrodger/optioner
// TODO: remove unnecessary vars+code
walk: function(
Joi: any,
start_joiobj: any,
obj: any,
path: any,
opts: any,
ctxt: any,
mod: any) {
let joiobj = start_joiobj
// NOTE: use explicit Joi construction for checking within arrays
if (Array.isArray(obj)) {
return Joi.array().default(obj)
}
else {
for (let p in obj) {
let v = obj[p]
let t = typeof v
let kv: any = {}
if (null != v && !Joi.isSchema(v, { legacy: true }) && 'object' === t) {
let np = '' === path ? p : path + '.' + p
let childjoiobj = Joi.object().default()
if (opts.allow_unknown) {
childjoiobj = childjoiobj.unknown()
}
kv[p] = intern.walk(Joi, childjoiobj, v, np, opts, ctxt, mod)
} else {
kv[p] = mod(v)
}
joiobj = joiobj.keys(kv)
}
return joiobj
}
}
*/
}
}
const Plugin = {
api_use,
intern,
}
export {
Plugin
}