index.js

import EventEmitter from 'eventemitter3';
import ShareJS      from 'share/lib/client';

const ABNORMAL      = 1006;
const NORMAL        = 1000;
const NOT_FOUND     = 4040;
const UNAUTHORIZED  = 4010;
const LOCAL_TIMEOUT = 4011;

/**
 * A two-element array consisting of a length of text to retain and a string to
 * insert
 *
 * @example
 * [10, "Foo"]
 *
 * @typedef {Array<(number|string)>} ShareJSWrapper~Insert
 */

/**
 * A two-element array consisting of a length of text to retain and a length of
 * text to remove
 *
 * @example
 * [10, 12]
 *
 * @typedef {Array<number>} ShareJSWrapper~Remove
 */

/**
 * @class ShareJSWrapper
 * @classdesc A wrapper around a
 *   [ShareJS](https://github.com/share/ShareJS/tree/v0.7.40) client, providing
 *   easier use for a single document per connection
 *
 * @param {Object} config An object of configuration options for this client
 * @param {string} config.accessToken A Canvas API authentication token
 * @param {string} config.canvasID An ID of a Canvas to connect to
 * @param {string} config.realtimeURL The URL of the realtime server
 * @param {string} config.orgID The ID of the org the canvas belongs to
 */
export default class ShareJSWrapper {
  constructor(config) {
    this.config = config;
    this.eventEmitter = new EventEmitter();
  }

  /**
   * A callback called on successful connection after a `connect` call
   *
   * @callback ShareJSWrapper~connectCallback
   */
  /**
   * Tell the wrapper client to connect to the configured ShareJS server.
   *
   * @example
   * share.connect(function onConnected() {
   *   console.log(share.content);
   * });
   *
   * @param {ShareJSWrapper~connectCallback} callback A callback to call once
   *   connected
   */
  connect(callback) {
    const { canvasID, orgID, realtimeURL } = this.config;

    this.socket = new WebSocket(realtimeURL);
    this.connection = this.getShareJSConnection();
    this.bindConnectionEvents();

    this.document = this.connection.get(orgID, canvasID);
    this.document.subscribe();

    this.document.whenReady(_ => {
      this.debug('whenReady');

      if (!this.document.type) {
        this.document.create('text');
      }

      this.context = this.getDocumentContext();
      this.context.onInsert = bind(this, 'onRemoteOperation');
      this.context.onRemove = bind(this, 'onRemoteOperation');

      /**
       * The current content of the ShareJS document
       *
       * @type {string}
       */
      this.content = this.context.get();

      if (callback) {
        callback();
      }
    });
  }

  /**
   * Tell the wrapper client to close the ShareJS connection and socket.
   *
   * @example
   * share.disconnect();
   */
  disconnect() {
    this.socket.close();
  }

  /**
   * Send an insert operation from this client to the server.
   *
   * @example
   * share.insert(10, 'Foo');
   *
   * @param {number} offset The offset at which to start the insert operation
   * @param {string} text The text to be inserted
   */
  insert(offset, text) {
    this.debug('insert', ...arguments);
    this.context.insert(offset, text);
    this.content = this.context.get();
  }

  /**
   * Add a listener to an event.
   *
   * @example
   * share.on('connect', function onConnect() {
   *   // ...
   * });
   *
   * @param {string} event The name of the event
   * @param {function} fn The function to call when the event occurs
   * @param {*} context The context on which to call the function
   * @return {ShareJSWrapper} This same instance
   */
  on(event, fn, context) {
    this.eventEmitter.on(event, fn, context);
    return this;
  }

  /**
   * Add a listener to an event which will fire once.
   *
   * @example
   * share.once('connect', function onConnect() {
   *   // ...
   * });
   *
   * @param {string} event The name of the event
   * @param {function} fn The function to call when the event occurs
   * @param {*} context The context on which to call the function
   * @return {ShareJSWrapper} This same instance
   */
  once(event, fn, context) {
    this.eventEmitter.once(event, fn, context);
    return this;
  }

