/** * 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) */