lib.js

/*
 *  Author: Vlad Seryakov vseryakov@gmail.com
 *  backendjs 2018
 */

/**
 * @module lib
 */
const fs = require('fs');
const util = require('util');
const path = require('path');
const logger = require(__dirname + '/logger');

const lib =

/**
 * General purpose utilities
 */
module.exports = {
    name: 'lib',
    deferTimeout: 50,
    deferId: 1,
    maxStackDepth: 150,

    /** @var {regexp} - number validation */
    rxNumber: /^(-|\+)?([0-9]+|[0-9]+\.[0-9]+)$/,

    /** @var {regexp} - float number validation */
    rxFloat: /^(-|\+)?([0-9]+)?\.[0-9]+$/,

    /** @var {regexp} - uuid validation */
    rxUuid: /^([0-9a-z_]{1,5})?[0-9a-z]{32}(_[0-9a-z]+)?$/,

    /** @var {regexp} - url validation */
    rxUrl: /^https?:\/\/.+/,

    /** @var {regexp} - ascii chars validation */
    rxAscii: /[\x20-\x7F]/,

    /** @var {regexp} - symbol name validation */
    rxSymbol: /^[a-z0-9_]+$/i,

    /** @var {regexp} - email validation */
    rxEmail: /^[A-Z0-9'._+-]+@[A-Z0-9.-]+\.[A-Z]{2,16}$/i,

    /** @var {regexp} - phonre validation */
    rxPhone: /^([0-9 .+()-]+)/,

    /** @var {regexp} - digits only */
    rxDigits: /^[0-9]+$/,

    /** @var {regexp} - no digits */
    rxNoDigits: /[^0-9]/g,

    /** @var {regexp} - html brackets */
    rxHtml: /[<>]/g,

    /** @var {regexp} - exclude html brackets */
    rxNoHtml: /[^<>]/g,

    /** @var {regexp} - XSS characters */
    rxXss: /[<>"'&%\\]/g,

    /** @var {regexp} - exclude XSS characters */
    rxNoXss: /[^<>"'&%\\]/g,

    /** @var {regexp} - punctuation and other special characters */
    rxSpecial: /[~!#^&*(){}[\]"'?<>|\\]/g,

    /** @var {regexp} - excclude punctuation and other special characters */
    rxNoSpecial: /[^~!#^&*(){}[\]"'?<>|\\]/g,

    /** @var {regexp} - empty or spaces only */
    rxEmpty: /^\s*$/,

    /** @var {regexp} - true validation */
    rxTrue: /^(true|on|yes|1|t)$/i,

    rxGeo: /^[0-9.]+,[0-9.]+$/,
    rxLine: /[\r\n]\n?/,

    /** @var {regexp} - IP address validation */
    rxIpaddress: /^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}(\/[0-9]{1,2})?$/,

    /** @var {regexp} - numeric column types */
    rxNumericType: /^(int|smallint|bigint|now|clock|mtime|ttl|random|counter|real|float|double|numeric|number|decimal|long)/i,

    /** @var {regexp} - object column types */
    rxObjectType: /^(obj|object|array)$/i,

    /** @var {regexp} - date and time column types */
    rxDateType: /^(date|m?time)/i,

    /** @var {regexp} - list and sets column types */
    rxListType: /^(list|set)$/i,

    /** @var {regexp} - characters for camelizing */
    rxCamel: /(?:[_.:-])(\w)/g,

    /** @var {regexp} - list split characters */
    rxSplit: /[,|]/,

    /** @var {regexp} - version validation */
    rxVersion: /^([<>=]+)? *([0-9.:]+)$|^([0-9.:]+) *- *([0-9.:]+)$/,

    /** @var {string} - word boundaries characters */
    wordBoundaries: ` ,.-_:;"'/?!()<>[]{}@#$%^&*+|\``,
    locales: {},
    locale: "",
    hashids: {},

    /** @var {string} - characters allowed in urls */
    uriSafe: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._~-",

    /** @var {regexp} - base64 characters */
    base64: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",

    /** @var {regexp} - base32 characters */
    base32: "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567",

    /** @var {regexp} - base36 characters */
    base36: "0123456789abcdefghijklmnopqrstuvwxyz",

    /** @var {regexp} - base62 characters */
    base62: "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
    base64Dict: {},

    /** @var {object} - empty object, frozen */
    empty: Object.freeze({}),

    /** @var {array} - empty array, frozen */
    emptylist: Object.freeze([]),

    /** @var {function} - empty function to be used when callback was no provided */
    noop: function() {},
};

/**
 * Call a function safely with context:
 * @param {function|object} obj - a function to try or an object with a method
 * @param {string} [method] - if obj is an object try to call the method by name
 * @param {...any} [args] - pass the rest arguments to the function
 * @return {any} the function result
 * @example
 * lib.call(func,..)
 * lib.call(context, func, ...)
 * lib.call(context, method, ...)
 * @memberof module:lib
 * @method call
 */
lib.call = function(obj, method, ...arg)
{
    if (typeof obj == "function") return obj(method, ...arg);
    if (typeof obj != "object") return;
    if (typeof method == "function") return method.call(obj, ...arg);
    if (typeof obj[method] == "function") return obj[method].call(obj, ...arg);
}

/**
 * Run a callback if a valid function, all arguments after the callback will be passed as is,
 * report a warning if callback is not a function but not empty
 * @param {function} callback - try to call this function
 * @param {...any} [args] - pass the rest arguments to the function
 * @return {any} the function result
 * @memberof module:lib
 * @method tryCall
 */
lib.tryCall = function(callback, ...args)
{
    if (typeof callback == "function") return callback.apply(null, args);
    if (callback) logger.trace("tryCall:", callback, ...args);
}

/**
 * Run a callback after timeout, returns a function so it can be used instead of actual callback,
 * report a warning if callback is not a function but not empty
 * @param {function} callback - try to call this function
 * @param {int} timeout - timeout in milliseconds
 * @param {...any} [args] - pass the rest arguments to the function
 * @memberof module:lib
 * @method tryLater
 */
lib.tryLater = function(callback, timeout, ...args)
{
    if (typeof callback == "function") {
        return (err) => {
            setTimeout(callback, timeout, err, ...args);
        }
    } else
    if (callback) logger.trace("tryLater:", callback, ...args);
}

/**
 * Run a callback inside try..catch block, all arguments after the callback will be passed as is, in case of error
 * all arguments will be printed in the log. If no callback passed do nothing.
 * @param {function} callback - try to call this function
 * @param {...any} [args] - pass the rest arguments to the function
 * @memberof module:lib
 * @method tryCatch
 */
lib.tryCatch = function(callback, ...args)
{
    if (typeof callback == "function") {
        try {
            callback.apply(null, args);
        } catch (e) {
            args.unshift(e.stack);
            args.unshift("tryCatch:");
            logger.error.apply(logger, args);
        }
    } else
    if (callback) logger.trace("tryCatch:", callback, ...args);
}

/**
 * Try loading a module, log if failed, no expection is raised
 * @param {string} path - module path
 * @param {object} [options]
 * @param {string} [options.logger] - use logger level
 * @param {string} [options.message] - additional error message
 * @return {object|undefined}
 * @memberof module:lib
 * @method tryRequire
 */
lib.tryRequire = function(path, options)
{
    try {
        return require(path);
    } catch (e) {
        logger.logger(options?.logger || "trace", "tryRequire:", path, options?.message || "not found");
    }
}

/**
 * Print all arguments into the console, for debugging purposes, if the first arg is an error only print the error
 * @param {...any} [args] - print all arguments
 * @memberof module:lib
 * @method log
 */
lib.log = function(...args)
{
    if (util.types.isNativeError(args[0])) {
        return console.log(lib.traceError(args[0]));
    }
    for (const i in args) {
        console.log(util.inspect(args[i], { depth: 7 }));
    }
}

/**
 * Simple i18n translation method compatible with other popular modules, supports the following usage:
 * @param {string} msg
 * @param {...any} [args]
 * @return {string}
 * @example
 * lib.__(name)
 * lib.__(fmt, arg,...)
 * lib.__({ phrase: "", locale: "" }, arg...
 * @memberof module:lib
 * @method __
 */
lib.__ = function(msg, ...args)
{
    var lang = this.locale, txt;

    if (msg?.phrase) {
        msg = msg.phrase;
        lang = msg.locale || lang;
    }
    var locale = lib.locales[lang];
    if (!locale && typeof lang == "string" && lang.indexOf("-") > 0) {
        locale = lib.locales[lang.split("-")[0]];
    }
    if (locale) {
        txt = locale[msg];
        if (!txt) logger.info("missing-locale:", lang, msg);
    }
    if (!txt) txt = msg;
    if (!args.length) return txt;
    return lib.sprintf(txt, ...args);
}

/**
 * Return commandline argument value by name
 * @param {string} name - argument name
 * @param {any} [dflt] - return this value if no argument found
 * @return {string}
 * @memberof module:lib
 * @method getArg
 */
lib.getArg = function(name, dflt)
{
    var idx = process.argv.lastIndexOf(name);
    var val = idx > -1 && idx + 1 < process.argv.length ? process.argv[idx + 1] : "";
    if (val[0] == "-") val = "";
    if (!val && typeof dflt != "undefined") val = dflt;
    return val;
}

/**
 * Return commandline argument value as a number
 * @param {string} name - argument name
 * @param {any} [dflt] - return this value if no argument found
 * @return {int}
 * @memberof module:lib
 * @method getArgInt
 */
lib.getArgInt = function(name, dflt)
{
    return this.toNumber(this.getArg(name, dflt));
}

/**
 * Returns true of given arg(s) are present in the command line, name can be a string or an array of strings.
 * @param {string} name - argument name
 * @return {boolean}
 * @memberof module:lib
 * @method isArg
 */
lib.isArg = function(name)
{
    if (!Array.isArray(name)) return process.argv.lastIndexOf(name) > 0;
    return name.some(function(x) { return process.argv.lastIndexOf(x) > 0 });
}

/**
 * Register the callback to be run later for the given message, the message may have the `__deferId`
 * property which will be used for keeping track of the responses or it will be generated.
 * A timeout is created for this message, if `runCallback` for this message will not be called in time the timeout handler will call the callback
 * anyway with the original message.
 * @param {object} parent - can be any object and is used to register the timer and keep reference to it, a `_defer` object will be created inside the parent.
 * @param {object} msg - the message
 * @param {function} callback - will be called with only one argument which is the message itself, what is inside the message this function does not care. If
 * any errors must be passed, use the message object for it, no other arguments are expected.
 * @param {int} [timeout] - how long to wait, if not given `lib.deferTimeout` is used
 * @return {object} an object with timer and callback
 * @memberof module:lib
 * @method deferCallback
 */
lib.deferCallback = function(parent, msg, callback, timeout)
{
    if (!parent || !this.isObject(msg) || !callback) return;

    if (!parent._defer) {
        parent._defer = {};
    }
    if (!msg.__deferId) {
        msg.__deferId = this.deferId++;
    }
    var defer = parent._defer[msg.__deferId] = {
        callback,
        id: msg.__deferId,
        timer: setTimeout(onDeferCallback.bind(parent, msg), timeout || this.deferTimeout)
    };
    return defer;
}

/**
 * Clear all pending timers
 * @memberof module:lib
 * @method deferShutdown
 */
lib.deferShutdown = function(parent)
{
    if (!parent?._defer) return;
    for (const p in parent._defer) {
        clearTimeout(parent._defer[p].timer);
        delete parent._defer[p];
    }
    delete parent._defer;
}

/**
 * To be called on timeout or when explicitely called by the `runCallback`, it is called in the context of the message.
 */
function onDeferCallback(msg)
{
    var item = this._defer && this._defer[msg.__deferId];
    if (!item) return;
    delete this._defer[msg.__deferId];
    clearTimeout(item.timer);
    logger.dev("onDeferCallback:", msg);
    try { item.callback(msg); } catch (e) { logger.error('onDeferCallback:', e, msg, e.stack); }
}

/**
 * Run delayed callback for the message previously registered with the `deferCallback` method.
 * The message must have `__deferId` property which is used to find the corresponding callback,
 * if the msg is a JSON string it will be converted into the object.
 *
 * Same parent object must be used for `deferCallback` and this method.
 * @memberof module:lib
 * @method runCallback
 */
lib.runCallback = function(parent, msg)
{
    if (!parent?._defer) return;
    if (msg && typeof msg == "string") msg = this.jsonParse(msg, { logger: "error" });
    if (!msg?.__deferId || !parent._defer[msg.__deferId]) return;
    setImmediate(onDeferCallback.bind(parent, msg));
}

/**
 * Assign or clear an interval timer by name, keep the reference in the given parent object
 * @memberof module:lib
 * @method deferInterval
 */
lib.deferInterval = function(parent, interval, name, callback)
{
    if (!parent._defer) {
        parent._defer = {};
    }

    var item = parent._defer[name];

    if (interval != item?.interval) {
        clearInterval(item?.timer);
        if (interval > 0) {
            parent._defer[name] = {
                timer: setInterval(callback, interval),
                interval,
            };
        } else {
            delete parent._defer[name];
        }
    }
}

/**
 * Async sleep version
 * @param {int} delay - number of milliseconds to wait
 * @returns {Promise}
 * @memberof module:lib
 * @method sleep
 */
lib.sleep = function(delay)
{
    return new Promise((resolve) => setTimeout(resolve, delay))
}

/**
 * Sort a list be version in descending order, an item can be a string or an object with
 * a property to sort by, in such case `name` must be specified which property to use for sorting.
 * The name format is assumed to be: `XXXXX-N.N.N`
 * @memberof module:lib
 * @method sortByVersion
 */
lib.sortByVersion = function(list, name)
{
    if (!Array.isArray(list)) return [];
    return list.sort(function(a, b) {
        var v1 = typeof a == "string" ? a : a[name];
        var v2 = typeof b == "string" ? b : b[name];
        var n1 = v1 && v1.match(/^(.+)[ -]([0-9.]+)$/);
        if (n1) n1[2] = lib.toVersion(n1[2]);
        var n2 = v2 && v2.match(/^(.+)[ -]([0-9.]+)$/);
        if (n2) n2[2] = lib.toVersion(n2[2]);
        return !n1 || !n2 ? 0 : n1[1] > n2[1] ? -1 : n1[1] < n2[1] ? 1 : n2[2] - n1[2];
    });
}

/**
 * Return a new Error object, message can be a string or an object with message, code, status and other properties.
 * The default error status is 400 if not specified.
 * @param {string|object} message
 * @param {number|object} [status] - if an object all properties are copied into the error
 * @param {string|number} [code] - if provided set as the .code property
 * @example
 * lib.newError("not found", 404)
 * lib.newError("not found", 404, "NOTFOUND")
 * lib.newError("not found", { status: 404, path: "/..." })
 * lib.newError({ message: "not found", status: 404, code: 123 })
 * @memberof module:lib
 * @method newError
 */
lib.newError = function(message, status, code)
{
    if (typeof message == "string") {
        message = { status: typeof status == "number" ? status : 400, message };
    }
    var err = new Error(message?.message || this.__("Internal error occurred, please try again later"));
    if (typeof message == "object") Object.assign(err, message);
    if (typeof status == "object") Object.assign(err, status);
    if (!err.status) err.status = 400;
    if (code) err.code = code;
    return err;
}

/**
 * Returns the error stack or the error itself, to be used in error messages
 * @param {Error} err
 * @memberof module:lib
 * @method traceError
 */
lib.traceError = function(err)
{
    return this.objDescr(err || "", { ignore: /^domain|req|res$/ }) + " " + (util.types.isNativeError(err) && err.stack ? err.stack : "");
}

/**
 * Load a file with locale translations into memory
 * @param {string} file
 * @param {function} [callback]
 * @memberof module:lib
 * @method loadLocale
 */
lib.loadLocale = function(file, callback)
{
    fs.readFile(file, function(err, data) {
        if (!err) {
            var d = lib.jsonParse(data.toString(), { logger: "error" });
            if (d) lib.locales[path.basename(file, ".json")] = d;
        }
        logger[err && err.code != "ENOENT" ? "error" : "debug"]("loadLocale:", file, err);
        if (typeof callback == "function") callback(err, d);
    });
}

/**
 * Randomize a list items in place
 * @param {any[]} list
 * @return {any[]}
 * @memberof module:lib
 * @method shuffle
 */
lib.shuffle = function(list)
{
    if (!Array.isArray(list) || !list.length) return [];
    for (let i = 0; i < list.length; i++) {
        var j = Math.round((list.length - 1) * this.randomFloat());
        if (i == j) {
            continue;
        }
        const item = list[j];
        list[j] = list[i];
        list[i] = item;
    }
    return list;
}

require(__dirname + "/lib/is");
require(__dirname + "/lib/system");
require(__dirname + "/lib/time");
require(__dirname + "/lib/conv");
require(__dirname + "/lib/parse");
require(__dirname + "/lib/hash");
require(__dirname + "/lib/hashids");
require(__dirname + "/lib/crypto");
require(__dirname + "/lib/uuid");
require(__dirname + "/lib/file");
require(__dirname + "/lib/flow");
require(__dirname + "/lib/obj");
require(__dirname + "/lib/str");
require(__dirname + '/lib/fetch');
require(__dirname + '/lib/pool');
require(__dirname + '/lib/lru');
require(__dirname + '/lib/jwt');
require(__dirname + '/lib/respawn');