UNPKG

message-relay-services

Version:

A simple server to route message to other services, such as email, admin, etc.

747 lines (685 loc) 28.8 kB
'use strict'; // load the Node.js TCP library const net = require('net'); const tls = require('tls'); const fs = require('fs'); const EventEmitter = require('events') const PORT = 1234; const HOST = 'localhost'; const NOISY = true const JSONMessageQueue = require('../json-message-queue'); // For pub/sub, this is an endpoint server. So, there is no forwarding, as with a relayer. Any path semantics ends here. // If this were an MQTT server, this would be enough to be a server (except for omitted parts of the standard) // When a sub message comes in, it means that a client somewhere upstream sent a request to receive publication. // When a publish message comes in, it mean that a client somewhere upstream sent a request for the message to go out // to all the subscribers to the topic. /** * */ class Replier extends JSONMessageQueue { // constructor(pars) { super(false,false) this.writer = pars.writer this.server = pars.server this.client_name = pars.client_name } // /** * Takes objects off the message queue and then uses their required messages fields to select * the operation that will be performed. * Those messages with `_ps_op` values will work a pub/sub operations. * Otherwise, the server `app_message_handler` will be invoked for application specific operations. * All types of message will call upon the writer to send responses back to the server... */ async dequeue_messages() { if ( this.message_queue.length ) { let msg_obj = this.dequeue() if ( msg_obj ) { // pub/sub message category if ( (typeof msg_obj._ps_op !== undefined) && msg_obj._ps_op ) { let topic = msg_obj.topic let state = "OK" let response_id = msg_obj._response_id // send back what came in (if it is keep track of replies) // _ps_op a pub/sub operation switch ( msg_obj._ps_op ) { case 'sub' : { // this client will receive this.server.add_to_topic(topic,this.client_name,this) // this.writer... keep track break; } case 'pub' : { // came in on this client and will fan out to others let ignore = this.writer delete msg_obj._response_id await this.server.send_to_all(topic,msg_obj,ignore,this) break; } case 'unsub' : { // remove from possible subscriptions this.server.remove_from_topics(topic,this.client_name) break; } default: { // not in pub/sub vocab state = "ERR" } } let response = { "_response_id" : response_id, "state" : state } let return_msg = this.encode_message(response) this.writer.write(return_msg) // actual reply } else { let response_id = msg_obj._response_id let response = await this.server.app_message_handler(msg_obj) response._response_id = response_id let return_msg = this.encode_message(response) this.writer.write(return_msg) } } } } } // ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- // Communicator // ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- /** * The Communicator for an endpoint server is a base class providing * connection management, pub/sub subscription management, and a framework * of methods that an application should override. * * This class managers a map of `messenger_connections`. The map maps client names * to repliers. The default replier is the Replier class defined in this module. * (if reading the code, see above.) * * How client names are constructed is up to the extending application class implementations. * The immediate descendant of this class is uses the information about the remote host to create a client name. * `client_name` = `${sock.remoteAddress}:${sock.remotePort}`. * * * The constructor for this class looks for three configuration fields: * * * app_can_block_and_respond - if true, app_publication_pre_fan_response will be called prior to publication. (pub/sub) * * app_handles_subscriptions - if true, app_subscription_handler will be called after publication. (pub/sub) * * EndpointReplierClass - an application defined class that take the place of the Replier class defined in and exported by this module. * */ class Communicator extends EventEmitter { // constructor(conf) { super() // this.app_handles_subscriptions = conf ? conf.app_handles_subscriptions || false : false this.app_can_block_and_respond = conf ? conf.app_can_block_and_respond || false : false this.all_topics = {} // pub/sub topics this.messenger_connections = {} // all connections wrappers (repliers) are kept here. // if ( conf.EndpointReplierClass ) { let mqClass = require(conf.EndpointReplierClass) this.replierClass = new mqClass(false) } else { this.replierClass = Replier } // this._init(conf); } /** * Subsclass must override this method. A few classes exported from this module override this method. And, it is more likely those * classes will be extended. However, some applications may want to begin with the communicator. * * @param {object} conf */ _init(conf) { throw new Error("Application must write an init method of a descendant of class Communicator - Message Endpoint") } // ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- // /** * Adds a new replier object to the `messenger_connections` table, keyed by `client_name`. * * The parameter `writer` is passed to the constructor of the replier class instance. * * @param {string} client_name * @param {object} writer */ add_connection(client_name,writer) { this.messenger_connections[client_name] = new this.replierClass({ 'writer' : writer, 'server' : this, 'client_name' : client_name }) } /** * Returns the replier if the `client_name` is in the table, false otherwise. * * @param {string} client_name * @returns {object|boolean} */ has_connection(client_name) { let con = this.messenger_connections[client_name] if ( con === undefined ) return false return con } /** * If the `client_name` is in the table, this deletes the entry. * Upon successful remove of the entry, this returns the writer uses in constructing the replier. * It returns false otherwise. * * This is typically called on close with the return value being ignored. * Some applications may have use for returned values. * * @param {string} client_name * @returns {object|false} */ remove_connection(client_name) { let mescon = this.messenger_connections[client_name] if ( mescon ) { delete this.messenger_connections[client_name] this.remove_from_all_topics(client_name) return mescon.writer } return false } // /** * Upon receipt of a message for a paticular client, the replier is found and data is added to it. * For reference of how data might be added to the replier see documentation about JSONMessageQueue. * This method then checks to see if data can be dequeued for the client. If it can, the messages are * dequeued and passed on to their handlers. * * The data is added and checked for completion immediately. The dequeue operation is handled asynchronously. * * @param {string} client_name * @param {Buffer} data */ async add_data_and_react(client_name,data) { let mescon = this.messenger_connections[client_name] mescon.add_data(data) mescon.message_complete() if ( mescon.message_queue.length ) { await mescon.dequeue_messages() } } /** * This method acts in a manner similar to `add_data_and_react`. But, the object is already parsed. * So, `add_object` is called instead of `add_data`. * * @param {string} client_name * @param {object} m_obj */ async add_object_and_react(client_name,m_obj) { let mescon = this.messenger_connections[client_name] mescon.add_object(m_obj) if ( mescon.message_queue.length ) { await mescon.dequeue_messages() } } // ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- /** * Any connection that sends a subscription message to this endpoint will be included for possible publication. * * This method calls `app_post_start_subscription` for application extensions that perform operations once a * client has been placed in the topic tables (maps) * * @param {string} topic - the pub/sub topic. * @param {string} client_name - the key used by `messenger_connections` and will be used to set its topic set membership. * @param {object} relayer - the replier to be used later to send a publication to a subscriber. */ add_to_topic(topic,client_name,relayer) { let tset = this.all_topics[topic] if ( tset == undefined ) { tset = {} this.all_topics[topic] = tset } tset[client_name] = relayer // this is the Replier that called this method. This means a client somehwere asked for a subscription this.app_post_start_subscription(topic,client_name,relayer) } /** * Send a message to a particular client. This method can be used at any time. * However, it is used in `send_to_all`. * * @param {object} relayer * @param {object} msg_obj */ send_to_one(relayer,msg_obj) { if ( relayer.writer.readyState === 'open' ) { let str_msg = relayer.encode_message(msg_obj) relayer.writer.write(str_msg) // sock made by this server managed by relayer ... pass on message } } /** * Send a pub/sub message to subscribers. * Send to all connections on the topic except the one that published * * @param {string} topic - the topic of the publication going to subscribers * @param {object} msg_obj - the message subscribers will receive from the publisher * @param {object} ignore - the writer (socket in the simplest case) which will not have data written to it for this operation * @param {object} replier - this parameter is available for an override */ async send_to_all(topic,msg_obj,ignore,replier) { // publish to all conncetions (fan out) // replier ignored in generic case if ( this.app_can_block_and_respond ) { let blocked = await this.app_publication_pre_fan_response(topic,msg_obj,ignore) if ( blocked ) return } let tset = this.all_topics[topic] // topic map -- all subscription clients if ( tset ) { for ( let client_name in tset ) { // all clients let relayer = tset[client_name] this.send_to_one(relayer,msg_obj) if ( relayer && (relayer.writer !== ignore) ) { this.send_to_one(relayer,msg_obj) } } if ( this.app_handles_subscriptions ) { // (override) this application acts as an endpoint to the topic this.app_subscription_handler(topic,msg_obj) } } } /** * Send to a subset of connections on the topic except the one that published the messsage. * * This method uses a characteristic function to decide on sending the message. The characteristic function * takes a `client_name` and a count of subscribers to a topic. The characteristic function must return true * for the message to be sent to the client. * * @param {string} topic - the topic of the publication going to subscribers * @param {object} msg_obj - the message subscribers will receive from the publisher * @param {object} ignore - a writer to be ignored (usually the sender) * @param {Function} characteristic - a test that if passed, the message will be sent to the subscriber */ send_to_subset(topic,msg_obj,ignore,characteristic) { // publish to all conncetions (fan out) if ( this.app_can_block_and_respond ) { if ( this.app_publication_pre_fan_response(topic,msg_obj,ignore) ) return } let tset = this.all_topics[topic] // topic map -- all subscription clients if ( tset ) { let t_count = Object.keys(test).length - 1 for ( let client_name in tset ) { // all clients let relayer = tset[relayer] if ( relayer && (relayer.writer !== ignore) && (relayer.writer.readyState === 'open') && characteristic(client_name,t_count) ) { let str_msg = relayer.encode_message(msg_obj) relayer.writer.write(str_msg) // sock made by this server managed by relayer ... pass on message } } if ( this.app_handles_subscriptions ) { // (override) this application acts as an endpoint to the topic this.app_subscription_handler(topic,msg_obj) } } } // remove_from_topics // this client has requested to stop receiving publications (just take it out of the list) /** * * @param {string} topic * @param {string} client_name */ remove_from_topics(topic,client_name) { let tset = this.all_topics[topic] // topic map if ( tset ) { delete tset[client_name] } } /** * A useful check for a number of applications. * * @param {string} topic * @returns {boolean} */ unkown_topic(topic) { return (this.all_topics[topic] === undefined) } // remove_from_all_topics // /** * * Search through all topics and remove the client. * * Usually used for closing a connection... * * @param {string} client_name */ remove_from_all_topics(client_name) { for ( let topic in this.all_topics ) { this.remove_from_topics(topic,client_name) } } // /** * Applications extend this class by overriding this method. * This method is the endpoint message handler for messages coming form relay clients * that expect responses. * * See the implementation of the Relay class above. It calls this method. * * @param {object} msg_obj * @returns {object} */ app_message_handler(msg_obj) { console.log("Endpoint descendent must implement app_message_handler") return({ "status" : "OK" }) } // app_publication_pre_fan_response // -- /** * Runs before writing publications. * * This runs if `app_can_block_and_respond`. A true value returned means that the application has blocked the publication. * * Gives the application the chance to refuse a publication or to pass it based on criterea. * * The application may take care of any operation it requires for publication prior to publishing * with the intention of returning false to pass this on to publication. (One might imagine a situation * where the application will read a measurement from sensor and publish the message if the sensor has actually changed. The application version of this * method would return false indicating that the update publication will not be blocked. The method can be used to write the new value onto * the message object.) * * The pulication method, `send_to_all` awaits the return of this method. * * @param {string} topic * @param {object} msg_obj * @returns {boolean} True if this appliction will return without publishing to subscribers. False if going on to publication. */ async app_publication_pre_fan_response(topic,msg_obj) { console.log("Descendent must implement app_publication_pre_fan_response") return false } // app_subscription_handler // -- runs post fanout of publication /** * * Applications override this method if `app_handles_subscriptions` has been set via configuration. * This method is called after publication of a topic message to subscribers. * This method gives applications the chance to handle internal publication or to make database updates (say) * or any other action required to manage the pub/sub process. * * * @param {string} topic * @param {object} msg_obj */ app_subscription_handler(topic,msg_obj) { console.log("Descendent must implement app_subscription_handler") } /** * This method is always called by the `add_to_topic` method. * The application has the chance to perform any operation it needs to mark the beginning of a subscription * and to set aside whatever resources or needed to manage the subscription. * * @param {string} topic * @param {string} client_name * @param {object} relayer */ app_post_start_subscription(topic,client_name,relayer) { console.log("Descendent must implement app_post_start_subscription") } // /** * This method does not have to be overridden to function. * However, the method is available to be called by the descending application. * * This method allows the application to publish to subscribing clients any time without sending a publication message to a server. * This message goes to all subscribers, external clients. (that is nothing is ignored) * * The `_repsonse_id` of the message is deleted. This is useful when a client sends a message to the endpoint and the endpoint server * decides to publish the message (for whatever reason). * * Note: publication has been simplified to a single case, which is always on a path. * This pathless version of app publication is kept, but sets the path to the topic. * A subscriber must subscribe to the topic on a path equal to the topic to receive from this method. * The method `app_publish_on_path` does not have this restriction. * * @param {string} topic * @param {object} msg_obj */ app_publish(topic,msg_obj) { let m_topic = msg_obj.topic if ( m_topic === undefined ) { msg_obj.topic = topic } if ( m_path === undefined ) { msg_obj._m_path = topic } if ( msg_obj._response_id !== undefined || msg_obj._response_id !== false ) { delete msg_obj._response_id } this.send_to_all(topic,msg_obj,false) } // /** * This method is almost the same as `app_publish`. But, it also takes a path for those * applications that are using message relay servers or at least the path concept. * * This method is also convenient for use at the application level in message relay servers. * * @param {string} topic * @param {string} path * @param {object} msg_obj */ app_publish_on_path(topic,path,msg_obj) { let m_topic = msg_obj.topic let m_path = msg_obj._m_path if ( m_topic === undefined ) { msg_obj.topic = topic } if ( m_path === undefined ) { msg_obj._m_path = path } if ( msg_obj._response_id !== undefined || msg_obj._response_id !== false ) { delete msg_obj._response_id } this.send_to_all(topic,msg_obj,false) } } // /** * */ class Server extends Communicator { // constructor(conf) { super(conf) } // ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- /** * * @param {object} conf */ _init_members(conf) { let uds_path = conf.UDS_path || conf.uds_path if ( uds_path === undefined ) { this.port = conf ? conf.port || PORT : PORT this.address = conf ? conf.address || HOST : HOST } else { this.UDS_path = uds_path this.uds_path_count = 0 if ( (conf.uds_path_count !== undefined) && !(isNaN(conf.uds_path_count)) && (conf.uds_path_count > 0) ) { this.uds_path_count = parseInt(conf.uds_path_count) } this.uds_server_list = {} } // this.use_tls = ((conf.tls !== undefined) && (conf.tls !== false)) || ((conf.default_tls !== undefined) && (conf.default_tls !== false)) this.tls_conf = conf.tls this.default_tls = conf.default_tls this.extended_tls_options = ((conf.extended_tls_options !== undefined) && (conf.extended_tls_options !== false)) ? conf.extended_tls_options : false // if ( this.use_tls ) { this.preloaded_tls_keys = this._load_tls_keys() } } // ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- /** * * @returns {object} */ _load_tls_keys() { // let base = process.cwd() // // allow exceptions to be thrown if ( this.tls_conf ) { // let server_key = false if ( this.tls_conf.preloaded && this.tls_conf.preloaded.server_key ) { server_key = this.tls_conf.preloaded.server_key } else { server_key = fs.readFileSync(`${base}/${this.tls_conf.server_key}`) } // let server_cert = false if ( this.tls_conf.preloaded && this.tls_conf.preloaded.server_cert ) { server_cert = this.tls_conf.preloaded.server_cert } else { server_cert = fs.readFileSync(`${base}/${this.tls_conf.server_cert}`) } // let client_cert = false if ( this.tls_conf.preloaded && this.tls_conf.preloaded.client_cert ) { client_cert = this.tls_conf.preloaded.client_cert } else { client_cert = fs.readFileSync(`${base}/${this.tls_conf.client_cert}`) } let tls_options = { key: server_key, cert: client_cert, requestCert: true, ca: [ client_cert ] }; return tls_options } // return false } // ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- /** * * @param {object} sock */ onClientConnected_func(sock,uds_path = undefined) { // if ( this.use_tls ) { if ( !(sock.authorized) ) { sock.end() return } } // let client_name = ((uds_path === undefined) && (sock.remoteAddress !== undefined)) ? `${sock.remoteAddress}:${sock.remotePort}` : uds_path; if ( client_name === undefined ) client_name = "UDS" if ( NOISY ) console.log(`new client connected: ${client_name}`); // this.add_connection(client_name,sock) // sock.on('data', async (data) => { await this.add_data_and_react(client_name,data) }); // sock.on('close', () => { this.remove_connection(client_name) sock.end() }); // sock.on('error', (err) => { console.error(`Connection ${client_name} error: ${err.message}`); }); // } /** * */ _create_connection() { if ( (this.UDS_path === undefined) || (this.uds_path_count == 0) ) { if ( !(this.use_tls) ) { this.connection = net.createServer((sock) => { this.onClientConnected_func(sock) }) } else { if ( this.default_tls ) { this.connection = tls.createServer((sock) => { this.onClientConnected_func(sock) }); } else { const options = this.preloaded_tls_keys; if ( this.extended_tls_options !== false ) { options = Object.assign({},options,this.extended_tls_options) } this.connection = tls.createServer(options,((sock) => { this.onClientConnected_func(sock) })); } } } else { for ( let i = 0; i < this.uds_path_count; i++ ) { let uds_path = `${this.UDS_path}-${i}` if ( !(this.use_tls) ) { this.connection = net.createServer((sock) => { this.onClientConnected_func(sock,uds_path) }) } else { if ( this.default_tls ) { this.connection = tls.createServer((sock) => { this.onClientConnected_func(sock,uds_path) }); } else { const options = this.preloaded_tls_keys; if ( this.extended_tls_options !== false ) { options = Object.assign({},options,this.extended_tls_options) } this.connection = tls.createServer(options,((sock) => { this.onClientConnected_func(sock,uds_path) })); } } this.uds_server_list[uds_path] = this.connection this.connection = false } } // UDS if ( this.UDS_path !== undefined ) { if ( this.uds_path_count == 0 ) { let srv_runner = this this.connection.listen({ 'path' : this.UDS_path }, () => { console.log(`Singler Server started at: ${this.UDS_path}`); if ( NOISY ) srv_runner.emit('SERVER-READY_test') }); } else { let srv_runner = this for ( let i = 0; i < this.uds_path_count; i++ ) { let uds_path = `${this.UDS_path}-${i}` let connection = this.uds_server_list[uds_path] connection.listen({ 'path' : uds_path }, () => { console.log(`Server started at: ${uds_path}`); if ( NOISY && ((i + 1) == this.uds_path_count) ) srv_runner.emit('SERVER-READY_test') }); } } } else { this.connection.listen(this.port, this.address, () => { console.log(`Server started at: ${this.address}:${this.port}`); }); } } // ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- /** * * @param {object} conf */ _init(conf) { // if ( conf === undefined ) { console.log("message relay client: cannot initialize -- no configuration") return; } // this._init_members(conf) this._create_connection() } // ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- ---- closeAll(cb,force) { if ( force ) { if ( Object.keys(this.messenger_connections).length > 0 ) { for ( let mcon of Object.entries(this.messenger_connections) ) { if ( mcon.writer && (typeof mcon.writer.close === 'function') ) { mcon.writer.close() } } } } if ( this.connection ) { if ( cb !== undefined ) { this.connection.close(cb); } else { this.connection.close(); } } else { for ( let srvr of Object.values(this.uds_server_list) ) { srvr.close() } } } } // module.exports = Server; module.exports.Communicator = Communicator module.exports.EndpointReplier = Replier