push.js

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

const { EventEmitter } = require("events");
const logger = require(__dirname + '/logger');
const lib = require(__dirname + '/lib');

/**
 * Messaging and push notifications for mobile and other clients, supports Webpush push notifications.
 *
 * Emits a signal **uninstall(client, device_id, user_id)** on device invalidation or if a device token is invalid as
 * reported by the server, user_id may not be available.
 * @module push
 */
class Push extends EventEmitter {

    constructor() {
        super();
        this.name = "push";

        this.args = [
            { name: "config", obj: "_config", type: "json", merge: 1, descr: "An object with client configs by type", example: '-push-config {"webpush":{"type":"webpush",key":XXX","pubkey":"XXXX"}}' },
            { name: "([a-z0-9]+)", obj: "_config.$1", type: "map", merge: 1, descr: "A single client type parameters", example: "-push-webpush type:webpush,key:K,pubkey:PK,email:XXX" },
        ];

        /** @var {object} - push modules by type */
        this.modules = {};

        /** @var {object} - push live clients by type */
        this.clients = {};

        this._config = {};
    }

    /**
     * Perform initial setup by calling static configure method for each module
     * @param {object|object[]} options - whole config object for the type
     * @memberof module:push
     * @method configureModule
     */
    configureModule(options, callback)
    {
        for (const type in this._config) {
            try {
                let Mod = this.modules[type];
                if (!Mod) {
                    Mod = this.modules[type] = require(__dirname + "/push/" + type);
                }
                if (!Mod) continue;
                lib.call(Mod.configure, this._config[type]);
            } catch (e) {
                logger.error("configureModule:", this.name, options, e.stack);
            }
        }
        callback();
    }

    /**
     * Initialize supported notification services, it supports jobs arguments convention so can be used in the jobs that
     * need to send push notifications in the worker process. Can be called muny times, only new modules will be initialized.
     * @param {object} options
     * @param {function} [callback]
     * @memberof module:push
     * @method init
     */
    init(options, callback)
    {
        if (typeof options == "function") callback = options, options = null;
        logger.debug("init:", this.name);

        for (const type in this._config) {
            if (!type || this.clients[type]) continue;

            const Mod = this.modules[type];
            if (!Mod) continue;

            this.clients[type] = new Mod(this._config[type]);
        }
        this.emit("init");
        if (typeof callback == "function") callback();
    }

    /**
     * Shutdown notification services, may wait till all pending messages are sent before calling the callback
     * @param {object} options
     * @param {function} [callback]
     * @memberof module:push
     * @method shutdown
     */
    shutdown(options, callback)
    {
        this.emit("shutdown");
        logger.debug("shutdown:", this.name);

        lib.forEach(Object.keys(this.clients), (type, next) => {
            const client = this.clients[type];
            delete this.clients[type];
            lib.call(client.close, next);
        }, callback);
    }

    /**
    * Deliver a notification for the given device token(s).
    *
    * @param {string} device - where to send the message to, can be multiple ids separated by , or |.
    * @param {object} options
    * @param {string} options.user_id - an user id associated with this token, for debugging and invalid token management
    * @param {string} options.app_id - send to the devices for the given app only
    * @param {string} options.msg - text message to send
    * @param {int} options.badge - badge number to show if supported by the service
    * @param {boolean} options.sound - set to true if a sound should be produced on message receive
    * @param {string} options.type - set type of the message, service specific
    * @param {string} options.category - action category for APN
    * @param {string} options.id - send id with the notification, this is application specific data, sent as is
    * @param {string} options.name - notification group name, can be used for grouping multiple messages under this name
    * @param {string} options.url - a launch url for the app, it show associated screen on launch if supported
    * @param {function} callback
    * @memberof module:push
    * @method send
    */
    send(device, options, callback)
    {
        // Collect all device tokens per app
        var devs = lib.split(device, null, "string").filter((x) => {
            var dev = this.parseDevice(x);
            if (!dev.token) return 0;
            if (options?.app_id && dev.app != options.app_id) return 0;
            return dev;
        });
        logger.debug("send:", this.name, device, options, devs);

        lib.forEach(devs, (dev, next) => {
            var client = this.clients[dev.type];
            if (!client) {
                logger.error("send:", "unsupported:", dev);
                return next();
            }
            client.send(dev, options, (err) => {
                if (err) logger.error("send:", dev, err);
                next();
            });
        }, callback);
    }

    /**
    * Parse device URN and returns an object with all parts into separate properties. A device URN can be in the following format:
    * @param {string} device - [type://]token[@app]
    *  - type - apn, fcm, webpush, sns, ...
    *  - token - device token
    *  - app - optional application name or bundle
    * @memberof module:push
    * @method parseDevice
    */
    parseDevice(device)
    {
        var dev = { token: "", type: "", app: "" };
        if (typeof device != "string") return dev;
        var d = device.match(/^([a-z]+:\/\/)([^@]+)?@?([a-z0-9._-]+)?/i);
        if (d) {
            if (d[2] && d[2] != "undefined") dev.token = d[2];
            if (d[1]) dev.type = d[1].slice(0, -3);
            if (d[3]) dev.app = d[3];
        }
        return dev;
    }

}

module.exports = new Push();