Source: libs/transports/WebRTCClient.js

/**
* @file Defines a single WebRTC client (peer-to-peer connection) interface.
*
* @version 0.4.1
*/

/**
* @class A WebRTC client interface used to connect to and communicate
* with a single peer.
* @extends EventDispatcher
*/
class WebRTCClient extends EventDispatcher {

   //Event definitions:

   /**
   * An ICE candidate has been received from one of the STUN servers,
   *
   * @event WebRTCClient#icecandidate
   * @type {Event}
   * @property {RTCIceCandidate} candidate The received candidate.
   * @property {RTCSessionDescription} description The local session description including
   * the <code>candidate</code> data.
   */
   /**
   * A received offer generated by an initiating peer has been received, processed,
   * and an answer has been generated.
   *
   * @event WebRTCClient#offer
   * @type {Event}
   * @property {Object} offer The received offer as sent by the initiating peer.
   * @property {Object} answer The generated answer.
   */
   /**
   * A received answer generated by an responding peer has been received and processed.
   *
   * @event WebRTCClient#answer
   * @type {Event}
   * @property {Object} answer The received answer generated by the responding peer.
   */
   /**
   * The data channel ({@link dataChannel}) created by the initiating peer has been created
   * locally. Note that this even will not be dispatched if this instance is the initiating peer.
   *
   * @event WebRTCClient#remotechannel
   * @type {Event}
   */
   /**
   * The data channel ({@link dataChannel}) has been successfully connected and
   * bi-directional communication can now take place (e.g. using the {@link sendMessage}
   * function).
   *
   * @event WebRTCClient#peerconnect
   * @type {Event}
   */
   /**
   * The peer connection ({@link peerConnection}) or data channel ({@link dataChannel})
   * has disconnected.
   *
   * @event WebRTCClient#peerdisconnect
   * @type {Event}
   */
   /**
   * The data channel ({@link dataChannel}) has received a message from a
   * connected peer.
   *
   * @event WebRTCClient#message
   * @type {Event}
   *
   * @property {*} message The received message.
   */
   /**
   * The private ID of this connection has changed and a notification
   * has been sent to the connected peer.
   *
   * @event WebRTCClient#privateid
   * @type {Event}
   *
   * @property {String} privateID The new private ID for this connection.
   */

   /**
   * Creates a new instance of WebRTCClient.
   *
   * @param {Object} [signallingServer=null] A reference to the signalling
   * server or mechanism for this instance to use during connection
   * attempts and negotiations. If null, the [router]{@link WebRTCClient@router}
   * property must be set manually prior to attempting a connection.
   */
   constructor(p2pRouter=null) {
      super();
      if (p2pRouter != null) {
         this.router = p2pRouter;
      }
   }

   /**
   * @property {Array|RTCIceServer} iceServers An indexed list of ICE/STUN servers
   * to use when creating a new connection.<br/>
   * Additional servers:<br/>
   * https://gist.github.com/zziuni/3741933
   * http://olegh.ftp.sh/public-stun.txt
   * https://gist.github.com/mondain/b0ec1cf5f60ae726202e
   * @readonly
   */
   get iceServers() {
      var serverList = [
         {urls:["stun.l.google.com:19302",
                "stun1.l.google.com:19302",
                "stun2.l.google.com:19302",
                "stun3.l.google.com:19302",
                "stun4.l.google.com:19302"]
         },
         {urls:"stunserver.org"},
         {urls:"stun.stunprotocol.org:3478"}
      ]
   }

   /**
   * @property {Object} connectionConfig A <code>RTCPeerConnection</code> configuration
   * object used to configure the instance.
   * @readonly
   */
   get connectionConfig() {
      var config = new Object();
      config.iceServers = this.iceServers;
   }

   /**
   * @property {RTCPeerConnection} peerConnection=null The peer connection object being
   * used by this instance. This property is set either when {@link createPeerConnection}
   * is invoked or when {@link setRemoteDescription} is invoked. Returns <code>null</code>
   * if no connection object currently exists.
   * @readonly
   */
   get peerConnection() {
      if (this["_peerConnection"] == undefined) {
         this._peerConnection = null;
      }
      return (this._peerConnection);
   }

   /**
   * @property {String} channelName="p2pchannel" The default data channel name used by this
   * instance.
   * @readonly
   */
   get channelName() {
      return ("p2pchannel");
   }

   /**
   * @property {Object} router=null The P2P router instance to use as a signalling mechanism
   * for exchanging messages during connection and negotiation attempts.
   */
   get router() {
      if (this._router == undefined) {
         this._router = null;
      }
      return (this._router);
   }

