// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/** The data structure the message pipe sends and receives. */
class MessageData {
constructor() {
/**
* The id of the message, this uniquely identifies a message
* and should only appear on the sent message and a response to that
* message.
*
* @type {number}
*/
this.messageId;
/**
* The message type. Indicates the structure of the data in
* `message` and is set to special reserved strings when the message is
* an generated messaged used to communicate between message pipe
* instances.
*
* @type {string}
*/
this.type;
/**
* The message being sent through the pipe, the structure of
* the object sent is implied by the type of the message.
*
* @type {!Object}
*/
this.message;
}
}
/**
* The Object placed in MessageData.message (and thrown by the Promise returned
* by sendMessage) if an exception is caught on the receiving end.
* @typedef {{
* name: string,
* message: string,
* stack: string,
* }}
*/
let GenericErrorResponse;
/**
* Error object allowing attributes to be undefined.
* @typedef {{
* name: (string|undefined),
* message: (string|undefined),
* stack: (string|undefined),
* }}
*/
let DefensiveError;
/**
* To handle generic errors such as `DOMException` not being an `Error`
* defensively assign '' if the attribute is undefined. Without explicitly
* extracting fields, `Errors` are sent as `{}` across the pipe.
* @param {!DefensiveError} error
* @return {!GenericErrorResponse}
*/
function serializeError(error) {
return {
message: error.message || '',
name: error.name || '',
stack: error.stack || '',
};
}
/**
* The type of a message handler function which gets called when the message
* pipe receives a message.
*
* @typedef {function(!Object): (!Object|undefined|!Promise<!Object|undefined>)}
*/
let MessageHandler;
/**
* Creates a new JavaScript native Promise and captures its resolve and reject
* callbacks. The promise, resolve, and reject are available as properties.
* Inspired by goog.promise.NativeResolver.
*/
class NativeResolver {
constructor() {
/** @type {function(!Object): void} */
this.resolve;
/** @type {function(!Object): void} */
this.reject;
/** @type {!Promise<!Object>} */
this.promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
}
}
/**
* A simplified "assert" that casts away null types. Assumes preconditions that
* satisfy the assert have already been checked. Inspired by
* webui/resources/js/assert/assert.js. However, this file is used (and tested)
* verbatim in multiple repositories with different dependency management, so
* that's not used directly. TODO(b/150650426): consolidate this better.
*
* @template T
* @param {?T|undefined} condition
* @return {T} A non-null |condition|.
* @closurePrimitive {asserts.truthy}
* @suppress {reportUnknownTypes} because T is not sufficiently constrained.
*/
/* #export */ function assertCast(condition) {
if (!condition) {
throw new Error('Failed assertion');
}
return condition;
}
/**
* Enum for reserved message types used in generated messages.
*
* @enum {string}
*/
const ReservedMessageTypes = {
/**
* Indicates a autogenerated response message for a previously received
* message.
*/
RESPONSE_TYPE: '___response',
/**
* Indicates a autogenerated error message for a previously received
* message.
*/
ERROR_TYPE: '___error',
};
/**
* Checks if a provided message type indicates a generated message.
*
* @param {string} messageType
* @return {boolean}
*/
function isGeneratedMessage(messageType) {
// Any message type with three underscores before it should only be used
// in generated messages.
return messageType.substr(0, 3) === '___';
}
/**
* Checks a message type is not reserved by generated messages, if it is, throws
* a error indicating this to the user.
*
* @param {string} messageType
*/
function throwIfReserved(messageType) {
if (isGeneratedMessage(messageType)) {
throw new Error(`Unexpected reserved message type: '${messageType}'`);
}
}
/**
* The message pipe allows two windows to communicate in 1 direction without
* having to handle the internals. The caller can send messages to the other
* window and receive async responses.
*/
/* #export */ class MessagePipe {
/**
* Constructs a new message pipe to the `target` window which has the
* `targetOrigin` origin.
*
* @param {string} targetOrigin
* @param {!Window=} target If not specified, the document tree will be
* queried for a iframe with src `targetOrigin` to target.
* @param {boolean=} rethrowErrors
*/
constructor(targetOrigin, target, rethrowErrors = true) {
if (!target) {
const frame = /** @type {!HTMLIFrameElement} */ (
document.querySelector(`iframe[src^='${targetOrigin}']`));
if (!frame || !frame.contentWindow) {
throw new Error('Unable to locate target content window.');
}
target = assertCast(frame.contentWindow);
}
/** @private @const {!Window} */
this.target_ = target;
/** @private @const {string} */
this.targetOrigin_ = targetOrigin;
/**
* If true any errors thrown in a handler during message handling will be
* thrown again in addition to being sent over the pipe to the message
* sender. true by default.
*
* @type {boolean}
*/
this.rethrowErrors = rethrowErrors;
/**
* Client error logger. Mockable for tests that check for errors. This is
* only used to log errors generated from handlers. Logging occurs on both
* sides of the message pipe if rethrowErrors is set, otherwise only on
* the side that sent the message.
*/
this.logClientError = (/** * */ object) =>
console.error(JSON.stringify(object));
/**
* Maps a message type to a message handler, a function which takes in
* the message and returns a response message or a promise which resolves
* with a response message.
*
* @private @const {!Map<string, !MessageHandler>}
*/
this.messageHandlers_ = new Map();
/**
* Maps a message id to a resolver.
*
* @private @const {!Map<number, !NativeResolver>}
*/
this.pendingMessages_ = new Map();
/**
* The id the next message the object sends will have.
*
* @private
*/
this.nextMessageId_ = 0;
/**
* The message listener we attach to the window. We need a reference to the
* function for later removal.
*
* @private @const {function(!Event): void}
*/
this.messageListener_ = (m) => this.receiveMessage_(m);
// Make sure we aren't trying to send messages to ourselves.
console.assert(this.target_ !== window, 'target !== window');
window.addEventListener('message', this.messageListener_);
}
/**
* Registers a handler to be called when a message of type `messageType` is
* received. The return value of this handler will automatically be sent to
* the message source as a response message. If the handler should throw an
* error while handling a message, the error message will be caught and sent
* to the message source automatically.
* NOTE: The message type can not be prefixed with 3 underscores as that is
* reserved for generated messages. i.e `___hello` is disallowed.
*
* @param {string} messageType
* @param {!MessageHandler} handler
*/
registerHandler(messageType, handler) {
throwIfReserved(messageType);
if (this.messageHandlers_.has(messageType)) {
throw new Error(`A handler already exists for ${messageType}`);
}
this.messageHandlers_.set(messageType, handler);
}
/**
* Wraps `sendMessageImpl()` catching errors from the target context to throw
* more useful errors with the current context stacktrace attached.
*
* @param {string} messageType
* @param {!Object=} message
* @return {!Promise<!Object>}
*/
async sendMessage(messageType, message = {}) {
try {
return await this.sendMessageImpl(messageType, message);
} catch (/** @type {!GenericErrorResponse} */ errorResponse) {
// Create an error with the name of the IPC function invoked, append the
// stacktrace from the target context (origin of the error) with the
// stacktrace of the current context.
const error = new Error(`${messageType}: ${errorResponse.message}`);
error.name = errorResponse.name || 'Unknown Error';
error.stack +=
`\nError from ${this.targetOrigin_}\n${errorResponse.stack}`;
// TODO(b/156205603): use internal `chrome.crashReportPrivate.reportError`
// to log this error.
throw error;
}
}
/**
* Sends a message to the target window and return a Promise that will resolve
* on response. If the target handler does not send a response the promise
* will resolve with a empty object.
*
* @private
* @param {string} messageType
* @param {!Object=} message
* @return {!Promise<!Object>}
*/
async sendMessageImpl(messageType, message = {}) {
throwIfReserved(messageType);
const messageId = this.nextMessageId_++;
const resolver = new NativeResolver();
this.pendingMessages_.set(messageId, resolver);
this.postToTarget_(messageType, message, messageId);
return resolver.promise;
}
/**
* Removes all listeners this object attaches to window in preparation for
* destruction.
*/
detach() {
window.removeEventListener('message', this.messageListener_);
}
/**
* Handles a message which represents the targets response to a previously
* sent message.
*
* @private
* @param {string} messageType
* @param {!Object} message
* @param {number} messageId
*/
handleMessageResponse_(messageType, message, messageId) {
const {RESPONSE_TYPE, ERROR_TYPE} = ReservedMessageTypes;
const resolver = this.pendingMessages_.get(messageId);
if (messageType === RESPONSE_TYPE) {
resolver.resolve(message);
} else if (messageType === ERROR_TYPE) {
this.logClientError(message);
resolver.reject(message);
} else {
console.error(`Response for message ${
messageId} received with invalid message type ${messageType}`);
}
this.pendingMessages_.delete(messageId);
}
/**
* Calls the relevant handler for a received message and generates the right
* response message to send back to the source.
*
* @private
* @param {string} messageType
* @param {!Object} message
* @param {number} messageId
* @return {!Promise<void>}
*/
async callHandlerForMessageType_(messageType, message, messageId) {
const {RESPONSE_TYPE, ERROR_TYPE} = ReservedMessageTypes;
/** @type {!Object|undefined} */
let response;
/** @type {?DefensiveError} */
let error = null;
/** @type {boolean} */
let sawError = false;
try {
response = await this.messageHandlers_.get(messageType)(message);
} catch (/** @type {!DefensiveError} */ err) {
// If an error happened capture the error and send it back.
sawError = true;
error = err;
response = serializeError(err);
}
this.postToTarget_(
sawError ? ERROR_TYPE : RESPONSE_TYPE, response, messageId);
if (sawError && this.rethrowErrors) {
// Rethrow the error so the current frame has visibility on its handler
// failures.
this.logClientError(error);
throw error;
}
}
/**
* @private
* @param {!Event} event
*/
receiveMessage_(event) {
const e = /** @type {!MessageEvent<!MessageData>} */ (event);
// Ignore message events missing a type.
if (typeof e.data !== 'object' || !e.data ||
typeof e.data.type !== 'string') {
return;
}
const {messageId, type, message} = e.data;
const {ERROR_TYPE} = ReservedMessageTypes;
// Ignore any messages that are not from the target origin unless we are
// explicitly accepting messages from any origin.
if (e.origin !== this.targetOrigin_ && this.targetOrigin_ !== '*') {
return;
}
// The case that the message is a response to a previously sent message.
if (isGeneratedMessage(type) && this.pendingMessages_.has(messageId)) {
this.handleMessageResponse_(type, message, messageId);
return;
}
if (isGeneratedMessage(type)) {
// Currently all generated messages are only sent in a response, so should
// have been handled above.
console.error(`Response with type ${type} for unknown message received.`);
return;
}
if (!this.messageHandlers_.has(type)) {
// If there is no listener for this event send a error message to source.
const error =
new Error(`No handler registered for message type '${type}'`);
const errorResponse = serializeError(error);
this.postToTarget_(ERROR_TYPE, errorResponse, messageId);
return;
}
this.callHandlerForMessageType_(type, message, messageId);
}
/**
* @private
* @param {string} messageType
* @param {!Object|undefined} message
* @param {number} messageId
*/
postToTarget_(messageType, message, messageId) {
const messageWrapper = {
messageId,
type: messageType,
message: message || {},
};
// The next line should probably be passing a transfer argument, but that
// causes Chrome to send a "null" message. The transfer seems to work
// without the third argument (but inefficiently, perhaps).
this.target_.postMessage(messageWrapper, this.targetOrigin_);
}
}