index.js

import EventEmitter from 'eventemitter3'
import ClientAPI from './client/ClientAPI'
import ConnectionRTC from './client/ConnectionRTC'
import ConnectionSocket from './client/ConnectionSocket'
import getDebugger from './common/debug'
import Cookies from 'universal-cookie'

/**
 * @external EventEmitter
 * @see {@link https://github.com/primus/eventemitter3 EventEmitter}
 */

/**
 * The client pairs another client via the server.
 *
 * A running PeerSox server is required for pairing – two clients can't pair
 * with eachother without a server.
 *
 * Usage is different between the initiator (the one to request a pairing code)
 * and the joiner (the one to validate a pairing code).
 *
 * @example <caption>The initiator</caption>
 * // Create a new client.
 * let peersox = new PeerSoxClient('http://localhost:3000', {
 *   debug: true
 * })
 *
 * await peersox.init()
 *
 * // Once the joiner (the peer of this client) is connected, we can start
 * // listening for incoming messages.
 * peersox.on('peerConnected', () => {
 *   // Receive binary data (e.g. ArrayBuffer).
 *   peersox.onBinary = (data) => {
 *     const buffer = new Uint8Array(data);
 *     console.log(buffer)
 *   }
 *
 *   // Receive string data.
 *   peersox.onString = (data) => {
 *     console.log(data)
 *   }
 * })
 *
 * // Request a new Pairing.
 * // If successful, the client is now connected to the PeerSox server and
 * // waiting for the joiner to connet to the server.
 * const pairing = await peersox.createPairing()
 *
 * // The pairing code the joiner will need to use.
 * console.log(pairing.code) // => "123456"
 *
 * // Connect with the received pairing.
 * peersox.connect(pairing)
 *
 *
 * @example <caption>The joiner</caption>
 * // Create a new client.
 * let peersox = new PeerSoxClient('http://localhost:3000', {
 *   debug: true
 * })
 *
 * await peersox.init()
 *
 * // Start pairing with the initiator.
 * const pairing = await peersox.joinPairing('123456')
 *
 * // The pairing with the peer succeeded when the following event is emitted.
 * peersox.on('peerConnected', () => {
 *   // The client is now connected to its peer.
 *   // Let's send a message every second.
 *   interval = window.setInterval(() => {
 *     const numbers = [
 *       Math.round(Math.random() * 100),
 *       Math.round(Math.random() * 100),
 *       Math.round(Math.random() * 100)
 *     ]
 *
 *     const byteArray = new Uint8Array(numbers)
 *
 *     // Send an ArrayBuffer.
 *     peersox.send(byteArray.buffer)
 *
 *     // Send a string.
 *     peersox.send(numbers.join(';'))
 *   }, 1000)
 * })
 *
 * // You can now connect with the pairing.
 * peersox.connect(pairing)
 *
 * @class
 * @extends {external:EventEmitter}
 * @fires PeerSoxClient#connectionEstablished
 * @fires PeerSoxClient#connectionClosed
 * @fires PeerSoxClient#peerConnected
 * @fires PeerSoxClient#peerTimeout
 * @fires PeerSoxClient#peerRtcClosed
 */
class PeerSoxClient extends EventEmitter {
  /**
   * Create a new PeerSox client.
   *
   * @param {string} url The URL where the PeerSox server is reachable.
   * @param {object} options The options for the client.
   * @param {boolean} options.autoUpgrade Automatically upgrade to a WebRTC
   * connection.
   * @param {boolean} options.debug When enabled, debugging info is logged.
   * @param {object} options.simplePeerOptions Options passed to SimplePeer.
   * @param {number} options.peerTimeout How long in seconds before closing peer
   * connection.
   */
  constructor (url = 'http://localhost:3000', {
    autoUpgrade = true,
    debug = false,
    simplePeerOptions = {},
    peerTimeout = 30,
    socketServerUrl
  } = {}) {
    super()

    /**
     * Makes requests to the PeerSox REST API server.
     *
     * @member {ClientAPI}
     * @private
     */
    this._api = new ClientAPI({
      debug,
      url: url + '/api'
    })

    /**
     * Manage WebSocket connection to the server.
     *
     * @member {ConnectionSocket}
     * @private
     */
    this._socket = new ConnectionSocket({
      url: socketServerUrl || url.replace(/^http/, 'ws') + '/ws',
      debug
    })

    /**
     * Manage WebRTC connection to the peer.
     *
     * @member {ConnectionRTC}
     * @private
     */
    this._rtc = new ConnectionRTC({ debug, simplePeerOptions })

    /**
     * Logs debugging messages to the console.
     *
     * @member {function}
     * @private
     */
    this._debug = getDebugger(debug, 'PeerSoxClient')

    /**
     * @member {boolean}
     * @private
     */
    this._autoUpgrade = autoUpgrade

    /**
     * Timeout timer for the WebRTC upgrade attempt.
     *
     * @member {object}
     * @private
     */
    this._upgradeTimeout = null

    /**
     * The configuration object fetched from the server.
     *
     * @member {object}
     * @private
     */
    this._config = {}

    /**
     * Duration in seconds of how long a peer connection can remain silent
     * before it is closed.
     *
     * @member {number}
     * @private
     */
    this._peerTimeout = peerTimeout

    /**
     * The cookie context of universal-cookie.
     *
     * @member {Cookies}
     * @private
     */
    this._cookies = new Cookies()

    /**
     * Store the timeout for the ping timeout.
     *
     * @member {object}
     * @private
     */
    this._pingTimeout = null

    this._addEventListeners()

    // Add a function to the global scope to retrive the client status.
    if (debug) {
      window.__PEERSOX_GET_STATUS = () => {
        return this.status
      }
    }
  }

