Source: index.js

import recursiveAssign from 'recursive-object-assign'
import WebSocket from 'isomorphic-ws'
import fetch from 'cross-fetch'

const CMD_RESPONSE = 'response'
const CMD_PING = 'ping'
const CMD_PONG = 'pong'

/**
 * Client class for ZeroFrame WebSocket.
 */
class ZeroFrame {
  // Initialization & Connection

  /**
   * Construct the class and set up a connection to the WebSocket server.
   *
   * @param {string}                       site                                   - Target ZeroNet site address
   * @param {ZeroFrame#constructorOptions} [options=ZeroFrame#constructorOptions] - Client options
   */
  constructor (site, options = {}) {
    options = recursiveAssign(ZeroFrame.constructorOptions, options)

    if (!site) {
      throw new Error('Site address is not specified')
    }

    this.site = site

    this.multiuser = options.multiuser
    this.instance = options.instance
    this.show = options.show
    this.reconnect = options.reconnect

    this.websocketConnected = false
    this.websocketClosing = false
    this.waitingCallbacks = {}
    this.waitingMessages = []
    this.nextMessageId = 1
    this.nextAttemptId = 1

    /**
     * Proxy for accessing ZeroFrame commands.
     *
     * Command name is accepted as an object's property and parameters are accepted as
     * a method's arguments. Command returns `Promise` with the result.
     *
     * * Command with no arguments can be accessed with `zeroframe.proxy.cmdName()`.
     * * Command with keyword arguments can be accessed with `zeroframe.proxy.cmdName({key1: value1, key2: value2})`.
     * * Command with normal arguments can be accessed with `zeroframe.proxy.cmdName(value1, value2)`.
     *
     * @name ZeroFrame#proxy
     * @type Proxy
     */
    this.proxy = new Proxy(this, {
      get: function get (target, name) {
        return function () {
          if (arguments.length === 0) {
            return target.cmdp(name)
          }

          if (arguments.length === 1 && typeof arguments[0] === 'object' && arguments[0] !== null) {
            return target.cmdp(name, arguments[0])
          }

          const params = Array.prototype.slice.call(arguments)
          return target.cmdp(name, params)
        }
      }
    })

    this._connect()
  }

  /**
   * User-based initialization code.
   *
   * @return {ZeroFrame}
   */
  init () {
    return this
  }

  /**
   * Get wrapper key and connect to WebSocket.
   *
   * @return {ZeroFrame}
   *
   * @private
   */
  async _connect () {
    this.wrapperKey = await this._getWrapperKey()
    this.websocket = await this._getWebsocket()

    return this.init()
  }

  /**
   * Get and return wrapper key.
   *
   * @return {string} - Wrapper key
   *
   * @private
   */
  async _getWrapperKey () {
    const siteUrl = 'http' + (this.instance.secure ? 's' : '') + '://' + this.instance.host + ':' + this.instance.port + '/' + this.site

    const wrapperRequest = await fetch(siteUrl, { headers: { Accept: 'text/html' } })
    const wrapperBody = await wrapperRequest.text()

    const wrapperKey = wrapperBody.match(/wrapper_key = "(.*?)"/)[1]

    return wrapperKey
  }

  /**
   * Connect and return WebSocket.
   *
   * @return {object} - WebSocket connection
   *
   * @private
   */
  async _getWebsocket () {
    const wsUrl = 'ws' + (this.instance.secure ? 's' : '') + '://' + this.instance.host + ':' + this.instance.port + '/Websocket?wrapper_key=' + this.wrapperKey
    let wsClient

    if (!this.multiuser.masterAddress) {
      wsClient = new WebSocket(wsUrl)
    } else {
      wsClient = new WebSocket(wsUrl, [], { headers: { Cookie: 'master_address=' + this.multiuser.masterAddress } })
    }

    wsClient.onmessage = this._onRequest.bind(this)
    wsClient.onopen = this._onOpenWebsocket.bind(this)
    wsClient.onerror = this._onErrorWebsocket.bind(this)
    wsClient.onclose = this._onCloseWebsocket.bind(this)

    return wsClient
  }

  // Internal handlers

