From 438a72724b0ce86f95b6215bdafac3ff501d5d1f Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Tue, 5 May 2020 17:17:12 -0500 Subject: [PATCH] add WebSocket reconnection logic --- assets/js/app.js | 8 ++ assets/js/main.js | 141 +------------------------------- assets/js/socket.js | 191 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 138 deletions(-) create mode 100644 assets/js/socket.js diff --git a/assets/js/app.js b/assets/js/app.js index 061da70..c291dc6 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -1,4 +1,5 @@ import { ServerRegistry } from './servers' +import { SocketManager } from './socket' import { SortController } from './sort' import { GraphDisplayManager } from './graph' import { MojangUpdater } from './mojang' @@ -13,6 +14,7 @@ export class App { this.tooltip = new Tooltip() this.caption = new Caption() this.serverRegistry = new ServerRegistry(this) + this.socketManager = new SocketManager(this) this.sortController = new SortController(this) this.graphDisplayManager = new GraphDisplayManager(this) this.mojangUpdater = new MojangUpdater() @@ -22,6 +24,11 @@ export class App { this._taskIds = [] } + // Called once the DOM is ready and the app can begin setup + init () { + this.socketManager.createWebSocket() + } + setPageReady (isReady) { document.getElementById('push').style.display = isReady ? 'block' : 'none' document.getElementById('footer').style.display = isReady ? 'block' : 'none' @@ -60,6 +67,7 @@ export class App { // Reset individual tracker elements to flush any held data this.serverRegistry.reset() + this.socketManager.reset() this.sortController.reset() this.graphDisplayManager.reset() this.mojangUpdater.reset() diff --git a/assets/js/main.js b/assets/js/main.js index 70e6b17..c0ee1b5 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -2,141 +2,8 @@ import { App } from './app' const app = new App() -document.addEventListener('DOMContentLoaded', function () { - const webSocket = new WebSocket('ws://' + location.host) - - // The backend will automatically push data once connected - webSocket.onopen = () => { - app.caption.set('Loading...') - } - - webSocket.onclose = (event) => { - app.handleDisconnect() - - // Modify page state to display loading overlay - // Code 1006 denotes "Abnormal closure", most likely from the server or client losing connection - // See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent - // Treat other codes as active errors (besides connectivity errors) when displaying the message - if (event.code === 1006) { - app.caption.set('Lost connection!') - } else { - app.caption.set('Disconnected due to error.') - } - - // Reset modified DOM structures - document.getElementById('big-graph-mobile-load-request').style.display = 'none' - - // TODO: Reconnect behavior - } - - webSocket.onmessage = (message) => { - const payload = JSON.parse(message.data) - - switch (payload.message) { - case 'init': - app.setPublicConfig(payload.config) - - // Display the main page component - // Called here instead of syncComplete so the DOM can be drawn prior to the graphs being drawn - // Otherwise flot.js will cause visual alignment bugs - app.setPageReady(true) - - // Allow the graphDisplayManager to control whether or not the historical graph is loaded - // Defer to isGraphVisible from the publicConfig to understand if the frontend will ever receive a graph payload - if (app.publicConfig.isGraphVisible) { - if (app.graphDisplayManager.isVisible) { - // Send request as a plain text string to avoid the server needing to parse JSON - // This is mostly to simplify the backend server's need for error handling - webSocket.send('requestHistoryGraph') - } else { - document.getElementById('big-graph-mobile-load-request').style.display = 'block' - } - } - - payload.servers.forEach(app.addServer) - - if (payload.mojangServices) { - app.mojangUpdater.updateStatus(payload.mojangServices) - } - - // Init payload contains all data needed to render the page - // Alert the app it is ready - app.handleSyncComplete() - - break - - case 'updateServer': { - // The backend may send "update" events prior to receiving all "add" events - // A server has only been added once it's ServerRegistration is defined - // Checking undefined protects from this race condition - const serverRegistration = app.serverRegistry.getServerRegistration(payload.serverId) - - if (serverRegistration) { - serverRegistration.updateServerStatus(payload, false, app.publicConfig.minecraftVersions) - } - - // Use update payloads to conditionally append data to graph - // Skip any incoming updates if the graph is disabled - if (payload.updateHistoryGraph && app.graphDisplayManager.isVisible) { - // Update may not be successful, safely append 0 points - const playerCount = payload.result ? payload.result.players.online : 0 - - app.graphDisplayManager.addGraphPoint(serverRegistration.serverId, payload.timestamp, playerCount) - - // Only redraw the graph if not mutating hidden data - if (serverRegistration.isVisible) { - app.graphDisplayManager.requestRedraw() - } - } - break - } - - case 'updateMojangServices': { - app.mojangUpdater.updateStatus(payload) - break - } - - case 'historyGraph': { - // Consider the graph visible since a payload has been received - // This is used for the manual graph load request behavior - app.graphDisplayManager.isVisible = true - - app.graphDisplayManager.buildPlotInstance(payload.graphData) - - // Build checkbox elements for graph controls - let lastRowCounter = 0 - let controlsHTML = '' - - app.serverRegistry.getServerRegistrations() - .map(serverRegistration => serverRegistration.data.name) - .sort() - .forEach(serverName => { - const serverRegistration = app.serverRegistry.getServerRegistration(serverName) - - controlsHTML += '' + - '' + - ' ' + serverName + - '' - - // Occasionally break table rows using a magic number - if (++lastRowCounter % 6 === 0) { - controlsHTML += '' - } - }) - - // Apply generated HTML and show controls - document.getElementById('big-graph-checkboxes').innerHTML = '' + - controlsHTML + - '
' - - document.getElementById('big-graph-controls').style.display = 'block' - - // Bind click event for updating graph data - app.graphDisplayManager.initEventListeners() - break - } - } - } +document.addEventListener('DOMContentLoaded', () => { + app.init() window.addEventListener('resize', function () { app.percentageBar.redraw() @@ -147,9 +14,7 @@ document.addEventListener('DOMContentLoaded', function () { document.getElementById('big-graph-mobile-load-request-button').addEventListener('click', function () { // Send a graph data request to the backend - // Send request as a plain text string to avoid the server needing to parse JSON - // This is mostly to simplify the backend server's need for error handling - webSocket.send('requestHistoryGraph') + app.socketManager.sendHistoryGraphRequest() // Hide the activation link to avoid multiple requests document.getElementById('big-graph-mobile-load-request').style.display = 'none' diff --git a/assets/js/socket.js b/assets/js/socket.js new file mode 100644 index 0000000..fd0d6de --- /dev/null +++ b/assets/js/socket.js @@ -0,0 +1,191 @@ +export class SocketManager { + constructor (app) { + this._app = app + this._hasRequestedHistoryGraph = false + this._reconnectDelayBase = 0 + } + + reset () { + this._hasRequestedHistoryGraph = false + } + + createWebSocket () { + this._webSocket = new WebSocket('ws://' + location.host) + + // The backend will automatically push data once connected + this._webSocket.onopen = () => { + this._app.caption.set('Loading...') + + // Reset reconnection scheduling since the WebSocket has been established + this._reconnectDelayBase = 0 + } + + this._webSocket.onclose = (event) => { + this._app.handleDisconnect() + + // Modify page state to display loading overlay + // Code 1006 denotes "Abnormal closure", most likely from the server or client losing connection + // See https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent + // Treat other codes as active errors (besides connectivity errors) when displaying the message + if (event.code === 1006) { + this._app.caption.set('Lost connection!') + } else { + this._app.caption.set('Disconnected due to error.') + } + + // Reset modified DOM structures + document.getElementById('big-graph-mobile-load-request').style.display = 'none' + + // Schedule socket reconnection attempt + this.scheduleReconnect() + } + + this._webSocket.onmessage = (message) => { + const payload = JSON.parse(message.data) + + switch (payload.message) { + case 'init': + this._app.setPublicConfig(payload.config) + + // Display the main page component + // Called here instead of syncComplete so the DOM can be drawn prior to the graphs being drawn + // Otherwise flot.js will cause visual alignment bugs + this._app.setPageReady(true) + + // Allow the graphDisplayManager to control whether or not the historical graph is loaded + // Defer to isGraphVisible from the publicConfig to understand if the frontend will ever receive a graph payload + if (this._app.publicConfig.isGraphVisible) { + if (this._app.graphDisplayManager.isVisible) { + this.sendHistoryGraphRequest() + } else { + document.getElementById('big-graph-mobile-load-request').style.display = 'block' + } + } + + payload.servers.forEach(this._app.addServer) + + if (payload.mojangServices) { + this._app.mojangUpdater.updateStatus(payload.mojangServices) + } + + // Init payload contains all data needed to render the page + // Alert the app it is ready + this._app.handleSyncComplete() + + break + + case 'updateServer': { + // The backend may send "update" events prior to receiving all "add" events + // A server has only been added once it's ServerRegistration is defined + // Checking undefined protects from this race condition + const serverRegistration = this._app.serverRegistry.getServerRegistration(payload.serverId) + + if (serverRegistration) { + serverRegistration.updateServerStatus(payload, false, this._app.publicConfig.minecraftVersions) + } + + // Use update payloads to conditionally append data to graph + // Skip any incoming updates if the graph is disabled + if (payload.updateHistoryGraph && this._app.graphDisplayManager.isVisible) { + // Update may not be successful, safely append 0 points + const playerCount = payload.result ? payload.result.players.online : 0 + + this._app.graphDisplayManager.addGraphPoint(serverRegistration.serverId, payload.timestamp, playerCount) + + // Only redraw the graph if not mutating hidden data + if (serverRegistration.isVisible) { + this._app.graphDisplayManager.requestRedraw() + } + } + break + } + + case 'updateMojangServices': { + this._app.mojangUpdater.updateStatus(payload) + break + } + + case 'historyGraph': { + // Consider the graph visible since a payload has been received + // This is used for the manual graph load request behavior + this._app.graphDisplayManager.isVisible = true + + this._app.graphDisplayManager.buildPlotInstance(payload.graphData) + + // Build checkbox elements for graph controls + let lastRowCounter = 0 + let controlsHTML = '' + + this._app.serverRegistry.getServerRegistrations() + .map(serverRegistration => serverRegistration.data.name) + .sort() + .forEach(serverName => { + const serverRegistration = this._app.serverRegistry.getServerRegistration(serverName) + + controlsHTML += '' + + '' + + ' ' + serverName + + '' + + // Occasionally break table rows using a magic number + if (++lastRowCounter % 6 === 0) { + controlsHTML += '' + } + }) + + // Apply generated HTML and show controls + document.getElementById('big-graph-checkboxes').innerHTML = '' + + controlsHTML + + '
' + + document.getElementById('big-graph-controls').style.display = 'block' + + // Bind click event for updating graph data + this._app.graphDisplayManager.initEventListeners() + break + } + } + } + } + + scheduleReconnect () { + // Release any active WebSocket references + this._webSocket = undefined + + this._reconnectDelayBase++ + + // Exponential backoff for reconnection attempts + // Clamp ceiling value to 30 seconds + this._reconnectDelaySeconds = Math.min((this._reconnectDelayBase * this._reconnectDelayBase), 30) + + const reconnectInterval = setInterval(() => { + this._reconnectDelaySeconds-- + + if (this._reconnectDelaySeconds === 0) { + // Explicitly clear interval, this avoids race conditions + // #clearInterval first to avoid potential errors causing pre-mature returns + clearInterval(reconnectInterval) + + // Update displayed text + this._app.caption.set('Reconnecting...') + + // Attempt reconnection + // Only attempt when reconnectDelaySeconds === 0 and not <= 0, otherwise multiple attempts may be started + this.createWebSocket() + } else if (this._reconnectDelaySeconds > 0) { + // Update displayed text + this._app.caption.set('Reconnecting in ' + this._reconnectDelaySeconds + 's...') + } + }, 1000) + } + + sendHistoryGraphRequest () { + if (!this._hasRequestedHistoryGraph) { + this._hasRequestedHistoryGraph = true + + // Send request as a plain text string to avoid the server needing to parse JSON + // This is mostly to simplify the backend server's need for error handling + this._webSocket.send('requestHistoryGraph') + } + } +}