  /**
   * Get the current status of the client.
   *
   * @returns {object}
   */
  get status () {
    return {
      isConnected: this.isConnected(),
      config: this._config,
      webSocket: this._socket.status,
      webRTC: this._rtc.status
    }
  }

  /**
   * Set the handler function for incoming binary data.
   *
   * @param {function}
   */
  set onBinary (fn) {
    this._socket._onBinary = fn
    this._rtc._onBinary = fn
  }

  /**
   * Set the handler function for incoming string data.
   *
   * @param {function}
   */
  set onString (fn) {
    this._socket._onString = fn
    this._rtc._onString = fn
  }

  /**
   * Initialize peersox by getting the config.
   *
   * @returns {Promise<object>} The config requested from the server.
   */
  init () {
    return this._requestConfig()
  }

  /**
   * Get information about the browser support of WebSocket and WebRTC.
   *
   * @returns {object}
   */
  getDeviceSupport () {
    return PeerSoxClient.SUPPORTS
  }

  /**
   * Return the current connection state.
   *
   * @returns {boolean}
   */
  isConnected () {
    return this._socket.isConnected() || this._rtc.isConnected()
  }

  /**
   * Initiate a pairing.
   *
   * The method will call the API to fetch a new Pairing, consisting of the code
   * and hash. The pairing is valid for a limited amount of time, depending on
   * the configuration done on the server side.
   *
   * The promise can be rejected when the API is not available or when too many
   * requests are made.
   *
   * @example
   * peersox.createPairing().then(pairing => {
   *   // The pairing code the joiner will need to use.
   *   // => { hash: 'xyzxyzxyz', code: '123456' }
   *   console.log(pairing)
   * })
   *
   * @returns {Promise<Pairing>} The pairing used for connecting.
   */
  createPairing () {
    return this._api.requestPairing()
  }

  /**
   * Join an initiated pairing.
   *
   * The given code is from a Pairing. It is first validated via the API and if
   * this is successful, a WebSocket connection to the server is attempted.
   *
   * @example
   * peersox.joinPairing('123456').then(pairing => {
   *   if (pairing) {
   *     // You can now connect to the server.
   *   }
   * })
   *
   * @param {number|string} code The code to validate.
   * @returns {Promise<Pairing>} The pairing to be used for connecting.
   */
  joinPairing (code) {
    return this._api.getHash(code)
  }

  /**
   * Validate a pairing.
   *
   * @example
   * peersox.validate({ code: 123456, hash: 'xyz' }).then(isValid => {
   *   // The pairing is valid.
   * })
   *
   * @param {pairing} pairing The pairing to validate.
   * @returns {Promise<boolean>} True if the pairing is valid.
   */
  validate (pairing) {
    return this._api.validate(pairing)
  }

  /**
   * Connect to the WebSocket server with the given pairing.
   *
   * This will directly attempt a connection to the WebSocket server, without
   * prior validation of the pairing. The client and server will first perform a
   * handshake, where the pairing is validated. The connection will be closed
   * immediately if the handshake fails, without emitting any of the events.
   *
   * The server will decide the role (initiator or joiner) of this client
   * depending on who was first.
   *
   * The promise can be rejected when the WebSocket server is not available,
   * when too many requests are made or when the WebSocket server refuses the
   * handshake with the fetched pairing.
   *
   * @example
   * peersox.connect({ code: 123456, hash: 'xyz' }).then(({ pairing, isInitiator }) => {
   *   // You are now connected to the server.
   *
   *   peersox.on('peerConnnected', () => {
   *     // You can now send messages to the other client.
   *   })
   * })
   *
   * @param {Pairing} pairing
   * @returns {Promise<{pairing:Pairing, isInitiator:boolean }>}
   */
  connect (pairing) {
    return this._api.getToken().then(token => this._socket.connect(pairing, token))
  }