   set router(ssSet) {
      this._router = ssSet;
   }

   /**
   * @property {String} peerID The private ID of the remote peer to which this
   * instance is connecting or connected.
   */
   get peerPID () {
      if (this._peerPID == undefined) {
         this._peerPID = null;
      }
      return (this._peerPID);
   }

   /**
   * @property {RTCDataChannel} dataChannel The main data channel used to communicate
   * with a connected peer. Returns <code>null</code> if no data channel has been established.
   * @readonly
   */
   get dataChannel() {
      if (this["_dataChannel"] == undefined) {
         this._dataChannel = null;
      }
      return (this._dataChannel);
   }

   /**
   * Adds the standard RTCDataChannel handlers to the {@link dataChannel} instance.
   *
   * @private
   */
   addChannelHandlers() {
      if (this.dataChannel == null) {
         throw (new Error("No data channel established to which to attach listeners."));
      }
      this.dataChannel._client = this;
      this.dataChannel.addEventListener("open", this.onChannelOpen);
      this.dataChannel.addEventListener("close", this.onChannelClose);
      this.dataChannel.addEventListener("message", this.onChannelMessage);
   }

   /**
   * Begins checking the state of the {@link WebRTCClient#peerConnection} by calling
   * {@link checkConnectionState} at regular intervals.
   *
   * @param {Number} checkInterval The millisecond interval at which to check the connection
   * state.
   * @private
   * @todo Investigate further to see if there's a better way to detect disconnections
   */
   startConnectionStateCheck(checkInterval=1000) {
      this._stateCheckInterval = setInterval (this.checkConnectionState, checkInterval, this);
   }

   /**
   * Function invoked at regular interval ticks to check the state of the
   * {@link WebRTCClient#peerConnection} connection state and fire
   * a "disconnect" event when a disconnection is detected.
   *
   * @param {WebRTCClient} context The execution context in which to invoke the
   * check.
   * @fires WebRTCClient#disconnect
   * @private
   */
   checkConnectionState(context) {
      if (context.peerConnection.iceConnectionState == "disconnected") {
         clearInterval(context._stateCheckInterval);
         var newEvent = new Event("peerdisconnect");
         context.dispatchEvent(newEvent);
      }
   }

   /**
   * Attempts to establish a direct peer connection over WebRTC.
   *
   * @param {String} privateID The private ID of the peer to connect to,
   * as assigned by the {@link P2PRouter} or other external ID manager.
   * @param {Object} [config=null] A RTCPeerConnection configuration object. If
   * omitted or null, the [connectionConfig]{@link WebRTCClient#connectionConfig} object is used.
   *
   * @async
   */
   async connect(privateID, config=null) {
      if (config == null) {
         config = this.connectionConfig;
      }
      this._peerPID = privateID;
      this._peerConnection = new RTCPeerConnection(config); //create initiating connection
      this.peerConnection._client = this; //store reference for event handlers
      this.peerConnection.addEventListener("negotiationneeded", this.onConnectionNegotiate);
      this.peerConnection.addEventListener("icecandidate", this.onICECandidate);
      this.router.addEventListener("message", this.onSignalMessage, this);
      this._dataChannel = this.peerConnection.createDataChannel(this.channelName);
      this.addChannelHandlers();
      return (true);
   }

   /**
   * Changes the private ID associated with this connection. Any attached
   * peer is notified of this changed via a <code>session</code> message.
   *
   * @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 JSONObj = buildJSONRPC("notification");
      JSONObj.result.type = "session";
      JSONObj.result.change = new Object();
      JSONObj.result.change.oldPrivateID = this.privateID;
      JSONObj.result.change.newPrivateID = newPrivateID;
      this.dataChannel.send(JSON.stringify(JSONObj)); //don't wait for response
      this._privateID = newPrivateID;
      var event = new Event("privateid");
      event.privateID = newPrivateID;
      this.dispatchEvent(event);
      return (true);
   }

   /**
   * Sets a remote offer SDP received from an initiating peer during a connection
   * negotiation.
   *
   * @param {String} privateID The private ID of the peer sending the offer SDP.
   * @param {Object|String} descData The SDP data sent by the <code>privateID</code>
   * If this is a string, it's assumed to be stringified JSON data which will be
   * parsed before being processed.
   *
   * @fires WebRTCClient#offer
   * @async
   * @private
   */
   async setRemoteOffer(privateID, descData) {
      if (typeof(descData) == "string") {
         var description = new RTCSessionDescription(JSON.parse(descData));
      } else {
         description = new RTCSessionDescription(descData);
      }
      this._peerPID = privateID;
      this._peerConnection = new RTCPeerConnection(this.connectionConfig); //create responding connection
      this.peerConnection.addEventListener("icecandidate", this.onICECandidate);
      this.peerConnection._client = this; //store reference for event handlers
      this.peerConnection.ondatachannel = this.onDataChannelEstablished; //use initiating data channel
      this.router.addEventListener("message", this.onSignalMessage, this);
      var result = await this.peerConnection.setRemoteDescription(description);
      var answerDesc = await this.peerConnection.createAnswer();
      result = await this.peerConnection.setLocalDescription(answerDesc);
      var answer = this.peerConnection.localDescription;
      this.sendSignalMessage("webrtcanswer", {
         answer: answer
      });
      var event = new Event("offer");
      event.offer = result;
      event.answer = answer;
      this.dispatchEvent(event);
      return (answer);
   }

