/**
* This module provides functionality for passing messages between the
* background page and content script environments. Messages between the
* environments are easily malformed, and minor errors in message handlers
* can have cascading effects. These problems can be quite difficult to debug.
* This module addresses these issues by providing a simple message type and
* type checking system on top of `browser.runtime.onMessage` and
* `browser.tabs.sendMessage`.
*
* ## Messages
* A message, for purposes of this module, must be an object and must have a
* type property with a string value.
*
* ## Schemas
* A schema, for purposes of this module, must be an object. Each property in
* the schema object is a property that is required in a corresponding message
* object. Each value in the schema object is a string that must match the
* `typeof` value for that property in a corresponding message.
*
* @module messaging
*/
import * as debugging from "./debugging.js";
import * as events from "./events.js";
/**
* @constant {debugging.debuggingLogger}
* @private
*/
const debugLog = debugging.getDebuggingLog("messaging");
/**
* A Map that stores message listeners. The keys are message types and the values
* are Sets of message listeners.
* @private
* @constant {Map<string,Set<Function>>}
*/
const messageRouter = new Map();
/**
* A Map that stores message schemas. The keys are message types and the values
* are schemas.
* @private
* @constant {Map<string,Object>}
*/
const messageSchemas = new Map();
/**
* Whether the module's `browser.runtime.onMessage` listener has been registered.
* @private
* @type {boolean}
* @default
*/
let initialized = false;
/**
* Validates that a message is an object with a type string.
* @param {Object} message - The message.
* @returns {boolean} Whether the message is an object with a type string.
* @private
*/
function validateMessageObject(message) {
// If the message does not have the right type, fail validation.
if ((typeof message !== "object") || (message === null)) {
debugLog(`Unable to validate message with type: ${typeof message}`);
return false;
}
// If there is no type string, fail validation.
if(!("type" in message) || (typeof message.type !== "string")) {
debugLog(`Unable to validate message object with missing type string: ${JSON.stringify(message)}`);
return false;
}
return true;
}
/**
* Validates a message against a registered schema. Assumes that the message is an object
* with a type string. If you cannot guarantee that, call `validateMessageObject` first.
* @param {Object} message - The message, which must be an object that matches the properties
* and types specified in the schema.
* @param {Object} [messageSchema] - The schema to use for validation. If no schema is
* specified, this function attempts to retrieve the registered schema for the message type.
* @returns {boolean} Whether the message successfully validated against the schema. Returns
* `false` if there is a schema mismatch or if there is no schema registered for the message
* type.
* @private
*/
function validateMessageAgainstSchema(message, messageSchema)
{
// If the caller doesn't specify a message schema, attempt to retrieve the registered schema.
if(messageSchema === undefined) {
messageSchema = messageSchemas.get(message.type);
if(messageSchema === undefined) {
debugLog(`No schema for message with type: ${message.type}`);
return false;
}
}
// Check the message against the schema.
for(const field in messageSchema) {
if (!(field in message) || (typeof message[field] !== messageSchema[field])) {
debugLog(`Mismatch between message and schema: ${JSON.stringify(message)}`);
return false;
}
}
return true;
}
/**
* A listener for `browser.runtime.onMessage` that routes messages to the right
* listener(s) based on message type. See the documentation for `browser.runtime.onMessage`
* for detail on the parameters.
* @returns {Promise} - An optional response to the message.
* @private
*/
function browserRuntimeListener(message, sender, sendResponse) {
let messageListeners, messageSchema, browserRuntimeReturnValue;
// If the message is not in an expected format, ignore it.
if(!validateMessageObject(message)) {
debugLog(`browser.runtime message with unexpected format: ${JSON.stringify(message)}`);
return;
}
// If the message does not have at least one registered listener, ignore it.
if ((messageListeners = messageRouter.get(message.type)) === undefined) {
debugLog(`browser.runtime message with no listener for message type: ${JSON.stringify(message)}`);
return;
}
// If there is a schema registered for this message type, check the message against the schema.
if(((messageSchema = messageSchemas.get(message.type)) !== undefined)
&& !validateMessageAgainstSchema(message, messageSchema)) {
debugLog(`browser.runtime message failed schema validation: ${JSON.stringify(message)}`);
return;
}
for (const messageListener of messageListeners) {
const messageListenerReturnValue = messageListener(message, sender, sendResponse);
if ((messageListenerReturnValue !== undefined) && (browserRuntimeReturnValue !== undefined))
debugLog(`Multiple listener return values for message type: ${message.type}`);
browserRuntimeReturnValue = messageListenerReturnValue;
}
return browserRuntimeReturnValue;
}
/**
* A listener for the `onMessage` event. See the documentation for
* `browser.runtime.onMessage` for additional detail on the parameters and
* using a `Promise` return value to send an asynchronous response.
* @callback onMessageListener
* @memberof module:messaging.onMessage
* @param {Object} message - The received message with a matching type string.
* @param {browser.runtime.MessageSender} sender - The sender of the message.
* @param {Function} sendResponse - A function that, when called, sends a
* response to the message.
* @returns {Promise|undefined}
*/
/**
* Add a listener for the `onMessage` event.
* @function addListener
* @memberof module:messaging.onMessage
* @param {onMessageListener} listener - The listener to add.
* @param {Object} options - Options for the listener.
* @param {string} options.type - A unique string that identifies the message type.
* @param {object} [options.schema] - A schema for validating messages with this type.
*/
/**
* Remove a listener for the `onMessage` event.
* @function removeListener
* @memberof module:messaging.onMessage
* @param {onMessageListener} listener - The listener to remove.
*/
/**
* Whether a specified listener has been added for the `onMessage` event.
* @function hasListener
* @memberof module:messaging.onMessage
* @param {onMessageListener} listener - The listener to check.
* @returns {boolean} Whether the listener has been added for the event.
*/
/**
* Whether the `onMessage` event has any listeners.
* @function hasAnyListeners
* @memberof module:messaging.onMessage
* @returns {boolean} Whether the event has any listeners.
*/
/**
* An event that fires when the background script environment receives a message, usually from
* a content script.
* @namespace
*/
export const onMessage = events.createEvent({
name: "webScience.messaging.onMessage",
addListenerCallback: (listener, options) => {
registerListener(options.type, listener, "schema" in options ? options.schema : undefined);
},
removeListenerCallback: (listener, options) => {
unregisterListener(options.type, listener);
},
notifyListenersCallback: () => { return false; }
});
/**
* Registers a message listener.
* @param {string} messageType - The type of message that triggers the listener.
* @param {Function} messageListener - The listener, which receives the same
* parameters as if it had been called by `browser.runtime.onMessage`, and that can
* return the same values as a listener to `browser.runtime.onMessage`.
* @param {Object} [messageSchema] - An optional schema to register for the message type.
* @private
*/
function registerListener(messageType, messageListener, messageSchema) {
if (!initialized) {
initialized = true;
browser.runtime.onMessage.addListener(browserRuntimeListener);
}
let messageListeners = messageRouter.get(messageType);
if (messageListeners === undefined) {
messageListeners = new Set();
messageRouter.set(messageType, messageListeners);
}
messageListeners.add(messageListener);
if(messageSchema !== undefined) {
registerSchema(messageType, messageSchema);
}
}
/**
* Unregisters a message listener.
* @param {string} messageType - The type of message that triggers the listener.
* @param {Function} messageListener - The listener.
* @private
*/
function unregisterListener(messageType, messageListener) {
const messageListeners = messageRouter.get(messageType);
if(messageListeners !== undefined) {
messageListeners.delete(messageListener);
if(messageListeners.size === 0) {
messageRouter.delete(messageType);
}
}
}
/**
* Registers a schema for a type of message.
* @param {string} messageType - The type of message that must follow the schema.
* @param {Object} messageSchema - An object where each field has a value that is
* a built-in type string.
*/
export function registerSchema(messageType, messageSchema) {
// Check whether the schema has already been registered
if(messageSchemas.has(messageType)) {
debugLog(`Multiple schemas for message type: ${messageType}`);
return;
}
messageSchemas.set(messageType, messageSchema);
}
/**
* Unregisters a schema for a type of message, if one is registered.
* @param {string} messageType - The type of message .
*/
export function unregisterSchema(messageType) {
messageSchemas.delete(messageType);
}
/**
* Sends a message to a tab after checking the message against the registered
* schema for the message type. Equivalent to calling `browser.tabs.sendMessage`
* with a `catch` handler after validating the message against the schema.
* @param {number} tabId - The ID of the tab that should receive the message.
* @param {Object} message - The contents of the message.
* @returns {Promise} - The same return value as `browser.tabs.sendMessage`,
* or a Promise that resolves to false if there was an errror sending the message.
*/
export function sendMessageToTab(tabId, message) {
// Validate the outbound message against the schema
if(!validateMessageObject(message) || !validateMessageAgainstSchema(message)) {
debugLog(`Attempted to send message that fails validation: ${JSON.stringify(message)}`);
return new Promise((resolve) => { resolve(false); });
}
return browser.tabs.sendMessage(tabId, message).catch((reason) => {
debugLog(`Unable to send message to tab: ${JSON.stringify(message), reason}`);
return false;
});
}