  /**
   * Send data to the peer.
   *
   * It's possible to send either string or binary data. Performance is likely
   * to be better when sending binary data, for example an ArrayBuffer.
   *
   * This method will not perform any checks on the validity or compatibility of
   * the given data's type with WebSocket or WebRTC.
   *
   * @example <caption>Sending binary data</caption>
   * const numbers = [
   *   Math.round(Math.random() * 100),
   *   Math.round(Math.random() * 100),
   *   Math.round(Math.random() * 100)
   * ]
   * const byteArray = new Uint8Array(numbers)
   * peersox.send(byteArray.buffer)
   *
   * @example <caption>Sending a string</caption>
   * peersox.send('This is my message.')
   *
   * @example <caption>Sending an object</caption>
   * // It's not possible to send objects directly. You need to stringify it and
   * // send it as a string.
   * const payload = {
   *   name: 'ping',
   *   data: Date.now()
   * }
   * const message = JSON.stringify(payload)
   * peersox.send(message)
   *
   * @param {string|ArrayBuffer} data The data to send to the peer.
   */
  send (data) {
    if (!this.isConnected()) {
      return
    }

    if (this._rtc.isConnected()) {
      this._rtc.send(data)
    } else {
      this._socket.send(data)
    }
  }

  /**
   * Upgrade the connection to WebRTC.
   *
   * Call this method when you disabled automatic upgrading.
   *
   * @param {boolean} isInitiator Whether this client is initiating.
   */
  upgrade (isInitiator) {
    this._rtc.connect(isInitiator)
  }

  /**
   * Close all connections and attempt to disconnect the peer.
   */
  close () {
    this._rtc.close()
    return this._socket.close()
  }

  /**
   * Return the connected WebSocket socket.
   *
   * @throws Will throw an error if no WebSocket connection is made.
   * @returns {WebSocket}
   */
  getSocket () {
    if (!this._socket.isConnected()) {
      throw new Error('Socket is not connected')
    }
    return this._socket.getSocket()
  }

  /**
   * Restore a pairing.
   *
   * @returns {Promise<Pairing|null>} The restorable pairing if it exists.
   */
  restorePairing () {
    const cookie = this._cookies.get('pairing')

    if (!cookie) {
      return Promise.resolve(null)
    }

    const [code, hash] = cookie.split('_')

    if (!code || !hash) {
      this.deletePairing()
      return Promise.resolve(null)
    }

    const pairing = { code, hash }

    return this.validate(pairing).then((isValid) => {
      if (!isValid) {
        this.deletePairing()
      }
      return isValid ? pairing : null
    })
  }

  /**
   * Save the given pairing in a cookie.
   *
   * @param {Pairing} pairing The pairing to save.
   */
  storePairing (pairing) {
    this._cookies.set('pairing', `${pairing.code}_${pairing.hash}`, { path: '/' })
  }

  /**
   * Delete all pairing cookies.
   */
  deletePairing () {
    this._cookies.remove('pairing')
  }

  /**
   * Delete the PeerSox instance.
   *
   * Closes all open connections and removes event listeners.
   */
  destroy () {
    this._socket.close()
    this._rtc.close()
    this._removeEventListeners()
  }

  /**
   * Handle when a connection to the WebSocket server is established.
   *
   * @private
   * @param {any} data
   */
  _handleConnectionEstablished (data) {
    this.emit(PeerSoxClient.EVENT_CONNECTION_ESTABLISHED, data)
  }

  /**
   * Handle the closing of a connection.
   *
   * @private
   * @param {string} connection The context name of the connection.
   */
  _handleConnnectionClosed (connection) {
    if (connection === 'WebRTC') {
      this.emit(PeerSoxClient.EVENT_PEER_WEBRTC_CLOSED)
    }

    if (!this._rtc.isConnected() && !this._socket.isConnected()) {
      this.emit(PeerSoxClient.EVENT_CONNECTION_CLOSED)
      window.clearTimeout(this._pingTimeout)
    }
  }

  /**
   * Handle the establishment of a peer connection.
   *
   * @private
   * @param {object} data
   */
  _handlePeerConnected (data) {
    const isInitiator = data.isInitiator === true
    this.emit(PeerSoxClient.EVENT_PEER_CONNECTED, data)

    if (this._autoUpgrade) {
      // Wait a second before initializing WebRTC.
      window.clearTimeout(this._upgradeTimeout)

      this._upgradeTimeout = window.setTimeout(() => {
        this.upgrade(isInitiator)
      }, 1000)
    }
  }

