dist/rpc.js
import { EventEmitter } from 'eventemitter3';
/**
* Checks whether the message duck-types into an Interactive message.
* This is needed to distinguish between postmessages that we get,
* and postmessages from other sources.
*/
function isRPCMessage(data) {
return (data.type === 'method' || data.type === 'reply') && typeof data.counter === 'number';
}
/**
* An RPCError can be thrown in socket.call() if bad input is
* passed to the service. See the Interactive protocol doc for an enumaration
* of codes and messages: https://dev.mixer.com/reference/interactive/protocol/protocol.pdf
*/
export class RPCError extends Error {
constructor(code, message, path) {
super(`Error #${code}: ${message}`);
this.code = code;
this.message = message;
this.path = path;
}
}
export function objToError(obj) {
return new RPCError(obj.code, obj.message, obj.path);
}
/**
* Primitive postMessage based RPC for the controls to interact with the
* parent frame.
*/
export class RPC extends EventEmitter {
/**
* Creates a new RPC instance. Note: you should use the `rpc` singleton,
* rather than creating this class directly, in your controls.
*
* @param {window} target The window instance to make calls to or from.
* @param {string} protocolVersion The protocol version to communicate
* to the remote.
* @param {string} [origin='*'] Optionally, allow communication with the
* target if its origin matches this.
*/
constructor(target, protocolVersion, origin = '*') {
super();
this.target = target;
this.origin = origin;
this.idCounter = 0;
this.calls = Object.create(null);
this.callCounter = 0;
this.remoteCallQueue = [];
this.lastSequentialCall = -1;
this.listener = (ev) => {
const packet = ev.data;
if (!isRPCMessage(packet) || packet.serviceID !== RPC.serviceID) {
return;
}
// postMessage does not guarantee message order, reorder messages as needed.
// Reset the call counter when we get a "ready" so that the other end sees
// calls starting from 0.
if (packet.type === 'method' && packet.method === 'ready') {
this.lastSequentialCall = packet.counter - 1;
this.remoteProtocolVersion = packet.params.protocolVersion;
this.callCounter = 0;
}
if (packet.counter <= this.lastSequentialCall + 1) {
this.dispatchIncoming(packet);
this.replayQueue();
return;
}
for (let i = 0; i < this.remoteCallQueue.length; i++) {
if (this.remoteCallQueue[i].counter > packet.counter) {
this.remoteCallQueue.splice(i, 0, packet);
return;
}
}
this.remoteCallQueue.push(packet);
};
window.addEventListener('message', this.listener);
this.call('ready', { protocolVersion }, false);
}
/**
* Attaches a method callable by the other window, to this one. The handler
* function will be invoked with whatever the other window gives us. Can
* return a Promise, or the results directly.
*
* @param {string} method
* @param {function(params: any): Promise|*} handler
*/
expose(method, handler) {
this.on(method, (data) => {
if (data.discard) {
handler(data.params);
return;
}
// tslint:disable-next-line
Promise.resolve(handler(data.params)).then(result => {
const packet = {
type: 'reply',
serviceID: RPC.serviceID,
id: data.id,
result,
};
this.emit('sendReply', packet);
this.post(packet);
});
});
}
/**
* Makes an RPC call out to the target window.
*
* @param {string} method
* @param {*} params
* @param {boolean} [waitForReply=true]
* @return {Promise.<object> | undefined} If waitForReply is true, a
* promise is returned that resolves once the server responds.
*/
call(method, params, waitForReply = true) {
const id = this.idCounter++;
const packet = {
type: 'method',
serviceID: RPC.serviceID,
id,
params,
method,
discard: !waitForReply,
};
this.emit('sendMethod', packet);
this.post(packet);
if (!waitForReply) {
return;
}
return new Promise((resolve, reject) => {
this.calls[id] = (err, res) => {
if (err) {
reject(err);
}
else {
resolve(res);
}
};
});
}
/**
* Tears down resources associated with the RPC client.
*/
destroy() {
this.emit('destroy');
window.removeEventListener('message', this.listener);
}
/**
* Returns the protocol version that the remote client implements. This
* will return `undefined` until we get a `ready` event.
* @return {string | undefined}
*/
remoteVersion() {
return this.remoteProtocolVersion;
}
handleReply(packet) {
const handler = this.calls[packet.id];
if (!handler) {
return;
}
if (packet.error) {
handler(objToError(packet.error), null);
}
else {
handler(null, packet.result);
}
delete this.calls[packet.id];
}
post(message) {
message.counter = this.callCounter++;
this.target.postMessage(message, this.origin);
}
replayQueue() {
while (this.remoteCallQueue.length) {
const next = this.remoteCallQueue[0];
if (next.counter > this.lastSequentialCall + 1) {
return;
}
this.dispatchIncoming(this.remoteCallQueue.shift());
}
}
dispatchIncoming(packet) {
this.lastSequentialCall = packet.counter;
switch (packet.type) {
case 'method':
this.emit('recvMethod', packet);
if (this.listeners(packet.method).length > 0) {
this.emit(packet.method, packet);
return;
}
this.post({
type: 'reply',
serviceID: RPC.serviceID,
id: packet.id,
error: { code: 4003, message: 'Unknown method name' },
result: null,
});
break;
case 'reply':
this.emit('recvReply', packet);
this.handleReply(packet);
break;
default:
}
}
}
/**
* Service ID for this module. This is used to prevent
* multiple postMessage-based APIs for clobbering each other.
*/
RPC.serviceID = '8f5b3a83-dd7b-4b8a-84ad-146948bc8d27';