diff options
Diffstat (limited to '')
-rw-r--r-- | toolkit/components/extensions/MessageChannel.sys.mjs | 1169 |
1 files changed, 1169 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..d3f1b7dc85 --- /dev/null +++ b/toolkit/components/extensions/MessageChannel.sys.mjs @@ -0,0 +1,1169 @@ +/* -*- 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/. */ + +/** + * 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"; + +// ESLint can't tell that these are referenced, so tell it that they're +// exported to make it happy. +/* exported _deferredResult, _makeDeferred */ +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(); |