socket/ConnectionManager.js

/**
 * @module flitter-socket/ConnectionManager
 */

const ClientServerTransaction = require('./ClientServerTransaction')
const ClientErrorTransaction = require('./ClientErrorTransaction')
const ServerClientTransaction = require('./ServerClientTransaction')
const uuid = require('uuid/v4')

/**
 * Transactional websocket connection manager.
 * @class
 */
class ConnectionManager {

    /**
     * Initialize the connection manager.
     * @param {WebSocket} ws - the open connection
     * @param {express/Request} req - the connection request
     * @param {object} controller - the connection's controller
     * @param {boolean} [do_bootstrap = true] - if false, the socket connection will NOT be automatically bootstrapped to use transactional communication
     */
    constructor(ws, req, controller, do_bootstrap = true){
        this.socket = ws
        this.request = req
        this.controller = controller
        this.active_transactions = {}
        this.id = uuid()
        this.tags = []
        this.open = false

        this.open_resolutions = []
        this.open_callbacks = []
        this.close_resolutions = []
        this.close_callbacks = []
        
        if ( do_bootstrap ) this._bootstrap(ws)
    }

    /**
     * Validates that a transaction is a valid flitter-sockets spec transaction.
     * If it is, return the transaction's data. Otherwise, send a {@link module:flitter-socket/ClientErrorTransaction~ClientErrorTransaction}.
     * @param {string} msg - the incoming client message
     * @returns {object|null}
     */
    validate_incoming_message(msg){
        let fail = false
        let valid_tid = true
        let error = ""
        let code = 400
        
        // check if valid JSON
        if ( !this._is_json(msg) ){ 
            fail = true
            error = "Incoming message must be valid FSP JSON object."
            valid_tid = false
        }
        
        let data
        if ( !fail ) data = JSON.parse(msg)
        
        // check for required fields: transaction_id, type
        if ( !fail && !Object.keys(data).includes('transaction_id') ){
            fail = true
            error = "Incoming message must include universally-unique transaction_id."
            valid_tid = false
        }
        if ( !fail && (!Object.keys(data).includes('type') || !(['request', 'response'].includes(data.type))) ){
            fail = true
            error = "Incoming message must include valid type, which may be one of: request, response."
        }
        
        // if request, check for required fields: endpoint
        if ( !fail && data.type === 'request' && !Object.keys(data).includes('endpoint') ) {
            fail = true
            error = "Incoming request message must include a valid endpoint."
        }
        
        // if request, check if transaction_id is unique
        if ( !fail && data.type === 'request' && Object.keys(this.active_transactions).includes(data.transaction_id) ){
            fail = true
            error = "Incoming request message must have a universally-unique, NON-EXISTENT transaction_id."
            valid_tid = false
        }
        
        // if request, check for valid endpoint
        if ( !fail && data.type === 'request' && !(typeof this.controller[data.endpoint] === 'function') ){
            fail = true
            error = "The requested endpoint does not exist or is invalid."
            code = 404
        }
        
        // if response, check if transaction_id exists
        if ( !fail && data.type === 'response' && !Object.keys(this.active_transactions).includes(data.transaction_id)){
            fail = true
            error = "The specified transaction_id does not exist. It's possible that this transaction has already resolved."
        }
        
        if ( fail ){
            // send failure response
            const t = new ClientErrorTransaction({
                transaction_id: valid_tid ? data.transaction_id : "unknown",
            }, this)
            
            t.status(code).message(error).send()
        }
        else {
            return data
        }
    }

    /**
     * Kind of a catch-all for registering transactions. If no transaction is provided,
     * gracefully return. If a transaction object is provided, register it in this.active_transactions
     * by its ID. Otherwise if it is a string, return the active transaction with that ID.
     * @param {module:flitter-socket/Transaction~Transaction|string} [t]
     * @returns {null|module:flitter-socket/Transaction~Transaction}
     */
    transaction(t = false){
        if ( !t ) return;
        
        if ( typeof t === 'object' ) this.active_transactions[t.id] = t
        else return this.active_transactions[t]
    }

    /**
     * Process a transaction by calling its endpoint method.
     * If the transaction is resolved, delete it.
     * @param {module:flitter-socket/Transaction~Transaction} t
     * @returns {module:flitter-socket/Transaction~Transaction} the processed transaction
     */
    process(t){
        // execute the endpoint function
        this.controller[t.endpoint](t, this.socket)
        
        // if the transaction resolves, mark it inactive
        if ( t.resolved ) delete this.active_transactions[t.id]
        
        // return the transaction
        return t
    }

    /**
     * Send a request to the client managed by this class, and wait for a valid response.
     * @param {string} endpoint - client endpoint to be called
     * @param {object} data - body data of the request
     * @param {function} handler - callback function for a valid response
     * @returns {*|boolean|void}
     * @private
     */
    _request(endpoint, data, handler = function(t,ws,data){t.resolved = true}){
        if ( !endpoint ) throw new Error('An endpoint is required when specifying a server-to-client request.')
        
        const t = new ServerClientTransaction({
            endpoint,
            data,
        }, this)
        
        this.active_transactions[t.id] = t
        
        return t.handler(handler).send()
    }

    /**
     * Bootstrap a websocket connection to use transactional processing.
     * When new messages come in, validate them and handle them as requests
     * or responses based on their data.
     * @param {WebSocket} socket
     * @private
     */
    _bootstrap(socket){
        socket.on('message', (msg) => {
            const data = this.validate_incoming_message(msg)
            if ( data ){
                // Handle client to server requests
                if ( data.type === 'request' ){
                    // create a new client to server transaction
                    const t = new ClientServerTransaction(data, this)
                    
                    // register the transaction
                    this.transaction(t)
                    
                    // process the transaction
                    this.process(t)
                }
                else if ( data.type === 'response' ){
                    // grab the existing server to client transaction
                    const t = this.transaction(data.transaction_id)
                    
                    // Note that the handler method is responsible for resolving the transaction
                    t.receipt(data.data)
                    
                    if ( t.resolved ) delete this.active_transactions[t.id]
                }
            }
        })

        socket.on('close', () => {
            this.open = false
            this.close_resolutions.forEach(r => r(this))
            this.close_resolutions = []

            this.close_callbacks.forEach(c => c(this))
            this.close_callbacks = []
        })

        socket.on('open', () => {
            this.open = true
            this.open_resolutions.forEach(r => r(this))
            this.open_resolutions = []

            this.open_callbacks.forEach(c => c(this))
            this.open_callbacks = []
        })
    }

    /**
     * Register a callback or promise to execute on close of the connection.
     * @param {function} [callback]
     * @returns {Promise<void>}
     */
    on_close(callback = false) {
        if ( callback ) {
            this.close_callbacks.push(callback)
        } else {
            return new Promise(resolve => {
                this.close_resolutions.push(resolve)
            })
        }
    }

    /**
     * Register a callback or promise to execute on open of the connection.
     * @param {function} [callback]
     * @returns {Promise<void>}
     */
    on_open(callback = false) {
        if ( callback ) {
            this.open_callbacks.push(callback)
        } else {
            return new Promise(resolve => {
                this.open_resolutions.push(resolve)
            })
        }
    }

    /**
     * Checks if a string is valid JSON
     * @param string
     * @returns {boolean}
     * @private
     */
    _is_json(string){
        try {
            JSON.parse(string)
            return true
        }
        catch (e) {
            return false
        }
    }
    
}

module.exports = exports = ConnectionManager