Commit 9d11dc86 authored by Eduardo Sanz García's avatar Eduardo Sanz García Committed by Eduardo

Implement `PortProvider` and `PortFinder`

The combination of these two classes enable the discovery and
establishment of `MessageChannel` communication between pairs of frames.

It works with cross-origin frames.

It is the last step of this epic: https://github.com/hypothesis/client/issues/3533

Closes: https://github.com/hypothesis/client/issues/3695
parent 329aaac7
import { ListenerCollection } from './listener-collection';
import { isMessageEqual, SOURCE as source } from './port-util';
const MAX_WAIT_FOR_PORT = 1000 * 30;
const POLLING_INTERVAL_FOR_PORT = 500;
/**
* @typedef {import('../types/annotator').Destroyable} Destroyable
* @typedef {import('./port-util').Message} Message
* @typedef {Message['channel']} Channel
* @typedef {Message['port']} Port
*/
/**
* PortFinder class should be used in frames that are not the `host` frame. It
* helps to discover `MessagePort` on a specific channel.
*
* Channel nomenclature is `[frame1]-[frame2]` so that:
* - `port1` should be owned by/transferred to `frame1`, and
* - `port2` should be owned by/transferred to `frame2`
*
* There should be the same amount of listener in this class as in PortProvider.
*
* @implements Destroyable
*/
export class PortFinder {
constructor() {
this._listeners = new ListenerCollection();
}
// Two important characteristics of `MessagePort`:
// - it can only be used by one frame; the port is neutered if, after started to
// be used to receive messages, the port is transferred to a different frame.
// - messages are queued until the other port is ready to listen (`port.start()`)
/**
* `guest-host` communication
* @typedef {{channel: 'guest-host', hostFrame: Window, port: 'guest'}} options0
*
* `guest-sidebar` communication
* @typedef {{channel: 'guest-sidebar', hostFrame: Window, port: 'guest'}} options1
*
* `host-sidebar` communication
* @typedef {{channel: 'host-sidebar', hostFrame: Window, port: 'sidebar'}} options2
*
* `notebook-sidebar` communication
* @typedef {{channel: 'notebook-sidebar', hostFrame: Window, port: 'notebook'}} options3
*
* @param {options0|options1|options2|options3} options
* @return {Promise<MessagePort>}
*/
discover(options) {
const { channel, port } = options;
return new Promise((resolve, reject) => {
if (
(channel === 'guest-host' && port === 'guest') ||
(channel === 'guest-sidebar' && port === 'guest') ||
(channel === 'host-sidebar' && port === 'sidebar') ||
(channel === 'notebook-sidebar' && port === 'notebook')
) {
this._requestPort({
...options,
reject,
resolve,
});
return;
}
reject(new Error('Invalid request of channel/port'));
});
}
/**
* @typedef RequestPortOptions
* @prop {Channel} channel - requested channel
* @prop {Window} hostFrame - the frame where the hypothesis client is loaded.
* It is used to send a `window.postMessage`.
* @prop {Port} port - requested port
* @prop {(reason: Error) => void} reject - execute the `Promise.reject` in case
* the `host` frame takes too long to answer the request.
* @prop {(port: MessagePort) => void} resolve - execute the `Promise.resolve`
* when `host` frame successfully answers the request.
*/
/**
* Register a listener for the port `offer` and sends a request for one port.
*
* @param {RequestPortOptions} options
*/
_requestPort({ channel, hostFrame, port, reject, resolve }) {
function postRequest() {
hostFrame.postMessage({ channel, port, source, type: 'request' }, '*');
}
const intervalId = window.setInterval(
() => postRequest(),
POLLING_INTERVAL_FOR_PORT
);
// The `host` frame maybe busy, that's why we should wait.
const timeoutId = window.setTimeout(() => {
clearInterval(intervalId);
reject(
new Error(`Unable to find '${port}' port on '${channel}' channel`)
);
}, MAX_WAIT_FOR_PORT);
// TODO: It would be nice to remove the listener after receiving the port.
this._listeners.add(window, 'message', event =>
this._handlePortOffer(/** @type {MessageEvent} */ (event), {
intervalId,
message: { channel, port, source, type: 'offer' },
resolve,
timeoutId,
})
);
postRequest();
}
/**
* Resolve with a MessagePort when the `offer` message matches.
*
* @param {MessageEvent} event
* @param {object} options
* @param {Message} options.message
* @param {(port: MessagePort) => void} options.resolve
* @param {number} options.timeoutId
* @param {number} [options.intervalId]
*/
_handlePortOffer(
{ data, ports },
{ message, resolve, timeoutId, intervalId }
) {
if (isMessageEqual(data, message)) {
clearInterval(intervalId);
clearTimeout(timeoutId);
resolve(ports[0]);
}
}
destroy() {
this._listeners.removeAll();
}
}
import { ListenerCollection } from './listener-collection';
import { isMessageEqual, SOURCE as source } from './port-util';
/**
* @typedef {import('../types/config').SidebarConfig} SidebarConfig
* @typedef {import('../types/annotator').Destroyable} Destroyable
* @typedef {import('./port-util').Message} Message
* @typedef {Message['channel']} Channel
* @typedef {Message['port']} Port
*/
/**
* PortProvider creates a `MessageChannel` for the communication between two
* frames.
*
* There are 4 types of frames:
* - `host`: frame where the hypothesis client is initially loaded.
* - `guest/s`: frame/s with annotatable content. In some instances the `guest`
* frame can be the same as the `host` frame, in other cases, it is an iframe
* where either (1) the hypothesis client has been injected or (2) the
* hypothesis client has been configured to act exclusively as a `guest` (not
* showing the sidebar).
* - `notebook`: it is an another hypothesis app that runs on a separate iframe.
* - `sidebar`: the main hypothesis app. It runs on an iframe on a different
* origin than the host and is responsible for the communication with the
* backend (fetching and saving annotations).
*
* This layout represents the current arrangement of frames:
*
* `host` frame
* |-> `sidebar` iframe
* |-> `notebook` iframe
* |-> [`guest` iframe/s]
*
* Currently, we support the communication between the following pair of frames:
* - `guest-host`
* - `guest-sidebar`
* - `host-sidebar`
* - `notebook-sidebar`
*
* `PortProvider` is used only on the `host` frame. The rest of the frames use the
* companion class, `PortFinder`. `PortProvider` creates a `MessageChannel`
* for two frames to communicate with each other. It also listens to requests for
* particular `MessagePort` and dispatches the corresponding `MessagePort`.
*
*
* PortProvider | PortFinder
* -------------------------------------------------------------------|------------------------------------------------------
*
* 2. listens to requests of `MessagePort` <---------------------------- 1. request a `MessagePort` using `window#postMessage`
* |
* V
* 3. sends offers of `MessagePort` using `event#source#postMessage` ---> 4. listens to offers of `MessagePort`
* |
* V
* 5. send reciprocal port to the `sidebar` frame using the `host-sidebar`
*
*
* In some situations, because `guest` iframe/s load in parallel to the `host`
* frame, we can not assume that the code in the `host` frame is executed before
* the code in a `guest` frame. Hence, we can't assume that `PortProvider` (in
* the `host` frame) is initialized before `PortFinder` (in the `guest` frame).
* Therefore, for the `PortFinder`, we implement a polling strategy (sending a
* message every N milliseconds) until a response is received.
*
* Channel nomenclature is `[frame1]-[frame2]` so that:
* - `port1` should be owned by/transferred to `frame1`, and
* - `port2` should be owned by/transferred to `frame2`
*
* @implements Destroyable
*/
export class PortProvider {
/**
* @param {string} hypothesisAppsURL
*/
constructor(hypothesisAppsURL) {
this._hypothesisAppsOrigin = new URL(hypothesisAppsURL).origin;
// Although some channels (v.gr. `notebook-sidebar`) have only one
// `MessageChannel`, other channels (v.gr. `guest-sidebar`) can have multiple
// `MessageChannel`s. In spite of the number channel, we store all
// `MessageChannel` on a `Map<Window, MessageChannel>`.
/** @type {Map<Channel, Map<Window, MessageChannel>>} */
this._channels = new Map();
// Create the `host-sidebar` channel immediately, while other channels are
// created on demand
this._hostSidebarChannel = new MessageChannel();
this._channels.set(
'host-sidebar',
new Map([[window, this._hostSidebarChannel]])
);
this._listeners = new ListenerCollection();
this._listen();
}
/**
* @param {'onHostPortRequest'} _eventName
* @param {(MessagePort, channel: 'guest') => void} handler - this handler
* fires when a request for the 'guest-host' channel is listened.
*/
addEventListener(_eventName, handler) {
this._onHostPortRequest = handler;
}
/**
* Returns a port from a channel. Currently, only returns the `host` port from
* the `host-Sidebar` channel. Otherwise, it returns `null`.
*
* @param {object} options
* @param {'host-sidebar'} options.channel
* @param {'host'} options.port
*/
getPort({ channel, port }) {
if (channel === 'host-sidebar' && port === 'host') {
return this._hostSidebarChannel.port1;
}
return null;
}
/**
* Initiate the listener of port requests by other frames.
*/
_listen() {
/** @type {Array<{allowedOrigin: string, channel: Channel, port: Port}>} */
([
{
allowedOrigin: '*',
channel: 'guest-host',
port: 'guest',
},
{
allowedOrigin: '*',
channel: 'guest-sidebar',
port: 'guest',
},
{
allowedOrigin: this._hypothesisAppsOrigin,
channel: 'host-sidebar',
port: 'sidebar',
},
{
allowedOrigin: this._hypothesisAppsOrigin,
channel: 'notebook-sidebar',
port: 'notebook',
},
]).forEach(({ allowedOrigin, channel, port }) => {
this._listeners.add(window, 'message', event =>
this._handlePortRequest(/** @type {MessageEvent} */ (event), {
allowedMessage: {
channel,
port,
source,
type: 'request',
},
allowedOrigin,
})
);
});
}
/**
* @typedef Options
* @prop {Message} allowedMessage - the request `MessageEvent` must match this
* object to grant the port.
* @prop {string} allowedOrigin - the origin in the `MessageEvent` must match
* this value to grant the port. If '*' allow all origins.
*/
/**
* Checks the `postMessage` origin and message.
*
* @param {MessageEvent} event
* @param {Options} options
*/
_isValidRequest({ data, origin, source }, { allowedMessage, allowedOrigin }) {
if (allowedOrigin !== '*' && origin !== allowedOrigin) {
return false;
}
if (
// `source` can be of type Window | MessagePort | ServiceWorker.
// The simple check `source instanceof Window`` doesn't work here.
// Alternatively, `source` could be casted `/** @type{Window} */ (source)`
!source ||
source instanceof MessagePort ||
source instanceof ServiceWorker
) {
return false;
}
if (!isMessageEqual(data, allowedMessage)) {
return false;
}
return true;
}
/**
* Send (1) the requested port via `frame#postMessage` (the origin is set
* to match the allowedOrigin) and (2) the reciprocal port, if one is provided,
* to the `sidebar` frame using `host-sidebar(channel).host(port)#postMessage`
*
* @param {MessageEvent} event
* @param {Options & {port: MessagePort, reciprocalPort? : MessagePort}} options
*/
_sendPort(event, { allowedMessage, port, reciprocalPort }) {
const message = { ...allowedMessage, type: 'offer' };
const source = /** @type {Window} */ (event.source);
source.postMessage(message, event.origin, [port]);
if (reciprocalPort) {
if (['notebook-sidebar', 'guest-sidebar'].includes(message.channel)) {
this._hostSidebarChannel.port1.postMessage(message, [reciprocalPort]);
}
if (message.channel === 'guest-host' && message.port === 'guest') {
this._onHostPortRequest?.(reciprocalPort, message.port);
}
}
}
/**
* Respond to request of ports on channels.
* @param {MessageEvent} event
* @param {Options} options
*/
_handlePortRequest(event, options) {
if (!this._isValidRequest(event, options)) {
return;
}
const { channel, port } = options.allowedMessage;
// Check if channel has already been created. `host-sidebar` channel is an
// special case, because is created in the constructor.
if (channel === 'host-sidebar' && port === 'sidebar') {
this._sendPort(event, {
...options,
port: this._hostSidebarChannel.port2,
});
return;
}
let windowChannelMap = this._channels.get(channel);
if (!windowChannelMap) {
windowChannelMap = new Map();
this._channels.set(channel, windowChannelMap);
}
const source = /** @type {Window} */ (event.source);
let messageChannel = windowChannelMap.get(source);
if (!messageChannel) {
messageChannel = new MessageChannel();
windowChannelMap.set(source, messageChannel);
}
const { port1, port2 } = messageChannel;
this._sendPort(event, { ...options, port: port1, reciprocalPort: port2 });
}
destroy() {
this._listeners.removeAll();
}
}
// Because there are many `postMessages` on the `host` frame, the SOURCE property
// is added to the hypothesis `postMessages` to identify the provenance of the
// message and avoid listening to messages that could have the same properties
// but different source. This is not a is not a security feature but an
// anti-collision mechanism.
export const SOURCE = 'hypothesis';
/**
* @typedef {'guest-host'|'guest-sidebar'|'host-sidebar'|'notebook-sidebar'} Channel
* @typedef {'guest'|'host'|'notebook'|'sidebar'} Port
*
* @typedef Message
* @prop {Channel} channel
* @prop {Port} port
* @prop {'offer'|'request'} type
* @prop {SOURCE} source
*/
/**
* The function checks if the data conforms to the expected format. It returns
* `true` if all the properties are including the correct value in the `source`
* property, otherwise it returns `false`.
*
* @param {any} data
* @return {data is Message}
*/
function isMessageValid(data) {
if (!data || typeof data !== 'object') {
return false;
}
for (let property of ['channel', 'port', 'source', 'type']) {
if (
data.hasOwnProperty(property) === false ||
typeof data[property] !== 'string'
) {
return false;
}
}
return data.source === SOURCE;
}
/**
* Compares a `postMessage` data to one `Message`
*
* @param {any} data
* @param {Message} message
*/
export function isMessageEqual(data, message) {
if (!isMessageValid(data)) {
return false;
}
try {
return (
JSON.stringify(data, Object.keys(data).sort()) ===
JSON.stringify(message, Object.keys(message).sort())
);
} catch (error) {
return false;
}
}
import { delay } from '../../test-util/wait';
import { PortFinder } from '../port-finder';
import { SOURCE as source } from '../port-util';
describe('PortFinder', () => {
let portFinder;
function sendMessage({ data, ports = [] }) {
const event = new MessageEvent('message', {
data,
ports,
});
window.dispatchEvent(event);
return event;
}
beforeEach(() => {
sinon.stub(window, 'postMessage');
portFinder = new PortFinder();
});
afterEach(() => {
window.postMessage.restore();
portFinder.destroy();
});
[
{ channel: 'invalid', port: 'guest' },
{ channel: 'guest-host', port: 'invalid' },
].forEach(({ channel, port }) =>
it('rejects if requesting an invalid port', async () => {
let error;
try {
await portFinder.discover({
channel,
hostFrame: window,
port,
});
} catch (e) {
error = e;
}
assert.equal(error.message, 'Invalid request of channel/port');
})
);
[
{ channel: 'guest-host', port: 'guest' },
{ channel: 'guest-sidebar', port: 'guest' },
{ channel: 'host-sidebar', port: 'sidebar' },
{ channel: 'notebook-sidebar', port: 'notebook' },
].forEach(({ channel, port }) =>
it('resolves if requesting a valid port', async () => {
const { port1 } = new MessageChannel();
let resolvedPort;
portFinder
.discover({
channel,
hostFrame: window,
port,
})
.then(port => (resolvedPort = port));
sendMessage({
data: { channel, port, source, type: 'offer' },
ports: [port1],
});
await delay(0);
assert.instanceOf(resolvedPort, MessagePort);
})
);
it("timeouts if host doesn't respond", async () => {
let error;
const channel = 'host-sidebar';
const port = 'sidebar';
const clock = sinon.useFakeTimers();
try {
portFinder
.discover({
channel,
hostFrame: window,
port,
})
.catch(e => (error = e));
clock.tick(30000);
} finally {
clock.restore();
}
assert.callCount(window.postMessage, 61);
assert.alwaysCalledWithExactly(
window.postMessage,
{ channel, port, source, type: 'request' },
'*'
);
await delay(0);
assert.equal(
error.message,
"Unable to find 'sidebar' port on 'host-sidebar' channel"
);
});
describe('#destroy', () => {
it('ignores `offer` messages of ports', async () => {
let error;
const channel = 'host-sidebar';
const port = 'sidebar';
const { port1 } = new MessageChannel();
const clock = sinon.useFakeTimers();
try {
portFinder
.discover({
channel,
hostFrame: window,
port,
})
.catch(e => (error = e));
portFinder.destroy();
sendMessage({
data: { channel, port, source, type: 'offer' },
ports: [port1],
});
clock.tick(30000);
} finally {
clock.restore();
}
await delay(0);
assert.equal(
error.message,
"Unable to find 'sidebar' port on 'host-sidebar' channel"
);
});
});
});
import { delay } from '../../test-util/wait';
import { PortProvider } from '../port-provider';
import { SOURCE as source } from '../port-util';
describe('PortProvider', () => {
let portProvider;
function sendMessage({
data,
origin = window.location.origin,
source = window,
}) {
const event = new MessageEvent('message', {
data,
origin,
source,
});
window.dispatchEvent(event);
return event;
}
beforeEach(() => {
sinon.stub(window, 'postMessage');
portProvider = new PortProvider(window.location.href);
});
afterEach(() => {
window.postMessage.restore();
portProvider.destroy();
});
describe('#getPort', () => {
it('returns `null` if called with wrong arguments', () => {
let hostPort;
// Incorrect channel
hostPort = portProvider.getPort({
channel: 'notebook-sidebar',
port: 'host',
});
assert.isNull(hostPort);
// Incorrect port
hostPort = portProvider.getPort({
channel: 'host-sidebar',
port: 'sidebar',
});
assert.isNull(hostPort);
});
it('returns the `host` port of the `host-sidebar` channel if called with the right arguments', () => {
const hostPort = portProvider.getPort({
channel: 'host-sidebar',
port: 'host',
});
assert.exists(hostPort);
});
});
describe('#destroy', () => {
it('ignores valid port request if `PortFinder` has been destroyed', async () => {
portProvider.destroy();
sendMessage({
data: {
channel: 'host-sidebar',
port: 'sidebar',
source,
type: 'request',
},
});
await delay(0);
assert.notCalled(window.postMessage);
});
});
describe('listens for port requests', () => {
it('ignores port requests with invalid sources', async () => {
const data = {
channel: 'host-sidebar',
port: 'sidebar',
source,
type: 'request',
};
sendMessage({
data,
source: null,
});
sendMessage({
data,
source: new MessageChannel().port1,
});
await delay(0);
assert.notCalled(window.postMessage);
});
it('ignores port request with invalid message data', async () => {
sendMessage({
data: {
channel: 'dummy1-dummy2', // invalid channel
port: 'sidebar',
source,
type: 'request',
},
});
sendMessage({
data: null,
});
await delay(0);
assert.notCalled(window.postMessage);
});
it('ignores port request with invalid message origin', async () => {
const data = {
channel: 'host-sidebar',
port: 'sidebar',
source,
type: 'request',
};
sendMessage({
data,
origin: 'https://dummy.com',
});
await delay(0);
assert.notCalled(window.postMessage);
});
it('responds to a valid port request', async () => {
const data = {
channel: 'host-sidebar',
port: 'sidebar',
source,
type: 'request',
};
sendMessage({
data,
});
await delay(0);
assert.calledWith(
window.postMessage,
{ ...data, type: 'offer' },
window.location.origin,
[sinon.match.instanceOf(MessagePort)]
);
});
it('sends the reciprocal port of the `guest-sidebar` channel (via the sidebar port)', async () => {
sendMessage({
data: {
channel: 'host-sidebar',
port: 'sidebar',
source,
type: 'request',
},
});
await delay(0);
const [sidebarPort] = window.postMessage.getCall(0).args[2];
const handler = sinon.stub();
sidebarPort.onmessage = handler;
const data = {
channel: 'guest-sidebar',
port: 'guest',
source,
type: 'request',
};
sendMessage({
data,
});
await delay(0);
assert.calledWith(
handler,
sinon.match({
data: { ...data, type: 'offer' },
})
);
});
it('sends the reciprocal port of the `guest-host` channel (via listener)', async () => {
const handler = sinon.stub();
portProvider.addEventListener('onHostPortRequest', handler);
const data = {
channel: 'guest-host',
port: 'guest',
source,
type: 'request',
};
sendMessage({
data,
});
await delay(0);
assert.calledWith(handler, sinon.match.instanceOf(MessagePort), 'guest');
});
});
});
import { isMessageEqual, SOURCE as source } from '../port-util';
describe('port-util', () => {
describe('isMessageEqual', () => {
[
{
data: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
},
message: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
},
expectedResult: true,
reason: 'data matches the message',
},
{
data: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
},
message: {
source,
type: 'offer',
channel: 'host-sidebar',
port: 'guest',
},
expectedResult: true,
reason: 'data matches the message (properties in different order)',
},
{
data: null,
message: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
},
expectedResult: false,
reason: 'data is null',
},
{
data: {
channel: 'host-sidebar',
port: 9, // wrong type
source,
type: 'offer',
},
message: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
},
expectedResult: false,
reason: 'data has a property with the wrong type',
},
{
data: {
// channel property missing
port: 'guest',
source,
type: 'offer',
},
message: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
},
expectedResult: false,
reason: 'data has one property that is missing',
},
{
data: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
extra: 'dummy', // additional
},
message: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
},
expectedResult: false,
reason: 'data has one additional property',
},
{
data: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
window, // no serializable
},
message: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
},
expectedResult: false,
reason: "data has one property that can't be serialized",
},
{
data: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
},
message: {
channel: 'guest-sidebar', // different
port: 'guest',
source,
type: 'offer',
},
expectedResult: false,
reason: 'data has one property that is different ',
},
].forEach(({ data, message, expectedResult, reason }) => {
it(`returns '${expectedResult}' because the ${reason}`, () => {
const result = isMessageEqual(data, message);
assert.equal(result, expectedResult);
});
});
});
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment