Source: libs/transports/WSSClient.js

/**
* @file WebSocket Sessions client interface.
*
* @version 0.4.1
*/

/**
* @class A WebSocket Session client interface used to connect to peers. Requires
* {@link RPC} and {@link EventPromise} to exist in the current execution
* context.
* @extends EventDispatcher
*/
class WSSClient extends EventDispatcher {

   /**
   * A new peer has connected to the WSS server.
   *
   * @event WSSClient#peerconnect
   * @type {Event}
   *
   * @property {Object} data The parsed JSON-RPC 2.0 object received
   * in the notification.
   */
   /**
   * A peer has disconnected from the WSS server.
   *
   * @event WSSClient#peerdisconnect
   * @type {Event}
   *
   * @property {Object} data The parsed JSON-RPC 2.0 object received
   * in the notification.
   */
   /**
   * A message has been received from a peer via the WSS server. This may either
   * be a direct message or a broadcast.
   *
   * @event WSSClient#message
   * @type {Event}
   *
   * @property {Object} data The parsed JSON-RPC 2.0 object containing
   * the received message.
   */
   /**
   * An update notification has been received from the WSS server.
   *
   * @event WSSClient#update
   * @type {Event}
   *
   * @property {Object} data The parsed JSON-RPC 2.0 object containing
   * the received message.
   */
   /**
   * The private ID of this connection has changed and a notification
   * has been sent to the connected peer.
   *
   * @event WSSClient#privateid
   * @type {Event}
   *
   * @property {String} privateID The new private ID for this connection.
   */
   /**
   * The private ID of a connected peer has changed and the internal
   * [peers]{@link WSSClient#peers} list has been updated.
   *
   * @event WSSClient#peerpid
   * @type {Event}
   *
   * @property {Object} data The parsed JSON-RPC 2.0 object containing
   * the change notification.
   * @property {String} oldPrivateID The old / previous private ID of the peer.
   * @property {String} newPrivateID The new / changed private ID of the peer.
   */

   /**
   * Creates an instance of WSSClient.
   *
   * @param {String} [handshakeServerAddress] The address of the WSS handshake
   * server (available as {@link handshakeServerAddr}). If not provided and
   * {@link connect} is called then the socket server address will be assigned
   * to the handshake server address and used for both.
   */
   constructor(handshakeServerAddress) {
      super();
      this._hsa = handshakeServerAddress;
   }

   /**
   * Produces a SHA-256 hash of an input string. The output of this
   * implementation <i>should</i> match the output of Node.js' built-in
   * SHA-256 hash (<code>crypto</code> module).<br/>
   * Uses the <a href="https://www.w3.org/TR/WebCryptoAPI/#subtlecrypto-interface">
   * SubtleCrypto</a> interface of the <a href="https://www.w3.org/TR/WebCryptoAPI/">
   * Web Cryptography API</a>.
   *
   * @param {String} input The string to hash.
   *
   * @return {Promise} A promise containing a hex-encoded result of the SHA-256
   * hash of the input.
   *
   * @see https://rawgit.com/w3c/webcrypto/master/PR-test-report.html
   * @see https://caniuse.com/#search=SubtleCrypto
   */
   async SHA256 (input) {
      var buffer = new TextEncoder("utf-8").encode(input);
      var hash_buffer = await crypto.subtle.digest("SHA-256", buffer);
      let hash_array = Array.from(new Uint8Array(hash_buffer));
      let hash_hex_str = hash_array.map(byte =>
         ("00" + byte.toString(16)).slice(-2)).join("");
      return (hash_hex_str);
   }

   /**
   * @property {String} handshakeServerAddr The assigned handshake server address of the WSS instance.
   */
   get handshakeServerAddr() {
      if (this["_hsa"] == undefined) {
         this._hsa = null;
      }
      return (this._hsa);
   }

   /**
   * @property {String} socketServerAddr The assigned WebSocket server address of the WSS instance.
   */
   get socketServerAddr() {
      if (this["_ssa"] == undefined) {
         return (null);
      }
      return (this._ssa);
   }

   /**
   * @property {WebSocket} webSocket The WebSocket object being used for this session.
   */
   get webSocket() {
      if (this["_websocket"] == undefined) {
         this._websocket = null;
      }
      return (this._websocket);
   }

   /**
   * @property {String} privateID The privateID assigned to the session by the
   * server. This value may also be derived by hashing ({@link SHA256}) a
   * concatenation of the {@link serverToken} and {@link userToken}.
   */
   get privateID() {
      if (this["_privateID"] == undefined) {
         this._privateID = null;
      }
      return (this._privateID);
   }

