Messenger.js

/* global platypus, window */
import EventHandlerList from './EventHandlerList.js';
import {arrayCache, greenSlice} from './utils/array.js';

const
    getPerfTools = () => typeof performance !== 'undefined' && performance.mark && performance.measure ? performance : null,
    runBoth = function (f1, f2) {
        return function () {
            f1.apply(this, arguments);
            f2.apply(this, arguments);
        };
    };

/**
 * Messenger provides prioritized event-based communication between
 * components, entities, and systems.
 *
 * Features include:
 * - prioritized listeners
 * - one-time listeners
 * - deterministic execution ordering
 * - safe listener mutation during dispatch
 * - pooled listener structures
 * - array/object event dispatch formats
 * - optional debug instrumentation
 *
 * Messenger is commonly mixed into entities and components to
 * provide lightweight pub/sub behavior without repeated allocations.
 *
 * @memberof platypus
 * @class Messenger
 */
class Messenger {
    /**
     * Creates a Messenger instance.
     *
     * @param {Object} [options]
     * Configuration options.
     *
     * @param {Boolean} [options.debug=false]
     * Enables nested-event debugging and performance instrumentation.
     */
    constructor ({debug} = {}) {
        this._listeners = {};
        this._destroyed = false;
        this.loopCheck = arrayCache.setUp();

        if (debug) {
            const
                triggerEvent = this.triggerEvent;

            this.triggerEvent = function (event, value) {
                const
                    debugLimit = 5,
                    debugLogging = value && value.debug;
                let debugCount = 0;
                
                // Debug logging.
                if (debugLogging || this.debug) {
                    const perfTools = getPerfTools();

                    for (let i = 0; i < this.loopCheck.length; i++) {
                        if (this.loopCheck[i] === event) {
                            debugCount += 1;
                            if (debugCount > debugLimit) {
                                throw "Endless loop detected for '" + event + "'.";
                            } else {
                                platypus.debug.warn("Event '" + event + "' is nested inside another '" + event + "' event.");
                            }
                        }
                    }
    
                    this.loopCheck.push(event);

                    if (perfTools) {
                        perfTools.mark("a");
                    }

                    const
                        count = triggerEvent.apply(this, arguments);

                    if (perfTools) {
                        perfTools.mark("b");
                        perfTools.measure(this.type + ":" + event, 'a', 'b');
                    }
                    this.loopCheck.length = this.loopCheck.length - 1;
                    if (debugLogging) {
                        if (count) {
                            platypus.debug.log('Entity "' + this.type + '": Event "' + event + '" has ' + count + ' subscriber' + ((count > 1) ? 's' : '') + '.', value);
                        } else {
                            platypus.debug.warn('Entity "' + this.type + '": Event "' + event + '" has no subscribers.', value);
                        }
                    }
                    return count;
                } else {
                    return triggerEvent.apply(this, arguments);
                }
            };
        }
    }

    /**
     * Registers an event listener.
     *
     * Listeners are executed in ascending priority order.
     * Lower priority values execute first.
     *
     * When priorities match, listeners execute in
     * registration order.
     *
     * If no context is supplied, the Messenger instance
     * becomes the callback context.
     *
     * @method on
     *
     * @param {String} name
     * Event name.
     *
     * @param {Function} callback
     * Function invoked when the event fires.
     *
     * @param {Object} [context=this]
     * Callback execution context.
     *
     * @param {Boolean} [once=false]
     * Whether the listener should automatically remove
     * itself after its first execution.
     *
     * @param {Number} [priority=MAX_SAFE_INTEGER]
     * Listener execution priority.
     *
     * Lower numbers execute first.
     */
    on (name, callback, context = null, once = false, priority = -1) {
        if (!this._destroyed) {
            const
                listenerList = this._listeners[name] = this._listeners[name] ?? EventHandlerList.setUp();

            return listenerList.add(callback, context ?? this, once, priority === -1 ? Number.MAX_SAFE_INTEGER : priority);
        }

        return null;
    }