  /**
   * Internal on request handler.
   *
   * It is triggered on every message from the WebSocket server.
   * It handles built-in commands and forwards others
   * to the user-based handler.
   *
   * @see ZeroFrame#onRequest
   *
   * @param {MessageEvent} event - Message event from the WebSocket library
   *
   * @return {void}
   *
   * @private
   */
  _onRequest (event) {
    const message = JSON.parse(event.data)
    const cmd = message.cmd

    if (cmd === CMD_RESPONSE) {
      if (this.waitingCallbacks[message.to] !== undefined) {
        this.waitingCallbacks[message.to](message.result)
        delete this.waitingCallbacks[message.to]
      }
    } else if (cmd === CMD_PING) {
      this.response(message.id, CMD_PONG)
    } else {
      this.onRequest(cmd, message)
    }
  }

  /**
   * Internal on open websocket handler.
   *
   * It is triggered when the WebSocket connection is opened.
   * It sends waiting message and calls the user-based handler.
   *
   * @see ZeroFrame#onOpenWebsocket
   *
   * @param {OpenEvent} event - Open event from the WebSocket library
   *
   * @return {void}
   *
   * @private
   */
  _onOpenWebsocket (event) {
    this.websocketConnected = true

    this.waitingMessages.forEach((message) => {
      if (!message.processed) {
        this.websocket.send(JSON.stringify(message))
        message.processed = true
      }
    })

    this.onOpenWebsocket(event)
  }

  /**
   * Internal on error websocket handler.
   *
   * It is triggered on the WebSocket error. It calls the user-based client.
   *
   * @see ZeroFrame#onErrorWebsocket
   *
   * @param {ErrorEvent} event - Error event from the WebSocket library
   *
   * @return {void}
   *
   * @private
   */
  _onErrorWebsocket (event) {
    this.onErrorWebsocket(event)
  }

  /**
   * Internal on close websocket handler.
   *
   * It is triggered when the WebSocket connection is closed.
   * It tries to reconnect if enabled and calls the user-based handler.
   *
   * @see ZeroFrame#onCloseWebsocket
   *
   * @param {CloseEvent} event - Close event from the WebSocket library
   *
   * @return {void}
   *
   * @private
   */
  _onCloseWebsocket (event) {
    this.websocketConnected = false

    this.onCloseWebsocket(event)

    // Don't attempt reconnection if user closes socket
    if (this.websocketClosing) return

    if (this.reconnect.attempts === 0) {
      return
    }

    if (this.reconnect.attempts !== -1 && this.nextAttemptId > this.reconnect.attempts) {
      return
    }

    setTimeout(async () => {
      this.websocket = await this._getWebsocket()
    }, this.reconnect.delay)
  }

  // External handlers

  /**
   * User-based on request handler.
   *
   * It is triggered on every message from the WebSocket server.
   * It can be used to add additional functionalities to
   * the client or handle received messages.
   *
   * @param {string} cmd     - Name of received command
   * @param {object} message - Message of received command
   *
   * @return {void}
   */
  onRequest (cmd, message) {
    this.log('Unknown request', message)
  }

  /**
   * User-based on open websocket handler.
   *
   * It is triggered when the WebSocket connection is opened.
   * It can be used to notify user or check for server details.
   *
   * @param {OpenEvent} event - Open event from the WebSocket library
   *
   * @return {void}
   */
  onOpenWebsocket (event) {
    this.log('Websocket open')
  }

  /**
   * User-based on error websocket handler.
   *
   * It is triggered on the WebSocket error.
   * It can be used to notify user or display errors.
   *
   * @param {ErrorEvent} event - Error event from the WebSocket library
   */
  onErrorWebsocket (event) {
    this.error('Websocket error')
  }

  /**
   * User-based on close websocket handler.
   *
   * It is triggered when the WebSocket connection is closed.
   * It can be used to notify user or display connection error.
   *
   * @param {CloseEvent} event - Close event from the WebSocket library
   */
  onCloseWebsocket (event) {
    this.log('Websocket close')
  }

  // Logging functions

  /**
   * Add log to console if enabled.
   *
   * @param {...*} args Logs to add to console
   *
   * @return {void}
   */
  log (...args) {
    if (this.show.log) {
      console.log.apply(console, ['[ZeroFrame]'].concat(args))
    }
  }

  /**
   * Add error to console if enabled.
   *
   * @param {...*} args Errors to add to console
   *
   * @return {void}
   */
  error (...args) {
    if (this.show.error) {
      console.error.apply(console, ['[ZeroFrame]'].concat(args))
    }
  }

  // Command functions

