Commit b6f3ba5b authored by Robert Knight's avatar Robert Knight

Add client for making JSON-RPC calls over postMessage

We need a way for the client to request configuration from an embedding
frame over `postMessage`.

JSON-RPC is a well-known, simple and convenient format for RPC messages.

There are several npm packages which claim to offer this functionality
but at the time of writing, none appeared to be sufficiently well
tested/supported/documented.

There is an existing partial postMessage JSON-RPC server implementation in
src/sidebar/cross-origin-rpc.js. The non-app specific parts of that will
be moved into this module in future.
parent 782de4f6
'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],
});
});
[{
// Wrong origin.
origin: 'https://not-the-embedder.com',
data: {
jsonrpc: '2.0',
id: messageId,
},
},{
// Non-object `data` field.
data: null,
},{
// No jsonrpc header
data: {},
},{
// No ID
data: {
jsonrpc: '2.0',
},
},{
// ID mismatch
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