611 lines
18 KiB
JavaScript
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)
|
|
|
|
*/
|