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'; ...@@ -2,7 +2,7 @@ import { ListenerCollection } from './listener-collection';
import { isMessageEqual, SOURCE as source } from './port-util'; import { isMessageEqual, SOURCE as source } from './port-util';
const MAX_WAIT_FOR_PORT = 1000 * 30; 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 * @typedef {import('../types/annotator').Destroyable} Destroyable
...@@ -12,8 +12,7 @@ const POLLING_INTERVAL_FOR_PORT = 500; ...@@ -12,8 +12,7 @@ const POLLING_INTERVAL_FOR_PORT = 500;
*/ */
/** /**
* PortFinder class should be used in frames that are not the `host` frame. It * PortFinder helps to discover `MessagePort` on a specific channel.
* helps to discover `MessagePort` on a specific channel.
* *
* Channel nomenclature is `[frame1]-[frame2]` so that: * Channel nomenclature is `[frame1]-[frame2]` so that:
* - `port1` should be owned by/transferred to `frame1`, and * - `port1` should be owned by/transferred to `frame1`, and
...@@ -28,118 +27,65 @@ export class PortFinder { ...@@ -28,118 +27,65 @@ export class PortFinder {
this._listeners = new ListenerCollection(); this._listeners = new ListenerCollection();
} }
// Two important characteristics of `MessagePort`: destroy() {
// - it can only be used by one frame; the port is neutered if, after started to this._listeners.removeAll();
// 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 * Polls the hostFrame for a specific port and returns a Promise of the port.
* @typedef {{channel: 'guest-host', hostFrame: Window, port: 'guest'}} options0
*
* `guest-sidebar` communication
* @typedef {{channel: 'guest-sidebar', hostFrame: Window, port: 'guest'}} options1
* *
* `host-sidebar` communication * @param {object} options
* @typedef {{channel: 'host-sidebar', hostFrame: Window, port: 'sidebar'}} options2 * @param {Channel} options.channel - requested channel
* * @param {Window} options.hostFrame - frame where the hypothesis client is
* `notebook-sidebar` communication * loaded and `PortProvider` is listening for messages
* @typedef {{channel: 'notebook-sidebar', hostFrame: Window, port: 'notebook'}} options3 * @param {Port} options.port - requested port
*
* @param {options0|options1|options2|options3} options
* @return {Promise<MessagePort>} * @return {Promise<MessagePort>}
*/ */
discover(options) { discover({ channel, hostFrame, port }) {
const { channel, port } = options; 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) => { return new Promise((resolve, reject) => {
if ( if (!isValidRequest) {
(channel === 'guest-host' && port === 'guest') || reject(new Error('Invalid request of channel/port'));
(channel === 'guest-sidebar' && port === 'guest') ||
(channel === 'host-sidebar' && port === 'sidebar') ||
(channel === 'notebook-sidebar' && port === 'notebook')
) {
this._requestPort({
...options,
reject,
resolve,
});
return; return;
} }
reject(new Error('Invalid request of channel/port')); function postRequest() {
}); hostFrame.postMessage({ channel, port, source, type: 'request' }, '*');
} }
/**
* @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 intervalId = setInterval(
const timeoutId = window.setTimeout(() => { () => postRequest(),
clearInterval(intervalId); POLLING_INTERVAL_FOR_PORT
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(); // 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.
* Resolve with a MessagePort when the `offer` message matches. this._listeners.add(window, 'message', event => {
* const { data, ports } = /** @type {MessageEvent} */ (event);
* @param {MessageEvent} event if (isMessageEqual(data, { channel, port, source, type: 'offer' })) {
* @param {object} options clearInterval(intervalId);
* @param {Message} options.message clearTimeout(timeoutId);
* @param {(port: MessagePort) => void} options.resolve resolve(ports[0]);
* @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() { postRequest();
this._listeners.removeAll(); });
} }
} }
This diff is collapsed.
// Because there are many `postMessages` on the `host` frame, the SOURCE property // Because there are many `postMessages` on the `host` frame, the SOURCE property
// is added to the hypothesis `postMessages` to identify the provenance of the // is added to the hypothesis `postMessages` to identify the provenance of the
// message and avoid listening to messages that could have the same properties // 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. // anti-collision mechanism.
export const SOURCE = 'hypothesis'; 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'|'guest-sidebar'|'host-sidebar'|'notebook-sidebar'} Channel
* @typedef {'guest'|'host'|'notebook'|'sidebar'} Port * @typedef {'guest'|'host'|'notebook'|'sidebar'} Port
* *
......
...@@ -26,92 +26,93 @@ describe('PortFinder', () => { ...@@ -26,92 +26,93 @@ describe('PortFinder', () => {
portFinder.destroy(); portFinder.destroy();
}); });
[ describe('#destroy', () => {
{ channel: 'invalid', port: 'guest' }, it('ignores `offer` messages of ports', async () => {
{ channel: 'guest-host', port: 'invalid' },
].forEach(({ channel, port }) =>
it('rejects if requesting an invalid port', async () => {
let error; let error;
const channel = 'host-sidebar';
const port = 'sidebar';
const { port1 } = new MessageChannel();
const clock = sinon.useFakeTimers();
try { try {
await portFinder.discover({ portFinder
channel, .discover({
hostFrame: window, channel,
port, hostFrame: window,
port,
})
.catch(e => (error = e));
portFinder.destroy();
sendMessage({
data: { channel, port, source, type: 'offer' },
ports: [port1],
}); });
} catch (e) { clock.tick(30000);
error = e; } 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); await delay(0);
assert.instanceOf(resolvedPort, MessagePort); assert.equal(
}) error.message,
); "Unable to find 'sidebar' port on 'host-sidebar' channel"
);
it("timeouts if host doesn't respond", async () => { });
let error; });
const channel = 'host-sidebar';
const port = 'sidebar'; describe('#discover', () => {
const clock = sinon.useFakeTimers(); [
{ channel: 'invalid', port: 'guest' },
try { { channel: 'guest-host', port: 'invalid' },
portFinder { channel: 'guest-host', port: 'host' },
.discover({ ].forEach(({ channel, port }) =>
channel, it('rejects if requesting an invalid port', async () => {
hostFrame: window, let error;
port, try {
}) await portFinder.discover({
.catch(e => (error = e)); channel,
clock.tick(30000); hostFrame: window,
} finally { port,
clock.restore(); });
} } catch (e) {
error = e;
assert.callCount(window.postMessage, 61); }
assert.alwaysCalledWithExactly( assert.equal(error.message, 'Invalid request of channel/port');
window.postMessage, })
{ channel, port, source, type: 'request' },
'*'
); );
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( portFinder
error.message, .discover({
"Unable to find 'sidebar' port on 'host-sidebar' channel" 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("timeouts if host doesn't respond", async () => {
it('ignores `offer` messages of ports', async () => {
let error; let error;
const channel = 'host-sidebar'; const channel = 'host-sidebar';
const port = 'sidebar'; const port = 'sidebar';
const { port1 } = new MessageChannel();
const clock = sinon.useFakeTimers(); const clock = sinon.useFakeTimers();
try { try {
...@@ -122,16 +123,18 @@ describe('PortFinder', () => { ...@@ -122,16 +123,18 @@ describe('PortFinder', () => {
port, port,
}) })
.catch(e => (error = e)); .catch(e => (error = e));
portFinder.destroy();
sendMessage({
data: { channel, port, source, type: 'offer' },
ports: [port1],
});
clock.tick(30000); clock.tick(30000);
} finally { } finally {
clock.restore(); clock.restore();
} }
assert.callCount(window.postMessage, 121);
assert.alwaysCalledWithExactly(
window.postMessage,
{ channel, port, source, type: 'request' },
'*'
);
await delay(0); await delay(0);
assert.equal( assert.equal(
......
...@@ -23,7 +23,8 @@ describe('PortProvider', () => { ...@@ -23,7 +23,8 @@ describe('PortProvider', () => {
beforeEach(() => { beforeEach(() => {
sinon.stub(window, 'postMessage'); sinon.stub(window, 'postMessage');
portProvider = new PortProvider(window.location.href); portProvider = new PortProvider(window.location.origin);
portProvider.listen();
}); });
afterEach(() => { afterEach(() => {
...@@ -31,6 +32,23 @@ describe('PortProvider', () => { ...@@ -31,6 +32,23 @@ describe('PortProvider', () => {
portProvider.destroy(); 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', () => { describe('#getPort', () => {
it('returns `null` if called with wrong arguments', () => { it('returns `null` if called with wrong arguments', () => {
let hostPort; let hostPort;
...@@ -59,20 +77,30 @@ describe('PortProvider', () => { ...@@ -59,20 +77,30 @@ describe('PortProvider', () => {
}); });
}); });
describe('#destroy', () => { describe('#listen', () => {
it('ignores valid port request if `PortFinder` has been destroyed', async () => { it('ignores all port request until start listening', async () => {
portProvider.destroy(); portProvider.destroy();
portProvider = new PortProvider(window.location.origin);
const data = {
channel: 'host-sidebar',
port: 'sidebar',
source,
type: 'request',
};
sendMessage({ sendMessage({
data: { data,
channel: 'host-sidebar',
port: 'sidebar',
source,
type: 'request',
},
}); });
await delay(0); await delay(0);
assert.notCalled(window.postMessage); assert.notCalled(window.postMessage);
portProvider.listen();
sendMessage({
data,
});
await delay(0);
assert.calledOnce(window.postMessage);
}); });
}); });
...@@ -94,7 +122,6 @@ describe('PortProvider', () => { ...@@ -94,7 +122,6 @@ describe('PortProvider', () => {
data, data,
source: new MessageChannel().port1, source: new MessageChannel().port1,
}); });
await delay(0); await delay(0);
assert.notCalled(window.postMessage); assert.notCalled(window.postMessage);
...@@ -132,7 +159,6 @@ describe('PortProvider', () => { ...@@ -132,7 +159,6 @@ describe('PortProvider', () => {
data, data,
origin: 'https://dummy.com', origin: 'https://dummy.com',
}); });
await delay(0); await delay(0);
assert.notCalled(window.postMessage); assert.notCalled(window.postMessage);
...@@ -148,7 +174,6 @@ describe('PortProvider', () => { ...@@ -148,7 +174,6 @@ describe('PortProvider', () => {
sendMessage({ sendMessage({
data, data,
}); });
await delay(0); await delay(0);
assert.calledWith( assert.calledWith(
...@@ -217,7 +242,7 @@ describe('PortProvider', () => { ...@@ -217,7 +242,7 @@ describe('PortProvider', () => {
it('sends the reciprocal port of the `guest-host` channel (via listener)', async () => { it('sends the reciprocal port of the `guest-host` channel (via listener)', async () => {
const handler = sinon.stub(); const handler = sinon.stub();
portProvider.addEventListener('onHostPortRequest', handler); portProvider.on('hostPortRequest', handler);
const data = { const data = {
channel: 'guest-host', channel: 'guest-host',
port: 'guest', 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