'use strict';
const WebSocket = require('isomorphic-ws');
const debug = require('debug')('Cantabile');
const EventEmitter = require('events');
/**
* Represents a connection to Cantabile.
*
* @class Cantabile
* @extends EventEmitter
* @constructor
* @param {String} [host] The host to connect to. This can be either <baseaddress> or http://<baseaddress> or ws://<baseaddress>
* When running in a browser, the defaults to `${window.location.host}`. In other environments it defaults to
`localhost:35007`.
*/
class Cantabile extends EventEmitter
{
constructor(host)
{
super();
this.setMaxListeners(30);
this.host = host;
this.shouldConnect = false;
this._nextRid = 1;
this._pendingResponseHandlers = {};
this._endPointEventHandlers = {};
this._setState("disconnected");
/**
* Gets the setList object
*
* @property setList
* @type {SetList}
*/
this.setList = new (require('./SetList'))(this);
/**
* Gets the states of the current song
*
* @property songStates
* @type {SongStates}
*/
this.songStates = new (require('./SongStates'))(this);
/**
* Gets the currently active key ranges
*
* @property keyRanges
* @type {KeyRanges}
*/
this.keyRanges = new (require('./KeyRanges'))(this);
/**
* Gets the current set of show notes
*
* @property showNotes
* @type {ShowNotes}
*/
this.showNotes = new (require('./ShowNotes'))(this);
/**
* Provides access to variable expansion facilities
*
* @property variables
* @type {Variables}
*/
this.variables = new (require('./Variables'))(this);
/**
* Provides access to controllers managed by Cantabile's onscreen keyboard device
*
* @property onscreenKeyboard
* @type {OnscreenKeyboard}
*/
this.onscreenKeyboard = new (require('./OnscreenKeyboard'))(this);
/**
* Provides access to global binding points
*
* @property bindings
* @type {Bindings}
*/
this.bindings = new (require('./Bindings'))(this);
/**
* Provides access to global binding v4 points
*
* @property bindings4
* @type {Bindings4}
*/
this.bindings4 = new (require('./Bindings4'))(this);
/**
* Provides access to global commands
*
* @property commands
* @type {Commands}
*/
this.commands = new (require('./Commands'))(this);
/**
* Provides access to information about the current song
*
* @property song
* @type {Song}
*/
this.song = new (require('./Song'))(this);
/**
* Provides access to master transport controls
*
* @property transport
* @type {Song}
*/
this.transport = new (require('./Transport'))(this);
/**
* Provides access to the application object
*
* @property application
* @type {Application}
*/
this.application = new (require('./Application'))(this);
/**
* Provides access to the engine object
*
* @property engine
* @type {Engine}
*/
this.engine = new (require('./Engine'))(this);
}
/**
* The current connection state, either "connecting", "connected" or "disconnected"
*
* @property state
* @type {String}
*/
get state()
{
return this._state;
}
/**
* Initiate connection and retry if fails
* @method connect
*/
connect()
{
this.shouldConnect = true;
this._internalConnect();
}
/**
* Disconnect and stop retries
* @method disconnect
*/
disconnect()
{
this.shouldConnect = false;
this._internalDisconnect();
}
/**
* Stringify an object as a JSON message and send it to the server
*
* @method send
* @param {object} obj The object to send
*/
send(obj)
{
debug('SEND: %j', obj);
this._ws.send(JSON.stringify(obj));
}
/**
* Stringify an object as a JSON message, send it to the server and returns
* a promise which will resolve to the result.
*
* @method request
* @param {object} obj The object to send
* @return {Promise|object}
*/
request(message)
{
return new Promise(function(resolve, reject) {
// Tag the message with the request id
message.rid = this._nextRid++;
// Store in the response handler map
this._pendingResponseHandlers[message.rid] = {
message: message,
resolve: resolve,
reject: reject,
};
// Send the request
this.send(message);
}.bind(this));
}
/**
* Returns a promise that will be resolved when connected
*
* @example
*
* let C = new CantabileApi();
* await C.untilConnected();
*
* @method untilConnected
* @return {Promise}
*/
untilConnected()
{
if (this._state == "connected")
{
return Promise.resolve();
}
else
{
return new Promise((resolve, reject) => {
if (!this.pendingConnectPromises)
this.pendingConnectPromises = [resolve];
else
this.pendingConnectPromises.push(resolve);
});
}
}
// PRIVATE:
// Internal helper to change state, log it and fire event
_setState(value)
{
if (this._state != value)
{
this._state = value;
this.emit('stateChanged', value);
this.emit(value);
debug(value);
if (this._state == "connected")
{
if (this.pendingConnectPromises)
{
for (let i=0; i<this.pendingConnectPromises.length; i++)
{
this.pendingConnectPromises[i]();
}
this.pendingConnectPromises = null;
}
}
}
}
/**
* The current host
*
* @property host
* @type {String}
*/
get host()
{
return this._host;
}
set host(value)
{
if (!value && process.browser)
value = window.location.host
if (!value)
value = "localhost"
// Crack protocol
let secure = false;
if (value.startsWith("https://"))
{
secure = true;
value = value.substring(8);
}
else if (value.startsWith("wss://"))
{
secure = true;
value = value.substring(6);
}
else if (value.startsWith("http://"))
{
value = value.substring(7);
}
else if (value.startsWith("ws://"))
{
value = value.substring(5);
}
// Remove trailing slashes
while (value.endsWith('/'))
value = value.substring(0, value.length - 1);
// Remove socket url
if (value.endsWith("/api/socket"))
value = value.substring(0, value.length - 11);
// Ensure port
if (value.indexOf(':') < 0)
{
let slashPos = value.indexOf('/');
if (slashPos < 0)
value += ":35007";
else
value = value.substring(0, slashPos) + ':35007' + value.substring(slashPos);
}
// Build final http and ws url
this._host = (secure ? "https://" : "http://") + value;
this._socketUrl = (secure ? "wss://" : "ws://") + value + "/api/socket/";
}
/**
* The base socket url
*
* @property host
* @type {String}
*/
get socketUrl()
{
return this._socketUrl;
}
/**
* The base host url
*
* @property host
* @type {String}
*/
get hostUrl()
{
return this._host;
}
set hostUrl(value)
{
throw new Error("The `hostUrl` property is read-only, use `host` instead");
}
set socketUrl(value)
{
throw new Error("The `socketUrl` property has been deprecated, use `host` instead");
}
// Internal helper to actually perform the connection
_internalConnect()
{
if (!this.shouldConnect)
return;
// Already connected?
if (this._ws)
return;
this._setState("connecting");
// Work out socket url
let socketUrl = this.socketUrl;
// Create the socket and hook up handlers
debug("Opening web socket '%s'", socketUrl);
this._ws = new WebSocket(socketUrl);
this._ws.onerror = this._onSocketError.bind(this);
this._ws.onopen = this._onSocketOpen.bind(this);
this._ws.onclose = this._onSocketClose.bind(this);
this._ws.onmessage = this._onSocketMessage.bind(this);
}
// Internal helper to disconnect
_internalDisconnect()
{
if (this.state == "connected")
this._setState("disconnected");
// Already disconnected?
if (!this._ws)
return;
this._ws.close();
delete this._ws;
}
// Internal helper to retry connection every 1 second
_internalReconnect()
{
if (this.shouldConnect && !this.timeoutPending)
{
this.timeoutPending = true;
this._setState("connecting");
setTimeout(function() {
this.timeoutPending = false;
this._internalConnect();
}.bind(this), 1000);
}
}
// Socket onerror handler
_onSocketError(evt)
{
// Disconnect
this._internalDisconnect();
// Try to reconnect...
this._internalReconnect();
}
// Socket onopen handler
_onSocketOpen()
{
this._setState("connected");
}
// Socket onclose handler
_onSocketClose()
{
if (this._ws)
{
this._setState("disconnected");
delete this._ws;
// Reject any pending requests
/*
var pending = this._pendingResponseHandlers;
console.log(pending);
this._pendingResponseHandlers = {};
for (let key in pending)
{
debugger;
console.log("===> disconnecting", key);
pending[key].reject(new Error("Disconnected"));
}
*/
}
// Try to reconnect...
this._internalReconnect();
}
// Socket onmessage handler
_onSocketMessage(msg)
{
msg = JSON.parse(msg.data);
debug('RECV: %j', msg);
// Request response?
if (msg.rid)
{
// Find the handler
let handlerInfo = this._pendingResponseHandlers[msg.rid];
if (!handlerInfo)
{
debug('ERROR: received response for unknown rid:', msg.rid)
return;
}
// Remove from pending map
delete this._pendingResponseHandlers[msg.rid];
// Resolve reject
if (msg.status >= 200 && msg.status < 300)
handlerInfo.resolve(msg);
else
handlerInfo.reject(new Error(`${msg.status} - ${msg.statusDescription}`));
}
// Event message?
if (msg.epid && msg.eventName)
{
var ep = this._endPointEventHandlers[msg.epid];
if (ep)
{
ep._dispatchEventMessage(msg.eventName, msg.data);
}
else
{
debug(`ERROR: No event handler found for end point ${msg.epid}`)
}
}
}
_registerEndPointEventHandler(epid, endPoint)
{
this._endPointEventHandlers[epid] = endPoint;
}
_revokeEndPointEventHandler(epid)
{
delete this._endPointEventHandlers[epid];
}
}
/**
* Fired when the {{#crossLink "Cantabile/state:property"}}{{/crossLink}} property value changes
*
* @event stateChanged
* @param {String} state The new connection state ("connecting", "connected" or "disconnected")
*/
const eventStateChanged = "stateChanged";
/**
* Fired when entering the connected state
*
* @event connected
*/
const eventConnected = "connected";
/**
* Fired when entering the connecting state
*
* @event connecting
*/
const eventConnecting = "connecting";
/**
* Fired when entering the disconnected state
*
* @event disconnected
*/
const eventDiconnected = "disconnected";
module.exports = Cantabile;