Commit 00c59e0e authored by Eduardo Sanz García's avatar Eduardo Sanz García Committed by Eduardo

Apply suggestions from code review

Co-authored-by: 's avatarRobert Knight <robertknight@gmail.com>
parent e78ada77
......@@ -2,7 +2,7 @@ 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;
const POLLING_INTERVAL_FOR_PORT = 250;
/**
* @typedef {import('../types/annotator').Destroyable} Destroyable
......@@ -12,8 +12,7 @@ const POLLING_INTERVAL_FOR_PORT = 500;
*/
/**
* PortFinder class should be used in frames that are not the `host` frame. It
* helps to discover `MessagePort` on a specific channel.
* PortFinder helps to discover `MessagePort` on a specific channel.
*
* Channel nomenclature is `[frame1]-[frame2]` so that:
* - `port1` should be owned by/transferred to `frame1`, and
......@@ -28,118 +27,65 @@ export class PortFinder {
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()`)
destroy() {
this._listeners.removeAll();
}
/**
* `guest-host` communication
* @typedef {{channel: 'guest-host', hostFrame: Window, port: 'guest'}} options0
*
* `guest-sidebar` communication
* @typedef {{channel: 'guest-sidebar', hostFrame: Window, port: 'guest'}} options1
* Polls the hostFrame for a specific port and returns a Promise of the port.
*
* `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
* @param {object} options
* @param {Channel} options.channel - requested channel
* @param {Window} options.hostFrame - frame where the hypothesis client is
* loaded and `PortProvider` is listening for messages
* @param {Port} options.port - requested port
* @return {Promise<MessagePort>}
*/
discover(options) {
const { channel, port } = options;
discover({ channel, hostFrame, port }) {
let isValidRequest = false;
if (
(channel === 'guest-host' && port === 'guest') ||
(channel === 'guest-sidebar' && port === 'guest') ||
(channel === 'host-sidebar' && port === 'sidebar') ||
(channel === 'notebook-sidebar' && port === 'notebook')
) {
isValidRequest = true;
}
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,
});
if (!isValidRequest) {
reject(new Error('Invalid request of channel/port'));
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
);
function postRequest() {
hostFrame.postMessage({ channel, port, source, type: 'request' }, '*');
}
// 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`)
const intervalId = setInterval(
() => postRequest(),
POLLING_INTERVAL_FOR_PORT
);
}, 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();
}
// 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);
/**
* 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]);
}
}
// TODO: It would be nice to remove the listener after receiving the port.
this._listeners.add(window, 'message', event => {
const { data, ports } = /** @type {MessageEvent} */ (event);
if (isMessageEqual(data, { channel, port, source, type: 'offer' })) {
clearInterval(intervalId);
clearTimeout(timeoutId);
resolve(ports[0]);
}
});
destroy() {
this._listeners.removeAll();
postRequest();
});
}
}
import { TinyEmitter } from 'tiny-emitter';
import { ListenerCollection } from './listener-collection';
import { isMessageEqual, SOURCE as source } from './port-util';
......@@ -38,7 +40,7 @@ import { isMessageEqual, SOURCE as source } from './port-util';
* - `host-sidebar`
* - `notebook-sidebar`
*
* `PortProvider` is used only on the `host` frame. The rest of the frames use the
* `PortProvider` is used only in the `host` frame. The other 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`.
......@@ -55,7 +57,14 @@ import { isMessageEqual, SOURCE as source } from './port-util';
* V
* 5. send reciprocal port to the `sidebar` frame using the `host-sidebar`
*
* 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
*/
/*
* 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
......@@ -63,23 +72,27 @@ import { isMessageEqual, SOURCE as source } from './port-util';
* 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
* Two important characteristics of `MessagePort`:
* - it can only be used by one frame: in Chrome the port is neutered if transferred twice
* - messages are queued until the other port is ready to listen (`port.start()`)
*/
export class PortProvider {
/**
* @param {string} hypothesisAppsURL
* @param {string} hypothesisAppsOrigin - the origin of the hypothesis apps
* is use to send the notebook and sidebar ports to only the frames that
* matches the origin.
*/
constructor(hypothesisAppsURL) {
this._hypothesisAppsOrigin = new URL(hypothesisAppsURL).origin;
constructor(hypothesisAppsOrigin) {
this._hypothesisAppsOrigin = hypothesisAppsOrigin;
this._emitter = new TinyEmitter();
// 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>`.
// `MessageChannel` on a `Map<Window, MessageChannel>`. The `Window` refers
// to the frame that sends the initial request that triggers creation of a
// channel.
/** @type {Map<Channel, Map<Window, MessageChannel>>} */
this._channels = new Map();
......@@ -88,90 +101,19 @@ export class PortProvider {
this._hostSidebarChannel = new MessageChannel();
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
* @param {Message} allowedMessage - the MessageEvent's data must match this
* object to grant the port.
* @param {string} allowedOrigin - the MessageEvent's origin must match this
* value to grant the port. If '*' allow all origins.
*/
_isValidRequest({ data, origin, source }, { allowedMessage, allowedOrigin }) {
_isValidRequest(event, allowedMessage, allowedOrigin) {
const { data, origin, source } = event;
if (allowedOrigin !== '*' && origin !== allowedOrigin) {
return false;
}
......@@ -195,16 +137,17 @@ export class PortProvider {
}
/**
* 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`
* Send a message and a port to the corresponding destinations.
*
* @param {MessageEvent} event
* @param {Options & {port: MessagePort, reciprocalPort? : MessagePort}} options
* @param {Message} message - the message to be sent.
* @param {MessagePort} port - the port to be sent via `window#postMessage`
* (the origin is set to match the MessageEvent's origin)frame that sends the initial request th
* @param {MessagePort} [reciprocalPort] - if a reciprocal port is provided,
* send this port (1) to the `sidebar` frame using the `host-sidebar`
* channel or (2) through the `onHostPortRequest` event listener.
*/
_sendPort(event, { allowedMessage, port, reciprocalPort }) {
const message = { ...allowedMessage, type: 'offer' };
_sendPort(event, message, port, reciprocalPort) {
const source = /** @type {Window} */ (event.source);
source.postMessage(message, event.origin, [port]);
......@@ -214,54 +157,110 @@ export class PortProvider {
this._hostSidebarChannel.port1.postMessage(message, [reciprocalPort]);
}
if (message.channel === 'guest-host' && message.port === 'guest') {
this._onHostPortRequest?.(reciprocalPort, message.port);
this._emitter.emit('hostPortRequest', reciprocalPort, message.port);
}
}
}
/**
* Respond to request of ports on channels.
* @param {MessageEvent} event
* @param {Options} options
* @param {'hostPortRequest'} eventName
* @param {(MessagePort, channel: 'guest') => void} handler - this handler
* fires when a request for the 'guest-host' channel is listened.
*/
_handlePortRequest(event, options) {
if (!this._isValidRequest(event, options)) {
return;
on(eventName, handler) {
this._emitter.on(eventName, 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;
}
const { channel } = options.allowedMessage;
return null;
}
let windowChannelMap = this._channels.get(channel);
if (!windowChannelMap) {
windowChannelMap = new Map();
this._channels.set(channel, windowChannelMap);
}
/**
* Initiate the listener of port requests by other frames.
*/
listen() {
this._listeners.add(window, 'message', messageEvent => {
const event = /** @type {MessageEvent} */ (messageEvent);
/** @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 }) => {
/** @type {Message} */
const allowedMessage = {
channel,
port,
source,
type: 'request',
};
const source = /** @type {Window} */ (event.source);
let messageChannel = windowChannelMap.get(source);
if (!this._isValidRequest(event, allowedMessage, allowedOrigin)) {
return;
}
// Ignore the port request if the channel for the specified window has
// already been created. This is to avoid transfer the port more than once.
if (messageChannel) {
return;
}
let windowChannelMap = this._channels.get(channel);
if (!windowChannelMap) {
windowChannelMap = new Map();
this._channels.set(channel, windowChannelMap);
}
// `host-sidebar` channel is an special case, because it is created in the
// constructor.
if (channel === 'host-sidebar') {
windowChannelMap.set(source, this._hostSidebarChannel);
this._sendPort(event, {
...options,
port: this._hostSidebarChannel.port2,
});
return;
}
const eventSource = /** @type {Window} */ (event.source);
let messageChannel = windowChannelMap.get(eventSource);
// Ignore the port request if the channel for the specified window has
// already been created. This is to avoid transfer the port more than once.
if (messageChannel) {
return;
}
messageChannel = new MessageChannel();
windowChannelMap.set(source, messageChannel);
/** @type {Message} */
const message = { ...allowedMessage, type: 'offer' };
const { port1, port2 } = messageChannel;
this._sendPort(event, { ...options, port: port1, reciprocalPort: port2 });
// `host-sidebar` channel is an special case, because it is created in the
// constructor.
if (channel === 'host-sidebar') {
windowChannelMap.set(eventSource, this._hostSidebarChannel);
this._sendPort(event, message, this._hostSidebarChannel.port2);
return;
}
messageChannel = new MessageChannel();
windowChannelMap.set(eventSource, messageChannel);
const { port1, port2 } = messageChannel;
this._sendPort(event, message, port1, port2);
});
});
}
destroy() {
......
// 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
// but different source. This is not a security feature but an
// anti-collision mechanism.
export const SOURCE = 'hypothesis';
/**
* These types are the used in by `PortProvider` and `PortFinder` for the
* inter-frame discovery and communication processes.
* @typedef {'guest-host'|'guest-sidebar'|'host-sidebar'|'notebook-sidebar'} Channel
* @typedef {'guest'|'host'|'notebook'|'sidebar'} Port
*
......
......@@ -26,92 +26,93 @@ describe('PortFinder', () => {
portFinder.destroy();
});
[
{ channel: 'invalid', port: 'guest' },
{ channel: 'guest-host', port: 'invalid' },
].forEach(({ channel, port }) =>
it('rejects if requesting an invalid port', async () => {
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 {
await portFinder.discover({
channel,
hostFrame: window,
port,
portFinder
.discover({
channel,
hostFrame: window,
port,
})
.catch(e => (error = e));
portFinder.destroy();
sendMessage({
data: { channel, port, source, type: 'offer' },
ports: [port1],
});
} catch (e) {
error = e;
clock.tick(30000);
} finally {
clock.restore();
}
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' },
'*'
assert.equal(
error.message,
"Unable to find 'sidebar' port on 'host-sidebar' channel"
);
});
});
describe('#discover', () => {
[
{ channel: 'invalid', port: 'guest' },
{ channel: 'guest-host', port: 'invalid' },
{ channel: 'guest-host', port: 'host' },
].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');
})
);
await delay(0);
[
{ 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;
assert.equal(
error.message,
"Unable to find 'sidebar' port on 'host-sidebar' channel"
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);
})
);
});
describe('#destroy', () => {
it('ignores `offer` messages of ports', async () => {
it("timeouts if host doesn't respond", async () => {
let error;
const channel = 'host-sidebar';
const port = 'sidebar';
const { port1 } = new MessageChannel();
const clock = sinon.useFakeTimers();
try {
......@@ -122,16 +123,18 @@ describe('PortFinder', () => {
port,
})
.catch(e => (error = e));
portFinder.destroy();
sendMessage({
data: { channel, port, source, type: 'offer' },
ports: [port1],
});
clock.tick(30000);
} finally {
clock.restore();
}
assert.callCount(window.postMessage, 121);
assert.alwaysCalledWithExactly(
window.postMessage,
{ channel, port, source, type: 'request' },
'*'
);
await delay(0);
assert.equal(
......
......@@ -23,7 +23,8 @@ describe('PortProvider', () => {
beforeEach(() => {
sinon.stub(window, 'postMessage');
portProvider = new PortProvider(window.location.href);
portProvider = new PortProvider(window.location.origin);
portProvider.listen();
});
afterEach(() => {
......@@ -31,6 +32,23 @@ describe('PortProvider', () => {
portProvider.destroy();
});
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('#getPort', () => {
it('returns `null` if called with wrong arguments', () => {
let hostPort;
......@@ -59,20 +77,30 @@ describe('PortProvider', () => {
});
});
describe('#destroy', () => {
it('ignores valid port request if `PortFinder` has been destroyed', async () => {
describe('#listen', () => {
it('ignores all port request until start listening', async () => {
portProvider.destroy();
portProvider = new PortProvider(window.location.origin);
const data = {
channel: 'host-sidebar',
port: 'sidebar',
source,
type: 'request',
};
sendMessage({
data: {
channel: 'host-sidebar',
port: 'sidebar',
source,
type: 'request',
},
data,
});
await delay(0);
assert.notCalled(window.postMessage);
portProvider.listen();
sendMessage({
data,
});
await delay(0);
assert.calledOnce(window.postMessage);
});
});
......@@ -94,7 +122,6 @@ describe('PortProvider', () => {
data,
source: new MessageChannel().port1,
});
await delay(0);
assert.notCalled(window.postMessage);
......@@ -132,7 +159,6 @@ describe('PortProvider', () => {
data,
origin: 'https://dummy.com',
});
await delay(0);
assert.notCalled(window.postMessage);
......@@ -148,7 +174,6 @@ describe('PortProvider', () => {
sendMessage({
data,
});
await delay(0);
assert.calledWith(
......@@ -217,7 +242,7 @@ describe('PortProvider', () => {
it('sends the reciprocal port of the `guest-host` channel (via listener)', async () => {
const handler = sinon.stub();
portProvider.addEventListener('onHostPortRequest', handler);
portProvider.on('hostPortRequest', handler);
const data = {
channel: 'guest-host',
port: 'guest',
......
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