@slimio/addon
Version:
Slim.IO Addon package
1,014 lines (907 loc) • 30 kB
JavaScript
/* eslint-disable no-param-reassign */
"use strict";
// Require Node.js Dependencies
const { extname } = require("path");
const { promisify } = require("util");
// Require Third-party dependencies
const is = require("@slimio/is");
const SafeEmitter = require("@slimio/safe-emitter");
const Logger = require("@slimio/logger");
const CallbackScheduler = require("@slimio/scheduler");
const { CallbackNotFound, SlimIOError } = require("@slimio/error");
const Observable = require("zen-observable");
const uuid = require("uuid/v4");
const timer = require("@slimio/timer");
// Require Internal dependencie(s)
const Stream = require("./stream.class");
const Callback = require("./callback.class");
const {
assertCallbackName,
CONSTANTS: { RESERVED_CALLBACK }
} = require("./utils");
// CONSTANTS
const SYM_ADDON = Symbol.for("Addon");
const SYM_INTERVAL = Symbol("interval");
const SLEEP_LOCK_MS = 25;
/**
* @function sleep
* @description Sleep async context for a given time in milliseconds
* @param {!number} durationMs sleep duration in milliseconds
* @returns {Promise<void>}
*/
function sleep(durationMs) {
return new Promise((resolve) => setTimeout(resolve, durationMs));
}
/**
* @callback Callback
* @returns {Promise<any>}
*/
/**
* @typedef {object} CallbackHeader
* @property {string} from The addon who made the request
* @property {string} id Message ID
*/
/**
* @typedef {object} LockRule
* @property {boolean} startAfter
* @property {boolean} lockCallback
*/
/**
* @typedef {object} MessageOptions
* @property {any[]} args Callback arguments
* @property {number} timeout Custom timeout
* @property {boolean} noReturn Dont expect a response!
*/
/**
* @class Addon
* @classdesc Slim.IO Addon container
* @augments Event
*
* @property {string} name Addon name
* @property {string} version
* @property {boolean} verbose
* @property {string} description
* @property {string} uid Addon unique id
* @property {boolean} isStarted
* @property {boolean} isAwake
* @property {boolean} isReady
* @property {number} lastStart
* @property {number} lastStop
* @property {Array} asserts
* @property {Set<string>} flags
* @property {Map<string, Callback>} callbacks
* @property {Map<string, CallbackScheduler>} schedules
* @property {Map<string, ZenObservable.SubscriptionObserver<any>>} observers
* @property {Map<string, any[]>} subscribers
* @property {Map<string, string>} callbacksAlias
* @property {Map<string, LockRule>} locks
*/
class Addon extends SafeEmitter {
/**
* @class
* @memberof Addon#
* @param {!string} name addon name
* @param {object} [options] options
* @param {boolean} [options.verbose=false] Enable verbose mode
* @param {string} [options.version=1.0.0] addon version
* @param {string} [options.description] addon description
*
* @throws {TypeError}
* @throws {Error}
*/
constructor(name, options = Object.create(null)) {
super();
if (typeof name !== "string") {
throw new TypeError("constructor name argument should be typeof string");
}
if (!is.plainObject(options)) {
throw new TypeError("options should be a plainObject");
}
const { version = "1.0.0", verbose = false, description = "" } = options;
if (typeof version !== "string") {
throw new TypeError("version argument should be typeof string");
}
if (typeof verbose !== "boolean") {
throw new TypeError("verbose argument should be typeof boolean");
}
if (typeof description !== "string") {
throw new TypeError("description argument should be typeof string");
}
if (name.length <= 2) {
throw new Error("constructor name argument length must be greater than 2");
}
this.name = name;
this.version = version;
this.verbose = verbose;
this.logger = null;
this.description = description;
this.uid = uuid();
this.isReady = false;
this.isStarted = false;
this.isAwake = false;
this.lastStart = null;
this.lastStop = null;
this.currentLockedAddon = null;
this.callbacksDescriptor = null;
this.asserts = [];
this[SYM_ADDON] = true;
/** @type {Map<string, any>} */
this.intervals = new Map();
/** @type {Map<string, any[]>} */
this.subscribers = new Map();
/** @type {Map<string, Callback>} */
this.callbacks = new Map();
/** @type {Map<string, string>} */
this.callbacksAlias = new Map();
/** @type {Map<string, CallbackScheduler>} */
this.schedules = new Map();
/** @type {Map<string, ZenObservable.SubscriptionObserver<any>>} */
this.observers = new Map();
/** @type {Map<string, LockRule>} */
this.locks = new Map();
this.callbacks.set("start", {
ACL: 2, callback: Addon.start.bind(this)
});
this.callbacks.set("stop", {
ACL: 2, callback: Addon.stop.bind(this)
});
this.callbacks.set("sleep", {
ACL: 2, callback: Addon.sleep.bind(this)
});
this.callbacks.set("status", {
ACL: 0, callback: Addon.status.bind(this)
});
this.callbacks.set("event", {
ACL: 0,
callback: async(header, name, data) => {
if (this.subscribers.has(name)) {
for (const observer of this.subscribers.get(name)) {
observer.next(data);
}
}
}
});
this.callbacks.set("health_check", {
ACL: 1,
callback: async() => {
await Promise.all(this.asserts);
return true;
}
});
}
/**
* @static
* @function isAddon
* @memberof Addon#
* @param {!any} obj
* @returns {boolean}
*/
static isAddon(obj) {
return obj && Boolean(obj[SYM_ADDON]);
}
get lastRegisteredAddon() {
return [...this.callbacks.keys()].pop();
}
/**
* @private
* @function awake
* @memberof Addon#
* @returns {Promise<void>}
*/
async awake() {
for (const interval of this.intervals.values()) {
if (interval.nodeTimer !== null) {
timer.clearInterval(interval.nodeTimer);
}
const handler = is.asyncFunction(interval.callback) ? interval.callback : promisify(interval.callback);
// eslint-disable-next-line
interval.nodeTimer = timer.setInterval(() => {
handler().catch((err) => {
const slimError = new SlimIOError(err.message);
slimError.stack = err.stack;
this.emit("error", slimError);
});
}, interval.ms);
}
this.isAwake = true;
await this.emitAndWait("awake");
}
/**
* @private
* @static
* @function start
* @memberof Addon#
* @description Function used to start an addon
* @returns {Promise<boolean>}
*
* @fires error
* @fires start
*
* @version 0.1.0
*/
static async start() {
if (this.isStarted) {
return false;
}
this.isStarted = true;
this.lastStart = Date.now();
this.logger = new Logger(void 0, { title: this.name });
/**
* @event Addon#start
* @type {void}
*/
await this.emitAndWait("start");
if (this.verbose) {
this.logger.writeLine("Start event triggered!");
}
// Check locks
await this.waitForAllLocks(true);
this.currentLockedAddon = null;
// The interval is used to execute Scheduled callbacks
// A Symbol primitive is used to make Interval private
this[SYM_INTERVAL] = timer.setInterval(async() => {
if (!this.isAwake) {
return;
}
// Retrieve scheduled callback
const toExecute = [...this.schedules.entries()]
.filter(([, scheduler]) => scheduler.walk())
.map(([name]) => this.callbacks.get(name).callback());
// Execute all calbacks (Promise) in asynchrone
try {
await Promise.all(toExecute);
}
catch (error) {
this.emit("error", error);
}
}, Addon.MAIN_INTERVAL_MS);
this.awake();
return true;
}
/**
* @private
* @static
* @function stop
* @memberof Addon#
* @description Function used to stop an addon
* @returns {Promise<boolean>}
*
* @fires stop
*
* @version 0.1.0
*/
static async stop() {
if (!this.isStarted) {
return false;
}
if (this.isAwake) {
this.isAwake = false;
await this.emitAndWait("sleep");
}
this.isStarted = false;
this.lastStop = Date.now();
// Clear current addon interval
if (typeof this[SYM_INTERVAL] === "number") {
timer.clearInterval(this[SYM_INTERVAL]);
}
this.intervals.clear();
// Complete subscribers
for (const [subject, observers] of this.subscribers.entries()) {
for (const observer of observers) {
observer.complete();
}
this.subscribers.delete(subject);
}
await this.emitAndWait("stop");
if (this.verbose) {
this.logger.writeLine("Stop event triggered!");
}
return true;
}
/**
* @public
* @async
* @function waitForAllLocks
* @memberof Addon#
* @param {boolean} [asStart=false]
* @returns {Promise<boolean>}
*
* @version 0.19.1
*/
async waitForAllLocks(asStart = false) {
if (this.locks.size === 0) {
return true;
}
let initialLockMs = SLEEP_LOCK_MS;
while (1) {
if (!this.isStarted) {
return false;
}
let allReady = true;
for (const [addonName, rules] of this.locks.entries()) {
if (asStart && !rules.startAfter) {
continue;
}
try {
const res = await this.sendOne(`${addonName}.status`);
if (typeof res === "undefined" || Boolean(res.ready) === false) {
allReady = false;
break;
}
}
catch (err) {
if (this.verbose) {
this.logger.writeLine(`Unlock failed on addon '${addonName}', breaking for ${initialLockMs}ms`);
}
allReady = false;
break;
}
finally {
this.currentLockedAddon = addonName;
}
}
if (allReady) {
break;
}
await sleep(initialLockMs);
if (initialLockMs < Addon.MAX_SLEEP_TIME_MS) {
initialLockMs = Math.floor(initialLockMs * 1.5);
}
}
return true;
}
/**
* @private
* @static
* @function sleep
* @memberof Addon#
* @description Function used to sleep an addon
* @returns {Promise<boolean>}
*
* @fires sleep
*
* @version 0.19.1
*/
static async sleep() {
if (!this.isAwake) {
return false;
}
// Cleanup intervals
for (const interval of this.intervals.values()) {
if (interval.nodeTimer !== null) {
timer.clearInterval(interval.nodeTimer);
interval.nodeTimer = null;
}
}
this.isAwake = false;
await this.emitAndWait("sleep");
// Ensure every locks are okay
const awakeAddon = await this.waitForAllLocks();
this.currentLockedAddon = null;
if (awakeAddon) {
this.awake();
}
return true;
}
/**
* @private
* @static
* @function status
* @memberof Addon#
* @description Function used to retrieve default options & properties of an addon
* @returns {Addon.CallbackGetInfo}
*
* @version 0.1.0
*/
static status() {
const callbacksAlias = new Map();
for (const [alias, callbackName] of this.callbacksAlias.entries()) {
if (callbacksAlias.has(callbackName)) {
callbacksAlias.get(callbackName).push(alias);
}
else {
callbacksAlias.set(callbackName, [alias]);
}
}
const callbacks = {};
for (const [name, { ACL }] of this.callbacks.entries()) {
const alias = callbacksAlias.has(name) ? callbacksAlias.get(name) : [];
callbacks[name] = { ACL, alias };
}
return {
uid: this.uid,
name: this.name,
version: this.version,
description: this.description,
containerVersion: Addon.VERSION,
ready: this.isReady,
started: this.isStarted,
awake: this.isAwake,
lastStart: this.lastStart,
lastStop: this.lastStop,
currentLockedAddon: this.currentLockedAddon,
lockOn: [...this.locks.keys()],
callbacksDescriptor: this.callbacksDescriptor,
callbacks
};
}
/**
* @public
* @function lockOn
* @memberof Addon#
* @description Create a new lock rule (wait for a given addon to be started).
* @param {!string} addonName addonName
* @param {LockRule} [rules={}] lock rules
* @returns {Addon}
*
* @throws {TypeError}
*
* @version 0.15.0
*/
lockOn(addonName, rules = Object.create(null)) {
if (typeof addonName !== "string") {
throw new TypeError("addonName must be a string");
}
const { startAfter = true, lockCallback = false } = rules;
if (typeof startAfter !== "boolean") {
throw new TypeError("rules.startAfter must be a boolean");
}
if (typeof lockCallback !== "boolean") {
throw new TypeError("rules.lockCallback must be a boolean");
}
this.locks.set(addonName, {
startAfter, lockCallback
});
return this;
}
/**
* @public
* @function of
* @memberof Addon#
* @description Subscribe to a given SlimIO kind of events (these events are managed by the built-in addon "events")
* @param {!string} subject subject
* @returns {ZenObservable.ObservableLike<any>}
*
* @version 0.12.0
*
* @example
* const myAddon = new Addon("myAddon");
*
* myAddon.of(Addon.Subjects.Addon.Ready).subscribe((addonName) => {
* console.log(`Addon with name ${addonName} is Ready !`);
* });
*/
of(subject) {
if (typeof subject !== "string") {
throw new TypeError("subject should be typeof string");
}
if (!this.subscribers.has(subject)) {
this.subscribers.set(subject, []);
}
const publishMsg = (observer) => {
this.sendMessage("events.subscribe", { args: [subject] }).subscribe({
error: (err) => observer.error(err)
});
};
return new Observable((observer) => {
if (this.isStarted) {
publishMsg(observer);
}
else {
this.once("start", 5000).then(() => publishMsg(observer)).catch(observer.error);
}
const index = this.subscribers.get(subject).push(observer);
return () => {
this.subscribers.get(subject).splice(index, 1);
};
});
}
/**
* @async
* @public
* @function ready
* @memberof Addon#
* @description Set/flag the current addon as Ready (will trigger "unlock" for other addons).
* @returns {Promise<boolean>}
*
* @version 0.5.0
*
* @throws {Error}
*
* @example
* const test = new Addon("test");
*
* // can be triggered on "start" or "awake".
* test.on("start", () => {
* test.ready();
* });
*/
async ready() {
if (!this.isStarted) {
throw new Error("Addon should be started before being ready!");
}
if (this.isReady) {
return false;
}
this.isReady = true;
this.once("stop").then(() => {
this.isReady = false;
}).catch(console.error);
this.emit("ready");
try {
await this.sendOne("events.publish", [["Addon", "ready", this.name]]);
}
catch (err) {
if (this.verbose) {
this.logger.writeLine(`${err.name}: ${err.message}`);
}
}
return true;
}
/**
* @public
* @function setCallbacksDescriptorFile
* @memberof Addon#
* @description Set a new callbacks descriptor file (.prototype)
* @param {!string} path Callback name
* @returns {void}
*
* @throws {TypeError}
* @throws {Error}
*
* @version 0.9.0
*/
setCallbacksDescriptorFile(path) {
if (typeof path !== "string") {
throw new TypeError("path should be typeof string!");
}
if (extname(path) !== ".proto") {
throw new Error("path should be a .prototype file");
}
this.callbacksDescriptor = path;
}
/**
* @public
* @function setACL
* @memberof Addon#
* @description Set a given ACL to a given callback
* @param {!string} callbackName Callback name
* @param {number} [ACL]
* @returns {this}
*
* @version 0.22.0
*/
setACL(callbackName, ACL = Addon.DEFAULT_ACL) {
if (is.nullOrUndefined(callbackName)) {
callbackName = this.lastRegisteredAddon;
}
callbackName = assertCallbackName(callbackName);
if (!this.callbacks.has(callbackName)) {
throw new Error(`Unable to found callback with name ${callbackName}`);
}
this.callbacks.get(callbackName).ACL = ACL;
return this;
}
/**
* @public
* @function registerCallback
* @memberof Addon#
* @description Register a new callback on the Addon. The callback name should be formatted in snake_case
* @param {!(string|Function)} name Callback name
* @param {!Callback} callback Async Callback to execute when the callback is triggered by the core or the addon itself
* @param {number} [ACL=0]
* @returns {this}
*
* @throws {TypeError}
*
* @version 0.0.0
*
* @example
* const myAddon = new Addon("test");
*
* async function hello() {
* return "hello world";
* }
* myAddon.registerCallback(hello);
* myAddon.executeCallback("hello").then((ret) => {
* assert.equal(ret, "hello world");
* });
*/
registerCallback(name, callback, ACL = 0) {
if (is.func(name) && is.nullOrUndefined(callback)) {
callback = name;
name = callback.name;
}
name = assertCallbackName(name);
if (!is.asyncFunction(callback)) {
throw new TypeError("Addon.registerCallback->callback should be an AsyncFunction");
}
this.callbacks.set(name, { callback, ACL });
return this;
}
/**
* @public
* @function setDeprecatedAlias
* @memberof Addon#
* @description Register One or Many deprecated Alias for a given callback
* @param {!string} callbackName Callback name
* @param {!Array<string>} alias List of alias to set for the given callback name (they will throw deprecated warning)
* @returns {void}
*
* @throws {TypeError}
* @throws {Error}
*
* @version 0.7.0
*
* @example
* const test = new Addon("test");
*
* async function sayHello(head) {
* console.log("hello world!");
* }
* test.registerCallback(sayHello);
*
* // A warning will be throw if "log_hello" is used!
* test.setDeprecatedAlias("say_hello", ["log_hello"]);
*/
setDeprecatedAlias(callbackName, alias) {
if (!this.callbacks.has(callbackName)) {
throw new Error(`Unknow callback with name ${callbackName}`);
}
if (!is.array(alias)) {
throw new TypeError("alias argument should be instanceof Array");
}
for (const cbAlias of alias) {
this.callbacksAlias.set(cbAlias, callbackName);
}
}
/**
* @public
* @template T
* @function executeCallback
* @memberof Addon#
* @description Execute a callback of the addon
* @param {!string} name Callback name
* @param {CallbackHeader} [header] callback header
* @param {any[]} args Callback arguments
* @returns {Promise<T>} Return the callback response (or void)
*
* @throws {TypeError}
* @throws {Error}
*
* @version 0.0.0
*
* @example
* const myAddon = new Addon("test");
*
* async function hello() {
* return "hello world";
* }
* myAddon.registerCallback(hello);
* myAddon.executeCallback("hello").then((ret) => {
* assert.equal(ret, "hello world");
* });
*/
executeCallback(name, header = Addon.DEFAULT_HEADER, ...args) {
if (!is.string(name)) {
throw new TypeError("Addon.executeCallback->name should be typeof <string>");
}
let callbackName = name;
foundCB: if (!this.callbacks.has(callbackName)) {
if (!this.callbacksAlias.has(callbackName)) {
callbackName = null;
break foundCB;
}
callbackName = this.callbacksAlias.get(name);
process.emitWarning(`Addon Callback Alias ${name} is deprecated. Please use ${callbackName}`);
}
if (callbackName === null) {
throw new CallbackNotFound(name);
}
// Return callback execution!
const handler = this.callbacks.get(callbackName).callback;
if (this.verbose && this.isStarted) {
this.logger.writeLine(`Executing callback ${callbackName}`);
}
return (new Callback(`${this.name}-${callbackName}`, handler)).execute(header, args);
}
/**
* @public
* @function schedule
* @memberof Addon#
* @description Schedule the execution of a given callback (not a precision scheduler).
* @param {!string} name Callback name
* @param {!CallbackScheduler} scheduler CallbackScheduler settings!
* @returns {this}
*
* @throws {TypeError}
* @throws {Error}
*
* @version 0.0.0
*
* @example
* const Scheduler = require("@slimio/scheduler");
* const myAddon = new Addon("test");
*
* // Schedule hello to be executed every second!
* async function hello() {
* console.log("hello world!");
* }
* myAddon
* .registerCallback(hello)
* .schedule("hello", new Scheduler({
* interval: 1000,
* executeOnStart: true
* }));
*/
schedule(name, scheduler) {
// If name is an instanceOf Scheduler, replace name by the latest callback registered!
if (CallbackScheduler.isScheduler(name)) {
if (this.callbacks.size <= Addon.RESERVED_CALLBACKS_NAME.size) {
throw new Error("Addon.schedule - No custom callback has been registered yet!");
}
scheduler = name;
name = this.lastRegisteredAddon;
}
if (!is.string(name)) {
throw new TypeError("Addon.schedule->name should be typeof <string>");
}
if (!this.callbacks.has(name)) {
throw new Error(`Addon.schedule - Unable to found callback with name ${name}`);
}
if (!CallbackScheduler.isScheduler(scheduler)) {
throw new TypeError("Addon.schedule->scheduler should be an instance of CallbackScheduler");
}
this.schedules.set(name, scheduler);
return this;
}
/**
* @public
* @function sendMessage
* @memberof Addon#
* @description Send a message to the Core
* @param {!string} target Target path to the callback
* @param {MessageOptions} [options={}] Message options
* @returns {Observable<any>}
*
* @throws {TypeError}
* @fires Addon#message
*
* @version 0.0.0
*
* @example
* const myAddon = new Addon("myAddon");
*
* myAddon.on("start", async function() {
* myAddon
* .sendMessage("cpu.status")
* .subscribe(console.log, console.error); // <-- dont forget to catch errors
* await myAddon.ready();
* });
*/
sendMessage(target, options = { noReturn: false }) {
if (!is.string(target)) {
throw new TypeError("Addon.sendMessage->target should be typeof <string>");
}
// Generate unique id for our message!
const messageId = uuid();
// Send a message (on the next event loop iteration).
setImmediate(() => {
/**
* @event Addon#message
* @param {string} messageId
* @param {string} target
* @param {Array} args
*/
this.emit("message", messageId, target, is.array(options.args) ? options.args : []);
if (this.verbose) {
this.logger.writeLine(`Sending message to ${target} with uuid: ${messageId}`);
}
});
// Return void 0 if noReturn is true
if (options.noReturn) {
return null;
}
// Return an Observable that stream response
return new Observable((observer) => {
// Setup a timeOut for our message
const timer = setTimeout(() => {
if (observer.closed) {
return;
}
observer.error(
// eslint-disable-next-line
`Failed to receive response for message id ${messageId} (from: ${this.name}, to: ${target}) in a delay of ${Addon.MESSAGE_TIMEOUT_MS}ms`
);
}, is.number(options.timeout) ? options.timeout : Addon.MESSAGE_TIMEOUT_MS);
// Setup the observer on the Addon.
this.observers.set(messageId, observer);
// On unsubcription clear the timeOut timer and the registered observer
return () => {
clearTimeout(timer);
this.observers.delete(messageId);
};
});
}
/**
* @public
* @async
* @function sendOne
* @memberof Addon#
* @description Send "one" message to the Core (Promise version of sendMessage)
* @param {!string} target Target path to the callback
* @param {MessageOptions|Array<any>} [options=[]] Message options a response!
* @returns {Promise<any>}
*
* @throws {TypeError}
*
* @version 0.17.0
*
* @example
* const myAddon = new Addon("myAddon");
*
* myAddon.on("start", async function() {
* const cpuInfo = await myAddon.sendOne("cpu.status");
* console.log(cpuInfo);
* await myAddon.ready();
* });
*/
async sendOne(target, options = []) {
if (!is.string(target)) {
throw new TypeError("Addon.sendOne->target must be typeof <string>");
}
const args = is.array(options) ? { args: options } : options;
if (!is.nullOrUndefined(args) && !is.plainObject(args)) {
throw new TypeError("Addon.sendOne->options must be a plain Object");
}
return new Promise((resolve, reject) => this.sendMessage(target, args).subscribe(resolve, reject));
}
/**
* @public
* @function registerInterval
* @memberof Addon#
* @description register a new interval (only work when addon is awake).
* @param {() => any} callback Target path to the callback
* @param {number} [ms=1000] Message options
* @returns {string}
*
* @throws {TypeError}
*
* @version 0.21.0
*/
registerInterval(callback, ms = 1000) {
if (!is.func(callback)) {
throw new TypeError("callback must be a function");
}
if (!is.number(ms)) {
throw new TypeError("ms must be a number");
}
const intervalId = uuid();
this.intervals.set(intervalId, { callback, ms, nodeTimer: null });
return intervalId;
}
}
// Register Static (CONSTANTS) Addon variables...
Addon.RESERVED_CALLBACKS_NAME = RESERVED_CALLBACK;
Addon.MESSAGE_TIMEOUT_MS = 5000;
Addon.MAIN_INTERVAL_MS = 500;
Addon.MAX_SLEEP_TIME_MS = 250;
Addon.DEFAULT_HEADER = { from: "self" };
Addon.ACL = Object.freeze({ read: 0, write: 1, admin: 2, super: 3 });
Addon.DEFAULT_ACL = Addon.ACL.write;
Addon.VERSION = "0.22.1";
Addon.REQUIRED_CORE_VERSION = ">=0.9";
// Subjects
Addon.Subjects = {
ready: "Addon.ready",
alarmOpen: "Alarm.open",
alarmUpdate: "Alarm.update",
alarmClose: "Alarm.close",
micCreate: "Metric.create",
micUpdate: "Metric.update"
};
// Register Sub classes
Addon.Stream = Stream;
Addon.Callback = Callback;
// Export (default) Addon
module.exports = Addon;