UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

564 lines (508 loc) 15.2 kB
/** * Contains useful unspecific classes and functions * may be required by all adaptor modules. * * index.js will make all functions and the log available via global namespace * * @requires @apidevtools/json-schema-ref-parser * @requires npmlog * @requires events * @requires fs-extra * @requires nanoid * @requires os * * @requires file * * @module adaptor * @copyright Lasse Marburg 2022 * @license MIT */ const { EventEmitter } = require("events") var npmlog = require("npmlog") const ref_parser = require("@apidevtools/json-schema-ref-parser") const { nanoid } = require("nanoid") const os = require("os") const fs = require("fs-extra") const file = require("./file.js") const { Variables } = require("./variables.js") const npm_package = require("./package.json") const { Plugin } = require("./plugins/plugin.js") const { PluginItem } = require("./plugins/plugin_items.js") const messenger = require("./plugins/messenger.js") const logic = require("./plugins/logic/logic.js") const socketio = require("./plugins/socketio/socketio.js") global.adaptor = {} adaptor.info = npm_package adaptor.locale = Intl.DateTimeFormat().resolvedOptions().locale adaptor.parseJSON = Variables.parseJSON adaptor.parseQuery = Variables.parseQuery adaptor.messenger = messenger adaptor.logic = logic adaptor.socketio = socketio adaptor.Plugin = Plugin adaptor.PluginItem = PluginItem /** webhook URL names for this adaptor instance and server */ adaptor.webhooks = [] /** * logging class based on [npmlog]{@link https://www.npmjs.com/package/npmlog}. * * patches unexpected behavior of npmlog: log of Error object results in duplicated output. * see: https://github.com/npm/npmlog/issues/62 and https://github.com/npm/npmlog/blob/master/log.js#L182 * * Uses fs stream to write logs to logfile */ adaptor.Log = class { constructor() { npmlog.addLevel("debug", 1000, { fg: "blue" }) npmlog.addLevel("trace", 500, { fg: "grey" }) Object.assign(this, npmlog) this.error = function (prefix, message, ...args) { if (message instanceof Error && message.stack) { message = message.stack } npmlog.error(prefix, message, ...args) } } /** * initialize log write stream */ init(log_level, logfile_dir) { return new Promise((resolve, reject) => { npmlog.level = log_level file.mkdir(logfile_dir) let date = this.getPrettyDate() date = date.replace(/\:/gm, "-").slice(0, -4) let logfile = logfile_dir + "/" + date + ".log" var logstream = fs.createWriteStream(logfile, { flags: "a" }) logstream.on("open", (res) => { npmlog.on("log", (l) => { logstream.write( this.getPrettyDate() + " " + l.level.toUpperCase() + " " + l.prefix + " " + l.message + "\n" ) }) return resolve() }) }) } /** * Allow to connect socket for each of the dynamic namespaces /log/{level} * * Publish each new log message if it is in the domain of the level * * On connect publishes a list of latest 50 log records in the level domain * * @param {Object} socket - socket.io connection object */ createSocket(socketio) { socketio.on("connection", (socket) => { let level = socket.nsp.name.split("/").pop() let latest = [] for (let r of npmlog.record) { if (npmlog.levels[r.level] >= npmlog.levels[level]) { latest.push({ id: r.id, level: r.level, topic: r.prefix, message: r.message }) } } latest = latest.slice(-50) if (latest.length) { socket.emit("log", latest) } let log_event = (l) => { if (npmlog.levels[l.level] >= npmlog.levels[level]) { socket.emit("log", [ { id: l.id, level: l.level, topic: l.prefix, message: l.message } ]) } } npmlog.on("log", log_event) socket.on("disconnect", (e) => { npmlog.off("log", log_event) }) }) } /** * change current log level * - trace (show everything) * - debug * - info * - warn * - error * - silent (logging off) * @param {string} log_level */ setLevel(log_level) { npmlog.level = log_level console.log("log level changed to '" + log_level + "'") } /** * return current date time in following string format: * [year]-[month]-[day] [hour]-[minute]-[second] */ getPrettyDate() { let tzoffset = new Date().getTimezoneOffset() * 60000 //offset in milliseconds return new Date(Date.now() - tzoffset) .toISOString() .slice(0, -1) .replace("T", " ") } /** * get log functions that prepends context info. * context log is itself a function that uses 'debug' log * * @param {string} context - log context that is prepended to each log message * @returns {Object} - log functions that aut prepend context */ getContextLog(context) { let context_log = (msg) => { this.debug(context, msg) } ;["trace", "debug", "info", "warn", "error"].forEach((log_level) => { context_log[log_level] = (msg) => { this[log_level](context, msg) } }) return context_log } } adaptor.log = new adaptor.Log() /** * Create an automated First in, first out queue. Runs a callback whenever something was added to the queue using the element as callback argument. * * When callback is done queue will go on running the callback with the next items in stack until its. */ adaptor.Queue = class { /** * @param {function} callback - function to call on each element in queue */ constructor(callback) { /** argument item list */ this.items = [] /** function to be called on each item */ this.callback = callback /** emits empty event */ this.event = new EventEmitter() /** queue will not accept any more items when joined */ this.joined = false log.debug("queue", "create queue with function " + callback.name) } /** * add an item to queue. If queue is empty, run callback right away with item as argument. * * goto next once callback resolves * * use return_callback to feedback the result of queue callback * * @param {*} item - argument for callback put into queue * @param {function} return_callback - is called with result arg once queue callback resolves */ put(item, return_callback) { let elem = { arg: item, call: return_callback } if (this.joined) { return_callback(undefined) return } if (this.items.length == 0) { this.execute(elem) } this.items.push(elem) } execute(elem) { Promise.resolve(this.callback(elem["arg"])) .then((result) => { elem.call(result) this.next() }) .catch((err) => { this.items = [] elem.call({ error: err }) }) } /** * release latest element from queue. If there is another, run callback with next element. * * When callback resolves, proceed with next element if any. * * Emit empty event once there are no more elements enqueued */ next() { if (this.items.length > 0) { this.items.shift() } if (this.items.length > 0) { this.execute(this.items[0]) } else { this.event.emit("empty") } } /** * wait for queue to finish. Block adding of further elements until finished. * * @returns {Promise} - resolves once queue is done and empty */ join() { return new Promise((resolve, reject) => { this.joined = true if (this.items.length > 0) { this.event.on("empty", (e) => { this.joined = false return resolve() }) } else { this.joined = false return resolve() } }) } /** * join queue but only wait for current item to finish. * Remove all leftover items. */ cancel() { if (this.items.length > 1) { this.items = [this.items.shift()] log.debug("queue", "cancel and drop " + this.items.length + " items") } return this.join() } } /** * * @returns object of local ip4 IP adresses (name:ip) */ adaptor.getIPs = function () { const interfaces = os.networkInterfaces() const results = Object.create(null) for (const name of Object.keys(interfaces)) { for (const net of interfaces[name]) { if (net.family === "IPv4" && !net.internal) { if (!results[name]) { results[name] = [] } results[name].push(net.address) } } } return results } /** * Generate short Unique Identifier String * * @returns {string} - uid string */ adaptor.createId = function () { return nanoid(8) } /** * Find out if a string or number is an integer value * * @param {string|number} value * @returns {boolean} - true if value is int or string that can be parsed to int */ adaptor.isInt = function (value) { return ( !isNaN(value) && parseInt(Number(value)) == value && !isNaN(parseInt(value, 10)) ) } /** * check if Object has any properties * Checkout this post: * https://coderwall.com/p/_g3x9q/how-to-check-if-javascript-object-is-empty * * @param {Object} obj - object to check on emptyness * * @returns {boolean} false if object is not empty */ adaptor.isEmpty = function (obj) { for (var key in obj) { if (obj.hasOwnProperty(key)) return false } return true } /** * see if a list of properties are all own properties of an object * * @param {Object} obj - Object to review * @param {Array} properties - list of property names (each a string) * * @returns {boolean} - true if all properties are own properties of object. Otherwise false */ adaptor.hasOwnProperties = function (obj, properties) { for (var i = 0; i < properties.length; i++) { if (!obj.hasOwnProperty(properties[i])) return false } return true } /** * address object property with '.' seperated string * Comes in handy especially when working with Mongodb and nested document find queries. * Mongo uses [dot notation]{@link https://docs.mongodb.com/manual/core/document/#document-dot-notation} * Inspired by: * {@link https://stackoverflow.com/questions/6491463/accessing-nested-javascript-objects-with-string-key/6491621#6491621} * * @param {Object} o - some Object * @param {string} s - some string that finds nested properties with '.' notation * * @returns {*} - value or object that was found in '.' noted path. undefined if path doesn't exist in o */ adaptor.getPath = function (o, s) { s = s.replace(/\[(\w+)\]/g, ".$1") // convert indexes to properties s = s.replace(/^\./, "") // strip a leading dot var a = s.split(".") for (var i = 0, n = a.length; i < n; ++i) { var k = a[i] if (typeof o !== "object" || Array.isArray(o)) { log.warn("getPath", o + " is not a valid object") return } if (k in o) { o = o[k] } else { return } } return o } /** * assign object property with '.' seperated string path * * Inspired by: * {@link https://stackoverflow.com/questions/13719593/how-to-set-object-property-of-object-property-of-given-its-string-name-in-ja/13719799#13719799} * * @param {Object} obj - object to assign value to * @param {string} prop - path * @param {*} value - value to assign to object at path */ adaptor.assignPath = function (obj, prop, value) { if (typeof prop === "string") prop = prop.split(".") if (prop.length > 1) { var e = prop.shift() adaptor.assignPath( (obj[e] = Object.prototype.toString.call(obj[e]) === "[object Object]" ? obj[e] : {}), prop, value ) } else { obj[prop[0]] = value } } /** * check if Objects have the same key/value pairs (keys also have to be in the same Order) * Simple string comparison * @todo find a more efficient and safe way then JSON.stringify comparison * @param {Object} objA - compare Object A * @param {Object} objB - compare Object B */ adaptor.equalValues = function (objA, objB) { if (JSON.stringify(objA) == JSON.stringify(objB)) { return true } else { return false } } /** * compare two objects and return what changes happened during the transition from * one object to the other. Checks for equality in each property and recognizes if * a property has changed (updated), is new (updated) or was removed. * * @param {Object} original - Original object * @param {Object} changed - Object that derived from original Object * * @returns {Object} 'updated' contains all keys and their values that are not equal or have * no equal key - value pair in the original object. 'removed' contains a list of all missing properties * in the changed object */ adaptor.changes = function (original_object, changed_object) { let changes = { updated: {}, removed: [] } for (let key in original_object) { if (changed_object.hasOwnProperty(key)) { if (!adaptor.equalValues(changed_object[key], original_object[key])) { changes.updated[key] = changed_object[key] } } else { changes.removed.push(key) } } for (let key in changed_object) { if (!original_object.hasOwnProperty(key)) { changes.updated[key] = changed_object[key] } } return changes } /** * make an object clone using serialization. Leave no references even in nested properties. * This does not work with complex object properties like functions, classes, dates etc.! * * @param {Object} source - Object to be deep cloned * * @returns {Object} - clone of object */ adaptor.deepClone = function (source) { return JSON.parse(JSON.stringify(source)) } /** * perform a function on all Object properties, including nested properties. * * is not being used to date. * * similar to, though way more primitive than, lodash {@link https://lodash.com/docs/4.17.15#transform|_transform} * @param {Object} obj - Object to be transformed * @param {function} funct - function to perform on Object properties */ adaptor.transform = function (obj, funct) { var result = null if (obj instanceof Array) { for (var i = 0; i < theObject.length; i++) { result = transform(obj[i], funct) if (result) { break } } } else { for (var prop in obj) { funct(prop, obj) if (obj[prop] instanceof Object || obj[prop] instanceof Array) { result = transform(obj[prop], funct) if (result) { break } } } } return result } /** * Use json-schema-ref-parser to resolve $ref references in json schema * * @param {Object} obj - json schema with references to be resolved */ adaptor.dereference = async function (obj) { return ref_parser.dereference(obj) } /** * check if Objects is of type object and not an array or null * @param {*} obj Object to check * @return {Boolean} */ adaptor.isObject = function (value) { return typeof value === "object" && !Array.isArray(value) && value !== null }