   /**
   * @property {String} userToken internally-generated user token that is
   * combined with the returned {@link serverToken} to produce the
   * {@link privateID}.
   */
   get userToken() {
      if (this["_userToken"] == undefined) {
         this._userToken = null;
      }
      return (this._userToken);
   }

   /**
   * @property {String} serverToken The server-generated user token that is
   * combined with the generated {@link userToken} to produce the
   * {@link privateID}.
   */
   get serverToken() {
      if (this["_serverToken"] == undefined) {
         this._serverToken = null;
      }
      return (this._serverToken);
   }

   /**
   * @property {Array} peers A list of currently connected peers, as managed by
   * this instance.
   * @readonly
   */
   get peers() {
      if (this["_peers"] == undefined) {
         this._peers = new Array();
      }
      return (this._peers);
   }

   /**
   * @property {Object} peerOptions Options objects associated with peer
   * private IDs in the {@link peers} list. That is:<br/>
   * <code>peerOptions[privateID]=optionsObject</code>
   * @readonly
   */
   get peerOptions() {
      if (this["_peerOptions"] == undefined) {
         this._peerOptions = new Object();
      }
      return (this._peerOptions);
   }

   /**
   * Initiates a WebSocket Session by performing a handshake and subsequent
   * connection to the WSS server.
   *
   * @param {String} [socketServerAddr=null] The WebSocket server address to connect
   * to. If omitted, the assigned handshake server address will be used for
   * both the handshake and the connection.
   * @param {Boolean} [useHTTPHandshake=false] If true, a HTTP /  HTTPS
   * request is used for the handshake otherwise the handshake and
   * connection both happen on the same WebSocket connection.
   * @param {Object} [connectData=null] Optional data to include with the connect API
   * call. If omitted or <code>null</code>, an empty object is created and a minimal <code>options</code>
   * object is appended. If included, any <code>user_token</code> and <code>server_token</code>
   * properties will be overriden with handshake {@link WSSClient#userToken} and
   * {@link WSSClient#serverToken} values.
   * @param {Object} [connectData.options] The peer connection options for this connection.
   * If omitted or <code>null</code> a minimal connection options object is created with
   * default values.
   * @param {Boolean} [connectData.options.wss=true] Denotes whether (<code>true</code>) or not
   * (<code>false</code>) WebSocket Sessions is supported by the client.
   * @param {Boolean} [connectData.options.webrtc=false] Denotes whether (<code>true</code>) or not
   * (<code>false</code>) <a href="https://webrtc.org/">WebRTC</a> is supported by the client.
   * @param {Boolean} [connectData.options.ortc=false] Denotes whether (<code>true</code>) or not
   * (<code>false</code>) <a href="https://ortc.org/">ORTC</a> is support by the client.
   * @param {Boolean} [connectData.listeners=true] If false, socket listeners will not be added
   * by this instance (for example, if this class has been extended).
   *
   * @throws {Error} Thrown when a valid handshake / socket server address was
   * not supplied, or the connection could not be established.
   */
   async connect(socketServerAddress=null, useHTTPHandshake=false, connectData=null) {
      if (typeof(socketServerAddress) == "string") {
         this._ssa = socketServerAddress;
      }
      if (this.handshakeServerAddr == null) {
         this._ssa = socketServerAddress;
         this._hsa = socketServerAddress;
      }
      if ((this._hsa == null) || (this._hsa == undefined) || (this._hsa.trim() == "")) {
         throw (new Error("No handshake or socket server address provided."));
      }
      if ((connectData.listeners == undefined) || (connectData.listeners == null)) {
         connectData.listeners = true;
      }
      //the user token can be almost any string; maybe we can improve on this...
      this._userToken = String(Math.random()).split("0.").join("");
      if (useHTTPHandshake) {
         this._xhr = new XMLHttpRequest();
         this._xhr.open("POST", this.handshakeServerAddr);
         var event = await RPC("WSS_Handshake", {"user_token":this.userToken}, this._xhr);
         if (typeof(event.target.response["error"]) == "object") {
            throw (new Error("Server responded with an error ("+event.target.response.error.code+"): "+event.target.response.error.message));
         } else {
            this._serverToken = event.target.response.result.server_token;
         }
      } else {
         this._websocket = new WebSocket(this.handshakeServerAddr);
         this.webSocket.session = this;
         this.webSocket.addEventListener("error", event => {
            //trigger following "await" statement (error will be thrown after that)
            this.webSocket.dispatchEvent(new Event("open"));
         });
         event = await this.webSocket.onEventPromise("open");
         if (this._websocket.readyState != this._websocket.OPEN) {
            throw (new Error("Couldn't connect WebSocket at: " + this.handshakeServerAddr));
         }
         event = await RPC("WSS_Handshake", {"user_token":this.userToken}, this.webSocket);
         var resultData = JSON.parse(event.data);
         if (typeof(resultData["error"]) == "object") {
            throw (new Error("Server responded with an error ("+resultData.error.code+"): "+resultData.error.message));
         } else {
            this._serverToken = resultData.result.server_token;
         }
      }
      var connectObj = new Object();
      if (connectData == null) {
         connectData = new Object();
      }
      connectObj.options = new Object();
      if ((connectData["options"] == undefined) || (connectData["options"] == null)) {
         //default connection options (as of v0.4.1)
         connectObj.options.wss = true;
         connectObj.options.webrtc = false;
         connectObj.options.ortc = false;
      } else {
         connectObj.options.wss = connectData.options.wss;
         connectObj.options.webrtc = connectData.options.webrtc;
         connectObj.options.ortc = connectData.options.ortc;
      }
      connectObj.user_token = this.userToken;
      connectObj.server_token = this.serverToken;
      if (this.webSocket == null) {
         this._websocket = new WebSocket(this.socketServerAddr);
         this.webSocket.addEventListener("error", event => {
            //trigger following "await" statement (error will be thrown after that)
            this.webSocket.dispatchEvent(new Event("open"));
         });
         event = await this.webSocket.onEventPromise("open");
         if (this._websocket.readyState != this._websocket.OPEN) {
            throw (new Error("Couldn't connect WebSocket at: " + this.socketServerAddr));
         }
         this.webSocket.session = this;
      }
      var message_event = await RPC("WSS_Connect", connectObj, this.webSocket); //connect the WebSocket
      var rpc_result_obj = JSON.parse(message_event.data);
      if (rpc_result_obj.error != undefined) {
         throw (new Error("Couldn't establish WebSocket Session: ("+rpc_result_obj.error.code+") "+rpc_result_obj.error.message));
      }
      if ((rpc_result_obj.result["private_id"] != undefined) && (rpc_result_obj.result["private_id"] != null)) {
         this._privateID = rpc_result_obj.result.private_id;
         //the following concatenation pattern matches the server
         //implementation:
         //var hash_source_str = this.serverToken+ ":" +this.userToken;
         //var generated_pid = await this.SHA256(hash_source_str);
         //console.log ("Are they the same? "+(this._privateID == generated_pid));
      } else {
         throw (new Error("No private ID returned in WSS_Connect response."));
      }
      if ((rpc_result_obj.result["connect"] != undefined) && (rpc_result_obj.result["connect"] != null)) {
         var connectedPeersList = rpc_result_obj.result["connect"];
         var optionsList = rpc_result_obj.result["options"]; //as of v0.4.1
         if (typeof(connectedPeersList) == "object") {
            connectedPeersList.forEach (function (peerPID, index, sourcArr) {
               this.peers.push(peerPID);
               this.peerOptions[peerPID] = optionsList[index];
            }, this);
         }
      }
      if (connectData.listeners == true) {
         this.webSocket.addEventListener("message", this.handleSocketMessage);
         this.webSocket.addEventListener("close", this.handleSocketClose);
      }
      return (message_event);
   }

