// Copyright 2020 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. */
interface MessageData {
/**
* The id of the message, this uniquely identifies a message
* and should only appear on the sent message and a response to that
* message.
*/
messageId: number;
/**
* 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;
/**
* The message being sent through the pipe, the structure of
* the object sent is implied by the type of the message.
*/
message: object;
}
/**
* The Object placed in MessageData.message (and thrown by the Promise returned
* by sendMessage) if an exception is caught on the receiving end.
* Note this must be a class (not an interface) whilst there are .js files
* importing `GenericErrorResponse`, otherwise the export is invisible.
*/
export class GenericErrorResponse {
name: string = '';
message: string = '';
stack: string = '';
}
/**
* 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.
*/
function serializeError(error: Partial<GenericErrorResponse>):
GenericErrorResponse {
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.
*/
type MessageHandler = (message: any) =>
object|undefined|void|Promise<object|undefined|void>;
/**
* 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<T = object> {
resolve!: (arg0: T|PromiseLike<T>) => void;
reject!: (reason: any) => void;
promise: Promise<T>;
constructor() {
this.promise = new Promise<T>((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.
* TODO(b/150650426): consolidate this better.
*/
export function assertCast<A extends object>(condition?: A|null): A {
if (!condition) {
throw new Error('Failed assertion');
}
return condition;
}
/**
* Enum for reserved message types used in generated messages.
*/
enum 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.
*/
function isGeneratedMessage(messageType: string): boolean {
// 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.
*/
function throwIfReserved(messageType: string) {
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 {
private readonly target_: Window;
private readonly targetOrigin_: string;
/**
* 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.
*/
rethrowErrors: boolean;
/**
* 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.
*/
logClientError = (object: unknown) => 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 readonly messageHandlers_ = new Map<string, MessageHandler>();
/**
* Maps a message id to a resolver.
*/
private readonly pendingMessages_ = new Map<number, NativeResolver>();
/**
* The id the next message the object sends will have.
*/
private nextMessageId_ = 0;
/**
* The message listener we attach to the window. We need a reference to the
* function for later removal.
*/
private readonly messageListener_ = (m: MessageEvent) =>
this.receiveMessage_(m);
/**
* Constructs a new message pipe to the `target` window which has the
* `targetOrigin` origin.
*
* @param target If not specified, the document tree will be
* queried for a iframe with src `targetOrigin` to target.
*/
constructor(
targetOrigin: string, target?: Window, rethrowErrors: boolean = true) {
if (!target) {
const frame = document.querySelector<HTMLIFrameElement>(
`iframe[src^='${targetOrigin}']`);
if (!frame || !frame.contentWindow) {
throw new Error('Unable to locate target content window.');
}
target = assertCast(frame.contentWindow);
}
this.target_ = target;
this.targetOrigin_ = targetOrigin;
this.rethrowErrors = rethrowErrors;
// 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.
*
*/
registerHandler(messageType: string, handler: MessageHandler) {
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.
*/
async sendMessage(messageType: string, message = {}): Promise<any> {
try {
return await this.sendMessageImpl(messageType, message);
} catch (errorResponse: any) {
// 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 async sendMessageImpl(messageType: string, message = {}):
Promise<object> {
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 handleMessageResponse_(
messageType: string, message: object, messageId: number) {
const {RESPONSE_TYPE, ERROR_TYPE} = ReservedMessageTypes;
const resolver = assertCast(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 async callHandlerForMessageType_(
messageType: string, message: object, messageId: number): Promise<void> {
const {RESPONSE_TYPE, ERROR_TYPE} = ReservedMessageTypes;
let response: object|undefined|void;
let error: Partial<GenericErrorResponse>|null = null;
let sawError = false;
try {
const handler = assertCast(this.messageHandlers_.get(messageType));
response = await handler(message);
} catch (err: any) {
// 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 receiveMessage_(e: MessageEvent) {
// 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 as MessageData;
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 postToTarget_(
messageType: string, message: object|undefined|void, messageId: number) {
const messageWrapper: MessageData = {
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_);
}
}