UNPKG

hooks-plugin

Version:

A plugin system built through various hooks.

990 lines (978 loc) 26.8 kB
'use strict'; var aidly = require('aidly'); const INTERNAL = Symbol('internal_hooks'); const INVALID_VALUE = Symbol('invalid_condition_value'); const PERFORMANCE_PLUGIN_PREFIX = '__performance_monitor__'; const isBrowser = typeof window !== 'undefined'; let taskId = 1; const createTaskId = () => taskId++; let monitorTaskId = 1; const createMonitorTaskId = () => monitorTaskId++; let monitorPluginId = 1; const createMonitorPluginId = () => monitorPluginId++; const checkReturnData = (originData, returnData) => { if (!aidly.isPlainObject(returnData)) return false; if (originData !== returnData) { for (const key in originData) { if (!(key in returnData)) { return false; } } } return true; }; const getTargetInArgs = (key, args) => { let target = args; const parts = key.split('.'); for (let i = 0, l = parts.length; i < l; i++) { if (!target) return INVALID_VALUE; let p = parts[i]; if (p.startsWith('[') && p.endsWith(']')) { p = Number(p.slice(1, -1)); } target = target[p]; } return target; }; class SyncHook { // Only `context` is allowed to be passed in from outside constructor(context, _type = 'SyncHook', _internal) { this.listeners = new Set(); this.tags = new WeakMap(); this.errors = new Set(); this.type = _type; this._locked = false; this.context = typeof context === 'undefined' ? null : context; // `before` and `after` hooks should not call other `before` and `after` hooks recursively, // as it can lead to infinite loops. if (_internal !== INTERNAL) { this.before = new SyncHook(null, 'SyncHook', INTERNAL); this.after = new SyncHook(null, 'SyncHook', INTERNAL); } } /** * @internal */ _emitError(error, hook, tag) { if (this.errors.size > 0) { this.errors.forEach((fn) => fn({ tag, hook, error, type: this.type, }), ); } else { throw error; } } /** * Determine whether there is an executable callback function. */ isEmpty() { return this.listeners.size === 0; } /** * By locking the current hook, you will no longer be able to add or remove callback functions from it. */ lock() { this._locked = true; if (this.before) this.before.lock(); if (this.after) this.after.lock(); return this; } /** * Unlock the current hook. */ unlock() { this._locked = false; if (this.before) this.before.unlock(); if (this.after) this.after.unlock(); return this; } on(tag, fn) { aidly.assert(!this._locked, 'The current hook is now locked.'); if (typeof tag === 'function') { fn = tag; tag = ''; } aidly.assert( typeof fn === 'function', `Invalid parameter in "${this.type}".`, ); if (tag && typeof tag === 'string') { this.tags.set(fn, tag); } this.listeners.add(fn); return this; } once(tag, fn) { if (typeof tag === 'function') { fn = tag; tag = ''; } const self = this; this.on(tag, function wrapper(...args) { self.remove(wrapper, INTERNAL); return fn.apply(this, args); }); return this; } /** * trigger hooks. */ emit(...data) { var _a, _b, _c; if (this.listeners.size > 0) { const id = createTaskId(); let map = null; if ( !((_a = this.after) === null || _a === void 0 ? void 0 : _a.isEmpty()) ) { map = Object.create(null); } (_b = this.before) === null || _b === void 0 ? void 0 : _b.emit(id, this.type, this.context, data); this.listeners.forEach((fn) => { const tag = this.tags.get(fn); if (map && tag) { map[tag] = Date.now(); } const record = () => { if (map && tag) { map[tag] = Date.now() - map[tag]; } }; try { fn.apply(this.context, data); record(); } catch (e) { record(); this._emitError(e, fn, tag); } }); // The data being mapped will only be meaningful if `after` is not empty. (_c = this.after) === null || _c === void 0 ? void 0 : _c.emit(id, this.type, this.context, data, map); } } /** * Remove all hooks. */ remove(fn, _flag) { if (_flag !== INTERNAL) { aidly.assert(!this._locked, 'The current hook is now locked.'); } this.listeners.delete(fn); return this; } /** * Remove a specific hook. */ removeAll() { aidly.assert(!this._locked, 'The current hook is now locked.'); this.listeners.clear(); return this; } /** * Listen for errors when the hook is running. */ listenError(fn) { aidly.assert(!this._locked, 'The current hook is now locked.'); this.errors.add(fn); } /** * Clone a clean instance. */ clone() { return new this.constructor( this.context, this.type, this.before ? null : INTERNAL, ); } } class AsyncHook extends SyncHook { constructor(context) { super(context, 'AsyncHook'); } emit(...data) { var _a, _b; let id; let result; const ls = Array.from(this.listeners); let map = null; if (ls.length > 0) { id = createTaskId(); if ( !((_a = this.after) === null || _a === void 0 ? void 0 : _a.isEmpty()) ) { map = Object.create(null); } (_b = this.before) === null || _b === void 0 ? void 0 : _b.emit(id, this.type, this.context, data); let i = 0; const call = (prev) => { if (prev === false) { return false; // Abort process } else if (i < ls.length) { let res; const fn = ls[i++]; const tag = this.tags.get(fn); if (map && tag) { map[tag] = Date.now(); } const record = () => { if (map && tag) { map[tag] = Date.now() - map[tag]; } }; try { res = fn.apply(this.context, data); } catch (e) { // If there is an error in the function call, // there is no need to monitor the result of the promise. record(); this._emitError(e, fn, tag); return call(prev); } return Promise.resolve(res) .finally(record) .then(call) .catch((e) => { this._emitError(e, fn, tag); return call(prev); }); } else { return prev; } }; result = call(); } return Promise.resolve(result).then((result) => { var _a; if (ls.length > 0) { // The data being mapped will only be meaningful if `after` is not empty. (_a = this.after) === null || _a === void 0 ? void 0 : _a.emit(id, this.type, this.context, data, map); } return result; }); } } class SyncWaterfallHook extends SyncHook { constructor(context) { super(context, 'SyncWaterfallHook'); } emit(data) { var _a, _b, _c; aidly.assert( aidly.isPlainObject(data), `"${this.type}" hook response data must be an object.`, ); if (this.listeners.size > 0) { const id = createTaskId(); let map = null; if ( !((_a = this.after) === null || _a === void 0 ? void 0 : _a.isEmpty()) ) { map = Object.create(null); } (_b = this.before) === null || _b === void 0 ? void 0 : _b.emit(id, this.type, this.context, [data]); for (const fn of this.listeners) { const tag = this.tags.get(fn); if (map && tag) { map[tag] = Date.now(); } const record = () => { if (map && tag) { map[tag] = Date.now() - map[tag]; } }; try { const tempData = fn.call(this.context, data); aidly.assert( checkReturnData(data, tempData), `The return value of hook "${this.type}" is incorrect.`, ); data = tempData; record(); } catch (e) { record(); this._emitError(e, fn, tag); } } (_c = this.after) === null || _c === void 0 ? void 0 : _c.emit(id, this.type, this.context, [data], map); } return data; } } class AsyncParallelHook extends SyncHook { constructor(context) { super(context, 'AsyncParallelHook'); } emit(...data) { var _a, _b; let id; let map = null; // Disclaimer in advance, `listeners` may change const size = this.listeners.size; const taskList = []; if (size > 0) { id = createTaskId(); if ( !((_a = this.after) === null || _a === void 0 ? void 0 : _a.isEmpty()) ) { map = Object.create(null); } (_b = this.before) === null || _b === void 0 ? void 0 : _b.emit(id, this.type, this.context, data); for (const fn of this.listeners) { taskList.push( Promise.resolve().then(() => { const tag = this.tags.get(fn); if (map && tag) { map[tag] = Date.now(); } const record = () => { if (map && tag) { map[tag] = Date.now() - map[tag]; } }; try { const res = fn.apply(this.context, data); if (aidly.isPromiseLike(res)) { // `Thenable` may not provide `catch` method, // It needs to be wrapped with a promise. return Promise.resolve(res).catch((e) => { record(); this._emitError(e, fn, tag); return null; }); } else { record(); return res; } } catch (e) { this._emitError(e, fn, tag); return null; } }), ); } } return Promise.all(taskList).then(() => { var _a; if (size > 0) { (_a = this.after) === null || _a === void 0 ? void 0 : _a.emit(id, this.type, this.context, data, map); } }); } } class AsyncWaterfallHook extends SyncHook { constructor(context) { super(context, 'AsyncWaterfallHook'); } emit(data) { var _a, _b; aidly.assert( aidly.isPlainObject(data), `"${this.type}" hook response data must be an object.`, ); let i = 0; let id; let map = null; const ls = Array.from(this.listeners); if (ls.length > 0) { id = createTaskId(); if ( !((_a = this.after) === null || _a === void 0 ? void 0 : _a.isEmpty()) ) { map = Object.create(null); } (_b = this.before) === null || _b === void 0 ? void 0 : _b.emit(id, this.type, this.context, [data]); const call = (prev) => { if (prev === false) { return false; } else { aidly.assert( checkReturnData(data, prev), `The return value of hook "${this.type}" is incorrect.`, ); data = prev; if (i < ls.length) { let res; const fn = ls[i++]; const tag = this.tags.get(fn); if (map && tag) { map[tag] = Date.now(); } const record = () => { if (map && tag) { map[tag] = Date.now() - map[tag]; } }; try { res = fn.call(this.context, prev); } catch (e) { // If there is an error in the function call, // there is no need to monitor the result of the promise. record(); this._emitError(e, fn, tag); return call(prev); } return Promise.resolve(res) .finally(record) .then(call) .catch((e) => { this._emitError(e, fn, tag); return call(prev); }); } } return data; }; return Promise.resolve(call(data)).then((data) => { var _a; (_a = this.after) === null || _a === void 0 ? void 0 : _a.emit(id, this.type, this.context, [data], map); return data; }); } else { return Promise.resolve(data); } } } function createPerformance(plSys, defaultCondition) { let hooks = {}; let closed = false; const pluginName = `${PERFORMANCE_PLUGIN_PREFIX}${createMonitorPluginId()}`; // If value is equivalent, it represents an event bus // Note (need to guide users): // The `value` is recorded here, // but the value is unknown and there may be a memory leak. // The user needs to manually close the performance monitoring to clear it. let records1 = new Map(); let records2 = Object.create(null); // Some information about each time a monitor is created is recorded here. let monitorTask = Object.create(null); const findCondition = (key, conditions) => { if (!conditions) return defaultCondition; return conditions[key] || defaultCondition; }; for (const key in plSys.lifecycle) { hooks[key] = function (...args) { let value; for (const id in monitorTask) { const [sk, ek, conditions, hook] = monitorTask[id]; const condition = findCondition(key, conditions); if (key === ek) { value = getTargetInArgs(condition, args); if (value !== INVALID_VALUE) { const prevObj = aidly.isPrimitiveValue(value) ? records2[value] : records1.get(value); if (prevObj) { const prevTime = prevObj[`${id}_${sk}`]; if (typeof prevTime === 'number') { hook.emit({ endArgs: args, endContext: this, events: [sk, ek], equalValue: value, time: Date.now() - prevTime, }); } } } } if (key === sk) { value = value || getTargetInArgs(condition, args); if (value !== INVALID_VALUE) { let obj; const k = `${id}_${sk}`; const t = Date.now(); if (aidly.isPrimitiveValue(value)) { obj = records2[value]; if (!obj) { obj = Object.create(null); records2[value] = obj; } } else { obj = records1.get(value); if (!obj) { obj = Object.create(null); records1.set(value, obj); } } obj[k] = t; } } } }; } plSys.use({ hooks, name: pluginName, }); return { /** * Turn off performance monitoring. */ close() { if (!closed) { closed = true; records1.clear(); records2 = Object.create(null); monitorTask = Object.create(null); this._taskHooks.hs.forEach((hook) => hook.removeAll()); this._taskHooks.hs.clear(); plSys.remove(pluginName); } }, /** * Add new observation task. */ monitor(sk, ek, conditions) { aidly.assert( !closed, 'Unable to add tasks to a closed performance observer.', ); const id = createMonitorTaskId(); const hook = new SyncHook(); const task = [sk, ek, conditions, hook]; monitorTask[id] = task; this._taskHooks.add(hook); return hook; }, _taskHooks: { hs: new Set(), watch: new Set(), add(hook) { this.hs.add(hook); this.watch.forEach((fn) => fn(hook)); }, }, }; } // If there is user defined performance data, // it should also be printed here. function logPerformance(p, performanceReceiver, tag) { const _tag = `[${tag || 'debug'}_performance]`; const fn = (e) => { if (typeof performanceReceiver === 'function') { performanceReceiver({ tag, e }); } else { console.log( `${_tag}(${e.events[0]} -> ${e.events[1]}): ${e.time}`, e.endArgs, e.endContext, ); } }; p._taskHooks.watch.add((hook) => hook.on(fn)); p._taskHooks.hs.forEach((hook) => hook.on(fn)); } function createDebugger(plSys, options) { let { tag, group, filter, receiver, listenError, logPluginTime, errorReceiver, performance, performanceReceiver, } = options; let unsubscribeError = null; let map = Object.create(null); const _tag = `[${tag || 'debug'}]: `; if (!('group' in options)) group = isBrowser; if (!('listenError' in options)) listenError = true; if (!('logPluginTime' in options)) logPluginTime = true; if (performance) logPerformance(performance, performanceReceiver, tag); const prefix = (e) => { let p = `${_tag}${e.name}_${e.id}(t, args, ctx`; p += logPluginTime ? ', pt)' : ')'; return p; }; const unsubscribeBefore = plSys.beforeEach((e) => { map[e.id] = { t: Date.now() }; if (typeof receiver !== 'function') { console.time(prefix(e)); if (group) console.groupCollapsed(e.name); } }); const unsubscribeAfter = plSys.afterEach((e) => { let t = null; if (typeof filter === 'string') { if (e.name.startsWith(filter)) { if (group) console.groupEnd(); return; } } else if (typeof filter === 'function') { t = Date.now() - map[e.id].t; if (filter({ e, tag, time: t })) { if (group) console.groupEnd(); return; } } if (typeof receiver === 'function') { if (t === null) { t = Date.now() - map[e.id].t; } receiver({ e, tag, time: t }); } else { console.timeLog( prefix(e), e.args, e.context, logPluginTime ? e.pluginExecTime : '', ); if (group) console.groupEnd(); } }); if (listenError) { unsubscribeError = plSys.listenError((e) => { if (typeof errorReceiver === 'function') { errorReceiver(e); } else { console.error( `[${tag}]: The error originated from "${e.tag}.${e.name}(${e.type})".\n`, `The hook function is: ${String(e.hook)}\n\n`, e.error, ); } }); } return () => { unsubscribeBefore(); unsubscribeAfter(); if (unsubscribeError) { unsubscribeError(); } map = Object.create(null); if (performance) { performance.close(); } }; } const HOOKS = { SyncHook, AsyncHook, AsyncParallelHook, SyncWaterfallHook, AsyncWaterfallHook, }; class PluginSystem { constructor(lifecycle) { this._locked = false; this._debugs = new Set(); this._performances = new Set(); this._lockListenSet = new Set(); this.plugins = Object.create(null); this.lifecycle = lifecycle || Object.create(null); } /** * @internal */ _onEmitLifeHook(type, fn) { aidly.assert( !this._locked, `The plugin system is locked and cannot add "${type}" hook.`, ); let map = Object.create(null); for (const key in this.lifecycle) { map[key] = (id, type, context, args, map) => { // Disallow deleting `id` as it may cause confusion. fn( Object.freeze({ id, type, args, context, name: key, pluginExecTime: map, }), ); }; this.lifecycle[key][type].on(map[key]); } return () => { for (const key in this.lifecycle) { this.lifecycle[key][type].remove(map[key]); } map = Object.create(null); }; } /** * Observing the changes in `lock`. */ listenLock(fn) { this._lockListenSet.add(fn); } /** * Lock the plugin system. After locking, you will not be able to register and uninstall plugins. */ lock() { this._locked = true; for (const key in this.lifecycle) { this.lifecycle[key].lock(); } if (this._lockListenSet.size > 0) { this._lockListenSet.forEach((fn) => fn(true)); } } /** * Unlock the plugin system. After unlocking, you can re-register and uninstall plugins. */ unlock() { this._locked = false; for (const key in this.lifecycle) { this.lifecycle[key].unlock(); } if (this._lockListenSet.size > 0) { this._lockListenSet.forEach((fn) => fn(false)); } } /** * Registers a (sync) callback to be called before each hook is being called. */ beforeEach(fn) { return this._onEmitLifeHook('before', fn); } /** * Registers a (sync) callback to be called after each hook is being called. */ afterEach(fn) { return this._onEmitLifeHook('after', fn); } /** * Monitor elapsed time between hooks. */ performance(defaultCondition) { aidly.assert( !this._locked, 'The plugin system is locked and performance cannot be monitored.', ); aidly.assert( defaultCondition && typeof defaultCondition === 'string', 'A judgment `conditions` is required to use `performance`.', ); const obj = createPerformance(this, defaultCondition); const { close } = obj; const fn = () => { aidly.assert( !this._locked, 'The plugin system is locked and removal operations are not allowed.', ); this._performances.delete(fn); return close.call(obj); }; obj.close = fn; this._performances.add(fn); return obj; } /** * Remove all performance monitoring. */ removeAllPerformance() { aidly.assert( !this._locked, 'The plugin system is locked and removal operations are not allowed.', ); this._performances.forEach((fn) => fn()); } /** * Add debugger. */ debug(options = {}) { aidly.assert( !this._locked, 'The plugin system is locked and the debugger cannot be added.', ); const close = createDebugger(this, options); const fn = () => { aidly.assert( !this._locked, 'The plugin system is locked and removal operations are not allowed.', ); this._debugs.delete(fn); close(); }; this._debugs.add(fn); return fn; } /** * Remove all debug instances. */ removeAllDebug() { aidly.assert( !this._locked, 'The plugin system is locked and removal operations are not allowed.', ); this._debugs.forEach((fn) => fn()); } /** * Get the `apis` of a plugin. */ getPluginApis(pluginName) { return this.plugins[pluginName].apis; } /** * Listen for errors when the hook is running. */ listenError(fn) { aidly.assert( !this._locked, 'The plugin system is locked and cannot listen for errors.', ); const map = Object.create(null); for (const key in this.lifecycle) { map[key] = (e) => { fn(Object.assign(e, { name: key })); }; this.lifecycle[key].listenError(map[key]); } return () => { aidly.assert( !this._locked, 'The plugin system is locked and the listening error cannot be removed.', ); for (const key in this.lifecycle) { this.lifecycle[key].errors.delete(map[key]); } }; } useRefine(plugin) { return this.use(plugin, INTERNAL); } use(plugin, _flag) { aidly.assert( !this._locked, `The plugin system is locked and new plugins cannot be added${ plugin.name ? `(${plugin.name})` : '' }.`, ); if (typeof plugin === 'function') plugin = plugin(this); aidly.assert(aidly.isPlainObject(plugin), 'Invalid plugin configuration.'); // Simplified version of the input if (_flag === INTERNAL) { plugin = { version: plugin.version, name: plugin.name || aidly.uuid(), hooks: aidly.omit(plugin, ['name', 'version']), }; } const { name } = plugin; aidly.assert( name && typeof name === 'string', 'Plugin must provide a "name".', ); aidly.assert( !this.isUsed(name), `Repeat to register plugin hooks "${name}".`, ); const register = (obj, once) => { if (obj) { for (const key in obj) { aidly.assert( aidly.hasOwn(this.lifecycle, key), `"${key}" hook is not defined in plugin "${name}".`, ); // The loss of built-in plugins for performance statistics is negligible const tag = name.startsWith(PERFORMANCE_PLUGIN_PREFIX) ? '' : name; if (once) { this.lifecycle[key].once(tag, obj[key]); } else { this.lifecycle[key].on(tag, obj[key]); } } } }; register(plugin.hooks, false); register(plugin.onceHooks, true); this.plugins[name] = plugin; return plugin; } /** * Remove plugin. */ remove(pluginName) { aidly.assert( !this._locked, 'The plugin system has been locked and the plugin cannot be cleared.', ); aidly.assert(pluginName, 'Must provide a "name".'); if (aidly.hasOwn(this.plugins, pluginName)) { const plugin = this.plugins[pluginName]; const rm = (obj) => { if (obj) { for (const key in obj) { this.lifecycle[key].remove(obj[key]); } } }; rm(plugin.hooks); rm(plugin.onceHooks); delete this.plugins[pluginName]; } } /** * Select some of the lifycycle hooks. */ pickLifyCycle(keys) { return aidly.pick(this.lifecycle, keys); } /** * Determine whether a plugin is registered. */ isUsed(pluginName) { aidly.assert(pluginName, 'Must provide a "name".'); return aidly.hasOwn(this.plugins, pluginName); } /** * Create a new plugin system. */ create(callback) { return new PluginSystem(callback(HOOKS)); } /** * Clone a brand new pluginSystem instance. */ clone(usePlugin) { const newLifecycle = Object.create(null); for (const key in this.lifecycle) { newLifecycle[key] = this.lifecycle[key].clone(); } const cloned = new this.constructor(newLifecycle); if (usePlugin) { for (const key in this.plugins) { cloned.use(this.plugins[key]); } } return cloned; } } exports.AsyncHook = AsyncHook; exports.AsyncParallelHook = AsyncParallelHook; exports.AsyncWaterfallHook = AsyncWaterfallHook; exports.PluginSystem = PluginSystem; exports.SyncHook = SyncHook; exports.SyncWaterfallHook = SyncWaterfallHook;