   /**
   * Changes the private ID associated with this connection. The private ID
   * is updated on the server and reflected in the [privateID]{@link WSSClient#privateID}
   * property.
   *
   * @param {String} newPrivateID The new private ID to set for this connection.
   *
   * @return {Promise} The promise resolves with <code>true</code> if the private
   * ID was successfully changed, otherwise it rejects with <code>false</code>.
   *
   * @async
   */
   async changePrivateID(newPrivateID) {
      var sendObj = new Object();
      sendObj.user_token = this.userToken;
      sendObj.server_token = this.serverToken;
      sendObj.action = "setPID";
      sendObj.privateID = newPrivateID;
      var requestID = "WSS_Session"+String(Math.random()).split("0.")[1];
      var rpc_result = await RPC("WSS_Session", sendObj, this.webSocket, false, requestID);
      var result = JSON.parse(rpc_result.data);
      //since raw API messages are asynchronous the next immediate message may not be ours so:
      while (requestID != result.id) {
         rpc_result = await this.webSocket.onEventPromise("message");
         result = JSON.parse(rpc_result.data);
         //we could include a max wait limit here
      }
      this._privateID = newPrivateID;
      var event = new Event("privateid");
      event.privateID = newPrivateID;
      this.dispatchEvent(event);
      return (true);
   }

