hooks-plugin
Version:
A plugin system built through various hooks.
990 lines (978 loc) • 26.8 kB
JavaScript
'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;