  /**
   * Callback with command result.
   *
   * Result will depend on called command.
   *
   * In most cases, the result will be object which contains data.
   * Some commands don't have any result. In this case, the result
   * will probably be string `ok`.
   *
   * @see {@link https://zeronet.io/docs/site_development/zeroframe_api_reference/|ZeroFrame API Reference}
   *
   * @typedef {function((object|string))} ZeroFrame#cmdCallback
   * @callback ZeroFrame#cmdCallback
   *
   * @param {object|string} [result] - Result from command
   *
   * @return {void}
   */

  /**
   * Internally send raw message to ZeroFrame server and call callback.
   *
   * If the connection is available, it directly sends a message. If the
   * connection is not available, it adds message to waiting message queue.
   *
   * @see ZeroFrame#cmd
   * @see ZeroFrame#cmdp
   * @see ZeroFrame#response
   *
   * @param {object}                message - Message to send
   * @param {ZeroFrame#cmdCallback} [cb]    - Message callback
   *
   * @return {void}
   *
   * @private
   */
  _send (message, cb = null) {
    if (!message.id) {
      message.id = this.nextMessageId
      this.nextMessageId++
    }

    if (this.websocketConnected) {
      this.websocket.send(JSON.stringify(message))
    } else {
      this.waitingMessages.push(message)
    }

    if (cb) {
      this.waitingCallbacks[message.id] = cb
    }
  }

  /**
   * Send command to ZeroFrame server and call callback.
   *
   * @param {string}                cmd      - Name of command to send
   * @param {object}                [params] - Parameters of command to send
   * @param {ZeroFrame#cmdCallback} [cb]     - Command callback
   *
   * @return {void}
   */
  cmd (cmd, params = {}, cb = null) {
    this._send({
      cmd: cmd,
      params: params
    }, cb)
  }

  /**
   * Send command to ZeroFrame server and return the result as promise.
   *
   * In most cases, the result will be object which contains data.
   * Some commands don't have any result. In this case, the result
   * will probably be string `ok`.
   *
   * @see {@link https://zeronet.io/docs/site_development/zeroframe_api_reference/|ZeroFrame API Reference}
   *
   * @param {string} cmd      - Name of command to send
   * @param {object} [params] - Parameters of command to send
   *
   * @return {Promise<(object|string)>} Command response
   */
  cmdp (cmd, params = {}) {
    return new Promise((resolve, reject) => {
      this.cmd(cmd, params, (response) => {
        if (response && response.error) {
          reject(response.error)
        } else {
          resolve(response)
        }
      })
    })
  }

  /**
   * Response to ZeroFrame message.
   *
   * @param {number} cmd    - Message ID to response
   * @param {object} result - Result to send
   *
   * @return {void}
   */
  response (to, result) {
    this._send({
      cmd: CMD_RESPONSE,
      to: to,
      result: result
    })
  }

  /**
   * Close websocket connection.
   *
   * @return {void}
   */
  close () {
    this.websocketClosing = true
    this.websocket.close()
    this.onCloseWebsocket()
  }
}

/**
 * Constructor's options structure with default values.
 *
 * @typedef {object} ZeroFrame#constructorOptions
 * @name ZeroFrame#constructorOptions
 *
 * @property {object}  [multiuser]                    - Multiuser options
 * @property {string}  [multiuser.masterAddress=null] - Master address for multiuser ZeroNet instance
 * @property {string}  [multiuser.masterSeed=null]    - Master seed for multiuser ZeroNet instance
 *
 * @property {object}  [instance]                     - Instance options
 * @property {string}  [instance.host=127.0.0.1]      - Host of ZeroNet instance
 * @property {number}  [instance.port=43110]          - Port of ZeroNet instance
 * @property {boolean} [instance.secure=false]        - Secure connection of ZeroNet instance
 *
 * @property {object}  [show]                         - Showing options
 * @property {boolean} [show.log=false]               - Show log messages in console
 * @property {boolean} [show.error=false]             - Show error messages in console
 *
 * @property {object}  [reconnect]                    - Reconnecting options
 * @property {number}  [reconnect.attempts=-1]        - Number of attempts (no limit with `-1` & no reconnect with `0`)
 * @property {number}  [reconnect.delay=5000]         - Number of delay in milliseconds
 */
ZeroFrame.constructorOptions = {
  multiuser: {
    masterAddress: null,
    masterSeed: null
  },
  instance: {
    host: '127.0.0.1',
    port: 43110,
    secure: false
  },
  show: {
    log: false,
    error: false
  },
  reconnect: {
    attempts: -1,
    delay: 5000
  }
}

try { module.exports = ZeroFrame } catch (err) { }

export default ZeroFrame