Source: webSocketServer.js

/**
 * @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 };