/**
* @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