diff options
Diffstat (limited to 'devtools/shared/event-emitter.js')
-rw-r--r-- | devtools/shared/event-emitter.js | 470 |
1 files changed, 470 insertions, 0 deletions
diff --git a/devtools/shared/event-emitter.js b/devtools/shared/event-emitter.js new file mode 100644 index 0000000000..2ba4ae927b --- /dev/null +++ b/devtools/shared/event-emitter.js @@ -0,0 +1,470 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const BAD_LISTENER = + "The event listener must be a function, or an object that has " + + "`EventEmitter.handler` Symbol."; + +const eventListeners = Symbol("EventEmitter/listeners"); +const onceOriginalListener = Symbol("EventEmitter/once-original-listener"); +const handler = Symbol("EventEmitter/event-handler"); +loader.lazyRequireGetter(this, "flags", "resource://devtools/shared/flags.js"); + +class EventEmitter { + /** + * Registers an event `listener` that is called every time events of + * specified `type` is emitted on the given event `target`. + * + * @param {Object} target + * Event target object. + * @param {String} type + * The type of event. + * @param {Function|Object} listener + * The listener that processes the event. + * @param {Object} options + * @param {AbortSignal} options.signal + * The listener will be removed when linked AbortController’s abort() method is called + * @returns {Function} + * A function that removes the listener when called. + */ + static on(target, type, listener, { signal } = {}) { + if (typeof listener !== "function" && !isEventHandler(listener)) { + throw new Error(BAD_LISTENER); + } + + if (signal?.aborted === true) { + // The signal is already aborted so don't setup the listener. + // We return an empty function as it's the expected returned value. + return () => {}; + } + + if (!(eventListeners in target)) { + target[eventListeners] = new Map(); + } + + const events = target[eventListeners]; + + if (events.has(type)) { + events.get(type).add(listener); + } else { + events.set(type, new Set([listener])); + } + + const offFn = () => EventEmitter.off(target, type, listener); + + if (signal) { + signal.addEventListener("abort", offFn, { once: true }); + } + + return offFn; + } + + /** + * Removes an event `listener` for the given event `type` on the given event + * `target`. If no `listener` is passed removes all listeners of the given + * `type`. If `type` is not passed removes all the listeners of the given + * event `target`. + * @param {Object} target + * The event target object. + * @param {String} [type] + * The type of event. + * @param {Function|Object} [listener] + * The listener that processes the event. + */ + static off(target, type, listener) { + const length = arguments.length; + const events = target[eventListeners]; + + if (!events) { + return; + } + + if (length >= 3) { + // Trying to remove from the `target` the `listener` specified for the + // event's `type` given. + const listenersForType = events.get(type); + + // If we don't have listeners for the event's type, we bail out. + if (!listenersForType) { + return; + } + + // If the listeners list contains the listener given, we just remove it. + if (listenersForType.has(listener)) { + listenersForType.delete(listener); + } else { + // If it's not present, there is still the possibility that the listener + // have been added using `once`, since the method wraps the original listener + // in another function. + // So we iterate all the listeners to check if any of them is a wrapper to + // the `listener` given. + for (const value of listenersForType.values()) { + if ( + onceOriginalListener in value && + value[onceOriginalListener] === listener + ) { + listenersForType.delete(value); + break; + } + } + } + } else if (length === 2) { + // No listener was given, it means we're removing all the listeners from + // the given event's `type`. + if (events.has(type)) { + events.delete(type); + } + } else if (length === 1) { + // With only the `target` given, we're removing all the listeners from the object. + events.clear(); + } + } + + static clearEvents(target) { + const events = target[eventListeners]; + if (!events) { + return; + } + events.clear(); + } + + /** + * Registers an event `listener` that is called only the next time an event + * of the specified `type` is emitted on the given event `target`. + * It returns a Promise resolved once the specified event `type` is emitted. + * + * @param {Object} target + * Event target object. + * @param {String} type + * The type of the event. + * @param {Function|Object} [listener] + * The listener that processes the event. + * @param {Object} options + * @param {AbortSignal} options.signal + * The listener will be removed when linked AbortController’s abort() method is called + * @return {Promise} + * The promise resolved once the event `type` is emitted. + */ + static once(target, type, listener, options) { + return new Promise(resolve => { + // This is the actual listener that will be added to the target's listener, it wraps + // the call to the original `listener` given. + const newListener = (first, ...rest) => { + // To prevent side effects we're removing the listener upfront. + EventEmitter.off(target, type, newListener); + + let rv; + if (listener) { + if (isEventHandler(listener)) { + // if the `listener` given is actually an object that handles the events + // using `EventEmitter.handler`, we want to call that function, passing also + // the event's type as first argument, and the `listener` (the object) as + // contextual object. + rv = listener[handler](type, first, ...rest); + } else { + // Otherwise we'll just call it + rv = listener.call(target, first, ...rest); + } + } + + // We resolve the promise once the listener is called. + resolve(first); + + // Listeners may return a promise, so pass it along + return rv; + }; + + newListener[onceOriginalListener] = listener; + EventEmitter.on(target, type, newListener, options); + }); + } + + static emit(target, type, ...rest) { + EventEmitter._emit(target, type, false, rest); + } + + static emitAsync(target, type, ...rest) { + return EventEmitter._emit(target, type, true, rest); + } + + /** + * Emit an event of a given `type` on a given `target` object. + * + * @param {Object} target + * Event target object. + * @param {String} type + * The type of the event. + * @param {Boolean} async + * If true, this function will wait for each listener completion. + * Each listener has to return a promise, which will be awaited for. + * @param {Array} args + * The arguments to pass to each listener function. + * @return {Promise|undefined} + * If `async` argument is true, returns the promise resolved once all listeners have resolved. + * Otherwise, this function returns undefined; + */ + static _emit(target, type, async, args) { + if (loggingEnabled) { + logEvent(type, args); + } + + const targetEventListeners = target[eventListeners]; + if (!targetEventListeners) { + return undefined; + } + + const listeners = targetEventListeners.get(type); + if (!listeners?.size) { + return undefined; + } + + const promises = async ? [] : null; + + // Creating a temporary Set with the original listeners, to avoiding side effects + // in emit. + for (const listener of new Set(listeners)) { + // If the object was destroyed during event emission, stop emitting. + if (!(eventListeners in target)) { + break; + } + + // If listeners were removed during emission, make sure the + // event handler we're going to fire wasn't removed. + if (listeners && listeners.has(listener)) { + try { + let promise; + if (isEventHandler(listener)) { + promise = listener[handler](type, ...args); + } else { + promise = listener.apply(target, args); + } + if (async) { + // Assert the name instead of `constructor != Promise` in order + // to avoid cross compartment issues where Promise can be multiple. + if (!promise || promise.constructor.name != "Promise") { + console.warn( + `Listener for event '${type}' did not return a promise.` + ); + } else { + promises.push(promise); + } + } + } catch (ex) { + // Prevent a bad listener from interfering with the others. + console.error(ex); + const msg = ex + ": " + ex.stack; + dump(msg + "\n"); + } + } + } + + if (async) { + return Promise.all(promises); + } + + return undefined; + } + + /** + * Returns a number of event listeners registered for the given event `type` + * on the given event `target`. + * + * @param {Object} target + * Event target object. + * @param {String} type + * The type of event. + * @return {Number} + * The number of event listeners. + */ + static count(target, type) { + if (eventListeners in target) { + const listenersForType = target[eventListeners].get(type); + + if (listenersForType) { + return listenersForType.size; + } + } + + return 0; + } + + /** + * Decorate an object with event emitter functionality; basically using the + * class' prototype as mixin. + * + * @param Object target + * The object to decorate. + * @return Object + * The object given, mixed. + */ + static decorate(target) { + const descriptors = Object.getOwnPropertyDescriptors(this.prototype); + delete descriptors.constructor; + return Object.defineProperties(target, descriptors); + } + + static get handler() { + return handler; + } + + on(...args) { + return EventEmitter.on(this, ...args); + } + + off(...args) { + EventEmitter.off(this, ...args); + } + + clearEvents() { + EventEmitter.clearEvents(this); + } + + once(...args) { + return EventEmitter.once(this, ...args); + } + + emit(...args) { + EventEmitter.emit(this, ...args); + } + + emitAsync(...args) { + return EventEmitter.emitAsync(this, ...args); + } + + emitForTests(...args) { + if (flags.testing) { + EventEmitter.emit(this, ...args); + } + } + + count(...args) { + return EventEmitter.count(this, ...args); + } +} + +module.exports = EventEmitter; + +const isEventHandler = listener => + listener && handler in listener && typeof listener[handler] === "function"; + +const { + getNthPathExcluding, +} = require("resource://devtools/shared/platform/stack.js"); +let loggingEnabled = false; + +if (!isWorker) { + loggingEnabled = Services.prefs.getBoolPref("devtools.dump.emit", false); + const observer = { + observe: () => { + loggingEnabled = Services.prefs.getBoolPref("devtools.dump.emit"); + }, + }; + Services.prefs.addObserver("devtools.dump.emit", observer); + + // Also listen for Loader unload to unregister the pref observer and + // prevent leaking + const unloadObserver = function (subject) { + if (subject.wrappedJSObject == require("@loader/unload")) { + Services.prefs.removeObserver("devtools.dump.emit", observer); + Services.obs.removeObserver(unloadObserver, "devtools:loader:destroy"); + } + }; + Services.obs.addObserver(unloadObserver, "devtools:loader:destroy"); +} + +function serialize(target) { + const MAXLEN = 60; + + // Undefined + if (typeof target === "undefined") { + return "undefined"; + } + + if (target === null) { + return "null"; + } + + // Number / String + if (typeof target === "string" || typeof target === "number") { + return truncate(target, MAXLEN); + } + + // HTML Node + if (target.nodeName) { + let out = target.nodeName; + + if (target.id) { + out += "#" + target.id; + } + if (target.className) { + out += "." + target.className; + } + + return out; + } + + // Array + if (Array.isArray(target)) { + return truncate(target.toSource(), MAXLEN); + } + + // Function + if (typeof target === "function") { + return `function ${target.name ? target.name : "anonymous"}()`; + } + + // Window + if (target?.constructor?.name === "Window") { + return `window (${target.location.origin})`; + } + + // Object + if (typeof target === "object") { + let out = "{"; + + const entries = Object.entries(target); + for (let i = 0; i < Math.min(10, entries.length); i++) { + const [name, value] = entries[i]; + + if (i > 0) { + out += ", "; + } + + out += `${name}: ${truncate(value, MAXLEN)}`; + } + + return out + "}"; + } + + // Other + return truncate(target.toSource(), MAXLEN); +} + +function truncate(value, maxLen) { + // We don't use value.toString() because it can throw. + const str = String(value); + return str.length > maxLen ? str.substring(0, maxLen) + "..." : str; +} + +function logEvent(type, args) { + let argsOut = ""; + + // We need this try / catch to prevent any dead object errors. + try { + argsOut = `${args.map(serialize).join(", ")}`; + } catch (e) { + // Object is dead so the toolbox is most likely shutting down, + // do nothing. + } + + const path = getNthPathExcluding(0, "devtools/shared/event-emitter.js"); + + if (args.length) { + dump(`EMITTING: emit(${type}, ${argsOut}) from ${path}\n`); + } else { + dump(`EMITTING: emit(${type}) from ${path}\n`); + } +} |