jam-cloud/web/app/assets/javascripts/bridge.es6

611 lines
18 KiB
JavaScript

/**
* Responsible for maintaining a websocket connection with the client software, and exposing functions that can be invoked across that bridge
*
* */
"use strict";
let logger = console
class Bridge {
constructor(options) {
this.options = options
this.connecting = false
this.connected = false
this.clientType = null
this.channelId = null
this.clientClosedConnection = false
this.connectPromise = null
this.initialConnectAttempt = true
this.reconnectAttemptLookup = [2, 2, 2, 4, 8, 15, 30]
this.reconnectAttempt = 0
this.reconnectingWaitPeriodStart = null
this.reconnectDueTime = null
this.connectTimeout = null
this.countdownInterval = null
this.lastDisconnectedReason = null
this.PROMISE_TIMEOUT_MSEC = 2000;
// heartbeat fields
this.heartbeatInterval = null
this.heartbeatMS = null
this.connection_expire_time = null;
this.heartbeatInterval = null
this.heartbeatAckCheckInterval = null
this.lastHeartbeatAckTime = null
this.lastHeartbeatFound = false
this.heartbeatId = 1;
this.lastHeartbeatSentTime = null;
// messaging fields
this.MESSAGE_ID = 1
this.unresolvedMessages = {}
}
connect() {
if(this.connecting) {
logger.error("client comm: connect should never be called if we are already connecting. cancelling.")
// XXX should return connectPromise, but needs to be tested/vetted
return;
}
if(this.connected) {
logger.error("client comm: connect should never be called if we are already connected. cancelling.")
// XXX should return connectPromise, but needs to be tested/vetted
return;
}
this.connectPromise = new P((resolve, reject) => {
//this.channelId = context.JK.generateUUID(); // create a new channel ID for every websocket connection
this.connectPromiseResolve = resolve
this.connectPromiseReject = reject
let uri = "ws://localhost:54321/TestWebSocketServer"
logger.debug("client comm: connecting websocket: " + uri);
this.socket = new window.WebSocket(uri);
this.socket.onopen = (() => this.onOpen() )
this.socket.onmessage = ((e) => this.onMessage(e) )
this.socket.onclose = (() => this.onClose() )
this.socket.onerror = ((e) => this.onError(e) )
this.socket.channelId = this.channelId; // so I can uniquely identify this socket later
this.connectTimeout = setTimeout(() => {
logger.debug("client commo: connection timeout fired", this)
this.connectTimeout = null;
if(this.connectPromise.isPending()) {
this.close(true);
this.connectPromise.reject();
}
}, 4000);
})
return this.connectPromise;
};
onMessage (event) {
console.log("ON MESSAGE", event)
var obj = JSON.parse(event.data);
if (obj.event) {
// event from server
// TODO triggerHandler...
logger.debug("client comm: event")
} else if (obj.msgid) {
// response from server to a request
if (obj.msgid in this.unresolvedMessages) {
logger.debug("client comm: response=", obj)
var msgInfo = this.unresolvedMessages[obj.msgid];
if (msgInfo) {
delete this.unresolvedMessages[obj.msgid];
// store result from server
msgInfo.result = obj.result;
var prom = msgInfo.promise;
// if promise is pending, call the resolve callback
// we don't want to parse the result here
// not sure how we can change the state of the promise at this point
if(!prom) {
logger.warn ("no promise for message!", msgInfo)
}
else if (prom.promise.isPending()) {
// TODO should pass obj.result to resolve callback
prom.resolve(msgInfo);
}
else {
logger.warn("promise is already resolved!", msgInfo)
}
}
}
}
else if(obj.heartbeat_interval_sec) {
// this is a welcome message from server
this.heartbeatMS = obj.heartbeat_interval_sec * 1000;
this.connection_expire_time = obj.connection_timeout_sec * 1000;
// start interval timer
this.heartbeatInterval = setInterval((() => this.sendHeartbeat()), this.heartbeatMS);
// client does not send down heartbeats yet
//this.heartbeatAckCheckInterval = setInterval((() => this.heartbeatAckCheck()), 1000);
this.lastHeartbeatAckTime = new Date(new Date().getTime() + this.heartbeatMS); // add a little forgiveness to server for initial heartbeat
// send first heartbeat right away
this.sendHeartbeat();
this.heartbeatAck(); // every time we get this message, it acts also as a heartbeat ack
}
else {
logger.warn("client comm: unknown message type", msgInfo)
}
//processWSMessage(serverMessage);
}
invokeMethod (method, args=[]) {
let msg_id = this.MESSAGE_ID.toString();
this.MESSAGE_ID += 1;
let invoke = {msgid:msg_id, args: args, method:method}
logger.debug(`client comm: invoking ${method}`, invoke)
let promise = this.send(invoke, true)
//promise.catch((e) => {logger.error("EEE", e)})
return promise
}
// resolve callback gets called when the request message is sent to the
// server *and* a response message is received from the server,
// regardless of what is in the response message, parsing of the
// response from the server is the responsibility of the resolve callback
getWSPromise(msg, id) {
logger.debug("client comm: getWSPromise")
let wrappedPromise = {}
let prom = new P((resolve, reject) => {
wrappedPromise.resolve = resolve;
wrappedPromise.reject = reject;
var msgInfo = {
msgId : id,
promise : wrappedPromise,
result : undefined
};
this.unresolvedMessages[id] = msgInfo;
try {
logger.debug("client comm: sending it: " + msg)
this.socket.send(msg)
} catch(e) {
logger.error("unable to send message", e)
delete this.unresolvedMessages[id];
reject({reason:'no_send', detail: "client com: unable to send message" + e.message});
}
}).cancellable().catch(P.CancellationError, (e) => {
// Don't swallow it
throw e;
});
wrappedPromise.then = prom.then.bind(prom);
wrappedPromise.catch = prom.catch.bind(prom);
wrappedPromise.timeout = prom.timeout.bind(prom);
// to access isPending(), etc
wrappedPromise.promise = prom;
return wrappedPromise.promise;
}
send(msg, expectsResponse = false) {
let wire_format = JSON.stringify(msg)
//logger.debug(`client comm: sending ${msg}`)
if(expectsResponse) {
let id = msg.msgid;
let requestPromise = this.getWSPromise(wire_format, msg.msgid)
requestPromise
.timeout(this.PROMISE_TIMEOUT_MSEC)
.catch(P.TimeoutError, (e) => {
logger.error("client comm: Promise timed out! " + e);
// call reject callback
// if id is in unresolved message map
if (id in this.unresolvedMessages) {
var msgInfo = this.unresolvedMessages[id];
if (msgInfo) {
var prom = msgInfo.promise;
// if promise is pending, call the reject callback
// not sure how we can change the state of the promise at this point
if (prom != undefined && prom.promise.isPending()) {
msgInfo.promise.reject({reason: 'send_timeout', detail: "We did not get a response from the client in a timely fashion"});
}
}
// remove from map
delete this.unresolvedMessages[id];
}
})
.catch(P.CancellationError, (e) => {
logger.warn("Promise cancelled! ", e);
// call reject callback
// if id is in unresolved message map
if (id in this.unresolvedMessages) {
var msgInfo = this.unresolvedMessages[id];
if (msgInfo) {
var prom = msgInfo.promise;
// if promise is pending, call the reject callback
// not sure how we can change the state of the promise at this point
if (prom != undefined && prom.promise.isPending()) {
msgInfo.promise.reject({reason: 'cancelled', detail: "The request was cacelled"});
}
}
// remove from map
delete this.unresolvedMessages[id];
}
})
.catch((e) => {
logger.warn("Promise errored! ", e);
// call reject callback
// if id is in unresolved message map
if (id in this.unresolvedMessages) {
var msgInfo = this.unresolvedMessages[id];
if (msgInfo) {
var prom = msgInfo.promise;
// if promise is pending, call the reject callback
// not sure how we can change the state of the promise at this point
if (prom != undefined && prom.promise.isPending()) {
msgInfo.promise.reject({reason: 'unknown_error', detail: e});
}
}
// remove from map
delete this.unresolvedMessages[id];
}
});
return requestPromise;
}
else {
this.socket.send(wire_format)
}
}
onError (error) {
logger.debug("client comm: error", error)
}
close (in_error) {
logger.info("client comm: closing websocket");
this.clientClosedConnection = true;
this.socket.close();
this.closedCleanup(in_error);
}
onOpen () {
logger.debug("client comm: server.onOpen");
// we should receive LOGIN_ACK very soon. we already set a timer elsewhere to give 4 seconds to receive it
this.fullyConnected()
};
fullyConnected() {
this.clearConnectTimeout();
this.heartbeatStateReset();
// this has to be after context.jamclient.OnLoggedIn, because it hangs in scenarios
// where there is no device on startup for the current profile.
// So, in that case, it's possible that a reconnect loop will attempt, but we *do not want*
// it to go through unless we've passed through .OnLoggedIn
this.connected = true;
this.reconnecting = false;
this.connecting = false;
this.initialConnectAttempt = false;
//this.heartbeatMS = payload.heartbeat_interval * 1000;
//connection_expire_time = payload.connection_expire_time * 1000;
//logger.info("loggedIn(): clientId=" + app.clientId + " heartbeat=" + payload.heartbeat_interval + "s expire_time=" + payload.connection_expire_time + 's');
//heartbeatInterval = context.setInterval(_heartbeat, heartbeatMS);
//heartbeatAckCheckInterval = context.setInterval(_heartbeatAckCheck, 1000);
//lastHeartbeatAckTime = new Date(new Date().getTime() + heartbeatMS); // add a little forgiveness to server for initial heartbeat
this.connectPromiseResolve();
//$self.triggerHandler(EVENTS.CONNECTION_UP)
}
// onClose is called if either client or server closes connection
onClose () {
logger.info("client comm: Socket to server closed.");
var disconnectedSocket = this;
if(disconnectedSocket.channelId != this.socket.channelId) {
logger.debug(" client comm: ignoring disconnect for non-current socket. current=" + this.socket.channelId + ", disc=" + disconnectedSocket.channelId)
return;
}
if (this.connectPromise.isPending()) {
this.connectPromise.reject();
}
this.closedCleanup(true);
};
// handles logic if the websocket connection closes, and if it was in error then also prompt for reconnect
closedCleanup(in_error) {
if(this.connected) {
//$self.triggerHandler(EVENTS.CONNECTION_DOWN);
}
this.connected = false;
this.connecting = false;
// stop future heartbeats
if (this.heartbeatInterval != null) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
// stop checking for heartbeat acks
if (this.heartbeatAckCheckInterval != null) {
clearTimeout(this.heartbeatAckCheckInterval);
this.heartbeatAckCheckInterval = null;
}
this.clearConnectTimeout();
// noReconnect is a global to suppress reconnect behavior, so check it first
// we don't show any reconnect dialog on the initial connect; so we have this one-time flag
// to cause reconnects in the case that the websocket is down on the initially connect
if(this.noReconnect) {
//renderLoginRequired();
}
else if ((this.initialConnectAttempt || !this.reconnecting)) {
this.reconnecting = true;
this.initialConnectAttempt = false;
this.initiateReconnect(in_error);
}
}
clearConnectTimeout() {
if (this.connectTimeout) {
clearTimeout(this.connectTimeout)
this.connectTimeout = null
}
}
initiateReconnect(in_error) {
if (in_error) {
this.reconnectAttempt = 0;
this.beginReconnectPeriod();
}
}
beginReconnectPeriod() {
this.reconnectingWaitPeriodStart = new Date().getTime();
this.reconnectDueTime = this.reconnectingWaitPeriodStart + this.reconnectDelaySecs() * 1000;
// update count down timer periodically
this.countdownInterval = setInterval(() => {
let now = new Date().getTime();
if (now > this.reconnectDueTime) {
this.clearReconnectTimers();
this.attemptReconnect();
}
else {
let secondsUntilReconnect = Math.ceil((this.reconnectDueTime - now) / 1000);
logger.debug("client comm: until reconnect :" + this.secondsUntilReconnect)
//$currentDisplay.find('.reconnect-countdown').html(formatDelaySecs(secondsUntilReconnect));
}
}, 333);
}
attemptReconnect() {
if(this.connecting) {
logger.warn("client comm: attemptReconnect called when already connecting");
return;
}
if(this.connected) {
logger.warn("client comm: attemptReconnect called when already connected");
return;
}
let start = new Date().getTime();
logger.debug("client comm: Attempting to reconnect...")
this.guardAgainstRapidTransition(start, this.internetUp);
}
internetUp() {
let start = new Date().getTime();
this.connect()
.then(() => {
this.guardAgainstRapidTransition(start, this.finishReconnect);
})
.catch(() => {
this.guardAgainstRapidTransition(start, this.closedOnReconnectAttempt);
});
}
finishReconnect() {
logger.debug("client comm: websocket reconnected")
if(!this.clientClosedConnection) {
this.lastDisconnectedReason = 'WEBSOCKET_CLOSED_REMOTELY'
this.clientClosedConnection = false;
}
else if(!this.lastDisconnectedReason) {
// let's have at least some sort of type, however generci
this.lastDisconnectedReason = 'WEBSOCKET_CLOSED_LOCALLY'
}
/**
if ($currentDisplay.is('.no-websocket-connection')) {
// this path is the 'not in session path'; so there is nothing else to do
$currentDisplay.removeClass('active');
// TODO: tell certain elements that we've reconnected
}
else {
window.location.reload();
}*/
}
// websocket couldn't connect. let's try again soon
closedOnReconnectAttempt() {
this.failedReconnect();
}
failedReconnect() {
this.reconnectAttempt += 1;
this.renderCouldNotReconnect();
this.beginReconnectPeriod();
}
renderCouldNotReconnect() {
return renderDisconnected();
}
renderDisconnected() {
//logger.debug("")
}
guardAgainstRapidTransition(start, nextStep) {
var now = new Date().getTime();
if ((now - start) < 1500) {
setTimeout(() => {
nextStep();
}, 1500 - (now - start))
}
else {
nextStep();
}
}
clearReconnectTimers() {
if (this.countdownInterval) {
clearInterval(this.countdownInterval);
this.countdownInterval = null;
}
}
reconnectDelaySecs() {
if (this.reconnectAttempt > this.reconnectAttemptLookup.length - 1) {
return this.reconnectAttemptLookup[this.reconnectAttemptLookup.length - 1];
}
else {
return this.reconnectAttemptLookup[this.reconnectAttempt];
}
}
////////////////////
//// HEARTBEAT /////
////////////////////
heartbeatAck() {
logger.debug("client comm: heartbeat ack")
this.lastHeartbeatAckTime = new Date()
}
heartbeatAckCheck() {
// if we've seen an ack to the latest heartbeat, don't bother with checking again
// this makes us resilient to front-end hangs
if (this.lastHeartbeatFound) {
return;
}
// check if the server is still sending heartbeat acks back down
// this logic equates to 'if we have not received a heartbeat within heartbeatMissedMS, then get upset
if (new Date().getTime() - this.lastHeartbeatAckTime.getTime() > this.connection_expire_time) {
logger.error("client comm: no heartbeat ack received from server after ", this.connection_expire_time, " seconds . giving up on socket connection");
this.lastDisconnectedReason = 'NO_HEARTBEAT_ACK';
this.close(true);
}
else {
this.lastHeartbeatFound = true;
}
}
heartbeatStateReset() {
this.lastHeartbeatSentTime = null;
this.lastHeartbeatAckTime = null;
this.lastHeartbeatFound = false;
}
sendHeartbeat() {
let msg = { heartbeat: this.heartbeatId.toString() }
this.heartbeatId += 1;
// for debugging purposes, see if the last time we've sent a heartbeat is way off (500ms) of the target interval
var now = new Date();
if(this.lastHeartbeatSentTime) {
var drift = new Date().getTime() - this.lastHeartbeatSentTime.getTime() - this.heartbeatMS;
if (drift > 500) {
logger.warn("client comm: significant drift between heartbeats: " + drift + 'ms beyond target interval')
}
}
this.lastHeartbeatSentTime = now;
this.send(msg);
this.lastHeartbeatFound = false;
}
async meh () {
logger.debug("meh ")
this.IsMyNetworkWireless()
logger.debug("lurp?")
}
async IsMyNetworkWireless() {
logger.debug("IsMyNetworkWireless invoking...")
let response = await this.invokeMethod('IsMyNetworkWireless()')
logger.debug("IsMyNetworkWireless invoked", response)
return response
}
}
/**
setTimeout(function(){
let bridge = new Bridge({})
bridge.connect().then(function(){
console.log("CONNECTED!!")
//bridge.meh()
bridge.IsMyNetworkWireless()
console.log("so fast")
})
}, 500)
*/