lib/push.js

/*
 *  Author: Vlad Seryakov vseryakov@gmail.com
 *  backendjs 2018
 */
'use strict';

const { EventEmitter } = require("node: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
 */

/**
 * @property {ConfigOptions[]}
 * @default
 */
const 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" },
];

class Push extends EventEmitter {

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

        this.args = args;

        /** @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 many 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;
        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];
            if (!lib.isFunc(client.close)) return next();
            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 - see each driver for specific properties,
    * {@link module:push.WebPushClient}, {@link module:push.APNPushClient}, {@link module:push.FCMPushClient},
    * {@link module:push.SNSPushClient},
    * @param {string} options.msg - message body
    * @param {string} [options.title] - message title
    * @param {string} [options.app_id] - send to the devices for the given app only
    * @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").map((x) => {
            var dev = this.parseDevice(x);
            if (!dev.token) return 0;
            if (options?.app_id && dev.app !== options.app_id) return 0;
            return dev;
        }).filter(x => x);

        logger.debug("send:", this.name, device, options, "DEV:", 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:", this.name, dev, err);
                next();
            });
        }, callback);
    }

    /**
     * Async version of {@link module:push.send}
    * @param {string} device - where to send the message to, can be multiple ids separated by , or |.
    * @param {object} options
    * @returns {Promise} as object { err }
    * @async
    * @memberof module:push
    * @method asend
     *
     */
    asend(device, options)
    {
        return new Promise((resolve, _reject) => {
            this.send(device, options, (err) => {
                resolve({ err });
            })
        });
    }

    /**
    * 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;
        const 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();