  /**
   * Add event listeners to both Connection instances.
   *
   * @private
   */
  _addEventListeners () {
    this._socket.on(ConnectionSocket.EVENT_ESTABLISHED, this._handleConnectionEstablished.bind(this))
    this._socket.on(ConnectionSocket.EVENT_CLOSED, this._handleConnnectionClosed.bind(this))
    this._socket.on(ConnectionSocket.EVENT_PEER_CONNECTED, this._handlePeerConnected.bind(this))
    this._socket.on(ConnectionSocket.EVENT_PEER_PING, this._handlePingMessage.bind(this))
    this._socket.on(ConnectionSocket.EVENT_PEER_SIGNAL, (signal) => this._rtc.signal(signal))
    this._rtc.on(ConnectionRTC.EVENT_RTC_SIGNAL, (signal) => this._socket.sendSignal(signal))
    this._rtc.on(ConnectionRTC.EVENT_PEER_PING, this._handlePingMessage.bind(this))
    this._rtc.on(ConnectionRTC.EVENT_CLOSED, this._handleConnnectionClosed.bind(this))
  }

  /**
   * Remove all attached event listeners from the connection instances.
   *
   * @private
   */
  _removeEventListeners () {
    this._socket.removeAllListeners(ConnectionSocket.EVENT_ESTABLISHED)
    this._socket.removeAllListeners(ConnectionSocket.EVENT_CLOSED)
    this._socket.removeAllListeners(ConnectionSocket.EVENT_PEER_CONNECTED)
    this._socket.removeAllListeners(ConnectionSocket.EVENT_PEER_PING)
    this._socket.removeAllListeners(ConnectionSocket.EVENT_PEER_SIGNAL)
    this._rtc.removeAllListeners(ConnectionRTC.EVENT_RTC_SIGNAL)
    this._rtc.removeAllListeners(ConnectionRTC.EVENT_PEER_PING)
    this._rtc.removeAllListeners(ConnectionRTC.EVENT_CLOSED)
  }

  /**
   * Request the config from the server.
   *
   * @private
   */
  _requestConfig () {
    return this._api.requestConfig().then(config => {
      this._config = config
      this._rtc.updateIceServers(config.iceServers)
      this.emit(PeerSoxClient.EVENT_SERVER_READY)
    })
  }

  /**
   * Handle incomming ping messages via WebSocket or WebRTC.
   *
   * @private
   */
  _handlePingMessage () {
    window.clearTimeout(this._pingTimeout)

    if (this.isConnected()) {
      this._pingTimeout = window.setTimeout(() => {
        this.emit(PeerSoxClient.EVENT_PEER_TIMEOUT)
        this._rtc.close()
        this._socket.close()
      }, this._peerTimeout * 1000)
    }
  }
}

/**
 * Config was requested from the server and a connection can be established.
 *
 * @event PeerSoxClient#serverReady
 */

/**
 * @member
 * @type {string}
 */
PeerSoxClient.EVENT_SERVER_READY = 'serverReady'

/**
 * A connection to the server has been established.
 *
 * @event PeerSoxClient#connectionEstablished
 * @type {Pairing}
 */

/**
 * @member
 * @type {string}
 */
PeerSoxClient.EVENT_CONNECTION_ESTABLISHED = 'connectionEstablished'

/**
 * The connection to the server has been closed.
 *
 * @event PeerSoxClient#connectionClosed
 */

/**
 * @member
 * @type {string}
 */
PeerSoxClient.EVENT_CONNECTION_CLOSED = 'connectionClosed'

/**
 * The peer is connected to this client.
 *
 * @member
 * @event PeerSoxClient#peerConnected
 * @type {Pairing}
 */

/**
 * @member
 * @type {string}
 */
PeerSoxClient.EVENT_PEER_CONNECTED = 'peerConnected'

/**
 * The peer connection has timed out.
 *
 * @member
 * @event PeerSoxClient#peerTimeout
 * @type {number}
 */

/**
 * @member
 * @type {string}
 */
PeerSoxClient.EVENT_PEER_TIMEOUT = 'peerTimeout'

/**
 * The WebRTC connection to the peer closed.
 *
 * @member
 * @event PeerSoxClient#peerRtcClosed
 * @type {Pairing}
 */

/**
 * The peer connection via WebRTC has closed.
 *
 * The connection might still be available via WebSocket.
 * @member
 * @type {string}
 */
PeerSoxClient.EVENT_PEER_WEBRTC_CLOSED = 'peerRtcClosed'

/**
 * An object containing
 * @member
 * @type {object} SUPPORTS
 * @property {boolean} SUPPORTS.WEBSOCKET
 * @property {boolean} SUPPORTS.WEBRTC
 */
PeerSoxClient.SUPPORTS = {
  WEBSOCKET: ConnectionSocket.IS_SUPPORTED,
  WEBRTC: ConnectionRTC.IS_SUPPORTED
}

export default PeerSoxClient