dist/participant.js
import { EventEmitter } from 'eventemitter3';
import { stringify } from 'querystring';
import { RPC, RPCError } from './rpc';
import { ErrorCode } from './typings';
/**
* Stringifies and appends the given query string to the URL.
*/
function appendQueryString(url, qs) {
const delimiter = url.indexOf('?') > -1 ? '&' : '?';
return `${url}${delimiter}${stringify(qs)}`;
}
/**
* Participant is a bridge between the Interactive service and an iframe that
* shows custom controls. It proxies calls between them and emits events
* when states change.
* @private (at least to most consumers!)
*/
export class Participant extends EventEmitter {
constructor(frame, settings) {
super();
this.frame = frame;
/**
* Buffer of packets from to replay once the controls load.
* As soon as we connect to interactive it'll send the initial state
* messages, but there's a good chance we won't have loaded the controls
* by that time, so buffer 'em until the controls say they're ready.
*/
this.replayBuffer = [];
/**
* Controls state.
*/
this.state = 0 /* Loading */;
/**
* onFrameLoad is called once the iframe loads.
* @private
*/
this.onFrameLoad = () => {
if (this.state === 0 /* Loading */) {
this.attachListeners();
}
this.frame.removeEventListener('load', this.onFrameLoad);
};
this.runOnRpc(rpc => {
rpc.call('updateSettings', settings, false);
const windowsAPI = window.Windows;
if (windowsAPI) {
const viewPane = windowsAPI.UI.ViewManagement.InputPane.getForCurrentView();
viewPane.onshowing = () => {
rpc.call('keyboardShowing', {}, false);
};
viewPane.onhiding = () => {
rpc.call('keyboardHiding', {}, false);
};
}
});
}
/**
* Creates a connection to the given Interactive address.
*/
connect(options) {
const qs = {
// cache bust the iframe to ensure that it reloads
// whenever we get a new connection.
bustCache: Date.now(),
key: options.key,
'x-protocol-version': Participant.protocolVersion,
'x-auth-user': options.xAuthUser ? JSON.stringify(options.xAuthUser) : undefined,
};
const ws = (this.websocket = new WebSocket(appendQueryString(options.socketAddress, qs)));
this.frame.src = options.contentAddress;
this.frame.addEventListener('load', this.onFrameLoad);
ws.addEventListener('message', data => {
this.sendInteractive(data.data);
});
ws.addEventListener('close', ev => {
this.emit('close', {
code: ev.code,
message: ev.reason,
expected: this.state === 2 /* Closing */,
ev,
});
this.state = 3 /* Closed */;
this.destroy();
});
ws.addEventListener('error', ev => {
this.handleWebsocketError(ev);
});
return this;
}
add(method, fn) {
this.runOnRpc(rpc => {
rpc.expose(method, fn);
});
return this;
}
/**
* Updates the controls' settings.
*/
updateSettings(settings) {
this.runOnRpc(rpc => {
rpc.call('updateSettings', settings, false);
});
}
/**
* Triggers a dump of state from the nested controls. Returns undefined if
* the controls do not expose a dumpState method.
*/
dumpState() {
if (!this.rpc) {
return Promise.resolve(undefined);
}
return this.rpc.call('dumpState', {}, true).catch(err => {
if (err instanceof RPCError && err.code === ErrorCode.AppBadMethod) {
return undefined; // controls don't expose dumpState, sad but we'll hide our sadness
}
throw new err();
});
}
/**
* Closes the participant connection and frees resources.
*/
destroy() {
if (this.state < 2 /* Closing */) {
this.state = 2 /* Closing */;
}
if (this.rpc) {
this.rpc.destroy();
}
try {
if (this.websocket) {
this.websocket.close();
}
}
catch (_e) {
// Ignored. Sockets can be fussy if they're closed at
// the wrong time but it doesn't cause issues.
}
}
on(event, handler) {
super.on(event, handler);
return this;
}
/**
* Calls the function with the RPC instance once it's ready and attached.
*/
runOnRpc(fn) {
if (this.state !== 1 /* Ready */) {
this.replayBuffer.push(fn);
}
else {
fn(this.rpc);
}
}
/**
* sendInteractive broadcasts the interactive payload down to the controls,
* and emits a `transmit` event.
*/
sendInteractive(data) {
const parsed = JSON.parse(data);
this.runOnRpc(rpc => {
rpc.call('recieveInteractivePacket', parsed, false);
});
this.emit('transmit', parsed);
}
/**
* attachListeners is called once the frame contents load to boot up
* the RPC system.
*/
attachListeners() {
this.rpc = new RPC(this.frame.contentWindow, '1.0');
this.rpc.expose('sendInteractivePacket', data => {
this.websocket.send(JSON.stringify(Object.assign({}, data, { type: 'method', discard: true })));
});
this.rpc.expose('controlsReady', () => {
if (this.state !== 0 /* Loading */) {
return;
}
this.state = 1 /* Ready */;
this.replayBuffer.forEach(p => {
p(this.rpc);
});
this.replayBuffer = [];
this.emit('loaded');
});
this.rpc.expose('maximize', (params) => {
this.emit('maximize', params.maximized, params.message);
});
this.rpc.expose('moveVideo', (options) => {
this.emit('moveVideo', options);
});
this.rpc.expose('unloading', () => {
this.emit('unload');
});
this.rpc.expose('log', params => {
this.emit('log', params);
});
this.rpc.expose('focusOut', () => {
this.emit('focusOut');
});
this.rpc.expose('handleExit', () => {
this.emit('handleExit');
});
this.rpc.expose('navigate', () => {
this.emit('navigate');
});
this.rpc.call('resendReady', {}, false);
}
/**
* handleWebsocketError is called when the websocket emits an `error`. This
* is generally called when the connection is terminated before a socket
* connection is established. We want to go back and get the error code/body.
*/
handleWebsocketError(ev) {
// tslint:disable-next-line
fetch(this.websocket.url.replace(/^ws/, 'http'))
.then(res => {
return res.text().then(message => {
this.emit('close', {
message,
code: res.status,
expected: this.state === 2 /* Closing */,
ev,
});
});
})
.catch(err => {
this.emit('close', {
code: -1,
message: err.message,
expected: this.state === 2 /* Closing */,
ev,
});
})
.then(() => {
this.state = 3 /* Closed */;
this.destroy();
});
}
}
/**
* Interactive protocol version this participant implements.
*/
Participant.protocolVersion = '2.0';