communication/websocket.js

/**
 * @module api/communication/websocket
 */

import autobahn from 'autobahn';
import debug from 'debug';
import { settings } from './index';

const log = debug('its-sdk:WebSocket');
const error = debug('its-sdk:WebSocket');
log.log = console.log.bind(console);

/**
 * Keep hold of the currently open autobahn connection.
 *
 * @type {Promise<autobahn.Connection>}
 */
let bundesautobahn;

/**
 * Allow the `autobahn.Connection` to challenge the provided authentication.
 *
 * @param {autobahn.Session} session - The session of the current {@link autobahn.Connection}.
 * @param {string} method - The authentication method it tries to use.
 *
 * @throws {Error} - When the given `method` is unknown to the SDK.
 */
function handleWebsocketAuthorisationChallenge(session, method) {
  switch (method) {
    case 'ticket':
      return settings.authorizationToken;
    default:
      throw new Error(
        'The websocket server tried to use the unknown ' +
          `authentication challenge: "${method}"`,
      );
  }
}

/**
 * Set {@link bundesautobahn} to a new Promise which resolves into a `autobahn.Connection` object
 * when a connection was successfully established.
 *
 * @returns {Promise<autobahn.Connection>} - A promise which resolves when the connection was
 * successfully created and opened.
 */
function establishNewBundesbahn() {
  bundesautobahn = new Promise((resolve, reject) => {
    const bahn = new autobahn.Connection({
      url: settings.wsUrl,
      realm: 'default',
      // Of course we want to use es6 promises if they are available.
      // But, the backend sometimes spits out progress. For that we need
      // a When.JS promise..
      use_es6_promises: false, // eslint-disable-line camelcase
      // The following options are required in order to authorise the
      // connection.
      authmethods: ['ticket'],
      authid: 'oauth2',
      details: {
        ticket: settings.authorizationToken,
      },
      max_retries: 0,
      onchallenge: handleWebsocketAuthorisationChallenge,
    });

    // The connection close callback is fired when the connection has been
    // closed explicitly, was lost or could not be established in the first
    // place. For more information on the connection callbacks go to
    // https://github.com/crossbario/autobahn-js/blob/master/doc/reference.md#connection-callbacks
    bahn.onclose = (reason, details) => {
      if (reason === 'closed' && details.reason === 'wamp.close.normal') {
        // A normal disconnect! No need to error out here;
        resolve(reason);
      } else {
        // Several errors might be true here
        // closed, lost, unreachable or unsupported
        reject(reason);
      }
    };

    // Connection got established; lets us it.
    bahn.onopen = () => {
      log('Successfully established a websocket connection.');
      resolve(bahn);
    };

    bahn.open();
  });

  // Return the promise to make it this function chainable. In case the
  // `bundesautobahn` is rejected; remove the reference so we can use simple
  // falsy checks to determine if there is a connection.
  return bundesautobahn.catch(reason => {
    bundesautobahn = null;
    return Promise.reject(reason);
  });
}

/**
 * Close the current websocket connection.
 *
 * @returns {Promise<string>} - A promise which will resolve as soon as the connection was
 * successfully closed.
 */
export function closeWebsocketConnection() {
  if (!bundesautobahn) {
    return Promise.resolve('There is no websocket connection to close.');
  }

  return bundesautobahn.then(bahn => {
    try {
      bahn.close();
      bundesautobahn = null;
      const message = 'The websocket connection has been closed successfully.';
      log(message);
      return message;
    } catch (reason) {
      // `autobahn.Connection.close()` throws a string when the connection is
      // already closed. The connection is not exposed and therefore cannot be
      // closed by anyone using the SDK. Regardless, when it happens just
      // return a resolved promise.
      bundesautobahn = null;
      const message = 'The websocket connection has already been closed.';
      error(message);
      return message;
    }
  });
}

/**
 * Open a new websocket connection.
 *
 * There there currently is a open connection, close it and open a new connection.
 *
 * @returns {Promise<string>} - A resolved promise which resolves when the connection was
 * successfully created and opened.
 */
export function openWebsocketConnection() {
  return (
    closeWebsocketConnection()
      .then(() => establishNewBundesbahn())
      // `bundesautobahn` actually resolved with the `autobahn.Connection`
      // object. This is only meant for internal usage and therefore should not
      // be exposed to the users of the SDK.
      .then(() => 'Successfully established a websocket connection.')
  );
}

/**
 * Get the current websocket connection, or open a new one.
 *
 * If there is no current connection, open one and return that in stead.
 *
 * @returns {Promise<autobahn.Connection>} - The current websocket connection.
 */
export function getWebsocketConnection() {
  if (!bundesautobahn) {
    return establishNewBundesbahn();
  }

  return bundesautobahn;
}

/**
 * Make a rpc call to the ITSLanguage websocket server.
 *
 * This method will try to establish a websocket connection if there isn't one already.
 *
 * @param {string} rpc - The RPC to make. This be prepended by `nl.itslanguage` as the websocket
 * server only handles websocket calls when the RPC starts with that prefix.
 * @param {Object} [options] - Destructed object with options to pass to the websocket server.
 * @param {Array} [options.args] - An array with arguments to pass to the RPC.
 * @param {Object} [options.kwargs] - An object (dictionary) with arguments to pass to the RPC.
 * @param {Object} [options.options] - The options to pass to the RPC.
 * @param {Function} [options.progressCb] - Optional callback to receive progressed results.
 *
 * @returns {Promise<*>} - The response of the websocket call.
 */
export function makeWebsocketCall(
  rpc,
  { args, kwargs, options, progressCb } = {},
) {
  let mergedOptions = options;
  if (progressCb) {
    mergedOptions = {
      ...options,
      receive_progress: true, // eslint-disable-line camelcase
    };
  }
  return getWebsocketConnection()
    .then(connection =>
      connection.session
        .call(`nl.itslanguage.${rpc}`, args, kwargs, mergedOptions)
        .progress(progressCb),
    )
    .catch(result => {
      const { error: wssError, kwargs: wssKwargs, args: wssArgs } = result;

      // Log the error to stderr
      error(result);

      const customError = new Error(wssError);
      customError.data = {
        args: wssArgs || [],
        kwargs: wssKwargs || {},
      };

      // Return a slightly simplistic version of the error that occurred
      return Promise.reject(customError);
    });
}