   /**
   * Sends a peer-to-peer "direct" message to the connected peer.
   *
   * @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
   * @TODO Update functionality to support message signing and forwarding for peer-to-peer meshes.
   */
   async send(data, recipients=null) {
      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 JSONObj = buildJSONRPC("notification"); //don't include id
      JSONObj.result.type = "direct";
      JSONObj.result.data = data;
      this.dataChannel.send(JSON.stringify(JSONObj));
      return (this.createMessageSentEvent());
   }

   /**
   * Sends a peer-to-peer "broadcast" message to the connected peer.
   *
   * @param {*} data The data / message to broadcast.
   * @param {Object|Array} [excludeRecipients=null] NOT CURRENTLY IMPLEMENTED.
   *
   * @return {Promise} An asynchronous Promise that will contain the result of
   * the send or reject with an <code>Error</code> object on failure.
   * @async
   * @TODO Update functionality to support message signing and forwarding for peer-to-peer meshes
   */
   async broadcast (data, excludeRecipients=null) {
      var JSONObj = buildJSONRPC("notification"); //don't include id
      JSONObj.result.type = "broadcast";
      JSONObj.result.data = data;
      this.dataChannel.send(JSON.stringify(JSONObj));
      return (this.createMessageSentEvent());
   }

   /**
   * Sends a peer-originating "update" message to the connected peer,
   * mimicking an API server "update" message.
   *
   * @param {String|Object} data The message to send. If the message is
   * not a string it is stringified into JSON string data.
   *
   * @todo Test this functionality
   */
   sendUpdate (data) {
      var JSONObj = buildJSONRPC("notification"); //don't include id
      JSONObj.result.type = "update";
      JSONObj.result.data = data;
      this.dataChannel.send(JSON.stringify(JSONObj));
   }

   /**
   * Creates a message-sent response event that mimics one received
   * when messages are sent to peers over WebSocket Sessions.
   *
   * @return {Event} A message-sent event mimicking that received from
   * a WebSocket Sessions server after a peer message was sent.
   */
   createMessageSentEvent() {
      var responseEvent = new Event("message");
      Object.defineProperty(responseEvent, "target", {writable: true});
      Object.defineProperty(responseEvent, "currentTarget", {writable: true});
      responseEvent.target = this;
      responseEvent.currentTarget = this;
      var responseObj = buildJSONRPC("result", {"id":uniqueRPCID()});
      responseObj = new Object();
      responseObj.message = "ok";
      responseEvent.data = responseObj;
   }

   /**
   * Sends a signalling message to the potential peer using the {@link P2PRouter}.
   *
   * @param {String} messageType The type of signalling message to send. See the
   * [onSignalMessage]{@link WebRTCClient#onSignalMessage} function for supported
   * message types.
   * @param {*} [data=null] Optional data to include with the signalling message.
   * @param {Boolean} internal=true If true, the message is handled by this WebRTCClient
   * instance. If false, it's handled by the {@link P2PRouter} instance (typically this
   * is just the negotiation-start message).
   */
   sendSignalMessage(messageType, data=null, internal=true) {
      if (internal == false) {
         //router-based (external) signalling message
         var message = this.router.buildRouterMessage(messageType);
      } else {
         //instance-based (internal) signalling message
         message = this.buildSignalMessage(messageType);
      }
      if (data != null) {
         for (var item in data) {
            if (item != "type") {
               message[item] = data[item];
            }
         }
      }
      this.router.send(message, [this.peerPID]);
   }

   /**
   * Sends an API request using the [dataChannel]{@link WebRTCClient#dataChannel}.
   * <b>NOT YET IMPLEMENTED</b>
   *
   * @param {Object} requestObj The JSON-RPC 2.0 request to send.
   *
   * @return {Promise} An asynchronous promise that will resolve with the JSON-RPC 2.0
   * API response.
   */
   request(requestObj) {
      var promise = new Promise((resolve, reject) => {
         reject (new Error("Function not implemented yet."));
      })
      return (promise);
   }

