UNPKG

@babblevoice/projectrtp

Version:
520 lines (443 loc) 13.6 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 ), "playrecord": ( chan, msg ) => chan.playrecord( msg.options ), "direction": ( chan, msg ) => chan.direction( msg.options ), /* "transcribe" carries no channel action of its own - it is consumed by the node's pre hook (the consumer registers a token->channel binding and taps audio itself via getchannel() + createReadStream). Registered here as a no-op so the framework acknowledges it rather than replying "Unknown method". */ "transcribe": () => {} } /** * 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.playrecord delete modifiedmsg.direction delete modifiedmsg.dtmf delete modifiedmsg.remote this.connection.write( message.createmessage( modifiedmsg ), () => { cb( modifiedmsg ) } ) } ) return retval } #cleanup() { this.connectionlength -= 1 } /** * @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 } 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 ) { clearTimeout( timerid ) console.error( "Error opening channel:", e.message || e ) const opentosend = { error: "open failed: " + ( e.message || e ), msg } await this.send( opentosend ) this.#cleanup() return false } if( !chan ) { clearTimeout( timerid ) console.error( "Error opening channel - returned null", msg ) const opentosend = { error: "open failed: returned null", 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 Look up a live channel object by its uuid. * Channels are created by the node when the server sends an "open" command * and stored in the module-level map. This exposes them to pre/post hooks * (e.g. to tap audio with channel.createReadStream for live transcription). * @param { string } uuid - our channel uuid * @returns { object | undefined } the channel object, or undefined if unknown */ getchannel( uuid ) { return channels.get( uuid ) } /** * @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 ) connection.on( "close", () => { this.connections.delete( con.connectionid ) } ) } 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