connectionManager.js

const net = require( "net" );
const heartBeatFactory = require( "heartbeatjs" );
const isFunction = require( "lodash.isfunction" );

const errors = require( "./errors.js" );
const cbException = errors.callbackNotAFunction;
const connHandlerException = errors.connectHandlerNotAFunction;
const connDownException = errors.connectionDown;
const noOptsException = errors.optionsNotProvided;

/**
 *  @public
 *  @author Pedro Miguel P. S. Martins
 *  @version 1.0.2
 *  @module connManager
 *  @desc   Takes care of remote tcp-ip connections by attempting automatic
 *          reconnects and handling timeouts.
 */
const connManager = ( heartBeat = heartBeatFactory() ) => {

    if ( heartBeat.getPing() === undefined )
        heartBeat.setPing( Buffer.from( [ 0x01 ] ) );

    if ( heartBeat.getPong() === undefined )
        heartBeat.setPong( Buffer.from( [ 0x02 ] ) );

    const eventFns = {
        onClose: () => {},
        onRead: () => {},
        onOpen: () => {},
        onRetry: () => {}
    };

    const connection = {
        socket: undefined,
        connectFn: function ( theOpts ) {
            return new Promise( ( resolve, reject ) => {
                const socket = net.createConnection( theOpts, () => resolve( socket ) );
                socket.once( "error", err => reject( err ) );
            } );
        },
        options: undefined,
        retriesCnt: 0
    };

    /**
     *  @public
     *  @func       connect
     *  @param      {Object}    connectOpts An object with connect options. This
     *                                      is the object that will be passed to
     *                                      <code>connectFn</code> to attempt
     *                                      connect. The default
     *                                      <code>connectFn</code> makes a socket
     *                                      connection, so the connections
     *                                      object passed to the default should
     *                                      be the one used is
     *                                      <code>net.createConnection()</code>.
     *  @returns    {Promise}
     *  @throws     {OptionsNotProvided}    If there is no connection options.
     *                                      There must always be a
     *                                      <code>connectOpts</code> parameter.
     *
     *  @description    <p>
     *                      Attempts to connect to the tcp-ip server provided
     *                      with the given options.
     *                  </p>
     *                  <p>
     *                      It will not resolve until a connection is made. Once
     *                      a connection is made it fires <code>onOpen</code>,
     *                      and every time it retries to establish a connection
     *                      it fires <code>onRetry()</code>.
     *                  </p>
     *                  <p>
     *                      Once a connection is established, it starts the
     *                      heartbeat to periodically check the health of the
     *                      target. If the connection dies or times out, it
     *                      fires <code>onClose</code> and automatic
     *                      reconnection is attempted, called
     *                      <code>onRetry</code> for every failed attempt.
     *                  </p>
     *
     *  @see            {@link https://nodejs.org/api/net.html#net_net_createconnection|net.createConnection()}
     *  @see            <code>onOpen</code>
     *  @see            <code>onRetry</code>
     *  @see            <code>onClose</code>
     */
    const connect = async function ( connectOpts ) {
        if ( connectOpts === undefined )
            throw noOptsException();

        connection.options = connectOpts;
        let done = false;
        while ( !done ) {
            try {
                connection.socket = await connection.connectFn( connectOpts );
                connection.retriesCnt = 0;
                connection.socket.on( "data", read );
                eventFns.onOpen( true );
                done = true;
            } catch ( err ) {
                connection.retriesCnt++;
                eventFns.onRetry( err, connection.retriesCnt );
            }
        }
        heartBeat.onTimeout( reconnect );
        heartBeat.start( pingFn );
    };

    const pingFn = function () {
        try {
            send( heartBeat.getPing() );
        } catch ( err ) {
            //the connection died as we were pinging
            reconnect();
        }
    };


    const reconnect = () => {
        disconnect();
        connect( connection.options ); //attempt reconnect and restart cycle
    };

    /**
     *  @public
     *  @func       disconnect
     *
     *  @description    Kills the connection to the target, stops the heartbeat
     *                  and fires <code>onClose</code>
     *  @see            <code>onClose</code>
     */
    const disconnect = function () {
        heartBeat.stop();
        connection.socket.destroy();
        eventFns.onClose( false );
    };

    /**
     *  @public
     *  @func       isConnected
     *  @returns    {boolean}   <code>true</code> if the connection is up,
     *                          <code>false</code> otherwise.
     *
     *  @description    Returns <code>true</code> if the connection is up,
     *                  <code>false</code> otherwise. If a reconnection is taking
     *                  place, it will still return <code>false</code>.
     */
    const isConnected = function () {
        return heartBeat.isBeating() && !connection.socket.destroyed;
    };

    const read = function ( data ) {
        if ( data.equals( Buffer.from( heartBeat.getPong() ) ) ) {
            heartBeat.receivedPong();
            return;
        }
        eventFns.onRead( data );
    };

    /**
     *  @public
     *  @func       send
     *  @param      {Object}  message The message object we want to send.
     *  @throws     {ConnectionDown}  If the connection is down. You can check
     *                                this via <code>isConnected</code>.
     *
     *  @description    Sends a message to the target.
     *  @see            <code>isConnected</code>
     */
    const send = function ( message ) {
        if ( !isConnected() )
            throw connDownException();

        connection.socket.write( message );
    };

    /**
     *  @public
     *  @func       onClose
     *  @param      {function}  newFn       The function to run when the
     *                                      <b>onClose</b> event is fired.
     *  @throws     {CallbackNotAFunction}  If <code>newFn</code> is not a
     *                                      function.
     *
     *  @description    Runs the given function every time the <b>onClose</b>
     *                  event is triggered, passing it the argument
     *                  <code>false</code>.
     */
    const onClose = function ( newFn ) {
        registerEventCallback( newFn, "onClose" );
    };

    /**
     *  @public
     *  @func       onOpen
     *  @param      {function}  newFn       The function to run when the
     *                                      <b>onOpen</b> event is fired.
     *  @throws     {CallbackNotAFunction}  If <code>newFn</code> is not a
     *                                      function.
     *
     *  @description    Runs the given function every time the <b>onOpen</b>
     *                  event is triggered, passing it the argument
     *                  code>true</code>.
     */
    const onOpen = function ( newFn ) {
        registerEventCallback( newFn, "onOpen" );
    };

    /**
     *  @public
     *  @func       onRead
     *  @param      {function}  newFn       The function to run when the
     *                                      <b>onRead</b> event is fired.
     *  @throws     {CallbackNotAFunction}  If <code>newFn</code> is not a
     *                                      function.
     *
     *  @description    Runs the given function every time the <b>onRead</b>
     *                  event is triggered, passing it the received data as
     *                  an argument.
     */
    const onRead = function ( newFn ) {
        registerEventCallback( newFn, "onRead" );
    };

    /**
     *  @public
     *  @func       onRetry
     *  @param      {function}  newFn       The function to run when the
     *                                      <b>onRetry</b> event is fired.
     *  @throws     {CallbackNotAFunction}  If <code>newFn</code> is not a
     *                                      function.
     *
     *  @description    Runs the given function every time the <b>onRetry</b>
     *                  event is triggered, passing it the error that caused the
     *                  retry, as well as a count of the number of retries.
     */
    const onRetry = function ( newFn ) {
        registerEventCallback( newFn, "onRetry" );
    };

    const registerEventCallback = ( newFn, eventName ) => {
        if ( !isFunction( newFn ) )
            throw cbException( newFn, eventName );

        eventFns[ eventName ] = newFn;
    };

    /**
     *  @public
     *  @func       setConnectFn
     *  @param      {function}      newFn         The function to be used when
     *                                            <code>connect</code> is called
     *                                            and every time automatic
     *                                            reconnection takes place.
     *  @throws     {ConnectHandlerNotAFunction}  If <code>newFn</code> is not a
     *                                            function.
     *
     *  @description    Used when you need a custom connect function because
     *                  the target has a specific handshake or protocol. The
     *                  function passed must return the socket used for the
     *                  connection.
     */
    const setConnectFn = function ( newFn ) {
        if ( !isFunction( newFn ) )
            throw connHandlerException( newFn );
        connection.connectFn = newFn;
    };

    return Object.freeze( {
        connect,
        disconnect,
        isConnected,
        send,
        onClose,
        onOpen,
        onRead,
        onRetry,
        setConnectFn
    } );
};

module.exports = connManager;