  /**
   * Send a remove operation from this client to the server.
   *
   * @example
   * share.remove(0, 3);
   *
   * @param {number} start The position at which to start the remove
   * @param {number} length The number of characters to remove
   */
  remove(start, length) {
    this.debug('remove', ...arguments);
    this.context.remove(start, length);
    this.content = this.context.get();
  }

  /**
   * Remove all listeners or only for the specified event.
   *
   * @example
   * share.removeAllListeners();
   *
   * @param {string} [event] The event to remove all listeners for
   * @return {ShareJSWrapper} This same instance
   */
  removeAllListeners(event) {
    this.eventEmitter.removeAllListeners(event);
    return this;
  }

  /**
   * Remove a listener from an event.
   *
   * @example
   * share.removeListener('connect', connectHandler, this, true);
   *
   * @param {string} event The event to remove listener(s) from
   * @param {function} [fn] The listener to remove
   * @param {*} [context] Only match listeners matching this context
   * @param {boolean} once Only remove "once" listeners
   * @return {ShareJSWrapper} This same instance
   */
  removeListener(event, fn, context, once) {
    this.eventEmitter.removeListener(event, fn, context, once);
    return this;
  }

  /**
   * Bind to events on the connection and handle them.
   *
   * @private
   */
  bindConnectionEvents() {
    this.connection.on('connected', bind(this, 'onConnectionConnected'));
    this.connection.on('disconnected', bind(this, 'onConnectionDisonnected'));
  }

  /**
   * Get an editing context for the ShareJS document.
   *
   * @private
   * @return {ShareJS.Context}
   */
  getDocumentContext() {
    const context = this.document.createContext();

    if (!context.provides.text) {
      throw new Error('Cannot attach to a non-text document');
    }

    context.detach = null;

    return context;
  }

  /**
   * Get a new ShareJS connection for this wrapper client.
   *
   * @private
   * @return {ShareJS.Connection}
   */
  getShareJSConnection() {
    const connection = new ShareJS.Connection(this.socket);
    this.setupSocketAuthentication();
    this.setupSocketPing();
    this.setupSocketOnMessage();
    return connection;
  }

  /**
   * Handle the ShareJS connection connecting successfully.
   *
   * @private
   */
  onConnectionConnected() {
    this.debug('connectionConnected');
    this.reconnectAttempts = 0;
    this.connected = true;
  }

  /**
   * Handle the ShareJS connection disconnecting by matching error codes.
   *
   * @private
   * @param {Event} event A disconnection event
   * @emits ShareJSWrapper#disconnect
   */
  onConnectionDisonnected(event) {
    this.debug('connectionDisconnected', ...arguments, event.code);
    this.connected = false;

    clearTimeout(this.pongWait);

    let error;
    switch (event.code) {
      case ABNORMAL:
      case LOCAL_TIMEOUT:
        if (this.reconnectAttempts > 5) {
          const reason = event.code === ABNORMAL ?
            'abnormal' : 'no_pong';
          error = new Error(reason);
        } else {
          this.reconnect();
        }

        break;
      case UNAUTHORIZED:
        error = new Error('forbidden');
        break;
      case NOT_FOUND:
        error = new Error('not_found');
        break;
      default:
        if (event.code !== NORMAL) {
          error = new Error('unexpected');
        }
    }

    if (error) {
      /**
       * An event emitted when the client has fatally disconnected and will no
       * longer attempt reconnects.
       *
       * Check `err.message` for "abnormal", "no_pong", "forbidden",
       * "not_found", or "unexpected".
       *
       * @event ShareJSWrapper#disconnect
       * @type {Error}
       */
      this.eventEmitter.emit('disconnect', error);
    }
  }

