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"
}
}