    /**
     * Removes listeners.
     *
     * Calling without arguments removes all listeners.
     *
     * Calling with only an event name removes all listeners
     * for that event.
     *
     * Calling with both name and callback removes matching
     * listeners for that callback.
     *
     * @method off
     *
     * @param {String} [name]
     * Event name.
     *
     * @param {Function} [callback]
     * Listener callback.
     */
    off (name, callback, context) {
        if (!this._destroyed) {
            // remove all
            if (typeof name === 'undefined') {
                this.getMessageIds().forEach((id) => this._listeners[id].recycle());
                this._listeners = {};
            } else {
                const
                    listenerList = this._listeners[name];

                if (listenerList) {
                    // remove all listeners for that event
                    if (typeof callback === 'undefined') {
                        listenerList.recycle();
                        delete this._listeners[name];
                    } else {
                        //remove single listener
                        listenerList.remove(callback, context);
                        if (!listenerList.handlers.length) {
                            listenerList.recycle();
                            delete this._listeners[name];
                        }
                    }
                }
            }
        }
    }

    /**
     * Returns a string describing the Messenger as "[Messenger object]".
     *
     * @return String
     */
    toString () {
        return "[Messenger Object]";
    }

    /**
     * Dispatches one or more events.
     *
     * Supported formats:
     *
     * String:
     * trigger('jump', value)
     *
     * Array:
     * trigger(['jump', 'land'], value)
     *
     * Object:
     * trigger({
     *     event: 'jump',
     *     message: value,
     *     debug: true
     * })
     *
     * @method trigger
     *
     * @param {String|Array|Object} events
     * Event descriptor.
     *
     * @param {*} [message]
     * Event payload.
     *
     * @param {Boolean} [debug]
     * Enables debug logging for this dispatch.
     *
     * @return {Number}
     * Total number of listeners triggered.
     */
    trigger (events, message, debug) {
        if (typeof events === 'string') {
            return this.triggerEvent.apply(this, arguments);
        } else if (Array.isArray(events)) {
            const
                args = greenSlice(arguments);
            let count = 0;

            for (let i = 0; i < events.length; i++) {
                args[0] = events[i];
                count += this.trigger.apply(this, args);
            }
            arrayCache.recycle(args);

            return count;
        } else if (events.event) {
            if (typeof events.debug !== 'undefined') {
                return this.triggerEvent(
                    events.event,
                    events.message ?? message,
                    events.debug
                );
            }

            return this.triggerEvent(
                events.event,
                events.message ?? message
            );
        } else {
            platypus.debug.warn('Event incorrectly formatted: must be string, array, or object containing an "event" property.', events);
            return 0;
        }
    }
    
    /**
     * Dispatches a single event directly.
     *
     * This bypasses trigger-format normalization and is
     * the fastest dispatch path.
     *
     * @method triggerEvent
     *
     * @param {String} type
     * Event name.
     *
     * @param {...*} args
     * Arguments forwarded to listeners.
     *
     * @return {Number}
     * Number of listeners triggered.
     */
    triggerEvent (type, ...args) {
        const
            {_listeners} = this;

        if (!this._destroyed && _listeners.hasOwnProperty(type) && (_listeners[type])) {
            return _listeners[type].trigger(args);
        }
        
        return 0;
    }
    
    /**
     * This method returns all the messages that this entity is concerned about.
     *
     * @return {Array} An array of strings listing all the messages for which this Messenger has handlers.
     */
    getMessageIds () {
        return Object.keys(this._listeners);
    }
    
    /**
     * This method relinguishes Messenger properties
     *
     */
    destroy () {
        arrayCache.recycle(this.loopCheck);
        this.loopCheck = null;
        this._destroyed = true;
        this.getMessageIds().forEach((id) => this._listeners[id].recycle());
        this._listeners = null;
    }

    /**
     * This read-only property shows whether this Messenger is destroyed.
     *
     * @property destroyed
     * @type Boolean
     * @default false
     */
    get destroyed () {
        return this._destroyed;
    }

    /**
     * Adds Messenger functionality to a Class.
     *
     * @param {Class|Function} ClassObject The class to add Messenger behavior to.
     */
    static mixin (ClassObject) {
        const
            fromProto = Messenger.prototype,
            toProto = ClassObject.prototype,
            methods = Object.getOwnPropertyNames(fromProto);
        let i = methods.length;

        while (i--) {
            const
                key = methods[i];

            if (key !== 'constructor') {
                if (toProto[key]) {
                    toProto[key] = runBoth(toProto[key], fromProto[key]);
                } else {
                    toProto[key] = fromProto[key];
                }
            }
        }
    }

    /**
     * Call this method in an Object's instantiation if `Messenger.mixin` has been called on its Class.
     *
     * @param {Object} object The object for which Messenger should be instantiated.
     */
    static initialize (object) {
        object._listeners = {};
        object._destroyed = false;
        object.loopCheck = arrayCache.setUp();
    }
}

export default Messenger;