Unverified Commit 674e3aae authored by Robert Knight's avatar Robert Knight Committed by GitHub

Merge pull request #767 from hypothesis/postmessage-json-rpc

Add client for making JSON-RPC calls over postMessage
parents aa404bef a957a1db
'use strict';
const { generateHexString } = require('./random');
/** Generate a random ID to associate RPC requests and responses. */
function generateId() {
return generateHexString(10);
}
/**
* Return a Promise that rejects with an error after `delay` ms.
*/
function createTimeout(delay, message) {
return new Promise((_, reject) => {
setTimeout(() => reject(new Error(message)), delay);
});
}
/**
* Make a JSON-RPC call to a server in another frame using `postMessage`.
*
* @param {Window} frame - Frame to send call to
* @param {string} origin - Origin filter for `window.postMessage` call
* @param {string} method - Name of the JSON-RPC method
* @param {any[]} params - Parameters of the JSON-RPC method
* @param [number] timeout - Maximum time to wait in ms
* @param [Window] window_ - Test seam.
* @param [id] id - Test seam.
* @return {Promise<any>} - A Promise for the response to the call
*/
function call(frame, origin, method, params=[], timeout=2000,
window_=window, id=generateId()) {
// Send RPC request.
const request = {
jsonrpc: '2.0',
method,
params,
id,
};
try {
frame.postMessage(request, origin);
} catch (err) {
return Promise.reject(err);
}
// Await response or timeout.
let listener;
const response = new Promise((resolve, reject) => {
listener = (event) => {
if (event.origin !== origin) {
// Not from the frame that we sent the request to.
return;
}
if (!(event.data instanceof Object) ||
event.data.jsonrpc !== '2.0' ||
event.data.id !== id) {
// Not a valid JSON-RPC response.
return;
}
const { error, result } = event.data;
if (error !== undefined) {
reject(error);
} else if (result !== undefined) {
resolve(result);
} else {
reject(new Error('RPC reply had no result or error'));
}
};
window_.addEventListener('message', listener);
});
const timeoutExpired = createTimeout(timeout, `Request to ${origin} timed out`);
// Cleanup and return.
// FIXME: If we added a `Promise.finally` polyfill we could simplify this.
return Promise.race([response, timeoutExpired]).then(result => {
window_.removeEventListener('message', listener);
return result;
}).catch(err => {
window_.removeEventListener('message', listener);
throw err;
});
}
module.exports = {
call,
};
'use strict';
const EventEmitter = require('tiny-emitter');
const { call } = require('../postmessage-json-rpc');
class FakeWindow {
constructor() {
this.emitter = new EventEmitter;
this.addEventListener = this.emitter.on.bind(this.emitter);
this.removeEventListener = this.emitter.off.bind(this.emitter);
}
}
function assertPromiseIsRejected(promise, expectedErr) {
const rejectFlag = {};
return promise.catch(err => {
assert.equal(err.message, expectedErr);
return rejectFlag;
}).then(result => {
assert.equal(result, rejectFlag, 'expected promise to be rejected but it was fulfilled');
});
}
describe('sidebar.util.postmessage-json-rpc', () => {
const origin = 'https://embedder.com';
const messageId = 42;
describe('call', () => {
let frame;
let fakeWindow;
function doCall() {
const timeout = 1;
return call(
frame, origin, 'testMethod', [1, 2, 3], timeout, fakeWindow, messageId
);
}
beforeEach(() => {
frame = { postMessage: sinon.stub() };
fakeWindow = new FakeWindow;
});
it('sends a message to the target frame', () => {
doCall().catch(() => {} /* Ignore timeout. */);
assert.calledWith(frame.postMessage, {
jsonrpc: '2.0',
id: messageId,
method: 'testMethod',
params: [1, 2, 3],
});
});
it('rejects if `postMessage` fails', () => {
frame.postMessage.throws(new Error('Nope!'));
const result = doCall();
assertPromiseIsRejected(result, 'Nope!');
});
[{
// Wrong origin.
origin: 'https://not-the-embedder.com',
data: {
jsonrpc: '2.0',
id: messageId,
},
},{
// Non-object `data` field.
origin,
data: null,
},{
// No jsonrpc header
origin,
data: {},
},{
// No ID
origin,
data: {
jsonrpc: '2.0',
},
},{
// ID mismatch
origin,
data: {
jsonrpc: '2.0',
id: 'wrong-id',
},
}].forEach(reply => {
it('ignores messages that do not have required reply fields', () => {
const result = doCall();
fakeWindow.emitter.emit('message', reply);
const notCalled = Promise.resolve('notcalled');
return Promise.race([result, notCalled]).then(result => {
assert.equal(result, 'notcalled');
});
});
});
it('rejects with an error if the `error` field is set in the response', () => {
const result = doCall();
fakeWindow.emitter.emit('message', {
origin,
data: {
jsonrpc: '2.0',
id: messageId,
error: {
message: 'Something went wrong',
},
},
});
return assertPromiseIsRejected(result, 'Something went wrong');
});
it('rejects if no `error` or `result` field is set in the response', () => {
const result = doCall();
fakeWindow.emitter.emit('message', {
origin,
data: { jsonrpc: '2.0', id: messageId },
});
return assertPromiseIsRejected(result, 'RPC reply had no result or error');
});
it('resolves with the result if the `result` field is set in the response', () => {
const result = doCall();
const expectedResult = { foo: 'bar' };
fakeWindow.emitter.emit('message', {
origin,
data: {
jsonrpc: '2.0',
id: messageId,
result: expectedResult,
},
});
return result.then(result => {
assert.deepEqual(result, expectedResult);
});
});
it('rejects with an error if the timeout is exceeded', () => {
const result = doCall();
return assertPromiseIsRejected(result, 'Request to https://embedder.com timed out');
});
});
});
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