summaryrefslogtreecommitdiffstats
path: root/devtools/shared/event-emitter.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/shared/event-emitter.js')
-rw-r--r--devtools/shared/event-emitter.js470
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`);
+ }
+}