   /**
   * Broadcasts a message to all connected peers.
   *
   * @param {*} data The data / message to broadcast.
   * @param {Object|Array} [excludeRecipients=null] If not omitted, null, or an empty
   * array this should be an indexed array of recipient private IDs to exclude
   * from the broadcast (if, for example, they will receive the data via
   * another communication channel).
   *
   * @return {Promise} An asynchronous Promise that will contain the result of
   * the broadcast or will throw an error on failure.
   */
   async broadcast(data, excludeRecipients=null) {
      var broadcastObj = new Object();
      broadcastObj.user_token = this.userToken;
      broadcastObj.server_token = this.serverToken;
      broadcastObj.type = "broadcast";
      if (excludeRecipients != null) {
         if (typeof(excludeRecipients["length"]) == "number") {
            //excludeRecipients is an array so add it to the "rcp" property
            var WSSExcObj = new Object();
            WSSExcObj.rcp = excludeRecipients;
         } else {
            //excludeRecipients is al_wssReady an object so assume everything is in place
            WSSExcObj = excludeRecipients;
         }
         broadcastObj.exclude = WSSExcObj;
      }
      broadcastObj.data = data;
      var rpc_result = await RPC("WSS_Send", broadcastObj, this.webSocket);
      return (rpc_result);
   }

   /**
   * Sends a direct message to one or more connected peers.
   *
   * @param {*} data The data / message to send.
   * @param {Object|Array} recipients An object or an array of recipient private
   * IDs. If this parameter is an object, one or more additional properties are
   * expected:
   * @param {Array} [recipients.rcp] An indexed array of recipient private IDs.
   * If this list is provided as the <code>recipients</code> parameter this
   * structure is dynamicaly generated before sending.
   *
   * @return {Promise} An asynchronous Promise that will contain the result of
   * the send or reject with an <code>Error</code> object on failure.
   */
   async send(data, recipients) {
      if (typeof(recipients) != "object") {
         throw (new Error(`"recipients" parameter must be an array!`));
      }
      if (typeof(recipients["length"]) == "number") {
         //recipients is an array so add it to the "rcp" property
         var WSSRecpObj = new Object();
         WSSRecpObj.rcp = recipients;
      } else {
         //recipients is al_wssReady an object so assume everything is in place
         WSSRecpObj = recipients;
      }
      var sendObj = new Object();
      sendObj.user_token = this.userToken;
      sendObj.server_token = this.serverToken;
      sendObj.type = "direct";
      sendObj.to = WSSRecpObj;
      sendObj.data = data;
      var rpc_result = await RPC("WSS_Send", sendObj, this.webSocket);
      return (rpc_result);
   }

   /**
   * Sends a routed JSON-RPC 2.0 request using the [webSocket]{@link WSSClient#webSocket}.
   *
   * @param {Object} requestObj The JSON-RPC 2.0 request to send.
   * @param {String|Number} [responseMsgID=null] The message ID if the response to
   * match before the returned promise resolves. If null, the first returned
   * response will resolve, even if it's not the expected response.
   *
   * @return {Promise} An asynchronous promise that will resolve with a JSON-RPC 2.0
   * response. if <code>responseMsgID</code> is specified, only the response with the
   * matching JSON-RPC id property will cause the promise to resolve,
   *
   * @async
   */
   async request(requestObj, responseMsgID=null) {      
      if (responseMsgID != null) {
         var promise = new Promise((resolve, reject) => {
            this.handleRequestResponse(responseMsgID, resolve, reject);
         })
      } else {
         promise = await this.webSocket.onEventPromise("message");
      }
      //send API request via WSS
      if (this.webSocket.readyState != this.webSocket.OPEN) {
        //socket not yet connected
        this.webSocket.onEventPromise("open").then((event) => {
          this.webSocket.send(JSON.stringify(requestObj));
        });
     } else {
        //socket already open
        this.webSocket.send(JSON.stringify(requestObj));
     }
     return (promise);
   }

