From ca9e127e3e1a39c8719ce6ae50e813a73c5aa78b Mon Sep 17 00:00:00 2001 From: Nick Krecklow Date: Tue, 5 May 2020 16:49:01 -0500 Subject: [PATCH] replace socket.io usage with WebSockets --- assets/js/app.js | 3 - assets/js/main.js | 203 +++++++++++++++++++++++++--------------------- config.json | 2 +- lib/app.js | 64 ++++++++------- lib/message.js | 6 ++ lib/mojang.js | 9 +- lib/ping.js | 5 +- lib/server.js | 79 ++++++++++-------- package-lock.json | 30 ++++++- package.json | 8 +- 10 files changed, 240 insertions(+), 169 deletions(-) create mode 100644 lib/message.js diff --git a/assets/js/app.js b/assets/js/app.js index c58425b..061da70 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -81,9 +81,6 @@ export class App { document.getElementById('stat_totalPlayers').innerText = 0 document.getElementById('stat_networks').innerText = 0 - // Modify page state to display loading overlay - this.caption.set('Lost connection!') - this.setPageReady(false) } diff --git a/assets/js/main.js b/assets/js/main.js index 2b3b36d..70e6b17 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,123 +1,142 @@ import { App } from './app' -import io from 'socket.io-client' - const app = new App() document.addEventListener('DOMContentLoaded', function () { - const socket = io.connect({ - reconnect: true, - reconnectDelay: 1000, - reconnectionAttempts: 10 - }) + const webSocket = new WebSocket('ws://' + location.host) // The backend will automatically push data once connected - socket.on('connect', function () { + webSocket.onopen = () => { app.caption.set('Loading...') - }) + } - socket.on('disconnect', function () { + 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' - }) - socket.on('historyGraph', function (data) { - // Consider the graph visible since a payload has been received - // This is used for the manual graph load request behavior - app.graphDisplayManager.isVisible = true + // TODO: Reconnect behavior + } - app.graphDisplayManager.buildPlotInstance(data) + webSocket.onmessage = (message) => { + const payload = JSON.parse(message.data) - // Build checkbox elements for graph controls - let lastRowCounter = 0 - let controlsHTML = '' + switch (payload.message) { + case 'init': + app.setPublicConfig(payload.config) - app.serverRegistry.getServerRegistrations() - .map(serverRegistration => serverRegistration.data.name) - .sort() - .forEach(serverName => { - const serverRegistration = app.serverRegistry.getServerRegistration(serverName) + // 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) - controlsHTML += '' + - '' + - ' ' + serverName + - '' - - // Occasionally break table rows using a magic number - if (++lastRowCounter % 6 === 0) { - controlsHTML += '' + // 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' + } } - }) - // Apply generated HTML and show controls - document.getElementById('big-graph-checkboxes').innerHTML = '' + - controlsHTML + - '
' + payload.servers.forEach(app.addServer) - document.getElementById('big-graph-controls').style.display = 'block' + if (payload.mojangServices) { + app.mojangUpdater.updateStatus(payload.mojangServices) + } - // Bind click event for updating graph data - app.graphDisplayManager.initEventListeners() - }) + // Init payload contains all data needed to render the page + // Alert the app it is ready + app.handleSyncComplete() - socket.on('add', function (data) { - data.forEach(app.addServer) - }) + break - socket.on('update', function (data) { - // 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(data.serverId) + 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(data, false, app.publicConfig.minecraftVersions) - } + 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 (data.updateHistoryGraph && app.graphDisplayManager.isVisible) { - // Update may not be successful, safely append 0 points - const playerCount = data.result ? data.result.players.online : 0 + // 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, data.timestamp, playerCount) + app.graphDisplayManager.addGraphPoint(serverRegistration.serverId, payload.timestamp, playerCount) - // Only redraw the graph if not mutating hidden data - if (serverRegistration.isVisible) { - app.graphDisplayManager.requestRedraw() + // 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 } } - }) - - socket.on('updateMojangServices', function (data) { - app.mojangUpdater.updateStatus(data) - }) - - socket.on('setPublicConfig', function (data) { - app.setPublicConfig(data) - - // 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 (data.isGraphVisible) { - if (app.graphDisplayManager.isVisible) { - socket.emit('requestHistoryGraph') - } else { - document.getElementById('big-graph-mobile-load-request').style.display = 'block' - } - } - }) - - // Fired once the backend has sent all requested data - socket.on('syncComplete', function () { - app.handleSyncComplete() - }) + } window.addEventListener('resize', function () { app.percentageBar.redraw() @@ -128,7 +147,9 @@ document.addEventListener('DOMContentLoaded', function () { document.getElementById('big-graph-mobile-load-request-button').addEventListener('click', function () { // Send a graph data request to the backend - socket.emit('requestHistoryGraph') + // 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') // Hide the activation link to avoid multiple requests document.getElementById('big-graph-mobile-load-request').style.display = 'none' diff --git a/config.json b/config.json index 3fd9359..0a11b03 100644 --- a/config.json +++ b/config.json @@ -9,6 +9,6 @@ "pingAll": 3000, "connectTimeout": 2500 }, - "logToDatabase": false, + "logToDatabase": true, "graphDuration": 86400000 } diff --git a/lib/app.js b/lib/app.js index b62f44a..820b42d 100644 --- a/lib/app.js +++ b/lib/app.js @@ -2,6 +2,7 @@ const Database = require('./database') const MojangUpdater = require('./mojang') const PingController = require('./ping') const Server = require('./server') +const MessageOf = require('./message') const config = require('../config') const minecraftVersions = require('../minecraft_versions') @@ -36,43 +37,46 @@ class App { handleClientConnection = (client) => { if (config.logToDatabase) { - client.on('requestHistoryGraph', () => { - // Send historical graphData built from all serverRegistrations - const graphData = {} + client.on('message', (message) => { + if (message === 'requestHistoryGraph') { + // Send historical graphData built from all serverRegistrations + const graphData = {} - this.serverRegistrations.forEach((serverRegistration) => { - graphData[serverRegistration.serverId] = serverRegistration.graphData - }) + this.serverRegistrations.forEach((serverRegistration) => { + graphData[serverRegistration.serverId] = serverRegistration.graphData + }) - client.emit('historyGraph', graphData) + // Send graphData in object wrapper to avoid needing to explicity filter + // any header data being appended by #MessageOf since the graph data is fed + // directly into the flot.js graphing system + client.send(MessageOf('historyGraph', { + graphData: graphData + })) + } }) } - client.emit('setPublicConfig', (() => { - // Remap minecraftVersion entries into name values - const minecraftVersionNames = {} - Object.keys(minecraftVersions).forEach(function (key) { - minecraftVersionNames[key] = minecraftVersions[key].map(version => version.name) - }) + const initMessage = { + config: (() => { + // Remap minecraftVersion entries into name values + const minecraftVersionNames = {} + Object.keys(minecraftVersions).forEach(function (key) { + minecraftVersionNames[key] = minecraftVersions[key].map(version => version.name) + }) - // Send configuration data for rendering the page - return { - graphDuration: config.graphDuration, - servers: this.serverRegistrations.map(serverRegistration => serverRegistration.data), - minecraftVersions: minecraftVersionNames, - isGraphVisible: config.logToDatabase - } - })()) + // Send configuration data for rendering the page + return { + graphDuration: config.graphDuration, + servers: this.serverRegistrations.map(serverRegistration => serverRegistration.data), + minecraftVersions: minecraftVersionNames, + isGraphVisible: config.logToDatabase + } + })(), + mojangServices: this.mojangUpdater.getLastUpdate(), + servers: this.serverRegistrations.map(serverRegistration => serverRegistration.getPingHistory()) + } - // Send last Mojang update, if any - this.mojangUpdater.sendLastUpdate(client) - - // Send pingHistory of all ServerRegistrations - client.emit('add', this.serverRegistrations.map(serverRegistration => serverRegistration.getPingHistory())) - - // Always send last - // This tells the frontend to do final processing and render - client.emit('syncComplete') + client.send(MessageOf('init', initMessage)) } } diff --git a/lib/message.js b/lib/message.js new file mode 100644 index 0000000..0a407f7 --- /dev/null +++ b/lib/message.js @@ -0,0 +1,6 @@ +module.exports = function MessageOf (name, data) { + return JSON.stringify({ + message: name, + ...data + }) +} diff --git a/lib/mojang.js b/lib/mojang.js index 5f932b5..7f83dab 100644 --- a/lib/mojang.js +++ b/lib/mojang.js @@ -1,6 +1,7 @@ const request = require('request') const logger = require('./logger') +const MessageOf = require('./message') const config = require('../config') @@ -65,14 +66,12 @@ class MojangUpdater { if (this._hasUpdated) { this._hasUpdated = false - this._app.server.broadcast('updateMojangServices', this._services) + this._app.server.broadcast(MessageOf('updateMojangServices', this._services)) } } - sendLastUpdate (client) { - if (this._services) { - client.emit('updateMojangServices', this._services) - } + getLastUpdate () { + return this._services } handleServiceUpdate (url, color) { diff --git a/lib/ping.js b/lib/ping.js index ac6a67c..65ea8af 100644 --- a/lib/ping.js +++ b/lib/ping.js @@ -4,6 +4,7 @@ const minecraftJavaPing = require('mc-ping-updated') const minecraftBedrockPing = require('mcpe-ping-fixed') const logger = require('./logger') +const MessageOf = require('./message') const config = require('../config') @@ -125,7 +126,9 @@ class PingController { // Generate a combined update payload // This includes any modified fields and flags used by the frontend // This will not be cached and can contain live metadata - this._app.server.broadcast('update', serverRegistration.getUpdate(timestamp, resp, err, version, updateHistoryGraph)) + const updateMessage = serverRegistration.getUpdate(timestamp, resp, err, version, updateHistoryGraph) + + this._app.server.broadcast(MessageOf('updateServer', updateMessage)) } } diff --git a/lib/server.js b/lib/server.js index eb52491..9a6b371 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1,8 +1,8 @@ const http = require('http') +const WebSocket = require('ws') const finalHttpHandler = require('finalhandler') const serveStatic = require('serve-static') -const io = require('socket.io') const logger = require('./logger') @@ -12,52 +12,65 @@ function getRemoteAddr (req) { class Server { constructor (clientSocketHandler) { - this._clientSocketHandler = clientSocketHandler - this._connectedSockets = 0 + this.createHttpServer() + this.createWebSocketServer(clientSocketHandler) + } - this._http = http.createServer(this.handleHttpRequest) + createHttpServer () { + const distServeStatic = serveStatic('dist/') + const faviconsServeStatic = serveStatic('favicons/') - this._distServeStatic = serveStatic('dist/') - this._faviconsServeStatic = serveStatic('favicons/') + this._http = http.createServer((req, res) => { + logger.log('info', '%s requested: %s', getRemoteAddr(req), req.url) + + // Attempt to handle req using distServeStatic, otherwise fail over to faviconServeStatic + // If faviconServeStatic fails, pass to finalHttpHandler to terminate + distServeStatic(req, res, () => { + faviconsServeStatic(req, res, finalHttpHandler(req, res)) + }) + }) + } + + createWebSocketServer (proxyClientSocketHandler) { + this._wss = new WebSocket.Server({ + server: this._http + }) + + this._wss.on('connection', (client, req) => { + logger.log('info', '%s connected, total clients: %d', getRemoteAddr(req), this.getConnectedClients()) + + // Bind disconnect event for logging + client.on('close', () => { + logger.log('info', '%s disconnected, total clients: %d', getRemoteAddr(req), this.getConnectedClients()) + }) + + // Pass client off to proxy handler + proxyClientSocketHandler(client) + }) } listen (host, port) { this._http.listen(port, host) - this._io = io.listen(this._http) - this._io.on('connect', this.handleClientSocket) - logger.log('info', 'Started on %s:%d', host, port) } - broadcast (event, payload) { - this._io.sockets.emit(event, payload) - } - - handleHttpRequest = (req, res) => { - logger.log('info', '%s requested: %s', getRemoteAddr(req), req.url) - - // Attempt to handle req using distServeStatic, otherwise fail over to faviconServeStatic - // If faviconServeStatic fails, pass to finalHttpHandler to terminate - this._distServeStatic(req, res, () => { - this._faviconsServeStatic(req, res, finalHttpHandler(req, res)) + broadcast (payload) { + this._wss.clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + client.send(payload) + } }) } - handleClientSocket = (client) => { - this._connectedSockets++ - - logger.log('info', '%s connected, total clients: %d', getRemoteAddr(client.request), this._connectedSockets) - - // Bind disconnect event for logging - client.on('disconnect', () => { - this._connectedSockets-- - - logger.log('info', '%s disconnected, total clients: %d', getRemoteAddr(client.request), this._connectedSockets) + getConnectedClients () { + let count = 0 + this._wss.clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) { + count++ + } }) - - // Pass client off to proxy handler - this._clientSocketHandler(client) + return count } } diff --git a/package-lock.json b/package-lock.json index c460edf..4522922 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1836,6 +1836,15 @@ "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", "dev": true }, + "bufferutil": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.1.tgz", + "integrity": "sha512-xowrxvpxojqkagPcWRQVXZl0YXhRhAtBEIq3VoER1NH5Mw1n1o0ojdspp+GS2J//2gCVyrzQDApQ4unGF+QOoA==", + "optional": true, + "requires": { + "node-gyp-build": "~3.7.0" + } + }, "bufferview": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/bufferview/-/bufferview-1.0.1.tgz", @@ -5876,6 +5885,12 @@ "integrity": "sha512-sol30LUpz1jQFBjOKwbjxijiE3b6pjd74YwfD0fJOKPjF+fONKb2Yg8rYgS6+bK6VDl+/wfr4IYpC7jDzLUIfw==", "dev": true }, + "node-gyp-build": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-3.7.0.tgz", + "integrity": "sha512-L/Eg02Epx6Si2NXmedx+Okg+4UHqmaf3TNcxd50SF9NQGcJaON3AtU++kax69XV7YWz4tUspqZSAsVofhFKG2w==", + "optional": true + }, "node-libs-browser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.2.1.tgz", @@ -8782,6 +8797,15 @@ "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", "dev": true }, + "utf-8-validate": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.2.tgz", + "integrity": "sha512-SwV++i2gTD5qh2XqaPzBnNX88N6HdyhQrNNRykvcS0QKvItV9u3vPEJr+X5Hhfb1JC0r0e1alL0iB09rY8+nmw==", + "optional": true, + "requires": { + "node-gyp-build": "~3.7.0" + } + }, "util": { "version": "0.11.1", "resolved": "https://registry.npmjs.org/util/-/util-0.11.1.tgz", @@ -9041,9 +9065,9 @@ } }, "ws": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.3.tgz", - "integrity": "sha512-HTDl9G9hbkNDk98naoR/cHDws7+EyYMOdL1BmjsZXRUjf7d+MficC4B7HLUPlSiho0vg+CWKrGIt/VJBd1xunQ==" + "version": "7.2.5", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.2.5.tgz", + "integrity": "sha512-C34cIU4+DB2vMyAbmEKossWq2ZQDr6QEyuuCzWrM9zfw1sGc0mYiJ0UnG9zzNykt49C2Fi34hvr2vssFQRS6EA==" }, "xml-name-validator": { "version": "3.0.0", diff --git a/package.json b/package.json index 90ca944..0c51944 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ "mcpe-ping-fixed": "0.0.3", "request": "2.88.2", "serve-static": "^1.14.1", - "socket.io": "2.3.0", "sqlite3": "4.1.1", - "winston": "^2.0.0" + "winston": "^2.0.0", + "ws": "^7.2.5" }, "repository": { "type": "git", @@ -44,5 +44,9 @@ "scripts": { "build": "eslint assets/js/*.js && parcel build assets/html/index.html", "dev": "parcel build assets/html/index.html --no-minify" + }, + "optionalDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" } }