jam-cloud/web/app/assets/javascripts/JamServer.js

624 lines
20 KiB
JavaScript
Raw Normal View History

// The wrapper around the web-socket connection to the server
2014-04-09 17:25:52 +00:00
// manages the connection, heartbeats, and reconnect logic.
// presents itself as a dialog, or in-situ banner (_jamServer.html.haml)
(function (context, $) {
"use strict";
context.JK = context.JK || {};
var logger = context.JK.logger;
var msg_factory = context.JK.MessageFactory;
// Let socket.io know where WebSocketMain.swf is
context.WEB_SOCKET_SWF_LOCATION = "assets/flash/WebSocketMain.swf";
context.JK.JamServer = function (app) {
// heartbeat
var heartbeatInterval = null;
var heartbeatMS = null;
2014-04-28 19:47:24 +00:00
var heartbeatMissedMS = 10000; // if 10 seconds go by and we haven't seen a heartbeat ack, get upset
var lastHeartbeatSentTime = null;
2014-04-09 17:25:52 +00:00
var lastHeartbeatAckTime = null;
var lastHeartbeatFound = false;
var heartbeatAckCheckInterval = null;
var notificationLastSeenAt = undefined;
var notificationLastSeen = undefined;
// reconnection logic
var connectDeferred = null;
var freezeInteraction = false;
var countdownInterval = null;
var reconnectAttemptLookup = [2, 2, 2, 4, 8, 15, 30];
var reconnectAttempt = 0;
var reconnectingWaitPeriodStart = null;
var reconnectDueTime = null;
var connectTimeout = null;
// elements
var $inSituBanner = null;
var $inSituBannerHolder = null;
var $messageContents = null;
var $dialog = null;
var $templateServerConnection = null;
var $templateDisconnected = null;
var $currentDisplay = null;
var server = {};
server.socket = {};
server.signedIn = false;
server.clientID = "";
server.publicIP = "";
server.dispatchTable = {};
server.socketClosedListeners = [];
server.connected = false;
2014-04-09 17:25:52 +00:00
// if activeElementVotes is null, then we are assuming this is the initial connect sequence
function initiateReconnect(activeElementVotes, in_error) {
var initialConnect = !!activeElementVotes;
freezeInteraction = activeElementVotes && ((activeElementVotes.dialog && activeElementVotes.dialog.freezeInteraction === true) || (activeElementVotes.screen && activeElementVotes.screen.freezeInteraction === true));
2014-04-28 19:51:27 +00:00
if (!initialConnect) {
2014-04-09 17:25:52 +00:00
context.JK.CurrentSessionModel.onWebsocketDisconnected(in_error);
}
2014-04-28 19:51:27 +00:00
if (in_error) {
2014-04-09 17:25:52 +00:00
reconnectAttempt = 0;
$currentDisplay = renderDisconnected();
beginReconnectPeriod();
}
}
2014-01-06 21:08:37 +00:00
// handles logic if the websocket connection closes, and if it was in error then also prompt for reconnect
function closedCleanup(in_error) {
2014-04-09 17:25:52 +00:00
// stop future heartbeats
if (heartbeatInterval != null) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
// stop checking for heartbeat acks
if (heartbeatAckCheckInterval != null) {
clearTimeout(heartbeatAckCheckInterval);
heartbeatAckCheckInterval = null;
}
if (server.connected) {
server.connected = false;
2014-04-28 19:51:27 +00:00
if (app.clientUpdating) {
2014-04-09 17:25:52 +00:00
// we don't want to do a 'cover the whole screen' dialog
// because the client update is already showing.
return;
}
2014-04-09 17:25:52 +00:00
server.reconnecting = true;
var result = app.activeElementEvent('beforeDisconnect');
initiateReconnect(result, in_error);
app.activeElementEvent('afterDisconnect');
// notify anyone listening that the socket closed
var len = server.socketClosedListeners.length;
for (var i = 0; i < len; i++) {
try {
server.socketClosedListeners[i](in_error);
} catch (ex) {
logger.warn('exception in callback for websocket closed event:' + ex);
}
}
}
}
////////////////////
//// HEARTBEAT /////
////////////////////
function _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 (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() - lastHeartbeatAckTime.getTime() > heartbeatMissedMS) {
logger.error("no heartbeat ack received from server after ", heartbeatMissedMS, " seconds . giving up on socket connection");
context.JK.JamServer.close(true);
}
else {
lastHeartbeatFound = true;
}
}
2014-04-09 17:25:52 +00:00
function _heartbeat() {
if (app.heartbeatActive) {
var message = context.JK.MessageFactory.heartbeat(notificationLastSeen, notificationLastSeenAt);
notificationLastSeenAt = undefined;
notificationLastSeen = undefined;
2014-04-28 19:47:24 +00:00
// 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();
2014-04-28 19:51:27 +00:00
if (lastHeartbeatSentTime) {
2014-04-28 19:47:24 +00:00
var drift = new Date().getTime() - lastHeartbeatSentTime.getTime() - heartbeatMS;
2014-04-28 19:51:27 +00:00
if (drift > 500) {
2014-04-28 19:47:24 +00:00
logger.error("significant drift between heartbeats: " + drift + 'ms beyond target interval')
}
}
lastHeartbeatSentTime = now;
2014-04-09 17:25:52 +00:00
context.JK.JamServer.send(message);
lastHeartbeatFound = false;
}
}
function loggedIn(header, payload) {
2014-04-28 19:51:27 +00:00
if (!connectTimeout) {
2014-04-09 17:25:52 +00:00
clearTimeout(connectTimeout);
connectTimeout = null;
}
app.clientId = payload.client_id;
// tell the backend that we have logged in
context.jamClient.OnLoggedIn(payload.user_id, payload.token);
$.cookie('client_id', payload.client_id);
heartbeatMS = payload.heartbeat_interval * 1000;
logger.debug("jamkazam.js.loggedIn(): clientId now " + app.clientId + "; Setting up heartbeat every " + heartbeatMS + " MS");
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
connectDeferred.resolve();
app.activeElementEvent('afterConnect', payload);
}
2014-04-09 17:25:52 +00:00
function heartbeatAck(header, payload) {
lastHeartbeatAckTime = new Date();
context.JK.CurrentSessionModel.trackChanges(header, payload);
}
function registerLoginAck() {
logger.debug("register for loggedIn to set clientId");
context.JK.JamServer.registerMessageCallback(context.JK.MessageType.LOGIN_ACK, loggedIn);
}
function registerHeartbeatAck() {
logger.debug("register for heartbeatAck");
context.JK.JamServer.registerMessageCallback(context.JK.MessageType.HEARTBEAT_ACK, heartbeatAck);
}
function registerSocketClosed() {
logger.debug("register for socket closed");
context.JK.JamServer.registerOnSocketClosed(socketClosed);
}
/**
* Called whenever the websocket closes; this gives us a chance to cleanup things that should be stopped/cleared
* @param in_error did the socket close abnormally?
*/
function socketClosed(in_error) {
// tell the backend that we have logged out
context.jamClient.OnLoggedOut();
}
///////////////////
/// RECONNECT /////
///////////////////
function internetUp() {
var start = new Date().getTime();
server.connect()
2014-04-28 19:51:27 +00:00
.done(function () {
2014-04-09 17:25:52 +00:00
guardAgainstRapidTransition(start, performReconnect);
})
2014-04-28 19:51:27 +00:00
.fail(function () {
2014-04-09 17:25:52 +00:00
guardAgainstRapidTransition(start, closedOnReconnectAttempt);
});
}
// websocket couldn't connect. let's try again soon
function closedOnReconnectAttempt() {
failedReconnect();
}
function performReconnect() {
2014-04-28 19:51:27 +00:00
if ($currentDisplay.is('.no-websocket-connection')) {
2014-04-09 17:25:52 +00:00
$currentDisplay.hide();
// TODO: tell certain elements that we've reconnected
}
else {
context.JK.CurrentSessionModel.leaveCurrentSession()
2014-04-28 19:51:27 +00:00
.always(function () {
2014-04-09 17:25:52 +00:00
window.location.reload();
});
}
server.reconnecting = false;
}
function buildOptions() {
return {};
}
function renderDisconnected() {
var content = null;
2014-04-28 19:51:27 +00:00
if (freezeInteraction) {
2014-04-09 17:25:52 +00:00
var template = $templateDisconnected.html();
var templateHtml = $(context.JK.fillTemplate(template, buildOptions()));
templateHtml.find('.reconnect-countdown').html(formatDelaySecs(reconnectDelaySecs()));
content = context.JK.Banner.show({
2014-04-28 19:51:27 +00:00
html: templateHtml,
2014-04-09 17:25:52 +00:00
type: 'reconnect'
2014-04-28 19:51:27 +00:00
});
2014-04-09 17:25:52 +00:00
}
else {
var $inSituContent = $(context._.template($templateServerConnection.html(), buildOptions(), { variable: 'data' }));
$inSituContent.find('.reconnect-countdown').html(formatDelaySecs(reconnectDelaySecs()));
$messageContents.empty();
$messageContents.append($inSituContent);
$inSituBannerHolder.show();
content = $inSituBannerHolder;
}
return content;
}
function formatDelaySecs(secs) {
2014-04-28 19:51:27 +00:00
return $('<span class="countdown-seconds"><span class="countdown">' + secs + '</span> ' + (secs == 1 ? ' second.<span style="visibility:hidden">s</span>' : 'seconds.') + '</span>');
2014-04-09 17:25:52 +00:00
}
function setCountdown($parent) {
$parent.find('.reconnect-countdown').html(formatDelaySecs(reconnectDelaySecs()));
}
function renderCouldNotReconnect() {
return renderDisconnected();
}
function renderReconnecting() {
$currentDisplay.find('.reconnect-progress-msg').text('Attempting to reconnect...')
2014-04-28 19:51:27 +00:00
if ($currentDisplay.is('.no-websocket-connection')) {
2014-04-09 17:25:52 +00:00
$currentDisplay.find('.disconnected-reconnect').removeClass('reconnect-enabled').addClass('reconnect-disabled');
}
else {
$currentDisplay.find('.disconnected-reconnect').removeClass('button-orange').addClass('button-grey');
}
}
function failedReconnect() {
reconnectAttempt += 1;
$currentDisplay = renderCouldNotReconnect();
beginReconnectPeriod();
}
function guardAgainstRapidTransition(start, nextStep) {
var now = new Date().getTime();
if ((now - start) < 1500) {
2014-04-28 19:51:27 +00:00
setTimeout(function () {
2014-04-09 17:25:52 +00:00
nextStep();
}, 1500 - (now - start))
}
else {
nextStep();
}
}
function attemptReconnect() {
var start = new Date().getTime();
renderReconnecting();
rest.serverHealthCheck()
2014-04-28 19:51:27 +00:00
.done(function () {
2014-04-09 17:25:52 +00:00
guardAgainstRapidTransition(start, internetUp);
})
2014-04-28 19:51:27 +00:00
.fail(function (xhr, textStatus, errorThrown) {
2014-04-09 17:25:52 +00:00
2014-04-28 19:51:27 +00:00
if (xhr && xhr.status >= 100) {
2014-04-09 17:25:52 +00:00
// we could connect to the server, and it's alive
guardAgainstRapidTransition(start, internetUp);
}
else {
guardAgainstRapidTransition(start, failedReconnect);
}
});
return false;
}
function clearReconnectTimers() {
2014-04-28 19:51:27 +00:00
if (countdownInterval) {
2014-04-09 17:25:52 +00:00
clearInterval(countdownInterval);
countdownInterval = null;
}
}
function beginReconnectPeriod() {
// allow user to force reconnect
2014-04-28 19:51:27 +00:00
$currentDisplay.find('a.disconnected-reconnect').unbind('click').click(function () {
if ($(this).is('.button-orange') || $(this).is('.reconnect-enabled')) {
2014-04-09 17:25:52 +00:00
clearReconnectTimers();
attemptReconnect();
}
return false;
});
reconnectingWaitPeriodStart = new Date().getTime();
reconnectDueTime = reconnectingWaitPeriodStart + reconnectDelaySecs() * 1000;
// update count down timer periodically
2014-04-28 19:51:27 +00:00
countdownInterval = setInterval(function () {
2014-04-09 17:25:52 +00:00
var now = new Date().getTime();
2014-04-28 19:51:27 +00:00
if (now > reconnectDueTime) {
2014-04-09 17:25:52 +00:00
clearReconnectTimers();
attemptReconnect();
}
else {
var secondsUntilReconnect = Math.ceil((reconnectDueTime - now) / 1000);
$currentDisplay.find('.reconnect-countdown').html(formatDelaySecs(secondsUntilReconnect));
}
2014-04-09 17:25:52 +00:00
}, 333);
}
function reconnectDelaySecs() {
if (reconnectAttempt > reconnectAttemptLookup.length - 1) {
return reconnectAttemptLookup[reconnectAttemptLookup.length - 1];
}
else {
return reconnectAttemptLookup[reconnectAttempt];
}
}
server.registerOnSocketClosed = function (callback) {
server.socketClosedListeners.push(callback);
}
server.registerMessageCallback = function (messageType, callback) {
if (server.dispatchTable[messageType] === undefined) {
server.dispatchTable[messageType] = [];
}
2014-04-09 17:25:52 +00:00
server.dispatchTable[messageType].push(callback);
};
2014-04-09 17:25:52 +00:00
server.unregisterMessageCallback = function (messageType, callback) {
if (server.dispatchTable[messageType] !== undefined) {
for (var i = server.dispatchTable[messageType].length; i--;) {
if (server.dispatchTable[messageType][i] === callback) {
server.dispatchTable[messageType].splice(i, 1);
break;
}
}
2014-04-09 17:25:52 +00:00
if (server.dispatchTable[messageType].length === 0) {
delete server.dispatchTable[messageType];
}
}
};
2014-04-09 17:25:52 +00:00
server.connect = function () {
connectDeferred = new $.Deferred();
logger.log("server.connect");
var uri = context.JK.websocket_gateway_uri; // Set in index.html.erb.
//var uri = context.gon.websocket_gateway_uri; // Leaving here for now, as we're looking for a better solution.
server.socket = new context.WebSocket(uri);
server.socket.onopen = server.onOpen;
server.socket.onmessage = server.onMessage;
server.socket.onclose = server.onClose;
2014-04-28 19:51:27 +00:00
connectTimeout = setTimeout(function () {
2014-04-09 17:25:52 +00:00
connectTimeout = null;
2014-04-28 19:51:27 +00:00
if (connectDeferred.state() === 'pending') {
2014-04-28 19:47:24 +00:00
server.close(true);
2014-04-09 17:25:52 +00:00
connectDeferred.reject();
}
}, 4000);
return connectDeferred;
};
2014-04-09 17:25:52 +00:00
server.close = function (in_error) {
logger.log("closing websocket");
2014-04-09 17:25:52 +00:00
server.socket.close();
2014-04-09 17:25:52 +00:00
closedCleanup(in_error);
}
2014-04-09 17:25:52 +00:00
server.rememberLogin = function () {
var token, loginMessage;
token = $.cookie("remember_token");
var clientType = context.jamClient.IsNativeClient() ? 'client' : 'browser';
loginMessage = msg_factory.login_with_token(token, null, clientType);
server.send(loginMessage);
};
2014-04-09 17:25:52 +00:00
server.onOpen = function () {
logger.log("server.onOpen");
server.rememberLogin();
};
2014-04-09 17:25:52 +00:00
server.onMessage = function (e) {
var message = JSON.parse(e.data),
messageType = message.type.toLowerCase(),
payload = message[messageType],
callbacks = server.dispatchTable[message.type];
if (message.type != context.JK.MessageType.HEARTBEAT_ACK && message.type != context.JK.MessageType.PEER_MESSAGE) {
logger.log("server.onMessage:" + messageType + " payload:" + JSON.stringify(payload));
}
if (callbacks !== undefined) {
var len = callbacks.length;
for (var i = 0; i < len; i++) {
try {
callbacks[i](message, payload);
} catch (ex) {
logger.warn('exception in callback for websocket message:' + ex);
throw ex;
}
}
2014-04-09 17:25:52 +00:00
}
else {
logger.log("Unexpected message type %s.", message.type);
}
};
2014-04-09 17:25:52 +00:00
server.onClose = function () {
logger.log("Socket to server closed.");
2014-04-28 19:51:27 +00:00
if (connectDeferred.state() === "pending") {
2014-04-09 17:25:52 +00:00
connectDeferred.reject();
}
2014-04-09 17:25:52 +00:00
closedCleanup(true);
};
2014-04-09 17:25:52 +00:00
server.send = function (message) {
2014-04-09 17:25:52 +00:00
var jsMessage = JSON.stringify(message);
2014-04-09 17:25:52 +00:00
if (message.type != context.JK.MessageType.HEARTBEAT && message.type != context.JK.MessageType.PEER_MESSAGE) {
logger.log("server.send(" + jsMessage + ")");
}
if (server !== undefined && server.socket !== undefined && server.socket.send !== undefined) {
server.socket.send(jsMessage);
} else {
logger.log("Dropped message because server connection is closed.");
}
};
2014-04-09 17:25:52 +00:00
server.loginSession = function (sessionId) {
var loginMessage;
2014-04-09 17:25:52 +00:00
if (!server.signedIn) {
logger.log("Not signed in!");
// TODO: surface the error
return;
}
2014-04-09 17:25:52 +00:00
loginMessage = msg_factory.login_jam_session(sessionId);
server.send(loginMessage);
};
/** with the advent of the reliable UDP channel, this is no longer how messages are sent from client-to-clent
* however, the mechanism still exists and is useful in test contexts; and maybe in the future
* @param receiver_id client ID of message to send
* @param message the actual message
*/
2014-04-09 17:25:52 +00:00
server.sendP2PMessage = function (receiver_id, message) {
//logger.log("P2P message from [" + server.clientID + "] to [" + receiver_id + "]: " + message);
//console.time('sendP2PMessage');
var outgoing_msg = msg_factory.client_p2p_message(server.clientID, receiver_id, message);
server.send(outgoing_msg);
//console.timeEnd('sendP2PMessage');
};
2014-04-28 19:51:27 +00:00
server.updateNotificationSeen = function (notificationId, notificationCreatedAt) {
2014-04-09 17:25:52 +00:00
var time = new Date(notificationCreatedAt);
2014-04-28 19:51:27 +00:00
if (!notificationCreatedAt) {
2014-04-09 17:25:52 +00:00
throw 'invalid value passed to updateNotificationSeen'
}
2014-04-28 19:51:27 +00:00
if (!notificationLastSeenAt) {
2014-04-09 17:25:52 +00:00
notificationLastSeenAt = notificationCreatedAt;
notificationLastSeen = notificationId;
logger.debug("updated notificationLastSeenAt with: " + notificationCreatedAt);
}
2014-04-28 19:51:27 +00:00
else if (time.getTime() > new Date(notificationLastSeenAt).getTime()) {
2014-04-09 17:25:52 +00:00
notificationLastSeenAt = notificationCreatedAt;
notificationLastSeen = notificationId;
logger.debug("updated notificationLastSeenAt with: " + notificationCreatedAt);
}
else {
logger.debug("ignored notificationLastSeenAt for: " + notificationCreatedAt);
}
}
// Message callbacks
2014-04-09 17:25:52 +00:00
server.registerMessageCallback(context.JK.MessageType.LOGIN_ACK, function (header, payload) {
server.signedIn = true;
logger.debug("Handling LOGIN_ACK. Updating client id to " + payload.client_id);
server.clientID = payload.client_id;
server.publicIP = payload.public_ip;
server.connected = true;
if (context.jamClient !== undefined) {
logger.debug("... (handling LOGIN_ACK) Updating backend client, connected to true and clientID to " +
payload.client_id);
context.jamClient.connected = true;
context.jamClient.clientID = server.clientID;
}
});
2014-04-09 17:25:52 +00:00
server.registerMessageCallback(context.JK.MessageType.PEER_MESSAGE, function (header, payload) {
if (context.jamClient !== undefined) {
context.jamClient.P2PMessageReceived(header.from, payload.message);
}
});
2014-04-09 17:25:52 +00:00
context.JK.JamServer = server;
// Callbacks from jamClient
2014-04-09 17:25:52 +00:00
if (context.jamClient !== undefined) {
context.jamClient.SendP2PMessage.connect(server.sendP2PMessage);
}
2014-04-09 17:25:52 +00:00
function initialize() {
registerLoginAck();
registerHeartbeatAck();
registerSocketClosed();
$inSituBanner = $('.server-connection');
$inSituBannerHolder = $('.no-websocket-connection');
$messageContents = $inSituBannerHolder.find('.message-contents');
$dialog = $('#banner');
$templateServerConnection = $('#template-server-connection');
$templateDisconnected = $('#template-disconnected');
2014-04-28 19:51:27 +00:00
if ($inSituBanner.length != 1) {
throw "found wrong number of .server-connection: " + $inSituBanner.length;
}
if ($inSituBannerHolder.length != 1) {
throw "found wrong number of .no-websocket-connection: " + $inSituBannerHolder.length;
}
if ($messageContents.length != 1) {
throw "found wrong number of .message-contents: " + $messageContents.length;
}
if ($dialog.length != 1) {
throw "found wrong number of #banner: " + $dialog.length;
}
if ($templateServerConnection.length != 1) {
throw "found wrong number of #template-server-connection: " + $templateServerConnection.length;
}
if ($templateDisconnected.length != 1) {
throw "found wrong number of #template-disconnected: " + $templateDisconnected.length;
}
2014-04-09 17:25:52 +00:00
}
2014-04-09 17:25:52 +00:00
this.initialize = initialize;
this.initiateReconnect = initiateReconnect;
2014-04-09 17:25:52 +00:00
return this;
}
})(window, jQuery);