   /**
   * Handles asynchronous JSON-RPC 2.0 responses made using [request]{@lilnk WSSClient#request}
   * when a <code>responseMsgID</code> is specified,
   *
   * @param {String|Number} expectedResponseID The expected response ID to match from messages
   * received by the WebSocket.
   * @param {Function} resolve A promise resolve function to invoke with the response data when
   * the response ID matches <code>expectedResponseID</code>.
   * @param {Function} resolve A promise reject function, Not currently used but may in future
   * be used for timeouts.
   *
   * @async
   */
   async handleRequestResponse(expectedResponseID, resolve, reject) {
      var responseID = null;
      while (responseID != expectedResponseID) {
         var response = await this.webSocket.onEventPromise("message");
         var responseObj = JSON.parse(response.data);
         responseID = responseObj.id;
      }
      resolve(response);
   }

   /**
   * Handles WebSocket message events for the WSS instance. Most events are
   * simply re-broadcast but some such as <code>session</code> messages are
   * handled internally. Listen to "message" events on the WSS's
   * {@link webSocket} to receive all messages.
   *
   * @param {Event} eventObj A "message" event dispatched by the associated
   * WebSocket instance.
   */
   handleSocketMessage(eventObj) {
      try {
         var dataObj = JSON.parse(eventObj.data);
         switch (dataObj.result.type) {
            case "broadcast":
               var event = new Event("message");
               event.data = dataObj;
               this.session.dispatchEvent(event);
               break;
            case "direct":
               event = new Event("message");
               event.data = dataObj;
               this.session.dispatchEvent(event);
               break;
            case "update":
               event = new Event("update");
               event.data = dataObj;
               this.session.dispatchEvent(event);
               break;
            case "session":
               if (typeof(dataObj.result.connect) == "string") {
                  //dataObj.result.connect is the private ID of the new connection
                  event = new Event("peerconnect");
                  event.data = dataObj;
                  this.session.peers.push(dataObj.result.connect);
                  this.session.peerOptions[dataObj.result.connect] = dataObj.result.options;
                  this.session.dispatchEvent(event);
               } else if (typeof(dataObj.result.disconnect) == "string") {
                  //dataObj.result.disconnect is the private ID of the disconnect
                  event = new Event("peerdisconnect");
                  event.data = dataObj;
                  for (var count = 0; count < this.session.peers.length; count++) {
                     if (this.session.peers[count] == dataObj.result.disconnect) {
                        this.session.peers.splice(count, 1);
                        this.session.peerOptions[dataObj.result.disconnect] = null;
                        delete this.session.peerOptions[dataObj.result.disconnect];
                        break;
                     }
                  }
                  this.session.dispatchEvent(event);
               } else if (typeof(dataObj.result.change) == "object") {
                  //in the future we may support different types of session updates
                  event = new Event("peerpid");
                  event.data = dataObj;
                  for (var count = 0; count < this.session.peers.length; count++) {
                     if (this.session.peers[count] == dataObj.result.oldPrivateID) {
                        this.session.peers.splice(count, 1);
                        //insert at the same index
                        this.session.peers.splice(count, 0, dataObj.result.newPrivateID);
                        break;
                     }
                  }
                  this.session.dispatchEvent(event);
               } else {
                  //unhandled session message
               }
               break;
            default:
               //unhandled message type
               break;
         }
      } catch (err) {
      }
   }

   /**
   * Handles WebSocket close events for the WSS instance.
   *
   * @param {Event} eventObj A "close" event dispatched by the associated
   * WebSocket instance.
   */
   handleSocketClose(eventObj) {
      var event = new Event("close");
      event._event = event;
      this.session.dispatchEvent(event);
   }

   /**
   * Disconnects the client WebSocket and removes any event listeners.
   *
   * @param {Number} [code=1000] The disconnection code to close the socket with. Valid
   * status codesmay be found at {@link https://devdocs.io/dom/closeevent#Status_codes}
   * @param {String} [reason] The brief, human-readable reason why the socket
   * is being disconnected.
   *
    * @see https://devdocs.io/dom/closeevent#Status_codes
   * @async
   */
   async disconnect(code=1000, reason="Session terminated by client") {
      try {
         this.webSocket.removeEventListener("message", this.handleSocketMessage);
         this.webSocket.removeEventListener("close", this.handleSocketClose);
         //These listeners should also be removed, but how?
         /*
         var errorListeners = this.webSocket.getListeners("error");
         for (var count = 0; count < errorListeners.length; count++) {
            this.webSocket.removeEventListener("error", errorListeners[count].listener);
         }
         */
         this.webSocket.close(code, reason);
         this._webSocket = null;
      } catch (err) {
         console.error(err);
      }
      return (true);
   }

   toString() {
      return ("WSSClient");
   }

}