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

Improve ergonomics of PortFinder and PortProvider

For PortFinder, I followed this advice:
https://github.com/hypothesis/client/pull/3881#discussion_r743030606,
except that I made the argument of `PorFinder#discover` and string
(instead of an object).

```
const portFinder = new PortFinder({ source: 'guest', hostFrame });
portFinder.discover('sidebar');
portFinder.discover('host');
```

For PortProvider, I followed this advice:
https://github.com/hypothesis/client/pull/3881#discussion_r743033013,
except that I used a getter.

```
cont portProvider = new PortProvider(...)
const bridge = new Bridge();
bridge.createChannel(portProvider.sidebarPort);
```

I have renamed the properties of `Message`:
- `source` becomes `authority`
- `channel` and `port` have been replaced by `frame1` and `frame2`

I did that to align `port1` and `frame1` and `port2` and `frame2`, while
also avoiding clashing with other names (`source`, `target`) which have
other meaning in the `window.postMessage` context.

I have removed one level of nesting in the `PortProvider#discover` that
make the method more readable.

I added some other suggestions from PR #3881.
parent 3d8982f8
...@@ -7,8 +7,7 @@ const POLLING_INTERVAL_FOR_PORT = 250; ...@@ -7,8 +7,7 @@ const POLLING_INTERVAL_FOR_PORT = 250;
/** /**
* @typedef {import('../types/annotator').Destroyable} Destroyable * @typedef {import('../types/annotator').Destroyable} Destroyable
* @typedef {import('./port-util').Message} Message * @typedef {import('./port-util').Message} Message
* @typedef {Message['channel']} Channel * @typedef {import('./port-util').Frame} Frame
* @typedef {Message['port']} Port
*/ */
/** /**
...@@ -16,14 +15,18 @@ const POLLING_INTERVAL_FOR_PORT = 250; ...@@ -16,14 +15,18 @@ const POLLING_INTERVAL_FOR_PORT = 250;
* MessagePort-based connection to other frames. It is used together with * MessagePort-based connection to other frames. It is used together with
* PortProvider which runs in the host frame. See PortProvider for an overview. * PortProvider which runs in the host frame. See PortProvider for an overview.
* *
* 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 * @implements Destroyable
*/ */
export class PortFinder { export class PortFinder {
constructor() { /**
* @param {object} options
* @param {Exclude<Frame, 'host'>} options.source - the role of this frame
* @param {Window} options.hostFrame - the frame where the `PortProvider` is
* listening for messages.
*/
constructor({ hostFrame, source }) {
this._hostFrame = hostFrame;
this._source = source;
this._listeners = new ListenerCollection(); this._listeners = new ListenerCollection();
} }
...@@ -34,20 +37,16 @@ export class PortFinder { ...@@ -34,20 +37,16 @@ export class PortFinder {
/** /**
* Request a specific port from `hostFrame` * Request a specific port from `hostFrame`
* *
* @param {object} options * @param {Frame} target - the frame aiming to be discovered
* @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>} * @return {Promise<MessagePort>}
*/ */
async discover({ channel, hostFrame, port }) { async discover(target) {
let isValidRequest = false; let isValidRequest = false;
if ( if (
(channel === 'guest-host' && port === 'guest') || (this._source === 'guest' && target === 'host') ||
(channel === 'guest-sidebar' && port === 'guest') || (this._source === 'guest' && target === 'sidebar') ||
(channel === 'host-sidebar' && port === 'sidebar') || (this._source === 'sidebar' && target === 'host') ||
(channel === 'notebook-sidebar' && port === 'notebook') (this._source === 'notebook' && target === 'sidebar')
) { ) {
isValidRequest = true; isValidRequest = true;
} }
...@@ -57,26 +56,34 @@ export class PortFinder { ...@@ -57,26 +56,34 @@ export class PortFinder {
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
function postRequest() { const postRequest = () => {
hostFrame.postMessage( this._hostFrame.postMessage(
{ channel, port, source: 'hypothesis', type: 'request' }, {
authority: 'hypothesis',
frame1: this._source,
frame2: target,
type: 'request',
},
'*' '*'
); );
} };
// In some situations, because `guest` iframe/s load in parallel to the `host` // Because `guest` iframes load in parallel to the `host` frame, we can
// frame, we can not assume that the code in the `host` frame is executed before // not assume that the code in the `host` frame is executed before the
// the code in a `guest` frame. Hence, we can't assume that `PortProvider` (in // code in a `guest` frame. Hence, we can't assume that `PortProvider` (in
// the `host` frame) is initialized before `PortFinder` (in the non-host frames). // the `host` frame) is initialized before `PortFinder` (in the non-host
// Therefore, for the `PortFinder`, we implement a polling strategy (sending a // frames). Therefore, for the `PortFinder`, we implement a polling
// message every N milliseconds) until a response is received. // strategy (sending a message every N milliseconds) until a response is
// received.
const intervalId = setInterval(postRequest, POLLING_INTERVAL_FOR_PORT); const intervalId = setInterval(postRequest, POLLING_INTERVAL_FOR_PORT);
// The `host` frame maybe busy, that's why we should wait. // The `host` frame maybe busy, that's why we should wait.
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
clearInterval(intervalId); clearInterval(intervalId);
reject( reject(
new Error(`Unable to find '${port}' port on '${channel}' channel`) new Error(
`Unable to establish ${this._source}-${target} communication channel`
)
); );
}, MAX_WAIT_FOR_PORT); }, MAX_WAIT_FOR_PORT);
...@@ -84,9 +91,9 @@ export class PortFinder { ...@@ -84,9 +91,9 @@ export class PortFinder {
const { data, ports } = /** @type {MessageEvent} */ (event); const { data, ports } = /** @type {MessageEvent} */ (event);
if ( if (
isMessageEqual(data, { isMessageEqual(data, {
channel, authority: 'hypothesis',
port, frame1: this._source,
source: 'hypothesis', frame2: target,
type: 'offer', type: 'offer',
}) })
) { ) {
......
...@@ -6,8 +6,8 @@ import { isMessageEqual } from './port-util'; ...@@ -6,8 +6,8 @@ import { isMessageEqual } from './port-util';
/** /**
* @typedef {import('../types/annotator').Destroyable} Destroyable * @typedef {import('../types/annotator').Destroyable} Destroyable
* @typedef {import('./port-util').Message} Message * @typedef {import('./port-util').Message} Message
* @typedef {Message['channel']} Channel * @typedef {import('./port-util').Frame} Frame
* @typedef {Message['port']} Port * @typedef {'guest-host'|'guest-sidebar'|'notebook-sidebar'|'sidebar-host'} Channel
*/ */
/** /**
...@@ -36,8 +36,8 @@ import { isMessageEqual } from './port-util'; ...@@ -36,8 +36,8 @@ import { isMessageEqual } from './port-util';
* Currently, we support communication between the following pairs of frames: * Currently, we support communication between the following pairs of frames:
* - `guest-host` * - `guest-host`
* - `guest-sidebar` * - `guest-sidebar`
* - `host-sidebar`
* - `notebook-sidebar` * - `notebook-sidebar`
* - `sidebar-host`
* *
* `PortProvider` is used only in the `host` frame. The other frames use the * `PortProvider` is used only in the `host` frame. The other frames use the
* companion class, `PortFinder`. `PortProvider` creates a `MessageChannel` * companion class, `PortFinder`. `PortProvider` creates a `MessageChannel`
...@@ -55,10 +55,6 @@ import { isMessageEqual } from './port-util'; ...@@ -55,10 +55,6 @@ import { isMessageEqual } from './port-util';
* (eg. via MessageChannel connection * (eg. via MessageChannel connection
* between host and other frame) * between host and other frame)
* *
* 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 * @implements Destroyable
*/ */
export class PortProvider { export class PortProvider {
...@@ -73,10 +69,8 @@ export class PortProvider { ...@@ -73,10 +69,8 @@ export class PortProvider {
// Although some channels (v.gr. `notebook-sidebar`) have only one // Although some channels (v.gr. `notebook-sidebar`) have only one
// `MessageChannel`, other channels (v.gr. `guest-sidebar`) can have multiple // `MessageChannel`, other channels (v.gr. `guest-sidebar`) can have multiple
// `MessageChannel`s. In spite of the number channel, we store all // `MessageChannel`s. The `Window` refers to the frame that sends the initial
// `MessageChannel` on a `Map<Window, MessageChannel>`. The `Window` refers // request that triggers creation of a channel.
// to the frame that sends the initial request that triggers creation of a
// channel.
/** @type {Map<Channel, Map<Window, MessageChannel>>} */ /** @type {Map<Channel, Map<Window, MessageChannel>>} */
this._channels = new Map(); this._channels = new Map();
...@@ -86,67 +80,114 @@ export class PortProvider { ...@@ -86,67 +80,114 @@ export class PortProvider {
// neutered. // neutered.
// - Messages are queued until the other port is ready to listen (`port.start()`) // - Messages are queued until the other port is ready to listen (`port.start()`)
// Create the `host-sidebar` channel immediately, while other channels are // Create the `sidebar-host` channel immediately, while other channels are
// created on demand // created on demand
this._hostSidebarChannel = new MessageChannel(); this._sidebarHostChannel = new MessageChannel();
this._listeners = new ListenerCollection(); this._listeners = new ListenerCollection();
/** @type {Array<Message & {allowedOrigin: string}>} */
this._allowedMessages = [
{
allowedOrigin: '*',
authority: 'hypothesis',
frame1: 'guest',
frame2: 'host',
type: 'request',
},
{
allowedOrigin: '*',
authority: 'hypothesis',
frame1: 'guest',
frame2: 'sidebar',
type: 'request',
},
{
allowedOrigin: this._hypothesisAppsOrigin,
authority: 'hypothesis',
frame1: 'sidebar',
frame2: 'host',
type: 'request',
},
{
allowedOrigin: this._hypothesisAppsOrigin,
authority: 'hypothesis',
frame1: 'notebook',
frame2: 'sidebar',
type: 'request',
},
];
} }
/** /**
* Checks the `postMessage` origin and message. * Check that source is of type Window.
* *
* @param {MessageEvent} event * @param {MessageEventSource|null} source
* @param {Message} allowedMessage - the MessageEvent's data must match this * @return {source is Window}
* 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(event, allowedMessage, allowedOrigin) { _isSourceWindow(source) {
const { data, origin, source } = event;
if (allowedOrigin !== '*' && origin !== allowedOrigin) {
return false;
}
if ( if (
// `source` can be of type Window | MessagePort | ServiceWorker. // `source` can be of type Window | MessagePort | ServiceWorker.
// The simple check `source instanceof Window`` doesn't work here. // The simple check `source instanceof Window`` doesn't work here.
// Alternatively, `source` could be casted `/** @type{Window} */ (source)` // Alternatively, `source` could be casted `/** @type{Window} */ (source)`
!source || source === null ||
source instanceof MessagePort || source instanceof MessagePort ||
source instanceof ServiceWorker source instanceof ServiceWorker
) { ) {
return false; return false;
} }
return true;
}
/**
* Check that data and origin matches the expected values.
*
* @param {object} options
* @param {Message} options.allowedMessage - the `data` must match this
* `Message`.
* @param {string} options.allowedOrigin - the `origin` must match this
* value. If `allowedOrigin` is '*', the origin is ignored.
* @param {any} options.data - the data to be compared with `allowedMessage`.
* @param {string} options.origin - the origin to be compared with
* `allowedOrigin`.
*/
_messageMatches({ allowedMessage, allowedOrigin, data, origin }) {
if (allowedOrigin !== '*' && origin !== allowedOrigin) {
return false;
}
return isMessageEqual(data, allowedMessage); return isMessageEqual(data, allowedMessage);
} }
/** /**
* Send a message and a port to the corresponding destinations. * Send a message and a port to the corresponding destinations.
* *
* @param {MessageEvent} event * @param {object} options
* @param {Message} message - the message to be sent. * @param {Channel} options.channel - communication channel enabled by this
* @param {MessagePort} port - the port requested. * port.
* @param {MessagePort} [counterpartPort] - if a counterpart port is provided, * @param {Message} options.message - the message to be sent.
* send this port either, (1) to the `sidebar` frame using the `host-sidebar` * @param {string} options.origin - the target origin to be used for sending
* channel or (2) through the `onHostPortRequest` event listener. * the port.
* @param {Window} options.source - the frame to be used for sending the port.
* @param {MessagePort} options.port1 - the port to be sent.
* @param {MessagePort} [options.port2] - if a counterpart port is provided,
* send this port either, (1) to the `sidebar` frame using the `sidebar-host`
* channel or (2) through the `onHostPortRequest` event listener.
*/ */
_sendPort(event, message, port, counterpartPort) { _sendPort({ channel, message, origin, source, port1, port2 }) {
const source = /** @type {Window} */ (event.source); source.postMessage(message, origin, [port1]);
source.postMessage(message, event.origin, [port]);
if (!counterpartPort) { if (!port2) {
return; return;
} }
if (['notebook-sidebar', 'guest-sidebar'].includes(message.channel)) { if (['notebook-sidebar', 'guest-sidebar'].includes(channel)) {
this._hostSidebarChannel.port1.postMessage(message, [counterpartPort]); this._sidebarHostChannel.port2.postMessage(message, [port2]);
} }
if (message.channel === 'guest-host' && message.port === 'guest') { if (channel === 'guest-host' && message.frame1 === 'guest') {
this._emitter.emit('hostPortRequest', message.port, counterpartPort); this._emitter.emit('hostPortRequest', message.frame1, port2);
} }
} }
...@@ -160,19 +201,10 @@ export class PortProvider { ...@@ -160,19 +201,10 @@ export class PortProvider {
} }
/** /**
* Returns a port from a channel. Currently, only returns the `host` port from * Returns the `host` port from the `sidebar-host` channel.
* the `host-sidebar` channel. Otherwise, it returns `null`.
*
* @param {object} options
* @param {'host-sidebar'} options.channel
* @param {'host'} options.port
*/ */
getPort({ channel, port }) { get sidebarPort() {
if (channel === 'host-sidebar' && port === 'host') { return this._sidebarHostChannel.port2;
return this._hostSidebarChannel.port1;
}
return null;
} }
/** /**
...@@ -180,74 +212,65 @@ export class PortProvider { ...@@ -180,74 +212,65 @@ export class PortProvider {
*/ */
listen() { listen() {
this._listeners.add(window, 'message', messageEvent => { this._listeners.add(window, 'message', messageEvent => {
const event = /** @type {MessageEvent} */ (messageEvent); const { data, origin, source } = /** @type {MessageEvent} */ (
/** @type {Array<{allowedOrigin: string, channel: Channel, port: Port}>} */ messageEvent
([ );
{
allowedOrigin: '*', if (!this._isSourceWindow(source)) {
channel: 'guest-host', return;
port: 'guest', }
},
{ const match = this._allowedMessages.find(
allowedOrigin: '*', ({ allowedOrigin, ...allowedMessage }) =>
channel: 'guest-sidebar', this._messageMatches({
port: 'guest', allowedMessage,
}, allowedOrigin,
{ data,
allowedOrigin: this._hypothesisAppsOrigin, origin,
channel: 'host-sidebar', })
port: 'sidebar', );
},
{ if (match === undefined) {
allowedOrigin: this._hypothesisAppsOrigin, return;
channel: 'notebook-sidebar', }
port: 'notebook',
},
]).forEach(({ allowedOrigin, channel, port }) => {
/** @type {Message} */
const allowedMessage = {
channel,
port,
source: 'hypothesis',
type: 'request',
};
if (!this._isValidRequest(event, allowedMessage, allowedOrigin)) { const { authority, frame1, frame2 } = match;
return; const channel = /** @type {Channel} */ (`${frame1}-${frame2}`);
}
let windowChannelMap = this._channels.get(channel); let windowChannelMap = this._channels.get(channel);
if (!windowChannelMap) { if (!windowChannelMap) {
windowChannelMap = new Map(); windowChannelMap = new Map();
this._channels.set(channel, windowChannelMap); this._channels.set(channel, windowChannelMap);
} }
const eventSource = /** @type {Window} */ (event.source); let messageChannel = windowChannelMap.get(source);
let messageChannel = windowChannelMap.get(eventSource);
// Ignore the port request if the channel for the specified window has // Ignore the port request if the channel for the specified window has
// already been created. This is to avoid transfering the port more than once. // already been created. This is to avoid transferring the port more than once.
if (messageChannel) { if (messageChannel) {
return; return;
} }
/** @type {Message} */ /** @type {Message} */
const message = { ...allowedMessage, type: 'offer' }; const message = { authority, frame1, frame2, type: 'offer' };
const options = { channel, message, origin, source };
// `host-sidebar` channel is an special case, because it is created in the // `sidebar-host` channel is an special case, because it is created in the
// constructor. // constructor.
if (channel === 'host-sidebar') { if (channel === 'sidebar-host') {
windowChannelMap.set(eventSource, this._hostSidebarChannel); windowChannelMap.set(source, this._sidebarHostChannel);
this._sendPort(event, message, this._hostSidebarChannel.port2); this._sendPort({
return; port1: this._sidebarHostChannel.port1,
} ...options,
});
return;
}
messageChannel = new MessageChannel(); messageChannel = new MessageChannel();
windowChannelMap.set(eventSource, messageChannel); windowChannelMap.set(source, messageChannel);
const { port1, port2 } = messageChannel; const { port1, port2 } = messageChannel;
this._sendPort(event, message, port1, port2); this._sendPort({ port1, port2, ...options });
});
}); });
} }
......
...@@ -3,20 +3,19 @@ ...@@ -3,20 +3,19 @@
// 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 security feature but an // but different source. This is not a security feature but an
// anti-collision mechanism. // anti-collision mechanism.
const SOURCE = 'hypothesis'; const AUTHORITY = 'hypothesis';
/** /**
* These types are the used in by `PortProvider` and `PortFinder` for the * These types are the used in by `PortProvider` and `PortFinder` for the
* inter-frame discovery and communication processes. * inter-frame discovery and communication processes.
* *
* @typedef {'guest-host'|'guest-sidebar'|'host-sidebar'|'notebook-sidebar'} Channel * @typedef {'guest'|'host'|'notebook'|'sidebar'} Frame
* @typedef {'guest'|'host'|'notebook'|'sidebar'} Port
* *
* @typedef Message * @typedef Message
* @prop {Channel} channel * @prop {AUTHORITY} authority
* @prop {Port} port * @prop {Frame} frame1
* @prop {'offer'|'request'} type * @prop {Frame} frame2
* @prop {SOURCE} source - * @prop {'offer'|'request'} type
*/ */
/** /**
...@@ -26,18 +25,18 @@ const SOURCE = 'hypothesis'; ...@@ -26,18 +25,18 @@ const SOURCE = 'hypothesis';
* @param {any} data * @param {any} data
* @return {data is Message} * @return {data is Message}
*/ */
function isMessageValid(data) { function isMessage(data) {
if (data === null || typeof data !== 'object') { if (data === null || typeof data !== 'object') {
return false; return false;
} }
for (let property of ['channel', 'port', 'source', 'type']) { for (let property of ['frame1', 'frame2', 'type']) {
if (typeof data[property] !== 'string') { if (typeof data[property] !== 'string') {
return false; return false;
} }
} }
return data.source === SOURCE; return data.authority === AUTHORITY;
} }
/** /**
...@@ -47,7 +46,7 @@ function isMessageValid(data) { ...@@ -47,7 +46,7 @@ function isMessageValid(data) {
* @param {Message} message * @param {Message} message
*/ */
export function isMessageEqual(data, message) { export function isMessageEqual(data, message) {
if (!isMessageValid(data)) { if (!isMessage(data)) {
return false; return false;
} }
......
...@@ -4,9 +4,19 @@ import { PortFinder } from '../port-finder'; ...@@ -4,9 +4,19 @@ import { PortFinder } from '../port-finder';
const MAX_WAIT_FOR_PORT = 1000 * 5; const MAX_WAIT_FOR_PORT = 1000 * 5;
describe('PortFinder', () => { describe('PortFinder', () => {
const authority = 'hypothesis';
const frame1 = 'guest';
const type = 'offer';
let portFinder; let portFinder;
let portFinders;
function portProviderOffer({ data, ports = [] }) { function createPortFinder(source = frame1) {
const instance = new PortFinder({ hostFrame: window, source });
portFinders.push(instance);
return instance;
}
function sendPortProviderOffer({ data, ports = [] }) {
const event = new MessageEvent('message', { const event = new MessageEvent('message', {
data, data,
ports, ports,
...@@ -18,34 +28,35 @@ describe('PortFinder', () => { ...@@ -18,34 +28,35 @@ describe('PortFinder', () => {
} }
beforeEach(() => { beforeEach(() => {
portFinders = [];
sinon.stub(window, 'postMessage'); sinon.stub(window, 'postMessage');
portFinder = new PortFinder(); portFinder = createPortFinder();
}); });
afterEach(() => { afterEach(() => {
window.postMessage.restore(); window.postMessage.restore();
portFinder.destroy(); portFinders.forEach(instance => instance.destroy());
}); });
describe('#destroy', () => { describe('#destroy', () => {
it('ignores subsequent `offer` messages of ports', async () => { it('ignores subsequent `offer` messages of ports', async () => {
let error; let error;
const channel = 'host-sidebar'; const target = 'host';
const port = 'sidebar';
const { port1 } = new MessageChannel(); const { port1 } = new MessageChannel();
const clock = sinon.useFakeTimers(); const clock = sinon.useFakeTimers();
try { try {
portFinder portFinder.discover(target).catch(e => (error = e));
.discover({
channel,
hostFrame: window,
port,
})
.catch(e => (error = e));
portFinder.destroy(); portFinder.destroy();
portProviderOffer({
data: { channel, port, source: 'hypothesis', type: 'offer' }, sendPortProviderOffer({
data: {
authority,
frame1,
frame2: target,
type,
},
ports: [port1], ports: [port1],
}); });
clock.tick(MAX_WAIT_FOR_PORT); clock.tick(MAX_WAIT_FOR_PORT);
...@@ -57,25 +68,17 @@ describe('PortFinder', () => { ...@@ -57,25 +68,17 @@ describe('PortFinder', () => {
assert.equal( assert.equal(
error.message, error.message,
"Unable to find 'sidebar' port on 'host-sidebar' channel" 'Unable to establish guest-host communication channel'
); );
}); });
}); });
describe('#discover', () => { describe('#discover', () => {
[ ['guest', 'invalid'].forEach(target =>
{ 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 () => { it('rejects if requesting an invalid port', async () => {
let error; let error;
try { try {
await portFinder.discover({ await portFinder.discover(target);
channel,
hostFrame: window,
port,
});
} catch (e) { } catch (e) {
error = e; error = e;
} }
...@@ -84,24 +87,24 @@ describe('PortFinder', () => { ...@@ -84,24 +87,24 @@ describe('PortFinder', () => {
); );
[ [
{ channel: 'guest-host', port: 'guest' }, { source: 'guest', target: 'host' },
{ channel: 'guest-sidebar', port: 'guest' }, { source: 'guest', target: 'sidebar' },
{ channel: 'host-sidebar', port: 'sidebar' }, { source: 'sidebar', target: 'host' },
{ channel: 'notebook-sidebar', port: 'notebook' }, { source: 'notebook', target: 'sidebar' },
].forEach(({ channel, port }) => ].forEach(({ source, target }) =>
it('resolves if requesting a valid port', async () => { it('resolves if requesting a valid port', async () => {
const { port1 } = new MessageChannel(); const { port1 } = new MessageChannel();
let resolvedPort; let resolvedPort;
portFinder = createPortFinder(source);
portFinder
.discover({ portFinder.discover(target).then(port => (resolvedPort = port));
channel, sendPortProviderOffer({
hostFrame: window, data: {
port, authority,
}) frame1: source,
.then(port => (resolvedPort = port)); frame2: target,
portProviderOffer({ type,
data: { channel, port, source: 'hypothesis', type: 'offer' }, },
ports: [port1], ports: [port1],
}); });
await delay(0); await delay(0);
...@@ -112,18 +115,11 @@ describe('PortFinder', () => { ...@@ -112,18 +115,11 @@ describe('PortFinder', () => {
it("times out if host doesn't respond", async () => { it("times out if host doesn't respond", async () => {
let error; let error;
const channel = 'host-sidebar'; const target = 'host';
const port = 'sidebar';
const clock = sinon.useFakeTimers(); const clock = sinon.useFakeTimers();
try { try {
portFinder portFinder.discover(target).catch(e => (error = e));
.discover({
channel,
hostFrame: window,
port,
})
.catch(e => (error = e));
clock.tick(MAX_WAIT_FOR_PORT); clock.tick(MAX_WAIT_FOR_PORT);
} finally { } finally {
clock.restore(); clock.restore();
...@@ -132,7 +128,7 @@ describe('PortFinder', () => { ...@@ -132,7 +128,7 @@ describe('PortFinder', () => {
assert.callCount(window.postMessage, 21); assert.callCount(window.postMessage, 21);
assert.alwaysCalledWithExactly( assert.alwaysCalledWithExactly(
window.postMessage, window.postMessage,
{ channel, port, source: 'hypothesis', type: 'request' }, { authority, frame1, frame2: target, type: 'request' },
'*' '*'
); );
...@@ -140,7 +136,7 @@ describe('PortFinder', () => { ...@@ -140,7 +136,7 @@ describe('PortFinder', () => {
assert.equal( assert.equal(
error.message, error.message,
"Unable to find 'sidebar' port on 'host-sidebar' channel" 'Unable to establish guest-host communication channel'
); );
}); });
}); });
......
import { delay } from '../../test-util/wait'; import { delay } from '../../test-util/wait';
import { PortProvider } from '../port-provider'; import { PortProvider } from '../port-provider';
const source = 'hypothesis'; const authority = 'hypothesis';
describe('PortProvider', () => { describe('PortProvider', () => {
let portProvider; let portProvider;
function portFinderRequest({ async function sendPortFinderRequest({
data, data,
origin = window.location.origin, origin = window.location.origin,
source = window, source = window,
...@@ -18,6 +18,7 @@ describe('PortProvider', () => { ...@@ -18,6 +18,7 @@ describe('PortProvider', () => {
}); });
window.dispatchEvent(event); window.dispatchEvent(event);
await delay(0);
return event; return event;
} }
...@@ -25,7 +26,6 @@ describe('PortProvider', () => { ...@@ -25,7 +26,6 @@ describe('PortProvider', () => {
beforeEach(() => { beforeEach(() => {
sinon.stub(window, 'postMessage'); sinon.stub(window, 'postMessage');
portProvider = new PortProvider(window.location.origin); portProvider = new PortProvider(window.location.origin);
portProvider.listen();
}); });
afterEach(() => { afterEach(() => {
...@@ -36,97 +36,76 @@ describe('PortProvider', () => { ...@@ -36,97 +36,76 @@ describe('PortProvider', () => {
describe('#destroy', () => { describe('#destroy', () => {
it('ignores valid port request if `PortFinder` has been destroyed', async () => { it('ignores valid port request if `PortFinder` has been destroyed', async () => {
portProvider.destroy(); portProvider.destroy();
portFinderRequest({ await sendPortFinderRequest({
data: { data: {
channel: 'host-sidebar', authority,
port: 'sidebar', frame1: 'sidebar',
source, frame2: 'host',
type: 'request', type: 'request',
}, },
}); });
await delay(0);
assert.notCalled(window.postMessage); assert.notCalled(window.postMessage);
}); });
}); });
describe('#getPort', () => { describe('#sidebarPort', () => {
it('returns `null` if called with wrong arguments', () => { it('returns the `host` port of the `sidebar-host` channel', () => {
let hostPort; assert.instanceOf(portProvider.sidebarPort, MessagePort);
// 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('#listen', () => { describe('#listen', () => {
it('ignores all port requests before `listen` is called', async () => { it('ignores all port requests before `listen` is called', async () => {
portProvider.listen();
portProvider.destroy(); portProvider.destroy();
portProvider = new PortProvider(window.location.origin); portProvider = new PortProvider(window.location.origin);
const data = { const data = {
channel: 'host-sidebar', authority,
port: 'sidebar', frame1: 'sidebar',
source, frame2: 'host',
type: 'request', type: 'request',
}; };
portFinderRequest({ await sendPortFinderRequest({
data, data,
}); });
await delay(0);
assert.notCalled(window.postMessage); assert.notCalled(window.postMessage);
portProvider.listen(); portProvider.listen();
portFinderRequest({ await sendPortFinderRequest({
data, data,
}); });
await delay(0);
assert.calledOnce(window.postMessage); assert.calledOnce(window.postMessage);
}); });
}); });
describe('listens for port requests', () => { describe('listens for port requests', () => {
it('ignores port requests with invalid sources', async () => { [
const data = { { source: null, reason: 'source is null' },
channel: 'host-sidebar', {
port: 'sidebar',
source,
type: 'request',
};
portFinderRequest({
data,
source: null,
});
portFinderRequest({
data,
source: new MessageChannel().port1, source: new MessageChannel().port1,
}); reason: 'source is a MessageChannel',
await delay(0); },
].forEach(({ source, reason }) =>
it(`ignores port requests if ${reason}`, async () => {
portProvider.listen();
const data = {
authority,
frame1: 'sidebar',
frame2: 'host',
type: 'request',
};
assert.notCalled(window.postMessage); await sendPortFinderRequest({
}); data,
source,
});
assert.notCalled(window.postMessage);
})
);
[ [
// Disabled this check because it make axes-core to crash // Disabled this check because it make axes-core to crash
...@@ -134,71 +113,73 @@ describe('PortProvider', () => { ...@@ -134,71 +113,73 @@ describe('PortProvider', () => {
//{ data: null, reason: 'if message is null' }, //{ data: null, reason: 'if message is null' },
{ {
data: { data: {
channel: 'sidebar-host', // invalid channel (swapped words) authority: 'dummy', // invalid authority
port: 'sidebar', frame1: 'sidebar',
source, frame2: 'host',
type: 'request', type: 'request',
}, },
reason: 'if message contains an invalid channel', reason: 'contains an invalid authority',
}, },
{ {
data: { data: {
channel: 'host-sidebar', authority,
port: 'host', // invalid port frame1: 'host', // invalid source
source, frame2: 'host',
type: 'request', type: 'request',
}, },
reason: 'if message contains an invalid port', reason: 'contains an invalid frame1',
}, },
{ {
data: { data: {
channel: 'host-sidebar', authority,
port: 'sidebar', frame1: 'sidebar',
source: 'dummy', frame2: 'dummy', // invalid target
type: 'request', type: 'request',
}, },
reason: 'if message contains an invalid source', reason: 'contains an invalid frame2',
}, },
{ {
data: { data: {
channel: 'host-sidebar', authority,
port: 'dummy', frame1: 'sidebar',
source, frame2: 'host',
type: 'offer', // invalid offer type: 'offer', // invalid type
}, },
reason: 'if message contains an invalid offer', reason: 'contains an invalid type',
}, },
{ {
data: { data: {
channel: 'host-sidebar', authority,
port: 'sidebar', frame1: 'sidebar',
source, frame2: 'host',
type: 'request', type: 'request',
}, },
origin: 'https://dummy.com', origin: 'https://dummy.com',
reason: 'if message comes from invalid origin', reason: 'comes from invalid origin',
}, },
].forEach(({ data, reason, origin }) => { ].forEach(({ data, reason, origin }) => {
it(`ignores port request ${reason}`, async () => { it(`ignores port request if message ${reason}`, async () => {
portFinderRequest({ data, origin: origin ?? window.location.origin }); portProvider.listen();
await sendPortFinderRequest({
await delay(0); data,
origin: origin ?? window.location.origin,
});
assert.notCalled(window.postMessage); assert.notCalled(window.postMessage);
}); });
}); });
it('responds to a valid port request', async () => { it('responds to a valid port request', async () => {
portProvider.listen();
const data = { const data = {
channel: 'host-sidebar', authority,
port: 'sidebar', frame1: 'sidebar',
source, frame2: 'host',
type: 'request', type: 'request',
}; };
portFinderRequest({ await sendPortFinderRequest({
data, data,
}); });
await delay(0);
assert.calledWith( assert.calledWith(
window.postMessage, window.postMessage,
...@@ -209,19 +190,19 @@ describe('PortProvider', () => { ...@@ -209,19 +190,19 @@ describe('PortProvider', () => {
}); });
it('responds to the first valid port request but ignores additional requests', async () => { it('responds to the first valid port request but ignores additional requests', async () => {
portProvider.listen();
const data = { const data = {
channel: 'guest-host', authority,
port: 'guest', frame1: 'guest',
source, frame2: 'sidebar',
type: 'request', type: 'request',
}; };
for (let i = 0; i < 4; ++i) { for (let i = 0; i < 4; ++i) {
portFinderRequest({ await sendPortFinderRequest({
data, data,
}); });
} }
await delay(0);
assert.calledOnceWithExactly( assert.calledOnceWithExactly(
window.postMessage, window.postMessage,
...@@ -232,30 +213,29 @@ describe('PortProvider', () => { ...@@ -232,30 +213,29 @@ describe('PortProvider', () => {
}); });
it('sends the counterpart port via the sidebar port', async () => { it('sends the counterpart port via the sidebar port', async () => {
portFinderRequest({ portProvider.listen();
await sendPortFinderRequest({
data: { data: {
channel: 'host-sidebar', authority,
port: 'sidebar', frame1: 'sidebar',
source, frame2: 'host',
type: 'request', type: 'request',
}, },
}); });
await delay(0);
const [sidebarPort] = window.postMessage.getCall(0).args[2]; const [sidebarPort] = window.postMessage.getCall(0).args[2];
const handler = sinon.stub(); const handler = sinon.stub();
sidebarPort.onmessage = handler; sidebarPort.onmessage = handler;
const data = { const data = {
channel: 'guest-sidebar', authority,
port: 'guest', frame1: 'guest',
source, frame2: 'sidebar',
type: 'request', type: 'request',
}; };
portFinderRequest({ await sendPortFinderRequest({
data, data,
}); });
await delay(0);
assert.calledWith( assert.calledWith(
handler, handler,
...@@ -266,18 +246,18 @@ describe('PortProvider', () => { ...@@ -266,18 +246,18 @@ describe('PortProvider', () => {
}); });
it('sends the counterpart port via the listener', async () => { it('sends the counterpart port via the listener', async () => {
portProvider.listen();
const handler = sinon.stub(); const handler = sinon.stub();
portProvider.on('hostPortRequest', handler); portProvider.on('hostPortRequest', handler);
const data = { const data = {
channel: 'guest-host', authority,
port: 'guest', frame1: 'guest',
source, frame2: 'host',
type: 'request', type: 'request',
}; };
portFinderRequest({ await sendPortFinderRequest({
data, data,
}); });
await delay(0);
assert.calledWith(handler, 'guest', sinon.match.instanceOf(MessagePort)); assert.calledWith(handler, 'guest', sinon.match.instanceOf(MessagePort));
}); });
......
import { isMessageEqual } from '../port-util'; import { isMessageEqual } from '../port-util';
const source = 'hypothesis';
describe('port-util', () => { describe('port-util', () => {
describe('isMessageEqual', () => { describe('isMessageEqual', () => {
const authority = 'hypothesis';
const frame1 = 'guest';
const frame2 = 'sidebar';
const type = 'offer';
[ [
{ {
data: { data: {
channel: 'host-sidebar', authority,
port: 'guest', frame1,
source, frame2,
type: 'offer', type,
},
message: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
}, },
expectedResult: true, expectedResult: true,
reason: 'data matches the message', reason: 'data matches the message',
}, },
{ {
data: { data: {
channel: 'host-sidebar', authority,
port: 'guest', frame1,
source, frame2,
type: 'offer', type,
},
message: {
source,
type: 'offer',
channel: 'host-sidebar',
port: 'guest',
}, },
expectedResult: true, expectedResult: true,
reason: 'data matches the message (properties in different order)', reason: 'data matches the message (properties in different order)',
}, },
{ {
data: null, data: null,
message: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
},
expectedResult: false, expectedResult: false,
reason: 'data is null', reason: 'data is null',
}, },
{ {
data: { data: {
channel: 'host-sidebar', authority,
port: 9, // wrong type // frame1 property missing
source, frame2,
type: 'offer', type,
},
message: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
}, },
expectedResult: false, expectedResult: false,
reason: 'data has a property with the wrong type', reason: 'data has one property that is missing',
}, },
{ {
data: { data: {
// channel property missing authority,
port: 'guest', frame1,
source, frame2: 9, // wrong type
type: 'offer', type,
},
message: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
}, },
expectedResult: false, expectedResult: false,
reason: 'data has one property that is missing', reason: 'data has one property with a wrong type',
}, },
{ {
data: { data: {
channel: 'host-sidebar', authority,
port: 'guest',
source,
type: 'offer',
extra: 'dummy', // additional extra: 'dummy', // additional
}, frame1,
message: { frame2,
channel: 'host-sidebar', type,
port: 'guest',
source,
type: 'offer',
}, },
expectedResult: false, expectedResult: false,
reason: 'data has one additional property', reason: 'data has one additional property',
}, },
{ {
data: { data: {
channel: 'host-sidebar', authority,
port: 'guest', frame1: 'dummy', // different
source, frame2,
type: 'offer', type,
window, // not serializable
},
message: {
channel: 'host-sidebar',
port: 'guest',
source,
type: 'offer',
}, },
expectedResult: false, expectedResult: false,
reason: "data has one property that can't be serialized", reason: 'data has one property that is different',
}, },
{ {
data: { data: {
channel: 'host-sidebar', authority,
port: 'guest', frame1,
source, frame2,
type: 'offer', type,
}, window, // not serializable
message: {
channel: 'guest-sidebar', // different
port: 'guest',
source,
type: 'offer',
}, },
expectedResult: false, expectedResult: false,
reason: 'data has one property that is different', reason: "data has one property that can't be serialized",
}, },
].forEach(({ data, message, expectedResult, reason }) => { ].forEach(({ data, expectedResult, reason }) => {
it(`returns '${expectedResult}' because the ${reason}`, () => { it(`returns '${expectedResult}' because the ${reason}`, () => {
const result = isMessageEqual(data, message); const result = isMessageEqual(data, {
authority,
frame1,
frame2,
type,
});
assert.equal(result, expectedResult); 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