blob: c3dbbdc87dec80382bc0f296399bde49b2f32d84 [file] [log] [blame]
// Copyright 2019 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
/**
* Initialization retry wait in milliseconds (subject to exponential backoff).
*/
const INITIALIZATION_ATTEMPT_RETRY_WAIT_MS = 100;
/**
* Maximum number of initialization attempts before resetting the
* initialization attempt cycle. With exponential backoff, this works out
* a maximum wait of 25 seconds on the 8th attempt before restarting.
*/
const MAX_INITIALIZATION_ATTEMPTS = 8;
/**
* Class that provides the functionality for talking to a client
* over the PostMessageAPI. This should be subclassed and the
* methods provided in methodList should be implemented as methods
* of the subclass.
*/
export class PostMessageAPIServer {
constructor(clientElement, methodList, targetURL, messageOriginURLFilter) {
/**
* The Window type element to which this server will listen for messages,
* probably a <webview>, but also could be an <iframe> or a browser window
* object.
* @private @const {!Element}
*/
this.clientElement_ = clientElement;
/**
* The guest URL embedded in the element above. Used for message targeting.
* This should be same as the URL loaded in the clientElement, i.e. the
* "src" attribute of a <webview>.
* @private @const {!URL}
*/
this.targetURL_ = new URL(targetURL);
/**
* Incoming messages received from origin URLs without this prefix
* will not be accepted. This should be used to restrict the API access
* to the intended guest content.
* @private @const {!URL}
*/
this.messageOriginURLFilter_ = new URL(messageOriginURLFilter);
/**
* Map that stores references to the methods implemented by the API.
* @private {!Map<string, function(!Array):?>}
*/
this.apiFns_ = new Map();
/**
* The ID of the timeout set before checking whether initialization has
* taken place yet.
* @private {number}
*/
this.initialization_timeout_id_ = 0;
/**
* Indicates how many attempts have been made to initialize the channel.
* @private {number}
*/
this.numInitializationAttempts_ = 0;
/**
* Indicates whether the communication channel between this server and the
* WebView has been established.
* @private {boolean}
*/
this.isInitialized_ = false;
// Wait for content to load before attempting to initializing the
// message listener.
this.clientElement_.addEventListener('contentload', () => {
this.numInitializationAttempts_ = 0;
this.isInitialized_ = false;
this.initialize();
});
// Listen for events.
window.addEventListener('message', (event) => {
this.onMessage_(event);
});
}
/**
* Registers the specified method name with the specified
* function.
*
* @param {!string} methodName name of the method to register.
* @param {!function(!Array):?} method The function to associate with the
* name.
*/
registerMethod(methodName, method) {
this.apiFns_.set(methodName, method);
}
/**
* Send initialization message to client element.
*/
initialize() {
if (this.isInitialized_ ||
!this.originMatchesFilter(this.clientElement_.src)) {
return;
}
if (this.numInitializationAttempts_ < MAX_INITIALIZATION_ATTEMPTS) {
// Tell the embedded webviews whose src matches our origin to initialize
// by sending it a message, which will include a handle for it to use to
// send messages back.
console.log(
'Sending init message to guest content, attempt # :' +
this.numInitializationAttempts_);
this.clientElement_.contentWindow.postMessage(
'init', this.targetURL_.toString());
// Set timeout to check if initialization message has been received using
// exponential backoff.
this.initialization_timeout_id_ = setTimeout(
() => {
// If the timeout id is non-zero, that indicates that initialization
// hasn't succeeded yet, so try to initialize again.
this.initialize();
},
INITIALIZATION_ATTEMPT_RETRY_WAIT_MS *
(2 ** this.numInitializationAttempts_));
this.numInitializationAttempts_++;
} else {
// Exponential backoff has maxed out. Show error page if present.
this.onInitializationError(this.clientElement_.src);
}
}
/**
* Virtual method to be overridden by implementations of this class to notify
* them that we were unable to initialize communication channel with the
* `clientElement_`.
*
* @param {!string} origin The origin URL that was not able to initialize
* communication.
*/
onInitializationError(origin) {}
/**
* Determines if the specified origin matches the origin filter.
* @param {!string} origin The origin URL to match with the filter.
* @return {boolean} whether the specified origin matches the filter.
*/
originMatchesFilter(origin) {
const originURL = new URL(origin);
// We allow the pathname portion of the URL to be a prefix filter,
// to permit for different paths communicating with this server.
return originURL.protocol == this.messageOriginURLFilter_.protocol &&
originURL.host == this.messageOriginURLFilter_.host &&
originURL.pathname.startsWith(this.messageOriginURLFilter_.pathname);
}
/**
* Handles postMessage events from the client.
* @private
* @param {Event} event The postMessage event to handle.
*/
onMessage_(event) {
if (!this.originMatchesFilter(event.origin)) {
console.log('Message received from unauthorized origin: ' + event.origin);
return;
}
if (event.data === 'init') {
if (this.initialization_timeout_id_) {
// Cancel the current init timeout, and signal to the initialization
// polling process that we have received an init message from the guest
// content, so it doesn't reschedule the timer.
clearTimeout(this.initialization_timeout_id_);
this.initialization_timeout_id_ = 0;
}
this.isInitialized_ = true;
return;
}
// If we have gotten this far, we have received a message from a trusted
// origin, and we should try to process it. We can't gate this on whether
// the channel is initialized, because we can receive events out of order,
// and method calls can be received before the init event. Essentially, we
// should treat the channel as being potentially as soon as we send 'init'
// to the guest content.
const methodId = event.data.methodId;
const fn = event.data.fn;
const args = event.data.args || [];
if (!this.apiFns_.has(fn)) {
console.error('Unknown function requested: ' + fn);
return;
}
const sendMessage = (methodId, result) => {
this.clientElement_.contentWindow.postMessage(
{
methodId: methodId,
result: result,
},
this.targetURL_.toString());
};
// Some methods return a promise and some don't. If we have a promise,
// we resolve it first, otherwise we send the result directly (e.g., for
// void functions we send 'undefined').
const result = this.apiFns_.get(fn)(args);
if (result instanceof Promise) {
result.then((result) => sendMessage(methodId, result));
} else {
sendMessage(methodId, result);
}
}
}
/**
* Class that provides the functionality for talking to a PostMessageAPIServer
* over the postMessage API. This should be subclassed and the methods in the
* server that the client needs to access should be provided in methodList.
*/
export class PostMessageAPIClient {
/**
* @param {!Array<string>} methodList The list of methods accessible via the
* client.
* @param {!string} serverOriginURLFilter Only messages from this origin
* will be accepted.
*/
constructor(methodList, serverOriginURLFilter) {
/**
* @private @const {!string} Filter to use to validate
* the origin of received messages. The origin of messages
* must exactly match this value.
*/
this.serverOriginURLFilter_ = serverOriginURLFilter;
/**
* The parent window.
* @private {Window}
*/
this.parentWindow_ = null;
/*
* @private {number}
*/
this.nextMethodId_ = 0;
/**
* Map of methods awaiting a response.
* @private {!Map}
*/
this.methodsAwaitingResponse_ = new Map;
/**
* Function property that tracks whether client has
* been initialized by the server.
* @private {Function}
*/
this.boundOnInitialize_ = null;
// Generate the client-callable methods.
this.generateAPIMethods_(methodList);
}
/*
* Generates methods for the specified method names.
* @private
* @param {!Array<string>} methods The names of the methods.
*/
generateAPIMethods_(methods) {
methods.forEach((method) => {
Object.getPrototypeOf(this)[method] = function(args) {
return this.callApiFn_(method, args);
};
});
}
initialize() {
this.boundOnInitialize_ = this.onInitialize_.bind(this);
// Wait for an init message from the server.
window.addEventListener('message', this.boundOnInitialize_);
}
//
// Private implementation:
//
/**
* Handles initialization event sent from the server to establish
* communication.
* @private
* @param {!Event} event An event received when the initialization message is
* sent from the server.
*/
onInitialize_(event) {
if (!this.originMatchesFilter(event.origin)) {
console.error(
'Initialization event received from non-authorized origin: ' +
event.origin);
return;
}
this.parentWindow_ = event.source;
window.removeEventListener('message', this.boundOnInitialize_);
this.boundOnInitialize_ = null;
window.addEventListener('message', this.onMessage_.bind(this));
}
/**
* Determine if the specified server origin URL matches the origin filter.
* @param {!string} origin The origin URL to match with the filter.
* @return {boolean} whether the specified origin matches the filter.
*/
originMatchesFilter(origin) {
return origin == this.serverOriginURLFilter_;
}
/**
* Handles postMessage events sent from the server.
* @param {Event} event An event received from the server via the postMessage
* API.
*/
onMessage_(event) {
if (!this.originMatchesFilter(event.origin)) {
console.error(
'Message received from non-authorized origin: ' + event.origin);
return;
}
if (event.source != this.parentWindow_) {
console.error('discarding event whose source is not the parent window');
return;
}
if (!this.methodsAwaitingResponse_.has(event.data.methodId)) {
console.error('discarding event method is not waiting for a response');
return;
}
let method = this.methodsAwaitingResponse_.get(event.data.methodId);
this.methodsAwaitingResponse_.delete(event.data.methodId);
method(event.data.result);
}
/**
* Converts a function call with arguments into a postMessage event
* and sends it to the server via the postMessage API.
* @param {string} fn The function to call.
* @param {!Array<Object>} args The arguments to pass to the function.
* @return {!Promise} A promise capturing the executing of the function.
*/
callApiFn_(fn, args) {
let newMethodId = this.nextMethodId_++;
let promise = new Promise((resolve, reject) => {
if (!this.parentWindow_) {
reject('No parent window defined');
}
this.parentWindow_.postMessage(
{
methodId: newMethodId,
fn: fn,
args: args,
},
'*');
this.methodsAwaitingResponse_.set(newMethodId, resolve);
});
return promise;
}
}