   /**
   * Builds a WebRTCClient signalling message.
   *
   * @param {String} messageType The signalling message type to create.
   *
   * @return {Object} A signalling message object containing the <code>webRTCSignalMsg</code>
   * property which contains the message type.
   */
   buildSignalMessage(messageType) {
      var messageObj = new Object();
      messageObj.webRTCSignalMsg = messageType;
      return (messageObj);
   }

   /**
   * Verifies if a supplied message event object contains a valid WebRTC signal message.
   *
   * @param {Event} event The "message" event, as usually dispatched by the
   * peer-to-peer interface, to examine.
   *
   * @return {Boolean} True if the event contains a valid WebRTC signal message
   * (though its type may not be supported).
   * @private
   */
   isSignalMsgEvent(event) {
      try {
         if (typeof(event["data"]) != "object") {
            //not sure what this is
            return (false);
         }
         if (typeof(event.data["result"]) != "object") {
            //may not be a JSON-RPC message
            return (false);
         }
         if (typeof(event.data.result["data"]) != "object") {
            //not a router-formatted message
            return (false);
         }
         return (this.isSignalMessage(event.data.result.data));
      } catch (err) {
         return (false);
      }
   }

   /**
   * Verifies if a supplied object is a valid WebRTC signal message.
   *
   * @param {Object} message The object to examine.
   *
   * @return {Boolean} True if the object seems to be a valid WebRTC signal message
   * (though it may not be supported).
   * @private
   */
   isSignalMessage(message) {
      if ((message["webRTCSignalMsg"] == undefined) || (message["webRTCSignalMsg"] == null) || (message["webRTCSignalMsg"] == "")) {
         //not a P2PRouter message or it's blank (mo message type)
         return (false);
      }
      return (true);
   }

   /**
   * Handles any "message" events received on the signalling transport. Any non-signalling messages
   * are ignored.
   *
   * @param {Event} event A message event received from the signalling transport.
   *
   * @private
   */
   async onSignalMessage(event) {
      if (this.isSignalMsgEvent(event)) {
         var fromPID = event.data.result.from;
         var msgData = event.data.result.data;
         var messageType = msgData.webRTCSignalMsg;
         switch (messageType) {
            case "webrtcnegotiate":
               event.target._client = this;
               this.onConnectionNegotiate(event);
               break;
            case "webrtcanswer":
               var result = await this.peerConnection.setRemoteDescription(msgData.answer);
               var newEvent = new Event("answer");
               newEvent.answer = msgData.answer;
               this.dispatchEvent(newEvent);
               break;
            case "webrtcicecandidate":
               var candidate = new RTCIceCandidate(msgData.ice);
               this.peerConnection.addIceCandidate(candidate);
               break;
            case "webrtcicecomplete":
               this.peerConnection.addIceCandidate(null);
               break;
            default:
               //not a recognized signalling message type
               break;
         }
      } else {
         //not a signalling message
         return (false);
      }
      return (true);
   }

   /**
   * Handles "negotiationneeded" events for the [peerConnection]{@link WebRTCClient#peerConnection}.
   *
   * @param {Event} event A "negotiationneeded" RTCPeerConnection event.
   *
   * @private
   */
   async onConnectionNegotiate(event) {
       var wrtcClient = event.target._client;
       wrtcClient.peerConnection.createOffer().then(offer => {
          wrtcClient.peerConnection.setLocalDescription(offer).then(result => {
             wrtcClient.sendSignalMessage("peerconnectreq", {
                transportType: "webrtc",
                offer: offer,
                options: wrtcClient.router.constructor.supportedTransports.options
            }, false);
         });
       }).catch(err => {
          console.error (err);
       });
   }

   /**
   * Handles "icecandidate" events for the [peerConnection]{@link WebRTCClient#peerConnection}.
   *
   * @param {Event} event A "icecandidate" RTCPeerConnection event.
   *
   * @fires WebRTCClient#icecandidate
   * @private
   */
   onICECandidate(event) {
      var wrtcClient = event.target._client;
      if ((event.target.iceGatheringState == "complete") || (event.candidate == null)) {
          wrtcClient.sendSignalMessage("webrtcicecomplete");
      } else if (event["candidate"] != undefined) {
          var ice = event.candidate;
          wrtcClient.sendSignalMessage("webrtcicecandidate", {"ice":ice});
          var newEvent = new Event("icecandidate");
          newEvent.candidate = ice;
          newEvent.description = ice.description;
          wrtcClient.dispatchEvent(newEvent);
      }
   }

