UNPKG

adaptorex

Version:

Connect all your live interactive storytelling devices and software

546 lines (488 loc) 15.8 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) }