/**
* @file This file contains the definition of the module's main class.
* @author Thorsten Kummerow <thorsten@textcontrol.com>
*/
'use strict';
const net = require('net');
const WebSocket = require('ws');
const DataContainer = require('./dataContainer');
const ServerCommandID = require('./serverCommandID');
const FileType = require('./fileType');
/**
* The main class.
*/
class WebSocketServer {
// --------------------------------------------
// Static readonly fields
// --------------------------------------------
/**
* Default websocket path.
* @private
*/
static get _DEFAULT_WS_PATH() { return '/TXWebSocket'; }
/**
* TXTextControl web editor WebSocket protocol name.
* @private
*/
static get _WS_PROTOCOL_NAME() { return 'TXTextControl.Web'; }
/**
* The default TX Text Control service address.
* @private
*/
static get _DEFAULT_SERVICE_ADDRESS() { return 'localhost'; }
/**
* The default TX Text Control service port (4281 for TX 32).
* Is incremented by 1 for every major TX version.
* @private
*/
static get _DEFAULT_SERVICE_PORT() { return 4281; }
/**
* The WebSocket server configuration object.
*
* @typedef WebSocketServerConfig
* @property {string} [serviceAddress] The service address. Default: "localhost".
* @property {number} [servicePort] The service port. Default: 4281.
* @property {string} [wsPath] The WebSocket path. Default: "/TXWebSocket".
*/
/**
* The constructor.
* @param {http.Server} server A node.js `http.Server` object.
* @param {WebSocketServerConfig} config The configuration object.
*/
constructor(server, config) {
/**
* The configuration object.
* @type {config}
* @private
*/
this._config = config || {};
this._state = { tcpSocketClosed: false, wsServerClosed: false };
const self = this;
// Get WebSocket path from configuration object. Ensure it being a string.
// Use the default path "/TXWebSocket" if no path is given.
var wsPath = config ? config.wsPath ? `${config.wsPath}` : WebSocketServer._DEFAULT_WS_PATH : WebSocketServer._DEFAULT_WS_PATH;
// Ensure '/' at the beginning of the WS path
if (wsPath[0] === '\\') wsPath[0] = '/';
else if (wsPath[0] !== '/') wsPath = '/' + wsPath;
// Sanitize settings:
// Get service address from configuration object. Ensure it being a string.
// Use the default address if no address is given.
var serviceAddress = config ? config.serviceAddress ? `${config.serviceAddress}` : WebSocketServer._DEFAULT_SERVICE_ADDRESS : WebSocketServer._DEFAULT_SERVICE_ADDRESS;
// Get service port from configuration object. Ensure it being a number.
// Use the default port if no port is given.
var servicePort = config ? config.servicePort ? config.servicePort : WebSocketServer._DEFAULT_SERVICE_PORT : WebSocketServer._DEFAULT_SERVICE_PORT;
this._config.serviceAddress = serviceAddress;
this._config.servicePort = servicePort;
this._config.wsPath = wsPath;
// Create WebSocket server
var wsServer = new WebSocket.Server({
server: server,
path: wsPath,
handleProtocols: (protocols, req) => {
const arrProtocols = Array.from(protocols);
if (arrProtocols.filter(prot => prot === WebSocketServer._WS_PROTOCOL_NAME).length > 0) {
return WebSocketServer._WS_PROTOCOL_NAME;
}
console.warn(`Unknown WebSocket protocol name(s) received. Expected protocol name: "${WebSocketServer._WS_PROTOCOL_NAME}". Received protocol name(s): ${arrProtocols.map(name => `"${name}"`).join(', ')}`);
return false;
}
});
// Wait for WebSocket request
wsServer.on('connection', (ws, req) => {
/**
* Buffer used to concatenate binary message fragments coming from the
* TCP server.
* @type {Uint8Array}
*/
var msgServer = null;
/**
* Server message length.
* @type {Number}
*/
var lenMsgSrv = 0;
/**
* Current byte position in the received message.
* @type {Number}
*/
var posMsgSrv = 0;
console.debug(`Incoming WebSocket connection from ${req.socket.remoteAddress}:${req.socket.remotePort}.`);
// --------------------------------------------
// Establish TCP socket connection to service
// --------------------------------------------
var socket = new net.Socket();
try {
console.debug(`Connecting to TCP server at ${serviceAddress}:${servicePort}...`);
socket.connect(servicePort, serviceAddress, () => {
console.debug(`Successfully connected to TCP server at ${serviceAddress}:${servicePort}.`);
});
}
catch (err) {
console.error(`Could not connect to TCP server at at ${serviceAddress}:${servicePort}: ${err.message}`);
return;
}
// --------------------------------------------
// Attach TCP socket handlers
// --------------------------------------------
// TCP socket "data" event
socket.on('data', data => {
if (!socket.writable) return;
// Validate WS ready state
if (ws.readyState !== WebSocket.OPEN) {
console.debug('WebSocket connection is not open. Ignoring TCP data.');
closeTCPSocket();
return;
}
// Split the incoming binary data at message boundary positions
let messages = splitMessageData(data, lenMsgSrv - posMsgSrv);
if (messages === null) {
console.log('Invalid TCP message received. Ignoring and closing WebSocket connection.');
// Close websocket and TCP socket connections
closeWS();
closeTCPSocket();
return;
}
// Concatenate binary message fragments and process complete messages.
for (var msg of messages) {
if (!lenMsgSrv) {
// At the beginning of a message
if (msg.length == null) {
throw new Error('A server message must have a length value.');
}
lenMsgSrv = msg.length;
msgServer = new Uint8Array(lenMsgSrv);
msgServer.set(msg.data);
posMsgSrv = msg.data.length;
}
else {
// In the middle of a message
if (msg.length !== null) {
throw new Error('A partial server message data buffer starting in the middle of a message must not have a length value.');
}
msgServer.set(msg.data, posMsgSrv);
posMsgSrv += msg.data.length;
}
if (lenMsgSrv && (lenMsgSrv == posMsgSrv)) {
// One complete message has arrived
let dataContainer = DataContainer.fromByteArray(msgServer);
handleServerMessage(dataContainer, ws);
lenMsgSrv = posMsgSrv = 0;
}
}
});
// TCP socket "closed" event
socket.on('close', () => {
console.debug('TCP connection closed.');
// Close the websocket connection
closeWS();
});
// TCP socket "error" event
socket.on('error', err => {
console.error(`TCP socket error: ${err.message}`);
// Close the websocket connection
closeWS();
socket.destroy();
});
// --------------------------------------------
// Attach WebSocket handlers
// --------------------------------------------
// WebSocket "message" event
ws.on('message', message => {
try {
let dataContainer = DataContainer.fromJSON(message);
handleClientMessage(dataContainer, socket);
}
catch (err) {
console.error(`Error in socket message: '${err.message}'`);
}
});
// WebSocket "close" event
ws.on('close', () => {
console.debug(`WebSocket connection to ${req.socket.remoteAddress}:${req.socket.remotePort} closing.`);
closeTCPSocket();
});
// WebSocket "error" event
ws.on('error', err => {
console.error(`WebSocket error: '${err.message}'`);
closeTCPSocket();
});
// --------------------------------------------
// Helper functions / event handlers
// --------------------------------------------
/**
* Sends a "Close" command to the TCP server and closes the TCP socket.
*/
function closeTCPSocket() {
if (self._state.tcpSocketClosed) return;
if (socket.writable) {
console.debug('Sending "Close" command to TCP server.');
let msg = new DataContainer.DataContainer();
msg.commandID = ServerCommandID.Close;
sendMessage(msg, socket);
}
console.debug('Closing TCP socket.');
self._state.tcpSocketClosed = true;
try { socket.end(); }
catch (err) { console.error(`Error in net.Socket.end(): '${err.message}'`); }
}
/**
* Closes the WebSocket connection.
*/
function closeWS() {
if (self._state.wsServerClosed) return;
if (ws.readyState === WebSocket.OPEN) {
console.debug('Closing the WebSocket connection.');
self._state.wsServerClosed = true;
ws.close();
}
}
});
}
} // class WebSocketServer
/**
* Handles messages coming from the service / server.
* @param {DataContainer.DataContainer} serverMessage A message coming from the service / server.
* @param {WebSocket} ws The WebSocket connection.
*/
function handleServerMessage(serverMessage, ws) {
/** @type {string} */
let serialized = null; // The browser message
if (ws.readyState !== WebSocket.OPEN) {
console.error('handleServerMessage(): WebSocket not open.');
return;
}
switch (serverMessage.commandID) {
case ServerCommandID.Close:
console.debug('Close command received from the server. Closing the WebSocket connection.');
try { ws.close(); }
catch (err) {
console.error(`handleServerMessage(): Error attempting to close the WebSocket connection. '${err.message}'`);
}
break;
default:
serialized = serverMessage.serialize();
break;
}
if (serialized != null) ws.send(serialized);
}
/**
* Handles messages coming from the client / browser.
* For now essentially just forwards the messages to the service.
* @param {DataContainer.DataContainer} dataContainer A message coming from the client / browser.
* @param {net.Socket} socket The TCP socket connection to the service / server.
*/
function handleClientMessage(dataContainer, socket) {
switch (dataContainer.commandID) {
case ServerCommandID.OpenFile:
prepareFileData(dataContainer);
break;
}
// Forward the message to the service
sendMessage(dataContainer, socket);
}
/**
* Sends a message to the service as binary data with an added 32 bit length value
* in front.
* @param {DataContainer.DataContainer} dataContainer A message.
* @param {net.Socket} socket The TCP socket connection to the service / server.
*/
function sendMessage(dataContainer, socket) {
// Convert message to byte array and get that array's size
let outBuf = dataContainer.toByteArray();
let outBufSize = outBuf.length;
// Convert the array size value to a 4 byte array
let sizeValBuf = new Uint8Array(4);
let sizeValView = new DataView(sizeValBuf.buffer);
sizeValView.setInt32(0, outBufSize, true);
// Send the size followed by the buffer
socket.write(Buffer.from(sizeValBuf));
socket.write(Buffer.from(outBuf));
}
/**
* Encapsulates a server message data buffer and its 32 bit length value (if present).
*/
class ServerMessageData {
/**
* Constructor.
* @param {Uint8Array} data The message data. Can be incomplete.
* @param {Number} length The complete message's length in bytes as received in the first 32 bit integer.
* Is null if the buffer starts in the middle of a message.
*/
constructor(data, length = null) {
this.data = data;
this.length = length;
}
}
/**
* Splits the incoming binary data buffer coming from the service into multiple buffers,
* each representing a message or part of a message.
* @param {Uint8Array} data The data buffer.
* @param {Number} remaining The remaining bytes of an incomplete previous message.
* @returns {ServerMessageData[]} The data split up into multiple messages or null in case
* of an error.
*/
function splitMessageData(data, remaining) {
var result = [];
var pos = 0;
var len = data.length;
remaining = Math.min(remaining, len);
if (remaining > 0) {
result.push(new ServerMessageData(data.subarray(0, remaining)));
pos = remaining;
}
while (pos <= (len - 4)) { // Need at least 4 bytes for the length value
// Data starts with 32 bit length information
let view = new DataView(data.buffer, pos);
let lenMsg = view.getInt32(0, true);
pos += 4; // Advance position to start of message data
remaining = len - pos;
let lenData = Math.min(remaining, lenMsg);
result.push(new ServerMessageData(data.subarray(pos, pos + lenData), lenMsg));
pos += lenData;
}
// At this point all data must have been read
if (pos < len) {
console.error(`Service message too short (${len - pos} bytes). Aborting.`);
return null;
}
return result;
} // splitMessageData
/**
* Converts base64 data in an OpenFile command already containing file data to binary data.
* @param {DataContainer.DataContainer} dataContainer
*/
function prepareFileData(dataContainer) {
let fileType = dataContainer.param1;
switch (fileType) {
case FileType.Document:
case FileType.Image:
try {
var strBase64 = Buffer.from(dataContainer.data, 'utf-8').toString();
dataContainer.data = Buffer.from(strBase64, 'base64');
}
catch (err) {
console.error(`Could not convert base64 file data to binary data: ${err.message}`);
}
break;
}
}
module.exports = { WebSocketServer };