rabbit_rpc
Version:
AMQP wrapper to make it easy to communicate between NodeJS microservices using RabbitMQ
475 lines (337 loc) • 14.4 kB
JavaScript
/*
ISC License
Copyright (c) 2017, Scott Larkin
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE
OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
*/
(_ => {
'use strict';
const amqp = require('amqplib/callback_api'),
onDeath = require('death'),
{ Readable, Writable } = require('stream'),
//String constants
STREAM_QUEUE_PREFIX = 'rabbit_rpc_dataQueue_',
ERROR_CAN_NOT_ASSERT_QUEUE = 'Couldn\'t assert queue',
ERROR_RPC_FAIL = 'Error executing Rabbbit RPC function.',
ERROR_NO_CALLBACK = 'Final parameter to rpcRequest must be a callback function.',
ERROR_IN_CALLBACK = 'Error executing callback',
ERROR_CAN_NOT_ASSERT_EXCAHNGE = 'Could not assert exchange',
ERROR_IN_BROADCAST_ACTION = 'Error in broadcast action',
ERROR = 'ERROR';
function generateUuid() {
return Math.random().toString() +
Math.random().toString() +
Math.random().toString();
}
function toRaw(obj) {
let o = JSON.parse(obj);
if (o instanceof Array) {
o = o.map(e => {
if (typeof e === 'object' && e && e.type === 'Buffer')
return new Buffer(e);
return e;
});
}
return o;
}
function toTransport(message) {
return JSON.stringify(message);
}
function connect(addr, cb) {
typeof addr === "function" && (cb = addr, addr = null);
amqp.connect(addr || 'amqp://localhost', (err, conn) => {
if (!err)
return conn.createChannel((err, ch) => {
cb(err, conn, ch);
});
cb(err, null);
});
}
function disconnect(conn, callback) {
callback || (callback = () => { });
if (!conn)
return callback();
setTimeout(function () {
conn.close();
callback();
}, 500);
}
let connections = {};
let pendingRequests = Symbol();
let connection = Symbol();
let channel = Symbol();
let conHost = Symbol();
let request = Symbol();
let instances = {};
function getConnection(host) {
if (connections[host])
return connections[host];
}
function setConnection(host, conn, ch) {
connections[host].connection = conn;
connections[host].channel = ch;
}
onDeath(() => {
Object.keys(connections).forEach(host => {
let instances = connections[host].instances;
instances.length && instances[0].close();
});
});
let getQueue = (broker, id, callback) => {
broker[request](() => {
broker[channel].assertQueue(`${STREAM_QUEUE_PREFIX}${id}`, {}, (err, q) => {
if (err) {
throw new Error(ERROR_CAN_NOT_ASSERT_QUEUE);
}
callback(err, q);
});
});
};
class ReadStream extends Readable {
constructor(id, broker, options) {
super(options);
this.id = id;
this.broker = broker;
this.on('end', () => {
this.broker[channel].deleteQueue(`${STREAM_QUEUE_PREFIX}${this.id}`)
});
getQueue(this.broker, this.id, (err, q) => {
this.broker[channel].consume(q.queue, msg => {
if (!msg) return;
let content = toRaw(msg.content.toString());
//content will be null if the stream has finished reading
if (!content) {
this.broker[channel].ack(msg);
return this.push(null); //end the stream
}
this.unshift(new Buffer(content));
this.broker[channel].ack(msg);
});
});
}
_read(size) {
}
};
class WriteStream extends Writable {
constructor(id, broker, options) {
super(options);
this.id = id;
this.broker = broker;
}
_write(chunk, encoding, callback) {
getQueue(this.broker, this.id, (err, q) => {
this.broker[channel].sendToQueue(q.queue, Buffer.from(toTransport(chunk)));
//tell the paired stream to this si the final chunk
if (this._writableState.ending) {
this.broker[channel].sendToQueue(q.queue, Buffer.from(toTransport(null)));
}
callback();
});
}
};
class Broker {
constructor(host) {
this[conHost] = host;
this[pendingRequests] = [];
let connObj = getConnection(host);
if (connObj) {
if (connObj.connecting) {
connObj.instances.push(this);
}
else {
this[connection] = connObj.connection;
this[channel] = connObj.channel;
}
}
else {
let onError = e => {
if (!connections[host].disconnect) {
console.error(ERROR);
console.error(e);
connections[host].instances.forEach(instance => {
instance[connection] = null;
instance[channel] = null;
});
setTimeout(() => {
fun(true);
}, 500);
}
};
let fun = (recon) => {
connections[host].connecting = true;
connect(host, (err, conn, ch) => {
if (err)
throw new Error(err);
if (recon) {
connections[host].disconnect = true;
connections[host].channel.close();
connections[host].connection.close();
}
connections[host].connecting = false;
connections[host].instances.forEach(instance => {
instance[connection] = conn;
instance[channel] = ch;
instance[pendingRequests].forEach(r => r());
if (!recon)
instance[pendingRequests] = [];
});
setConnection(host, conn, ch);
//handle unexcpected dosconnects
conn.on('error', onError);
ch.on('error', onError);
ch.on('close', onError);
let dataStreams = {};
});
};
connections[host] = {};
connections[host].instances = [this];
fun(false);
}
this[request] = req => {
if (this[channel])
return req();
this[pendingRequests].push(req);
};
}
createWriteStream(id, options, callback) {
return new WriteStream(id, this, options);
}
createReadStream(id, options, callback) {
return new ReadStream(id, this, options);
}
close() {
//flag intentional disconnect
connections[this[conHost]].disconnect = true;
disconnect(this[connection].connection);
}
rpcResponse(queue, action) {
this[request](() => {
this[channel].assertQueue(queue, { durable: false }, (err, q) => {
if (err) {
this[channel].emit('error', ERROR_CAN_NOT_ASSERT_QUEUE);
}
this[channel].consume(queue, msg => {
try {
action(...toRaw(msg.content.toString()), (err, resp) => {
let response = {
error: err,
response: resp
};
this[channel].sendToQueue(msg.properties.replyTo, Buffer.from(toTransport(response)), { correlationId: msg.properties.correlationId });
this[channel].ack(msg);
});
}
catch (caught) {
console.error(ERROR_RPC_FAIL);
console.error(caught);
this[channel].sendToQueue(msg.properties.replyTo, Buffer.from(toTransport({ error: 'action failed', response: caught })), { correlationId: msg.properties.correlationId });
this[channel].ack(msg);
}
});
});
});
}
rpcRequest(/*queue, arg1, arg2, ...argn, callback*/) {
this[request](() => {
let args = [...arguments];
let queue = args[0];
let parameters = args.slice(1, args.length - 1);
let callback = args.pop();
let rabbitChannel = this[channel];
if (typeof callback !== 'function') {
return console.error(ERROR_NO_CALLBACK);
}
let corr = generateUuid();
rabbitChannel.assertQueue('', { noack: true, durable: false, exclusive: true, 'auto-delete': true }, (err, q) => {
rabbitChannel.consume(q.queue, function (msg) {
if (!msg) return;
if (msg.properties.correlationId === corr) {
try {
let message = toRaw(msg.content.toString());
callback(message.error, message.response);
}
catch (err) {
console.error(ERROR_IN_CALLBACK, err);
}
rabbitChannel.deleteQueue(q.queue);
}
});
rabbitChannel.sendToQueue(queue, Buffer.from(toTransport(parameters)), { correlationId: corr, replyTo: q.queue });
});
});
}
//curry the rpc request slightly. Will abstract the fact that this is a amqp call a bit and seem like you are callign any old js function
/*
let addNumbers = rpcRequestWithName('addNumbers');
addNumbers(1,2,3,4,5, (error, result) => console.log(result));
*/
rpcRequestWithName(queue) {
let that = this;
return function () {
that.rpcRequest(queue, ...arguments);
}
}
//send a message to all clients listening for an event
broadcast(event, message, callback) {
this[request](() => {
let rabbitChannel = this[channel];
rabbitChannel.assertExchange(event, 'fanout', {}, (err, exchange) => {
if (err)
return callback(err);
try {
rabbitChannel.publish(event, '', Buffer.from(toTransport(message)));
callback();
}
catch (er) {
callback(er);
}
});
});
}
//listen for broadcasted events and do soemthing when an event is detected
listen(event, action, callback) {
callback = callback || (() => { });
this[request](() => {
let rabbitChannel = this[channel];
rabbitChannel.assertExchange(event, 'fanout', {}, (err, exchange) => {
if (err) {
console.error(`${ERROR_CAN_NOT_ASSERT_EXCAHNGE} - ${event}`);
return callback(err);
}
let id = generateUuid();
rabbitChannel.assertQueue(`${event}_${id}`, { noack: true, durable: false, exclusive: true }, (err, queue) => {
if (err) {
console.error(`${ERROR_CAN_NOT_ASSERT_QUEUE} - ${event}_${id}`);
return callback(err);
}
rabbitChannel.bindQueue(`${event}_${id}`, event, '', {}, (err) => {
if (err) {
console.error(`Could not bind queue ${event}_${id} to exchange ${event}`);
return callback(err);
}
rabbitChannel.consume(`${event}_${id}`, function (msg) {
rabbitChannel.ack(msg);
try {
action(toRaw(msg.content));
}
catch (ex) {
console.error(ERROR_IN_BROADCAST_ACTION);
}
});
callback(null, `listening for ${event}`);
});
});
});
});
}
}
module.exports = Broker;
})();