   /**
   * Handles "iceconnectionstatechange" events for the [peerConnection]{@link WebRTCClient#peerConnection}.
   *
   * @param {Event} event A "iceconnectionstatechange" RTCPeerConnection event.
   *
   * @fires WebRTCClient#peerdisconnect
   * @private
   */
   onIceConnectionStateChange(event) {
      if (this.peerConnection.iceConnectionState == "disconnected") {
         var newEvent = new Event("peerdisconnect");
         this.dispatchEvent(newEvent);
      }
   }

   /**
   * Event handler invoked when the initiating peer's channel has been locally established.
   *
   * @param {Event} event A <code>RTCPeerConnection</code> event.
   *
   * @fires WebRTCClient#remotechannel
   * @private
   */
   onDataChannelEstablished(event) {
      var wrtcClient = event.target._client;
      wrtcClient._dataChannel = event.channel;
      wrtcClient.addChannelHandlers();
      var newEvent = new Event("remotechannel");
      wrtcClient.dispatchEvent(newEvent);
   }

   /**
   * Event handler invoked when the {@link dataChannel} connects.
   *
   * @param {Event} event A <code>RTCPeerConnection</code> event.
   *
   * @fires WebRTCClient#peerconnect
   * @private
   */
   onChannelOpen(event) {
      var wrtcClient = event.target._client;
      if (event.target.readyState == "open") {
         wrtcClient.startConnectionStateCheck();
         wrtcClient.addEventListener("iceconnectionstatechange", wrtcClient.onIceConnectionStateChange, wrtcClient);
         var newEvent = new Event("peerconnect");
         wrtcClient.dispatchEvent(newEvent);
      }
   }

   /**
   * Event handler invoked when the {@link dataChannel} disconnects.
   *
   * @param {Event} event A <code>RTCDataChannel</code> event.
   *
   * @fires WebRTCClient#peerdisconnect
   * @private
   */
   onChannelClose(event) {
      var wrtcClient = event.target._client;
      var newEvent = new Event("peerdisconnect");
      wrtcClient.dispatchEvent(newEvent);
   }

   /**
   * Event handler invoked when the {@link dataChannel} receives a message
   * from a connected peer.
   *
   * @param {Event} event A <code>RTCDataChannel</code> event.
   *
   * @fires WebRTCClient#message
   * @private
   */
   onChannelMessage(event) {
      var wrtcClient = event.target._client;
      try {
         var dataObj = JSON.parse(event.data);
         switch (dataObj.result.type) {
            case "broadcast":
               var newEvent = new Event("message");
               newEvent.data = dataObj;
               wrtcClient.dispatchEvent(newEvent);
               break;
            case "direct":
               newEvent = new Event("message");
               newEvent.data = dataObj;
               wrtcClient.dispatchEvent(newEvent);
               break;
            case "update":
               newEvent = new Event("update");
               newEvent.data = dataObj;
               wrtcClient.dispatchEvent(newEvent);
               break;
            case "session":
               if (typeof(dataObj.result.change) == "object") {
                  //in the future we may support different types of session updates
                  event = new Event("peerpid");
                  event.data = dataObj;
                  wrtcClient.peerID = dataObj.result.newPrivateID;
                  wrtcClient.dispatchEvent(event);
               } else {
                  //unhandled session message
               }
               break;
            default:
               console.error ("Unrecognized messaged type \""+dataObj.result.type+"\"");
               //unhandled message type
               break;
         }
      } catch (err) {
         console.error(err);
      }
   }

   /**
   * Disconnects the WebRTC client connection and removes any event listeners.
   *
   * @async
   */
   async disconnect() {
      try {
         this.router.removeEventListener("message", this.onSignalMessage);
      } catch (err) {
      }
      try {
         this.dataChannel.removeEventListener("open", this.onChannelOpen);
         this.dataChannel.removeEventListener("close", this.onChannelClose);
         this.dataChannel.removeEventListener("message", this.onChannelMessage);
         delete this.dataChannel._client;
      } catch (err) {
      }
      try {
         this.peerConnection.removeEventListener("message", this.handleSocketMessage);
         this.peerConnection.removeEventListener("close", this.handleSocketClose);
         this.peerConnection.removeEventListener("negotiationneeded", this.onConnectionNegotiate);
         this.peerConnection.removeEventListener("icecandidate", this.onICECandidate);
         this.peerConnection.removeEventListener("iceconnectionstatechange", this.onIceConnectionStateChange);
         this.peerConnection.close();
         delete this.peerConnection._client;
         this._peerConnection = null;
      } catch (err) {
      }
      return (true);
   }

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

}