UNPKG

@babblevoice/projectrtp

Version:
503 lines (426 loc) 12.8 kB
const net = require( "net" ) const { v4: uuidv4 } = require( "uuid" ) const message = require( "./message.js" ) const instance = uuidv4() const channels = new Map() const channelmap = { "close": ( chan ) => chan.close(), "remote": ( chan, msg ) => chan.remote( msg.remote ), "mix": ( chan, msg ) => { if( channels.has( msg.other.uuid ) ) { chan.mix( channels.get( msg.other.uuid ) ) } }, "unmix": ( chan ) => chan.unmix(), "dtmf": ( chan, msg ) => { if( !msg.digits ) return if( "string" !== typeof msg.digits ) return if( 10 < msg.digits.length ) return chan.dtmf( msg.digits ) }, "echo": ( chan ) => chan.echo(), "play": ( chan, msg ) => chan.play( msg.soup ), "record": ( chan, msg ) => chan.record( msg.options ), "direction": ( chan, msg ) => chan.direction( msg.options ) } /** * One for each connection spawned by rtpnode. */ class rtpnodeconnection { constructor( parent, connection ) { this.connectionid = uuidv4() this.connection = connection this.connectionlength = 0 this.mode = "listen" this.parent = parent this.messagestate = message.newstate() this._reconnecttime = 500 /* mS */ this._destroying = false // spawned from listen if( connection ) { connection.setKeepAlive( true ) connection.on( "data", this._onsocketdata.bind( this ) ) } } static create( parent, connection ) { return new rtpnodeconnection( parent, connection ) } destroy() { this._destroying = true if( this._reconnecttimerid ) { clearTimeout( this._reconnecttimerid ) delete this._reconnecttimerid } this.connection.destroy() } _onsocketdata( data ) { message.parsemessage( this.messagestate, data, ( msg ) => { try { this.parent._pre( msg, ( modifiedmsg ) => { this._processmessage( modifiedmsg ) } ) } catch( e ) { console.error( "Unhandled exception in projectrtp", e ) } } ) } /** * @private * @param { object } msg * @returns boolean */ _updatechannel( msg ) { if( undefined === msg.channel ) return false if( undefined === msg.uuid ) return false const chan = channels.get( msg.uuid ) if( undefined == chan ) return false if( msg.channel in channelmap ) { channelmap[ msg.channel ]( chan, msg ) } else { const channelidentifiers = { "id": msg.id, "uuid": msg.uuid } this.send( { ...{ "error": "Unknown method" }, ...channelidentifiers } ) } return true } /** * * @param { object } msg * @returns { Promise< Boolean > } */ async _processmessage( msg ) { if( "open" == msg.channel ) return await this._openchannel( msg ) return this._updatechannel( msg ) } /** * Send a message back to the main server, include stats to help with load balancing. * @param { object } msg * @param { function } [ cb ] * @returns { Promise | undefined } */ send( msg, cb = undefined ) { let retval if( !cb ) retval = new Promise( ( r ) => cb = r ) this.parent._post( msg, ( modifiedmsg ) => { if( this._destroying ) { return } modifiedmsg.status = this.parent.prtp.stats() modifiedmsg.status.instance = instance delete modifiedmsg.forcelocal delete modifiedmsg.em delete modifiedmsg.openchannel delete modifiedmsg.close delete modifiedmsg.mix delete modifiedmsg.unmix delete modifiedmsg.echo delete modifiedmsg.play delete modifiedmsg.record delete modifiedmsg.direction delete modifiedmsg.dtmf delete modifiedmsg.remote this.connection.write( message.createmessage( modifiedmsg ), () => { cb( modifiedmsg ) } ) } ) return retval } #cleanup() { this.connectionlength -= 1 if( 0 == this.connectionlength && "listen" == this.mode ) { this.parent.connections.delete( this.connectionid ) this.connection.destroy() } } /** * @private * @param { object } msg * @returns { Promise< Boolean > } */ async _openchannel( msg ) { this.connectionlength += 1 msg.forcelocal = true const timerid = setTimeout( () => { console.trace( "Timeout opening channel", msg ) process.exit(1) }, 4000 ) let chan try { chan = await this.parent.prtp.openchannel( msg, ( cookie ) => { if( !chan ) { console.error( "Error opening channel - message before open", msg, cookie ) return } // TODO: we might want to ensure the actual event has been written // to the server before cleaning up the channel on our side? const tosend = { ...{ "id": chan.id, "uuid": chan.uuid }, ...cookie } this.send( tosend, () => { if ( "close" === cookie.action ) { if( !chan ) { const closebforeopen = { error: "unknown error opening channel", cookie, msg } this.send( closebforeopen, () => { this.#cleanup() } ) return } channels.delete( chan.uuid ) this.#cleanup() } } ) } ) } catch( e ) { console.error( "Error opening channel", e ) const opentosend = { error: "unknown error opening channel", msg } await this.send( opentosend ) this.#cleanup() return false } if( !chan ) { console.trace( "Error opening channel", msg ) const opentosend = { error: "unknown error opening channel", msg } await this.send( opentosend ) this.#cleanup() return false } channels.set( chan.uuid, chan ) clearTimeout( timerid ) const opentosend = { ...chan, ...{ "action": "open" } } this.send( opentosend ) return true } /** * @summary Connect to a server. * @param {string} host * @param {number} port * @return {Promise} - Promise which resolves to an rtpnode */ connect( host, port ) { this.host = host this.port = port this.mode = "connect" return new Promise( resolve => { this.connection = net.createConnection( this.port, this.host ) this.connection.on( "data", this._onsocketdata.bind( this ) ) this.connection.setKeepAlive( true ) this.connection.on( "connect", () => { this.send( {} ) resolve() this._reconnecttime = 500 /* mS */ } ) this.connection.on( "error", () => { if( this._destroying ) return this._runreconnect() } ) this.connection.on( "close", () => { if( this._destroying ) return this._runreconnect() } ) } ) } /** * @private * @returns { void } */ _reconnect() { delete this._reconnecttimerid this.connect( this.host, this.port ) } /** * @private * @returns { void } */ _runreconnect() { if( this._reconnecttimerid ) return console.log( "Disconnected - trying to reconnect to " + this.host + ":" + this.port ) this._reconnecttimerid = setTimeout( this._reconnect.bind( this ), this._reconnecttime ) this._reconnecttime = this._reconnecttime * 2 if( this._reconnecttime > ( 1000 * 2 ) ) this._reconnecttime = this._reconnecttime / 2 } } /** * @summary An RTP node. We are a remote RTP node which waits for intruction from our server. */ class rtpnode { /** * * @param { object } prtp * @param { string } address * @param { number } port */ constructor( prtp, address = "", port = -1 ) { this.prtp = prtp this.address = address this.port = port this.instance = uuidv4() this.connections = new Map() /* pre callbacks are called when we receive an instruction from our server and we want to check if there is anything to do such as download a file from a remote storage facility or generate a wav file from a TTS engine. */ this._pre = this._defaulthandler.bind( this ) /* post callbacks are called when our addon wants to pass something back to our server - this is to, for example, upload a recording to a remote storage facility. */ this._post = this._defaulthandler.bind( this ) } /** * @summary Connect to a server. * @param {string} host * @param {number} port * @return {Promise<rtpnode>} - Promise which resolves to an rtpnode */ async connect( host, port ) { const con = rtpnodeconnection.create( this ) await con.connect( host, port ) this.connections.set( con.connectionid, con ) return this } /** * @summary Listen for new connections ( when openchannel is called in lib/server.js ). */ listen() { let listenresolve const listenpromise = new Promise( ( r ) => listenresolve = r ) this.server = net.createServer( ( connection ) => { try { const con = rtpnodeconnection.create( this, connection ) this.connections.set( con.connectionid, con ) } catch( e ) { console.error( "Uncaught exception in node listen", e ) } } ) this.server.listen( this.port, this.address ) this.server.on( "listening", () => listenresolve() ) this.server.on( "close", () => {} ) return listenpromise } /** * This callback is displayed as a global member. * @callback requestComplete * @param {object} message */ /** * This callback is displayed as a global member. * @callback requestCallback * @param {object} message * @param {requestComplete} cb - message passed back into projectrtp framework to process */ /** * @summary When a message is sent to our node - pre process any request * in the callback. Useful for things like downloading actual wav files * or create wav files using a TTS engine etc. This pre and post processing * is only available when running the RTP server as a node. * @param {requestCallback} cb - The callback that handles the response. * @returns { void } */ onpre( cb ) { this._pre = cb } /** * @summary When a message is sent back to our server - post process any request * in the callback before final transmission to the server. Useful for uploading * recordings or other processing. * @param {requestCallback} cb - The callback that handles the response. * @returns { void } */ onpost( cb ) { this._post = cb } /** * @summary Destroy this node * @returns { void } */ destroy() { this._destroying = true nodeinterface.clean() this.connections.forEach( ( con ) => con.destroy() ) this.connections.clear() if ( this.server ) this.server.close() } /** * * @param { object } msg * @param { function } cb */ _defaulthandler( msg, cb ) { cb( msg ) } } /** * Node Interface. Use this if we are operating as a remote node to a central management server (server.interface running elsewhere). * Use this to either listen for inbound connections from a remote server.interface or activly connect back to a remote server.interface which is listening for connections. * @alias node.interface */ class nodeinterface { /** * @private * @type { rtpnode } */ static _n /** * @hideconstructor * @param { object } prtp */ constructor( prtp ) { this.prtp = prtp } /** * * @param { object } prtp * @returns nodeinterface * @ignore */ static create( prtp ) { return new nodeinterface( prtp ) } /** * Connect to a listening server. * @param { number } port * @param { string } host * @returns { Promise< rtpnode > } */ connect( port = 9002, host ) { if( nodeinterface._n ) return new Promise( r => r( nodeinterface._n ) ) nodeinterface._n = new rtpnode( this.prtp ) return nodeinterface._n.connect( host, port ) } /** * listen to allow a server connect to us. * @param { string } address * @param { number } port * @returns { Promise< rtpnode > } */ async listen( address, port ) { if( nodeinterface._n ) return nodeinterface._n nodeinterface._n = new rtpnode( this.prtp, address, port ) await nodeinterface._n.listen() return nodeinterface._n } /** * Returns a node (connect must have been called first). For testing only. * @returns { rtpnode } * @ignore */ get() { return nodeinterface._n } /** * Clean up references */ static clean() { delete nodeinterface._n } /** * Close connections including clean up references * @returns { void } */ static destroy() { if( !nodeinterface._n ) return nodeinterface._n.destroy() } } module.exports.rtpnode = rtpnode module.exports.interface = nodeinterface