chromium/ash/webui/system_apps/public/js/message_pipe.ts

// 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_);
  }
}