Skip to content

Commit

Permalink
Refactored WebSocket logic along with reconnect corrections, touch #456.
Browse files Browse the repository at this point in the history
  • Loading branch information
PaulDalek committed Jun 6, 2024
1 parent bf86c33 commit 832697e
Show file tree
Hide file tree
Showing 8 changed files with 130 additions and 80 deletions.
4 changes: 2 additions & 2 deletions dist/index.cjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.esm.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/index.esm.js.map

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions lib/server/routes/export.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { WebSocket } from 'ws';
import { getAllowCodeExecution, startExport } from '../../chart.js';
import { getOptions, mergeConfigOptions } from '../../config.js';
import { log } from '../../logger.js';
import { getClient as getWebSocketClient } from '../web_socket.js';
import { getClients as getWebSocketClient } from '../web_socket.js';
import {
fixType,
isCorrectJSON,
Expand Down Expand Up @@ -102,8 +102,8 @@ const exportHandler = async (request, response, next) => {
// Get the current server's general options
const defaultOptions = getOptions();

// Get the WebSocket client
const webSocketClient = getWebSocketClient();
// Get the first WebSocket client
const webSocketClient = getWebSocketClient().values().next().value;

const body = request.body;
const id = ++requestsCounter;
Expand Down
17 changes: 2 additions & 15 deletions lib/server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,11 @@ import cors from 'cors';
import express from 'express';
import http from 'http';
import https from 'https';
import jwt from 'jsonwebtoken';
import multer from 'multer';

import errorHandler from './error.js';
import rateLimit from './rate_limit.js';
import webSocket from './web_socket.js';
import { envs } from '../envs.js';
import { log, logWithStack } from '../logger.js';
import { __dirname } from '../utils.js';

Expand Down Expand Up @@ -187,19 +185,8 @@ export const startServer = async (serverConfig) => {
// Set up centralized error handler
errorHandler(app);

// Set the WebSocket connection if enabled
if (envs.WEB_SOCKET_ENABLE === true) {
webSocket.connect(envs.WEB_SOCKET_URL, {
rejectUnauthorized: envs.WEB_SOCKET_REJECT_UNAUTHORIZED,
headers: {
// Set an access token that lasts only 5 minutes
auth: jwt.sign({ success: 'success' }, envs.WEB_SOCKET_SECRET, {
algorithm: 'HS256',
expiresIn: '5m'
})
}
});
}
// Start a WebSocket connection, if feature enabled
webSocket.init();
} catch (error) {
throw new ExportError(
'[server] Could not configure and start the server.'
Expand Down
152 changes: 107 additions & 45 deletions lib/server/web_socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,113 +11,175 @@ Additionally a valid Highcharts license is required for use.
See LICENSE file in root for details.
*******************************************************************************/
import jwt from 'jsonwebtoken';
import { v4 as uuid } from 'uuid';
import WebSocket from 'ws';

import { envs } from '../envs.js';
import { log, logWithStack } from '../logger.js';
import { log } from '../logger.js';

// WebSocket client
let webSocketClient;
// WebSocket clients map
const webSocketClients = new Map();

// In case of closing or termination of a client connection
let reconnectInterval;
/**
* Init WebSocket client and connection options
*/
function init() {
if (envs.WEB_SOCKET_ENABLE === true) {
// Options for the WebSocket connection
const connectionOptions = {
rejectUnauthorized: envs.WEB_SOCKET_REJECT_UNAUTHORIZED,
headers: {
// Set an access token that lasts only 5 minutes
auth: jwt.sign({ success: 'success' }, envs.WEB_SOCKET_SECRET, {
algorithm: 'HS256',
expiresIn: '5m'
})
}
};

// Options for the WebSocket client
const clientOptions = {
id: uuid(),
reconnect: false,
reconnectTry: 0,
reconnectInterval: null,
pingTimeout: null
};

// Start the WebSocket connection
connect(envs.WEB_SOCKET_URL, connectionOptions, clientOptions);
}
}

/**
* Connects to WebSocket on a provided url.
* Creates WebSocket client and connects to WebSocket server on a provided url.
*
* @param {string} webSocketUrl - The WebSocket server's URL.
* @param {object} options - Options for WebSocket connection.
* @param {object} connectionOptions - Options for WebSocket connection.
* @param {object} clientOptions - Options for WebSocket client.
*/
function connect(webSocketUrl, options) {
function connect(webSocketUrl, connectionOptions, clientOptions) {
// Try to connect to indicated WebSocket server
webSocketClient = new WebSocket(webSocketUrl, options);
let webSocketClient = new WebSocket(webSocketUrl, connectionOptions);

// Open event
webSocketClient.on('open', () => {
log(3, `[websocket] Connected to WebSocket server: ${webSocketUrl}`);
clearInterval(reconnectInterval);
// Not need for the reconnect interval anymore
clearInterval(clientOptions.reconnectInterval);

// Save the client under its id
webSocketClients.set(clientOptions.id, webSocketClient);

// Log a success message
log(
3,
`[websocket] WebSocket: ${clientOptions.id} - connected to server: ${webSocketUrl}.`
);
});

// Close event where ping timeout is cleared
webSocketClient.on('close', (code) => {
log(
3,
'[websocket]',
`Disconnected from WebSocket server: ${webSocketUrl} with code: ${code}`
`WebSocket: ${clientOptions.id} - disconnected from server: ${webSocketUrl} with code: ${code}.`
);
clearTimeout(webSocketClient._pingTimeout);

// Stop the heartbeat mechanism
clearTimeout(clientOptions.pingTimeout);

// Removed client if exists
webSocketClients.delete(clientOptions.id);
webSocketClient = null;

// Try to reconnect only when enabled and if not already attempting to do so
if (clientOptions.reconnect && !clientOptions.reconnectInterval) {
reconnect(webSocketUrl, connectionOptions, clientOptions);
}
});

// Error event
webSocketClient.on('error', (error) => {
logWithStack(1, error, `[websocket] WebSocket error occured.`);
log(1, `[websocket] WebSocket: ${clientOptions.id} - error occured.`);

// Block the reconnect mechanism when getting 403
if (error.message.includes('403')) {
clientOptions.reconnect = false;
clientOptions.reconnectTry = envs.WEB_SOCKET_RECONNECT_ATTEMPTS;
} else {
// Or set the option accordingly
clientOptions.reconnect = envs.WEB_SOCKET_RECONNECT;
}
});

// Message event
webSocketClient.on('message', (message) => {
log(3, `[websocket] Data received: ${message}`);
log(
3,
`[websocket] WebSocket: ${clientOptions.id} - data received: ${message}`
);
});

// The 'ping' event from a WebSocket connection with the health check
// and termination logic
webSocketClient.on('ping', () => {
log(3, '[websocket] PING');
clearTimeout(webSocketClient._pingTimeout);
webSocketClient._pingTimeout = setTimeout(() => {
log(
3,
`[websocket] WebSocket: ${clientOptions.id} - received PING from server: ${webSocketUrl}.`
);
clearTimeout(clientOptions.pingTimeout);
clientOptions.pingTimeout = setTimeout(() => {
// Terminate the client connection
webSocketClient.terminate();

// Try to reconnect if required
if (envs.WEB_SOCKET_RECONNECT === true) {
reconnect(webSocketUrl, options);
if (clientOptions.reconnect) {
reconnect(webSocketUrl, connectionOptions, clientOptions);
}
}, envs.WEB_SOCKET_PING_TIMEOUT);
});
}

/**
* Reconnects to WebSocket on a provided url.
* Reconnects to WebSocket server on a provided url.
*
* @param {string} webSocketUrl - The WebSocket server's URL.
* @param {object} options - Options for WebSocket connection.
* @param {object} connectionOptions - Options for WebSocket connection.
* @param {object} clientOptions - Options for WebSocket client.
*/
function reconnect(webSocketUrl, options) {
// The reconnect attempt counter
let reconnectTry = 0;

function reconnect(webSocketUrl, connectionOptions, clientOptions) {
// Start the reconnect interval
reconnectInterval = setInterval(() => {
if (reconnectTry < envs.WEB_SOCKET_RECONNECT_ATTEMPTS) {
try {
if (webSocketClient === null) {
connect(webSocketUrl, options);
}
} catch (error) {
reconnectTry++;
log(
2,
`[websocket] Attempt ${reconnectTry} of ${envs.WEB_SOCKET_RECONNECT_ATTEMPTS} to reconnect to the WebSocket at ${webSocketUrl} failed.`
);
}
clientOptions.reconnectInterval = setInterval(() => {
if (clientOptions.reconnectTry < envs.WEB_SOCKET_RECONNECT_ATTEMPTS) {
log(
3,
`[websocket] WebSocket: ${clientOptions.id} - Attempt ${++clientOptions.reconnectTry} of ${envs.WEB_SOCKET_RECONNECT_ATTEMPTS} to reconnect to server: ${webSocketUrl}.`
);

connect(webSocketUrl, connectionOptions, clientOptions);
} else {
clearInterval(reconnectInterval);
clientOptions.reconnect = false;
clearInterval(clientOptions.reconnectInterval);
log(
2,
`[websocket] Could not reconnect to the WebSocket at ${webSocketUrl}.`
`[websocket] WebSocket: ${clientOptions.id} - Could not reconnect to server: ${webSocketUrl}.`
);
}
}, envs.WEB_SOCKET_RECONNECT_INTERVAL);
}

/**
* Gets the instance of the WebSocket connection.
* Gets map of current WebSocket clients.
*
* @param {string} id - The uuid of WebSocket client.
*/
export function getClient() {
return webSocketClient;
export function getClients(id) {
return id ? webSocketClients.get(id) : webSocketClients;
}

export default {
init,
connect,
getClient
getClients
};
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 6 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,18 @@
"start": "node ./bin/cli.js --enableServer 1 --logLevel 2",
"start:dev": "npx nodemon ./bin/cli.js --enableServer 1 --logLevel 4",
"start:debug": "node --inspect-brk=9229 ./bin/cli.js --enableDebug 1 --enableServer 1 --logLevel 4",
"complete": "npm run lint && npm run format && npm run build",
"lint": "eslint ./ --ext .js,.ts --fix",
"format": "prettier ./ --config .prettierrc --write",
"build": "rollup -c",
"cli-tests": "node ./tests/cli/cli_test_runner.js",
"cli-tests-single": "node ./tests/cli/cli_test_runner_single.js",
"http-tests": "node ./tests/http/http_test_runner.js",
"http-tests-single": "node ./tests/http/http_test_runner_single.js",
"node-tests": "node ./tests/node/node_test_runner.js",
"node-tests-single": "node ./tests/node/node_test_runner_single.js",
"prepare": "husky install || true",
"build": "rollup -c",
"unit:test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
"unit:test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
"prepare": "husky install || true"
},
"dependencies": {
"colors": "1.4.0",
Expand Down Expand Up @@ -66,8 +67,8 @@
"husky": "^9.0.11",
"jest": "^29.7.0",
"lint-staged": "^15.2.4",
"nodemon": "^3.1.2",
"prettier": "^3.3.0",
"nodemon": "^3.1.3",
"prettier": "^3.3.1",
"rollup": "^4.18.0"
}
}

0 comments on commit 832697e

Please sign in to comment.