summaryrefslogtreecommitdiffstats
path: root/toolkit/components/extensions/MessageChannel.sys.mjs
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-19 00:47:55 +0000
commit26a029d407be480d791972afb5975cf62c9360a6 (patch)
treef435a8308119effd964b339f76abb83a57c29483 /toolkit/components/extensions/MessageChannel.sys.mjs
parentInitial commit. (diff)
downloadfirefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz
firefox-26a029d407be480d791972afb5975cf62c9360a6.zip
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'toolkit/components/extensions/MessageChannel.sys.mjs')
-rw-r--r--toolkit/components/extensions/MessageChannel.sys.mjs1168
1 files changed, 1168 insertions, 0 deletions
diff --git a/toolkit/components/extensions/MessageChannel.sys.mjs b/toolkit/components/extensions/MessageChannel.sys.mjs
new file mode 100644
index 0000000000..65ab2720aa
--- /dev/null
+++ b/toolkit/components/extensions/MessageChannel.sys.mjs
@@ -0,0 +1,1168 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* 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/. */
+
+// @ts-nocheck TODO bug 1580774: Remove this file and its uses.
+
+/**
+ * This module provides wrappers around standard message managers to
+ * simplify bidirectional communication. It currently allows a caller to
+ * send a message to a single listener, and receive a reply. If there
+ * are no matching listeners, or the message manager disconnects before
+ * a reply is received, the caller is returned an error.
+ *
+ * The listener end may specify filters for the messages it wishes to
+ * receive, and the sender end likewise may specify recipient tags to
+ * match the filters.
+ *
+ * The message handler on the listener side may return its response
+ * value directly, or may return a promise, the resolution or rejection
+ * of which will be returned instead. The sender end likewise receives a
+ * promise which resolves or rejects to the listener's response.
+ *
+ *
+ * A basic setup works something like this:
+ *
+ * A content script adds a message listener to its global
+ * ContentFrameMessageManager, with an appropriate set of filters:
+ *
+ * {
+ * init(messageManager, window, extensionID) {
+ * this.window = window;
+ *
+ * MessageChannel.addListener(
+ * messageManager, "ContentScript:TouchContent",
+ * this);
+ *
+ * this.messageFilterStrict = {
+ * innerWindowID: getInnerWindowID(window),
+ * extensionID: extensionID,
+ * };
+ *
+ * this.messageFilterPermissive = {
+ * outerWindowID: getOuterWindowID(window),
+ * };
+ * },
+ *
+ * receiveMessage({ target, messageName, sender, recipient, data }) {
+ * if (messageName == "ContentScript:TouchContent") {
+ * return new Promise(resolve => {
+ * this.touchWindow(data.touchWith, result => {
+ * resolve({ touchResult: result });
+ * });
+ * });
+ * }
+ * },
+ * };
+ *
+ * A script in the parent process sends a message to the content process
+ * via a tab message manager, including recipient tags to match its
+ * filter, and an optional sender tag to identify itself:
+ *
+ * let data = { touchWith: "pencil" };
+ * let sender = { extensionID, contextID };
+ * let recipient = { innerWindowID: tab.linkedBrowser.innerWindowID, extensionID };
+ *
+ * MessageChannel.sendMessage(
+ * tab.linkedBrowser.messageManager, "ContentScript:TouchContent",
+ * data, {recipient, sender}
+ * ).then(result => {
+ * alert(result.touchResult);
+ * });
+ *
+ * Since the lifetimes of message senders and receivers may not always
+ * match, either side of the message channel may cancel pending
+ * responses which match its sender or recipient tags.
+ *
+ * For the above client, this might be done from an
+ * inner-window-destroyed observer, when its target scope is destroyed:
+ *
+ * observe(subject, topic, data) {
+ * if (topic == "inner-window-destroyed") {
+ * let innerWindowID = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
+ *
+ * MessageChannel.abortResponses({ innerWindowID });
+ * }
+ * },
+ *
+ * From the parent, it may be done when its context is being destroyed:
+ *
+ * onDestroy() {
+ * MessageChannel.abortResponses({
+ * extensionID: this.extensionID,
+ * contextID: this.contextID,
+ * });
+ * },
+ *
+ */
+
+export let MessageChannel;
+
+import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs";
+
+import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs";
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ MessageManagerProxy: "resource://gre/modules/MessageManagerProxy.sys.mjs",
+});
+
+function getMessageManager(target) {
+ if (typeof target.sendAsyncMessage === "function") {
+ return target;
+ }
+ return new lazy.MessageManagerProxy(target);
+}
+
+function matches(target, messageManager) {
+ return target === messageManager || target.messageManager === messageManager;
+}
+
+const { DEBUG } = AppConstants;
+
+// Idle callback timeout for low-priority message dispatch.
+const LOW_PRIORITY_TIMEOUT_MS = 250;
+
+const MESSAGE_MESSAGES = "MessageChannel:Messages";
+const MESSAGE_RESPONSE = "MessageChannel:Response";
+
+var _deferredResult;
+var _makeDeferred = (resolve, reject) => {
+ // We use arrow functions here and refer to the outer variables via
+ // `this`, to avoid a lexical name lookup. Yes, it makes a difference.
+ // No, I don't like it any more than you do.
+ _deferredResult.resolve = resolve;
+ _deferredResult.reject = reject;
+};
+
+/**
+ * Helper to create a new Promise without allocating any closures to
+ * receive its resolution functions.
+ *
+ * I know what you're thinking: "This is crazy. There is no possible way
+ * this can be necessary. Just use the ordinary Promise constructor the
+ * way it was meant to be used, you lunatic."
+ *
+ * And, against all odds, it turns out that you're wrong. Creating
+ * lambdas to receive promise resolution functions consistently turns
+ * out to be one of the most expensive parts of message dispatch in this
+ * code.
+ *
+ * So we do the stupid micro-optimization, and try to live with
+ * ourselves for it.
+ *
+ * (See also bug 1404950.)
+ *
+ * @returns {object}
+ */
+let Deferred = () => {
+ let res = {};
+ _deferredResult = res;
+ res.promise = new Promise(_makeDeferred);
+ _deferredResult = null;
+ return res;
+};
+
+/**
+ * Handles the mapping and dispatching of messages to their registered
+ * handlers. There is one broker per message manager and class of
+ * messages. Each class of messages is mapped to one native message
+ * name, e.g., "MessageChannel:Message", and is dispatched to handlers
+ * based on an internal message name, e.g., "Extension:ExecuteScript".
+ */
+class FilteringMessageManager {
+ /**
+ * @param {string} messageName
+ * The name of the native message this broker listens for.
+ * @param {Function} callback
+ * A function which is called for each message after it has been
+ * mapped to its handler. The function receives two arguments:
+ *
+ * result:
+ * An object containing either a `handler` or an `error` property.
+ * If no error occurs, `handler` will be a matching handler that
+ * was registered by `addHandler`. Otherwise, the `error` property
+ * will contain an object describing the error.
+ *
+ * data:
+ * An object describing the message, as defined in
+ * `MessageChannel.addListener`.
+ * @param {nsIMessageListenerManager} messageManager
+ */
+ constructor(messageName, callback, messageManager) {
+ this.messageName = messageName;
+ this.callback = callback;
+ this.messageManager = messageManager;
+
+ this.messageManager.addMessageListener(this.messageName, this, true);
+
+ this.handlers = new Map();
+ }
+
+ /**
+ * Receives a set of messages from our message manager, maps each to a
+ * handler, and passes the results to our message callbacks.
+ */
+ receiveMessage({ data, target }) {
+ data.forEach(msg => {
+ if (msg) {
+ let handlers = Array.from(
+ this.getHandlers(msg.messageName, msg.sender || null, msg.recipient)
+ );
+
+ msg.target = target;
+ this.callback(handlers, msg);
+ }
+ });
+ }
+
+ /**
+ * Iterates over all handlers for the given message name. If `recipient`
+ * is provided, only iterates over handlers whose filters match it.
+ *
+ * @param {string|number} messageName
+ * The message for which to return handlers.
+ * @param {object} sender
+ * The sender data on which to filter handlers.
+ * @param {object} recipient
+ * The recipient data on which to filter handlers.
+ */
+ *getHandlers(messageName, sender, recipient) {
+ let handlers = this.handlers.get(messageName) || new Set();
+ for (let handler of handlers) {
+ if (
+ MessageChannel.matchesFilter(
+ handler.messageFilterStrict || null,
+ recipient
+ ) &&
+ MessageChannel.matchesFilter(
+ handler.messageFilterPermissive || null,
+ recipient,
+ false
+ ) &&
+ (!handler.filterMessage || handler.filterMessage(sender, recipient))
+ ) {
+ yield handler;
+ }
+ }
+ }
+
+ /**
+ * Registers a handler for the given message.
+ *
+ * @param {string} messageName
+ * The internal message name for which to register the handler.
+ * @param {object} handler
+ * An opaque handler object. The object may have a
+ * `messageFilterStrict` and/or a `messageFilterPermissive`
+ * property and/or a `filterMessage` method on which to filter messages.
+ *
+ * Final dispatching is handled by the message callback passed to
+ * the constructor.
+ */
+ addHandler(messageName, handler) {
+ if (!this.handlers.has(messageName)) {
+ this.handlers.set(messageName, new Set());
+ }
+
+ this.handlers.get(messageName).add(handler);
+ }
+
+ /**
+ * Unregisters a handler for the given message.
+ *
+ * @param {string} messageName
+ * The internal message name for which to unregister the handler.
+ * @param {object} handler
+ * The handler object to unregister.
+ */
+ removeHandler(messageName, handler) {
+ if (this.handlers.has(messageName)) {
+ this.handlers.get(messageName).delete(handler);
+ }
+ }
+}
+
+/**
+ * A message dispatch and response manager that wrapse a single native
+ * message manager. Handles dispatching messages through the manager
+ * (optionally coalescing several low-priority messages and dispatching
+ * them during an idle slice), and mapping their responses to the
+ * appropriate response callbacks.
+ *
+ * Note that this is a simplified subclass of FilteringMessageManager
+ * that only supports one handler per message, and does not support
+ * filtering.
+ */
+class ResponseManager extends FilteringMessageManager {
+ constructor(messageName, callback, messageManager) {
+ super(messageName, callback, messageManager);
+
+ this.idleMessages = [];
+ this.idleScheduled = false;
+ this.onIdle = this.onIdle.bind(this);
+ }
+
+ /**
+ * Schedules a new idle callback to dispatch pending low-priority
+ * messages, if one is not already scheduled.
+ */
+ scheduleIdleCallback() {
+ if (!this.idleScheduled) {
+ ChromeUtils.idleDispatch(this.onIdle, {
+ timeout: LOW_PRIORITY_TIMEOUT_MS,
+ });
+ this.idleScheduled = true;
+ }
+ }
+
+ /**
+ * Called when the event queue is idle, and dispatches any pending
+ * low-priority messages in a single chunk.
+ *
+ * @param {IdleDeadline} deadline
+ */
+ onIdle(deadline) {
+ this.idleScheduled = false;
+
+ let messages = this.idleMessages;
+ this.idleMessages = [];
+
+ let msgs = messages.map(msg => msg.getMessage());
+ try {
+ this.messageManager.sendAsyncMessage(MESSAGE_MESSAGES, msgs);
+ } catch (e) {
+ for (let msg of messages) {
+ msg.reject(e);
+ }
+ }
+ }
+
+ /**
+ * Sends a message through our wrapped message manager, or schedules
+ * it for low-priority dispatch during an idle callback.
+ *
+ * @param {any} message
+ * The message to send.
+ * @param {object} [options]
+ * Message dispatch options.
+ * @param {boolean} [options.lowPriority = false]
+ * If true, dispatches the message in a single chunk with other
+ * low-priority messages the next time the event queue is idle.
+ */
+ sendMessage(message, options = {}) {
+ if (options.lowPriority) {
+ this.idleMessages.push(message);
+ this.scheduleIdleCallback();
+ } else {
+ this.messageManager.sendAsyncMessage(MESSAGE_MESSAGES, [
+ message.getMessage(),
+ ]);
+ }
+ }
+
+ receiveMessage({ data, target }) {
+ data.target = target;
+
+ this.callback(this.handlers.get(data.messageName), data);
+ }
+
+ *getHandlers(messageName, sender, recipient) {
+ let handler = this.handlers.get(messageName);
+ if (handler) {
+ yield handler;
+ }
+ }
+
+ addHandler(messageName, handler) {
+ if (DEBUG && this.handlers.has(messageName)) {
+ throw new Error(
+ `Handler already registered for response ID ${messageName}`
+ );
+ }
+ this.handlers.set(messageName, handler);
+ }
+
+ /**
+ * Unregisters a handler for the given message.
+ *
+ * @param {string} messageName
+ * The internal message name for which to unregister the handler.
+ * @param {object} handler
+ * The handler object to unregister.
+ */
+ removeHandler(messageName, handler) {
+ if (DEBUG && this.handlers.get(messageName) !== handler) {
+ Cu.reportError(
+ `Attempting to remove unexpected response handler for ${messageName}`
+ );
+ }
+ this.handlers.delete(messageName);
+ }
+}
+
+/**
+ * Manages mappings of message managers to their corresponding message
+ * brokers. Brokers are lazily created for each message manager the
+ * first time they are accessed. In the case of content frame message
+ * managers, they are also automatically destroyed when the frame
+ * unload event fires.
+ */
+class FilteringMessageManagerMap extends Map {
+ // Unfortunately, we can't use a WeakMap for this, because message
+ // managers do not support preserved wrappers.
+
+ /**
+ * @param {string} messageName
+ * The native message name passed to `FilteringMessageManager` constructors.
+ * @param {Function} callback
+ * The message callback function passed to
+ * `FilteringMessageManager` constructors.
+ * @param {Function} [constructor = FilteringMessageManager]
+ * The constructor for the message manager class that we're
+ * mapping to.
+ */
+ constructor(messageName, callback, constructor = FilteringMessageManager) {
+ super();
+
+ this.messageName = messageName;
+ this.callback = callback;
+ this._constructor = constructor;
+ }
+
+ /**
+ * Returns, and possibly creates, a message broker for the given
+ * message manager.
+ *
+ * @param {nsIMessageListenerManager} target
+ * The message manager for which to return a broker.
+ *
+ * @returns {FilteringMessageManager}
+ */
+ get(target) {
+ let broker = super.get(target);
+ if (broker) {
+ return broker;
+ }
+
+ broker = new this._constructor(this.messageName, this.callback, target);
+ this.set(target, broker);
+
+ // XXXbz if target is really known to be a MessageListenerManager,
+ // do we need this isInstance check?
+ if (EventTarget.isInstance(target)) {
+ let onUnload = event => {
+ target.removeEventListener("unload", onUnload);
+ this.delete(target);
+ };
+ target.addEventListener("unload", onUnload);
+ }
+
+ return broker;
+ }
+}
+
+/**
+ * Represents a message being sent through a MessageChannel, which may
+ * or may not have been dispatched yet, and is pending a response.
+ *
+ * When a response has been received, or the message has been canceled,
+ * this class is responsible for settling the response promise as
+ * appropriate.
+ *
+ * @param {number} channelId
+ * The unique ID for this message.
+ * @param {any} message
+ * The message contents.
+ * @param {object} sender
+ * An object describing the sender of the message, used by
+ * `abortResponses` to determine whether the message should be
+ * aborted.
+ * @param {ResponseManager} broker
+ * The response broker on which we're expected to receive a
+ * reply.
+ */
+class PendingMessage {
+ constructor(channelId, message, sender, broker) {
+ this.channelId = channelId;
+ this.message = message;
+ this.sender = sender;
+ this.broker = broker;
+ this.deferred = Deferred();
+
+ MessageChannel.pendingResponses.add(this);
+ }
+
+ /**
+ * Cleans up after this message once we've received or aborted a
+ * response.
+ */
+ cleanup() {
+ if (this.broker) {
+ this.broker.removeHandler(this.channelId, this);
+ MessageChannel.pendingResponses.delete(this);
+
+ this.message = null;
+ this.broker = null;
+ }
+ }
+
+ /**
+ * Returns the promise which will resolve when we've received or
+ * aborted a response to this message.
+ */
+ get promise() {
+ return this.deferred.promise;
+ }
+
+ /**
+ * Resolves the message's response promise, and cleans up.
+ *
+ * @param {any} value
+ */
+ resolve(value) {
+ this.cleanup();
+ this.deferred.resolve(value);
+ }
+
+ /**
+ * Rejects the message's response promise, and cleans up.
+ *
+ * @param {any} value
+ */
+ reject(value) {
+ this.cleanup();
+ this.deferred.reject(value);
+ }
+
+ get messageManager() {
+ return this.broker.messageManager;
+ }
+
+ /**
+ * Returns the contents of the message to be sent over a message
+ * manager, and registers the response with our response broker.
+ *
+ * Returns null if the response has already been canceled, and the
+ * message should not be sent.
+ *
+ * @returns {any}
+ */
+ getMessage() {
+ let msg = null;
+ if (this.broker) {
+ this.broker.addHandler(this.channelId, this);
+ msg = this.message;
+ this.message = null;
+ }
+ return msg;
+ }
+}
+
+// Web workers has MessageChannel API, which is unrelated to this.
+// eslint-disable-next-line no-global-assign
+MessageChannel = {
+ init() {
+ Services.obs.addObserver(this, "message-manager-close");
+ Services.obs.addObserver(this, "message-manager-disconnect");
+
+ this.messageManagers = new FilteringMessageManagerMap(
+ MESSAGE_MESSAGES,
+ this._handleMessage.bind(this)
+ );
+
+ this.responseManagers = new FilteringMessageManagerMap(
+ MESSAGE_RESPONSE,
+ this._handleResponse.bind(this),
+ ResponseManager
+ );
+
+ /**
+ * @property {Set<Deferred>} pendingResponses
+ * Contains a set of pending responses, either waiting to be
+ * received or waiting to be sent.
+ *
+ * The response object must be a deferred promise with the following
+ * properties:
+ *
+ * promise:
+ * The promise object which resolves or rejects when the response
+ * is no longer pending.
+ *
+ * reject:
+ * A function which, when called, causes the `promise` object to be
+ * rejected.
+ *
+ * sender:
+ * A sender object, as passed to `sendMessage.
+ *
+ * messageManager:
+ * The message manager the response will be sent or received on.
+ *
+ * When the promise resolves or rejects, it must be removed from the
+ * list.
+ *
+ * These values are used to clear pending responses when execution
+ * contexts are destroyed.
+ */
+ this.pendingResponses = new Set();
+
+ /**
+ * @property {LimitedSet<string>} abortedResponses
+ * Contains the message name of a limited number of aborted response
+ * handlers, the responses for which will be ignored.
+ */
+ this.abortedResponses = new ExtensionUtils.LimitedSet(30);
+ },
+
+ RESULT_SUCCESS: 0,
+ RESULT_DISCONNECTED: 1,
+ RESULT_NO_HANDLER: 2,
+ RESULT_MULTIPLE_HANDLERS: 3,
+ RESULT_ERROR: 4,
+ RESULT_NO_RESPONSE: 5,
+
+ REASON_DISCONNECTED: {
+ result: 1, // this.RESULT_DISCONNECTED
+ message: "Message manager disconnected",
+ },
+
+ /**
+ * Specifies that only a single listener matching the specified
+ * recipient tag may be listening for the given message, at the other
+ * end of the target message manager.
+ *
+ * If no matching listeners exist, a RESULT_NO_HANDLER error will be
+ * returned. If multiple matching listeners exist, a
+ * RESULT_MULTIPLE_HANDLERS error will be returned.
+ */
+ RESPONSE_SINGLE: 0,
+
+ /**
+ * If multiple message managers matching the specified recipient tag
+ * are listening for a message, all listeners are notified, but only
+ * the first response or error is returned.
+ *
+ * Only handlers which return a value other than `undefined` are
+ * considered to have responded. Returning a Promise which evaluates
+ * to `undefined` is interpreted as an explicit response.
+ *
+ * If no matching listeners exist, a RESULT_NO_HANDLER error will be
+ * returned. If no listeners return a response, a RESULT_NO_RESPONSE
+ * error will be returned.
+ */
+ RESPONSE_FIRST: 1,
+
+ /**
+ * If multiple message managers matching the specified recipient tag
+ * are listening for a message, all listeners are notified, and all
+ * responses are returned as an array, once all listeners have
+ * replied.
+ */
+ RESPONSE_ALL: 2,
+
+ /**
+ * Fire-and-forget: The sender of this message does not expect a reply.
+ */
+ RESPONSE_NONE: 3,
+
+ /**
+ * Initializes message handlers for the given message managers if needed.
+ *
+ * @param {Array<nsIMessageListenerManager>} messageManagers
+ */
+ setupMessageManagers(messageManagers) {
+ for (let mm of messageManagers) {
+ // This call initializes a FilteringMessageManager for |mm| if needed.
+ // The FilteringMessageManager must be created to make sure that senders
+ // of messages that expect a reply, such as MessageChannel:Message, do
+ // actually receive a default reply even if there are no explicit message
+ // handlers.
+ this.messageManagers.get(mm);
+ }
+ },
+
+ /**
+ * Returns true if the properties of the `data` object match those in
+ * the `filter` object. Matching is done on a strict equality basis,
+ * and the behavior varies depending on the value of the `strict`
+ * parameter.
+ *
+ * @param {object?} filter
+ * The filter object to match against.
+ * @param {object} data
+ * The data object being matched.
+ * @param {boolean} [strict=true]
+ * If true, all properties in the `filter` object have a
+ * corresponding property in `data` with the same value. If
+ * false, properties present in both objects must have the same
+ * value.
+ * @returns {boolean} True if the objects match.
+ */
+ matchesFilter(filter, data, strict = true) {
+ if (!filter) {
+ return true;
+ }
+ if (strict) {
+ return Object.keys(filter).every(key => {
+ return key in data && data[key] === filter[key];
+ });
+ }
+ return Object.keys(filter).every(key => {
+ return !(key in data) || data[key] === filter[key];
+ });
+ },
+
+ /**
+ * Adds a message listener to the given message manager.
+ *
+ * @param {nsIMessageListenerManager|Array<nsIMessageListenerManager>} targets
+ * The message managers on which to listen.
+ * @param {string|number} messageName
+ * The name of the message to listen for.
+ * @param {MessageReceiver} handler
+ * The handler to dispatch to. Must be an object with the following
+ * properties:
+ *
+ * receiveMessage:
+ * A method which is called for each message received by the
+ * listener. The method takes one argument, an object, with the
+ * following properties:
+ *
+ * messageName:
+ * The internal message name, as passed to `sendMessage`.
+ *
+ * target:
+ * The message manager which received this message.
+ *
+ * channelId:
+ * The internal ID of the transaction, used to map responses to
+ * the original sender.
+ *
+ * sender:
+ * An object describing the sender, as passed to `sendMessage`.
+ *
+ * recipient:
+ * An object describing the recipient, as passed to
+ * `sendMessage`.
+ *
+ * data:
+ * The contents of the message, as passed to `sendMessage`.
+ *
+ * The method may return any structured-clone-compatible
+ * object, which will be returned as a response to the message
+ * sender. It may also instead return a `Promise`, the
+ * resolution or rejection value of which will likewise be
+ * returned to the message sender.
+ *
+ * messageFilterStrict:
+ * An object containing arbitrary properties on which to filter
+ * received messages. Messages will only be dispatched to this
+ * object if the `recipient` object passed to `sendMessage`
+ * matches this filter, as determined by `matchesFilter` with
+ * `strict=true`.
+ *
+ * messageFilterPermissive:
+ * An object containing arbitrary properties on which to filter
+ * received messages. Messages will only be dispatched to this
+ * object if the `recipient` object passed to `sendMessage`
+ * matches this filter, as determined by `matchesFilter` with
+ * `strict=false`.
+ *
+ * filterMessage:
+ * An optional function that prevents the handler from handling a
+ * message by returning `false`. See `getHandlers` for the parameters.
+ */
+ addListener(targets, messageName, handler) {
+ if (!Array.isArray(targets)) {
+ targets = [targets];
+ }
+ for (let target of targets) {
+ this.messageManagers.get(target).addHandler(messageName, handler);
+ }
+ },
+
+ /**
+ * Removes a message listener from the given message manager.
+ *
+ * @param {nsIMessageListenerManager|Array<nsIMessageListenerManager>} targets
+ * The message managers on which to stop listening.
+ * @param {string|number} messageName
+ * The name of the message to stop listening for.
+ * @param {MessageReceiver} handler
+ * The handler to stop dispatching to.
+ */
+ removeListener(targets, messageName, handler) {
+ if (!Array.isArray(targets)) {
+ targets = [targets];
+ }
+ for (let target of targets) {
+ if (this.messageManagers.has(target)) {
+ this.messageManagers.get(target).removeHandler(messageName, handler);
+ }
+ }
+ },
+
+ /**
+ * Sends a message via the given message manager. Returns a promise which
+ * resolves or rejects with the return value of the message receiver.
+ *
+ * The promise also rejects if there is no matching listener, or the other
+ * side of the message manager disconnects before the response is received.
+ *
+ * @param {nsIMessageSender} target
+ * The message manager on which to send the message.
+ * @param {string} messageName
+ * The name of the message to send, as passed to `addListener`.
+ * @param {object} data
+ * A structured-clone-compatible object to send to the message
+ * recipient.
+ * @param {object} [options]
+ * An object containing any of the following properties:
+ * @param {object} [options.recipient]
+ * A structured-clone-compatible object to identify the message
+ * recipient. The object must match the `messageFilterStrict` and
+ * `messageFilterPermissive` filters defined by recipients in order
+ * for the message to be received.
+ * @param {object} [options.sender]
+ * A structured-clone-compatible object to identify the message
+ * sender. This object may also be used to avoid delivering the
+ * message to the sender, and as a filter to prematurely
+ * abort responses when the sender is being destroyed.
+ * @see `abortResponses`.
+ * @param {boolean} [options.lowPriority = false]
+ * If true, treat this as a low-priority message, and attempt to
+ * send it in the same chunk as other messages to the same target
+ * the next time the event queue is idle. This option reduces
+ * messaging overhead at the expense of adding some latency.
+ * @param {integer} [options.responseType = RESPONSE_SINGLE]
+ * Specifies the type of response expected. See the `RESPONSE_*`
+ * contents for details.
+ * @returns {Promise}
+ */
+ sendMessage(target, messageName, data, options = {}) {
+ let sender = options.sender || {};
+ let recipient = options.recipient || {};
+ let responseType = options.responseType || this.RESPONSE_SINGLE;
+
+ let channelId = ExtensionUtils.getUniqueId();
+ let message = {
+ messageName,
+ channelId,
+ sender,
+ recipient,
+ data,
+ responseType,
+ };
+ data = null;
+
+ if (responseType == this.RESPONSE_NONE) {
+ try {
+ target.sendAsyncMessage(MESSAGE_MESSAGES, [message]);
+ } catch (e) {
+ // Caller is not expecting a reply, so dump the error to the console.
+ Cu.reportError(e);
+ return Promise.reject(e);
+ }
+ return Promise.resolve(); // Not expecting any reply.
+ }
+
+ let broker = this.responseManagers.get(target);
+ let pending = new PendingMessage(channelId, message, recipient, broker);
+ message = null;
+ try {
+ broker.sendMessage(pending, options);
+ } catch (e) {
+ pending.reject(e);
+ }
+ return pending.promise;
+ },
+
+ _callHandlers(handlers, data) {
+ let responseType = data.responseType;
+
+ // At least one handler is required for all response types but
+ // RESPONSE_ALL.
+ if (!handlers.length && responseType != this.RESPONSE_ALL) {
+ return Promise.reject({
+ result: MessageChannel.RESULT_NO_HANDLER,
+ message: "No matching message handler",
+ });
+ }
+
+ if (responseType == this.RESPONSE_SINGLE) {
+ if (handlers.length > 1) {
+ return Promise.reject({
+ result: MessageChannel.RESULT_MULTIPLE_HANDLERS,
+ message: `Multiple matching handlers for ${data.messageName}`,
+ });
+ }
+
+ // Note: We use `new Promise` rather than `Promise.resolve` here
+ // so that errors from the handler are trapped and converted into
+ // rejected promises.
+ return new Promise(resolve => {
+ resolve(handlers[0].receiveMessage(data));
+ });
+ }
+
+ let responses = handlers.map((handler, i) => {
+ try {
+ return handler.receiveMessage(data, i + 1 == handlers.length);
+ } catch (e) {
+ return Promise.reject(e);
+ }
+ });
+ data = null;
+ responses = responses.filter(response => response !== undefined);
+
+ switch (responseType) {
+ case this.RESPONSE_FIRST:
+ if (!responses.length) {
+ return Promise.reject({
+ result: MessageChannel.RESULT_NO_RESPONSE,
+ message: "No handler returned a response",
+ });
+ }
+
+ return Promise.race(responses);
+
+ case this.RESPONSE_ALL:
+ return Promise.all(responses);
+ }
+ return Promise.reject({ message: "Invalid response type" });
+ },
+
+ /**
+ * Handles dispatching message callbacks from the message brokers to their
+ * appropriate `MessageReceivers`, and routing the responses back to the
+ * original senders.
+ *
+ * Each handler object is a `MessageReceiver` object as passed to
+ * `addListener`.
+ *
+ * @param {Array<MessageHandler>} handlers
+ * @param {object} data
+ * @param {nsIMessageSender|{messageManager:nsIMessageSender}} data.target
+ */
+ _handleMessage(handlers, data) {
+ if (data.responseType == this.RESPONSE_NONE) {
+ handlers.forEach(handler => {
+ // The sender expects no reply, so dump any errors to the console.
+ new Promise(resolve => {
+ resolve(handler.receiveMessage(data));
+ }).catch(e => {
+ Cu.reportError(e.stack ? `${e}\n${e.stack}` : e.message || e);
+ });
+ });
+ data = null;
+ // Note: Unhandled messages are silently dropped.
+ return;
+ }
+
+ let target = getMessageManager(data.target);
+
+ let deferred = {
+ sender: data.sender,
+ messageManager: target,
+ channelId: data.channelId,
+ respondingSide: true,
+ };
+
+ let cleanup = () => {
+ this.pendingResponses.delete(deferred);
+ if (target.dispose) {
+ target.dispose();
+ }
+ };
+ this.pendingResponses.add(deferred);
+
+ deferred.promise = new Promise((resolve, reject) => {
+ deferred.reject = reject;
+
+ this._callHandlers(handlers, data).then(resolve, reject);
+ data = null;
+ })
+ .then(
+ value => {
+ let response = {
+ result: this.RESULT_SUCCESS,
+ messageName: deferred.channelId,
+ recipient: {},
+ value,
+ };
+
+ if (target.isDisconnected) {
+ // Target is disconnected. We can't send an error response, so
+ // don't even try.
+ return;
+ }
+ target.sendAsyncMessage(MESSAGE_RESPONSE, response);
+ },
+ error => {
+ if (target.isDisconnected) {
+ // Target is disconnected. We can't send an error response, so
+ // don't even try.
+ if (
+ error.result !== this.RESULT_DISCONNECTED &&
+ error.result !== this.RESULT_NO_RESPONSE
+ ) {
+ Cu.reportError(
+ Cu.getClassName(error, false) === "Object"
+ ? error.message
+ : error
+ );
+ }
+ return;
+ }
+
+ let response = {
+ result: this.RESULT_ERROR,
+ messageName: deferred.channelId,
+ recipient: {},
+ error: {},
+ };
+
+ if (error && typeof error == "object") {
+ if (error.result) {
+ response.result = error.result;
+ }
+ // Error objects are not structured-clonable, so just copy
+ // over the important properties.
+ for (let key of [
+ "fileName",
+ "filename",
+ "lineNumber",
+ "columnNumber",
+ "message",
+ "stack",
+ "result",
+ "mozWebExtLocation",
+ ]) {
+ if (key in error) {
+ response.error[key] = error[key];
+ }
+ }
+ }
+
+ target.sendAsyncMessage(MESSAGE_RESPONSE, response);
+ }
+ )
+ .then(cleanup, e => {
+ cleanup();
+ Cu.reportError(e);
+ });
+ },
+
+ /**
+ * Handles message callbacks from the response brokers.
+ *
+ * @param {MessageHandler?} handler
+ * A deferred object created by `sendMessage`, to be resolved
+ * or rejected based on the contents of the response.
+ * @param {object} data
+ * @param {nsIMessageSender|{messageManager:nsIMessageSender}} data.target
+ */
+ _handleResponse(handler, data) {
+ // If we have an error at this point, we have handler to report it to,
+ // so just log it.
+ if (!handler) {
+ if (this.abortedResponses.has(data.messageName)) {
+ this.abortedResponses.delete(data.messageName);
+ Services.console.logStringMessage(
+ `Ignoring response to aborted listener for ${data.messageName}`
+ );
+ } else {
+ Cu.reportError(
+ `No matching message response handler for ${data.messageName}`
+ );
+ }
+ } else if (data.result === this.RESULT_SUCCESS) {
+ handler.resolve(data.value);
+ } else {
+ handler.reject(data.error);
+ }
+ },
+
+ /**
+ * Aborts pending message response for the specific channel.
+ *
+ * @param {string} channelId
+ * A string for channelId of the response.
+ * @param {object} reason
+ * An object describing the reason the response was aborted.
+ * Will be passed to the promise rejection handler of the aborted
+ * response.
+ */
+ abortChannel(channelId, reason) {
+ for (let response of this.pendingResponses) {
+ if (channelId === response.channelId && response.respondingSide) {
+ this.pendingResponses.delete(response);
+ response.reject(reason);
+ }
+ }
+ },
+
+ /**
+ * Aborts any pending message responses to senders matching the given
+ * filter.
+ *
+ * @param {object} sender
+ * The object on which to filter senders, as determined by
+ * `matchesFilter`.
+ * @param {object} [reason]
+ * An optional object describing the reason the response was aborted.
+ * Will be passed to the promise rejection handler of all aborted
+ * responses.
+ */
+ abortResponses(sender, reason = this.REASON_DISCONNECTED) {
+ for (let response of this.pendingResponses) {
+ if (this.matchesFilter(sender, response.sender)) {
+ this.pendingResponses.delete(response);
+ this.abortedResponses.add(response.channelId);
+ response.reject(reason);
+ }
+ }
+ },
+
+ /**
+ * Aborts any pending message responses to the broker for the given
+ * message manager.
+ *
+ * @param {nsIMessageListenerManager} target
+ * The message manager for which to abort brokers.
+ * @param {object} reason
+ * An object describing the reason the responses were aborted.
+ * Will be passed to the promise rejection handler of all aborted
+ * responses.
+ */
+ abortMessageManager(target, reason) {
+ for (let response of this.pendingResponses) {
+ if (matches(response.messageManager, target)) {
+ this.abortedResponses.add(response.channelId);
+ response.reject(reason);
+ }
+ }
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "message-manager-close":
+ case "message-manager-disconnect":
+ try {
+ if (this.responseManagers.has(subject)) {
+ this.abortMessageManager(subject, this.REASON_DISCONNECTED);
+ }
+ } finally {
+ this.responseManagers.delete(subject);
+ this.messageManagers.delete(subject);
+ }
+ break;
+ }
+ },
+};
+
+MessageChannel.init();