  /**
   * Handle a remote operation from the server to this client.
   *
   * @private
   * @param {number} retain The number of characters to retain before
   *   insert/remove
   * @param {(number|string)} value The value of the operation—a string for an
   *   insert or a number for a remove
   * @emits ShareJSWrapper#insert
   * @emits ShareJSWrapper#remove
   */
  onRemoteOperation(retain, value) {
    this.content = this.context.get();

    let type = 'remove';
    if (typeof value === 'string') {
      type = 'insert';
    }

    /**
     * An event emitted when the client receives an insert operation. Emits an
     * event with an exploded {@link ShareJSWrapper~Insert Insert} operation.
     *
     * @event ShareJSWrapper#insert
     * @param {number} retain The length being retained in the insert
     * @param {string} value The value being inserted
     */
    /**
     * An event emitted when the client receives a remove operation. Emits an
     * exploded {@link ShareJSWrapper~Remove Remove} operation.
     *
     * @event ShareJSWrapper#remove
     * @param {number} retain The length being retained in the insert
     * @param {number} value The length being removed
     */
    this.eventEmitter.emit(type, retain, value);
  }

  /**
   * Handle a pong by waiting a few seconds and sending another ping.
   *
   * @private
   */
  onSocketPong() {
    this.receivedPong = true;

    setTimeout(_ => {
      this.sendPing();
    }, 5000);
  }

  /**
   * Attempt to reconnect to the server, using a backoff algorithm.
   *
   * @private
   */
  reconnect() {
    this.reconnectAttempts = this.reconnectAttempts || 0;

    const attempts = this.reconnectAttempts;
    const time     = getInterval();

    setTimeout(_ => {
      this.reconnectAttempts = this.reconnectAttempts + 1;
      this.connect();
    }, time);

    function getInterval() {
      let max = (Math.pow(2, attempts) - 1) * 1000;

      if (max > 5 * 1000) {
        max = 5 * 1000;
      }

      return Math.random() * max;
    }
  }

  /**
   * Send a ping over the WebSocket, and await pong.
   *
   * @private
   */
  sendPing() {
    if (this.config.noPing) {
      return;
    }

    const { socket } = this;

    if (socket.readyState === socket.OPEN) {
      this.receivedPong = false;
      socket.send('ping');
      this.pongWait = setTimeout(_ => {
        if (!this.receivedPong) {
          this.socket.close(LOCAL_TIMEOUT);
        }
      }, 1000);
    }
  }

  /**
   * Set up the authentication step.
   *
   * ShareJS immediately sets `socket.onopen` so we override that and ensure
   * that an authentication message is sent to the realtime server as early as
   * possible.
   *
   * @private
   */
  setupSocketAuthentication() {
    const { accessToken } = this.config;
    const { socket      } = this;

    const _onopen = bind(socket, 'onopen');
    socket.onopen = _ => {
      this.debug('sending auth token');

      socket.send(`auth-token:${accessToken}`);
      _onopen(...arguments);
    };
  }

  /**
   * Set up the message handler for the socket.
   *
   * ShareJS immediately sets `socket.onmessage`, so we override that and ensure
   * it does not try to parse messages like "pong".
   *
   * @private
   */
  setupSocketOnMessage() {
    const { socket } = this;

    const _onmessage = bind(socket, 'onmessage');
    socket.onmessage = function onMessage({ data }) {
      if (data === 'pong') {
        this.onSocketPong();
        return;
      }

      return _onmessage(...arguments);
    }.bind(this);
  }

  /**
   * Set up a ping/pong cycle for this socket.
   *
   * @private
   */
  setupSocketPing() {
    const { socket } = this;

    const _onopen = bind(socket, 'onopen');
    socket.onopen = function onOpen() {
      this.sendPing();
      _onopen(...arguments);
    }.bind(this);
  }

  /**
   * Conditionally log a debug statement.
   *
   * @private
   */
  debug() {
    if (this.config.debug) {
      console.log(...arguments);
    }
  }
}

function bind(target, method) {
  return target[method].bind(target);
}