@frankvdb/node-red-contrib-amqp
Version:
RabbitMQ nodes for node-red
692 lines (691 loc) • 28.8 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const uuid_1 = require("uuid");
const lodash_clonedeep_1 = __importDefault(require("lodash.clonedeep"));
const amqplib_1 = require("amqplib");
const types_1 = require("./types");
const constants_1 = require("./constants");
class Amqp {
constructor(RED, node, config) {
var _a, _b;
this.RED = RED;
this.node = node;
this.rpcTimeouts = new Set();
this.closed = false;
this.config = {
name: config.name,
broker: config.broker,
prefetch: config.prefetch,
reconnectOnError: config.reconnectOnError,
noAck: config.noAck,
waitForConfirms: config.waitForConfirms,
exchange: {
name: config.exchangeName,
type: config.exchangeType,
routingKey: config.exchangeRoutingKey,
durable: config.exchangeDurable,
autoCreate: (_a = config.autoCreateExchangeBindings) !== null && _a !== void 0 ? _a : false,
},
queue: {
name: config.queueName,
exclusive: config.queueExclusive,
durable: config.queueDurable,
autoDelete: config.queueAutoDelete,
autoCreate: (_b = config.autoCreateQueue) !== null && _b !== void 0 ? _b : false,
queueType: config.queueType,
queueArguments: this.parseJsonObject(config.queueArguments),
},
amqpProperties: this.parseJsonObject(config.amqpProperties),
headers: this.parseJsonObject(config.headers),
outputs: config.outputs,
rpcTimeout: config.rpcTimeoutMilliseconds,
};
}
async connect() {
var _a;
const { broker } = this.config;
// wtf happened to the types?
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.broker = this.RED.nodes.getNode(broker);
if (!this.broker) {
const err = new Error(`AMQP broker node not found: ${broker}`);
this.node.error(err.message);
throw err;
}
const brokerConfig = Object.assign(Object.assign({}, this.broker), { vhost: (_a = this.vhostOverride) !== null && _a !== void 0 ? _a : this.broker.vhost });
const brokerUrl = this.getBrokerUrl(brokerConfig);
const { host, port, vhost } = brokerConfig;
const brokerInfo = `${host}:${port}${vhost ? `/${vhost}` : ''}`;
const key = `${broker}:${vhost}`;
let entry = Amqp.connectionPool.get(key);
if (entry && !this.isConnectionOpen(entry.connection)) {
Amqp.connectionPool.delete(key);
entry = undefined;
}
if (!entry) {
this.node.log(`Connecting to AMQP broker ${brokerInfo}`);
try {
const connection = await (0, amqplib_1.connect)(brokerUrl, { heartbeat: 2 });
this.node.log(`Connected to AMQP broker ${brokerInfo}`);
connection.on('close', () => {
const current = Amqp.connectionPool.get(key);
if ((current === null || current === void 0 ? void 0 : current.connection) === connection) {
Amqp.connectionPool.delete(key);
}
});
entry = { connection, count: 0 };
Amqp.connectionPool.set(key, entry);
}
catch (err) {
this.setBrokerNodeState('errored', err);
this.node.warn(`Failed to connect to AMQP broker ${brokerInfo}: ${err}`);
throw err;
}
}
entry.count += 1;
this.connection = entry.connection;
this.connectionErrorHandler = (e) => {
/* istanbul ignore next */
this.setBrokerNodeState('errored', e);
this.node.status(constants_1.NODE_STATUS.Disconnected);
this.node.warn(`AMQP connection error ${e}`);
};
this.connectionCloseHandler = () => {
/* istanbul ignore next */
this.setBrokerNodeState('disconnected', new Error('AMQP connection closed'));
this.node.status(constants_1.NODE_STATUS.Disconnected);
this.node.log(`AMQP Connection closed`);
};
this.connection.on('error', this.connectionErrorHandler);
this.connection.on('close', this.connectionCloseHandler);
this.closed = false;
return this.connection;
}
markConnected() {
this.setBrokerNodeState('connected');
this.node.status(constants_1.NODE_STATUS.Connected);
}
removeBrokerNodeState() {
var _a;
if (!this.broker || !((_a = this.node) === null || _a === void 0 ? void 0 : _a.id)) {
return;
}
if (this.broker.nodeStates) {
delete this.broker.nodeStates[this.node.id];
}
if (this.broker.lastError) {
delete this.broker.lastError[this.node.id];
}
}
async initialize() {
await this.createChannel();
if (this.shouldAutoCreateExchangeBindings()) {
await this.assertExchange();
}
return this.channel;
}
async consume() {
try {
const { noAck } = this.config;
if (this.shouldAutoCreateQueue()) {
await this.assertQueue();
}
else {
this.useExistingQueue();
}
if (this.shouldAutoCreateExchangeBindings()) {
await this.bindQueue();
}
await this.channel.consume(this.q.queue, amqpMessage => {
var _a, _b;
if (!amqpMessage) {
this.node.warn('AMQP consumer was cancelled');
this.node.status(constants_1.NODE_STATUS.Disconnected);
const eventEmitterNode = this.node;
(_a = eventEmitterNode.emit) === null || _a === void 0 ? void 0 : _a.call(eventEmitterNode, 'amqp:consumer-cancelled');
return;
}
const msg = this.assembleMessage(amqpMessage);
this.node.log(`Received message with deliveryTag: ${(_b = msg === null || msg === void 0 ? void 0 : msg.fields) === null || _b === void 0 ? void 0 : _b.deliveryTag}`);
this.node.send(msg);
/* istanbul ignore else */
if (!noAck && !this.isManualAck()) {
this.ack(msg);
}
}, { noAck });
}
catch (e) {
this.node.error(`Could not consume message: ${e}`);
throw e;
}
}
setRoutingKey(newRoutingKey) {
this.config.exchange.routingKey = newRoutingKey;
}
async setVhost(newVhost) {
var _a;
const broker = this.broker;
const currentVhost = (_a = this.vhostOverride) !== null && _a !== void 0 ? _a : broker === null || broker === void 0 ? void 0 : broker.vhost;
if (!broker || currentVhost === newVhost) {
return;
}
try {
await this.close();
this.vhostOverride = newVhost;
await this.connect();
await this.initialize();
this.markConnected();
}
catch (e) {
this.node.error(`Could not switch vhost: ${e}`);
throw e;
}
}
getConnection() {
return this.connection;
}
getChannel() {
return this.channel;
}
ack(msg) {
var _a, _b;
const allUpTo = !!((_a = msg.manualAck) === null || _a === void 0 ? void 0 : _a.allUpTo);
try {
this.node.log(`Acking message with deliveryTag: ${(_b = msg === null || msg === void 0 ? void 0 : msg.fields) === null || _b === void 0 ? void 0 : _b.deliveryTag}`);
this.channel.ack(msg, allUpTo);
}
catch (e) {
this.node.error(`Could not ack message: ${e}`);
}
}
ackAll() {
try {
this.node.log('Acking all outstanding messages');
this.channel.ackAll();
}
catch (e) {
this.node.error(`Could not ackAll messages: ${e}`);
}
}
nack(msg) {
var _a, _b, _c, _d;
const allUpTo = !!((_a = msg.manualAck) === null || _a === void 0 ? void 0 : _a.allUpTo);
const requeue = (_c = (_b = msg.manualAck) === null || _b === void 0 ? void 0 : _b.requeue) !== null && _c !== void 0 ? _c : true;
try {
this.node.log(`Nacking message with deliveryTag: ${(_d = msg === null || msg === void 0 ? void 0 : msg.fields) === null || _d === void 0 ? void 0 : _d.deliveryTag}`);
this.channel.nack(msg, allUpTo, requeue);
}
catch (e) {
this.node.error(`Could not nack message: ${e}`);
}
}
nackAll(msg) {
var _a, _b;
const requeue = (_b = (_a = msg.manualAck) === null || _a === void 0 ? void 0 : _a.requeue) !== null && _b !== void 0 ? _b : true;
try {
this.node.log('Nacking all outstanding messages');
this.channel.nackAll(requeue);
}
catch (e) {
this.node.error(`Could not nackAll messages: ${e}`);
}
}
reject(msg) {
var _a, _b, _c;
const requeue = (_b = (_a = msg.manualAck) === null || _a === void 0 ? void 0 : _a.requeue) !== null && _b !== void 0 ? _b : true;
try {
this.node.log(`Rejecting message with deliveryTag: ${(_c = msg === null || msg === void 0 ? void 0 : msg.fields) === null || _c === void 0 ? void 0 : _c.deliveryTag}`);
this.channel.reject(msg, requeue);
}
catch (e) {
this.node.error(`Could not reject message: ${e}`);
}
}
async publish(msg, properties) {
const routingKeys = this.parseRoutingKeys();
await Promise.all(routingKeys.map(routingKey => this.handlePublish(this.config, msg, properties, routingKey)));
}
async handlePublish(config, msg, properties, routingKey) {
var _a, _b;
const { exchange: { name }, outputs: rpcRequested, } = config;
let cancelRpcConsumer = null;
try {
let correlationId = '';
let replyTo = '';
if (rpcRequested) {
// Send request for remote procedure call
correlationId =
(properties === null || properties === void 0 ? void 0 : properties.correlationId) ||
((_a = this.config.amqpProperties) === null || _a === void 0 ? void 0 : _a.correlationId) ||
(0, uuid_1.v4)();
replyTo =
(properties === null || properties === void 0 ? void 0 : properties.replyTo) || ((_b = this.config.amqpProperties) === null || _b === void 0 ? void 0 : _b.replyTo) || (0, uuid_1.v4)();
cancelRpcConsumer = await this.handleRemoteProcedureCall(correlationId, replyTo);
}
const options = Object.assign(Object.assign({ correlationId,
replyTo }, this.config.amqpProperties), properties);
// when the name field is empty, publish just like the sendToQueue method;
// see https://amqp-node.github.io/amqplib/channel_api.html#channel_publish
this.channel.publish(name, routingKey, this.toPublishBuffer(msg), options);
if (config.waitForConfirms) {
await this.channel.waitForConfirms();
}
}
catch (e) {
if (cancelRpcConsumer) {
await cancelRpcConsumer();
}
this.node.error(`Could not publish message: ${e}`);
throw e;
}
}
getRpcConfig(replyTo) {
const rpcConfig = (0, lodash_clonedeep_1.default)(this.config);
rpcConfig.exchange.name = '';
rpcConfig.queue.name = replyTo;
rpcConfig.queue.autoDelete = true;
rpcConfig.queue.exclusive = true;
rpcConfig.queue.durable = false;
rpcConfig.noAck = true;
return rpcConfig;
}
async handleRemoteProcedureCall(correlationId, replyTo) {
const rpcConfig = this.getRpcConfig(replyTo);
let queueName = '';
try {
// If we try to delete a queue that's already deleted
// bad things will happen.
let rpcQueueHasBeenDeleted = false;
let rpcResponseFinalized = false;
let additionalErrorMessaging = '';
let rpcTimeout = null;
let rpcConsumerTag = '';
let cleanupPromise = null;
const clearRpcTimeout = () => {
if (rpcTimeout) {
clearTimeout(rpcTimeout);
this.rpcTimeouts.delete(rpcTimeout);
rpcTimeout = null;
}
};
const finalizeRpcResponse = () => {
if (rpcResponseFinalized) {
return false;
}
rpcResponseFinalized = true;
clearRpcTimeout();
return true;
};
const cleanupRpcResources = async () => {
if (rpcQueueHasBeenDeleted || !queueName) {
return;
}
if (!cleanupPromise) {
cleanupPromise = (async () => {
try {
await this.channel.deleteQueue(queueName);
rpcQueueHasBeenDeleted = true;
}
catch (deleteError) {
this.node.error(`Error trying to cancel RPC consumer: ${deleteError}`);
const canCancelConsumer = typeof this.channel.cancel === 'function';
if (canCancelConsumer && rpcConsumerTag) {
try {
await this.channel.cancel(rpcConsumerTag);
rpcQueueHasBeenDeleted = true;
}
catch (cancelError) {
this.node.error(`Error trying to cancel RPC consumer: ${cancelError}`);
}
}
}
finally {
cleanupPromise = null;
}
})();
}
await cleanupPromise;
};
const cancelRpcConsumer = async () => {
finalizeRpcResponse();
await cleanupRpcResources();
};
/************************************
* assert queue and set up consumer
************************************/
queueName = await this.assertQueue(rpcConfig);
const consumeResponse = await this.channel.consume(queueName, amqpMessage => {
if (amqpMessage) {
const msg = this.assembleMessage(amqpMessage);
if (msg.properties.correlationId === correlationId) {
if (finalizeRpcResponse()) {
this.node.send(msg);
void cleanupRpcResources();
}
}
else {
additionalErrorMessaging += ` Correlation ids do not match. Expecting: ${correlationId}, received: ${msg.properties.correlationId}`;
}
}
}, { noAck: rpcConfig.noAck });
rpcConsumerTag = (consumeResponse === null || consumeResponse === void 0 ? void 0 : consumeResponse.consumerTag) || '';
/****************************************
* Check if RPC has timed out and handle
****************************************/
rpcTimeout = setTimeout(async () => {
clearRpcTimeout();
if (this.closed) {
return;
}
try {
if (finalizeRpcResponse()) {
this.node.send({
payload: {
message: `Timeout while waiting for RPC response.${additionalErrorMessaging}`,
config: rpcConfig,
},
});
}
await cleanupRpcResources();
}
catch (e) {
// TODO: Keep an eye on this
// This might close the whole channel
this.node.error(`Error trying to cancel RPC consumer: ${e}`);
}
}, rpcConfig.rpcTimeout || 3000);
this.rpcTimeouts.add(rpcTimeout);
return cancelRpcConsumer;
}
catch (e) {
// If setup failed after queue assertion, try to clean up the temporary RPC queue.
if (queueName) {
await this.channel.deleteQueue(queueName).catch(deleteError => {
this.node.error(`Error trying to cancel RPC consumer: ${deleteError}`);
});
}
this.node.error(`Could not consume RPC message: ${e}`);
throw e;
}
}
async close() {
if (this.closed) {
return;
}
this.closed = true;
this.clearRpcTimeouts();
await this.unbindQueues();
await this.closeChannel();
await this.releaseConnection();
}
async unbindQueues() {
var _a;
const { name: exchangeName } = this.config.exchange;
const queueName = (_a = this.q) === null || _a === void 0 ? void 0 : _a.queue;
if (exchangeName && queueName && this.shouldUnbindQueueOnClose()) {
const routingKeys = this.parseRoutingKeys();
for (const routingKey of routingKeys) {
try {
await this.channel.unbindQueue(queueName, exchangeName, routingKey);
}
catch (e) {
/* istanbul ignore next */
this.node.error(`Error unbinding queue for routing key ${routingKey}: ${e.message}`);
}
}
}
}
shouldUnbindQueueOnClose() {
const { name, exclusive, autoDelete } = this.config.queue;
// Keep bindings for long-lived queues so reconnects don't temporarily
// remove routes and drop unroutable messages in-flight.
return this.shouldAutoCreateExchangeBindings() && (!name || exclusive || autoDelete);
}
shouldAutoCreateExchangeBindings(configParams) {
return (configParams || this.config).exchange.autoCreate;
}
shouldAutoCreateQueue(configParams) {
return (configParams || this.config).queue.autoCreate;
}
async closeChannel() {
var _a, _b, _c, _d, _e, _f;
if (this.channel) {
(_b = (_a = this.channel).off) === null || _b === void 0 ? void 0 : _b.call(_a, 'error', this.channelErrorHandler);
(_d = (_c = this.channel).off) === null || _d === void 0 ? void 0 : _d.call(_c, 'close', this.channelCloseHandler);
(_f = (_e = this.channel).off) === null || _f === void 0 ? void 0 : _f.call(_e, 'return', this.channelReturnHandler);
try {
await this.channel.close();
}
catch (e) {
this.node.error(`Error closing AMQP channel: ${e}`);
}
}
}
async releaseConnection() {
var _a, _b, _c, _d, _e;
const brokerId = this.config.broker;
const broker = this.broker;
const vhost = (_a = this.vhostOverride) !== null && _a !== void 0 ? _a : broker === null || broker === void 0 ? void 0 : broker.vhost;
const key = `${brokerId}:${vhost}`;
this.setBrokerNodeState('disconnected');
this.node.status(constants_1.NODE_STATUS.Disconnected);
if (this.connection) {
(_c = (_b = this.connection).off) === null || _c === void 0 ? void 0 : _c.call(_b, 'error', this.connectionErrorHandler);
(_e = (_d = this.connection).off) === null || _e === void 0 ? void 0 : _e.call(_d, 'close', this.connectionCloseHandler);
}
const entry = Amqp.connectionPool.get(key);
if (entry) {
entry.count -= 1;
if (entry.count <= 0) {
Amqp.connectionPool.delete(key);
try {
await entry.connection.close();
}
catch (e) {
/* istanbul ignore next */
this.node.error(`Error closing AMQP connection: ${e}`);
}
}
}
}
async createChannel() {
const { prefetch, waitForConfirms } = this.config;
this.channel = await (waitForConfirms
? this.connection.createConfirmChannel()
: this.connection.createChannel());
this.channel.prefetch(Number(prefetch));
this.channelErrorHandler = (e) => {
/* istanbul ignore next */
this.setBrokerNodeState('errored', e);
this.node.status(constants_1.NODE_STATUS.Disconnected);
this.node.error(`AMQP Connection Error ${e}`, { payload: { error: e, source: 'Amqp' } });
};
this.channelCloseHandler = () => {
/* istanbul ignore next */
this.node.status(constants_1.NODE_STATUS.Disconnected);
this.node.log('AMQP Channel closed');
};
this.channelReturnHandler = () => {
/* istanbul ignore next */
this.node.warn('AMQP Message returned');
};
this.channel.on('error', this.channelErrorHandler);
this.channel.on('close', this.channelCloseHandler);
this.channel.on('return', this.channelReturnHandler);
return this.channel;
}
async assertExchange() {
const { name, type, durable } = this.config.exchange;
/* istanbul ignore else */
if (name) {
await this.channel.assertExchange(name, type, {
durable,
});
}
}
async assertQueue(configParams) {
const { queue } = configParams || this.config;
const { name, exclusive, durable, autoDelete, queueType, queueArguments } = queue;
this.q = await this.channel.assertQueue(name, {
exclusive,
durable,
autoDelete,
arguments: Object.assign({ "x-queue-type": queueType }, (queueArguments || {})),
});
return name;
}
useExistingQueue(configParams) {
const { queue } = configParams || this.config;
const { name } = queue;
if (!name) {
throw new Error('Queue Name is required when "Auto-create" queue is disabled');
}
this.q = { queue: name };
return name;
}
async bindQueue(configParams) {
const { name, type, routingKey } = (configParams === null || configParams === void 0 ? void 0 : configParams.exchange) || this.config.exchange;
const { headers } = (configParams === null || configParams === void 0 ? void 0 : configParams.amqpProperties) || this.config;
if (this.canHaveRoutingKey(type) && name) {
const promises = this.parseRoutingKeys(routingKey).map(key => this.channel.bindQueue(this.q.queue, name, key));
await Promise.all(promises);
}
if (type === types_1.ExchangeType.Fanout) {
await this.channel.bindQueue(this.q.queue, name, '');
}
if (type === types_1.ExchangeType.Headers) {
await this.channel.bindQueue(this.q.queue, name, '', headers);
}
}
canHaveRoutingKey(type) {
return type === types_1.ExchangeType.Direct || type === types_1.ExchangeType.Topic;
}
getBrokerUrl(broker) {
let url = '';
if (broker) {
const { host, port, vhost, tls, credsFromSettings, credentials } = broker;
const { username, password } = credsFromSettings
? this.getCredsFromSettings()
: credentials;
const protocol = tls ? /* istanbul ignore next */ 'amqps' : 'amqp';
const vhostPath = vhost ? `/${encodeURIComponent(vhost)}` : '/';
url = `${protocol}://${encodeURIComponent(username)}:${encodeURIComponent(password)}@${host}:${port}${vhostPath}`;
}
return url;
}
getCredsFromSettings() {
return {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
username: this.RED.settings.MW_CONTRIB_AMQP_USERNAME,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
password: this.RED.settings.MW_CONTRIB_AMQP_PASSWORD,
};
}
parseRoutingKeys(routingKeyArg) {
var _a;
const routingKey = routingKeyArg || this.config.exchange.routingKey || ((_a = this.q) === null || _a === void 0 ? void 0 : _a.queue) || '';
const keys = routingKey === null || routingKey === void 0 ? void 0 : routingKey.split(',').map(key => key.trim());
return keys;
}
assembleMessage(amqpMessage) {
const payload = this.parseJson(amqpMessage.content.toString(), true);
amqpMessage.payload = payload;
return amqpMessage;
}
isManualAck() {
return this.node.type === types_1.NodeType.AmqpInManualAck;
}
parseJson(jsonInput, logError = false) {
let output;
try {
output = JSON.parse(jsonInput);
}
catch (e) {
output = jsonInput;
/* istanbul ignore next */
if (logError) {
this.node.error(`Invalid JSON payload: ${e}`);
}
}
return output;
}
parseJsonObject(jsonInput, logError = false) {
const output = this.parseJson(jsonInput, logError);
return this.isJsonObject(output) ? output : {};
}
isJsonObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
clearRpcTimeouts() {
for (const timeout of this.rpcTimeouts) {
clearTimeout(timeout);
}
this.rpcTimeouts.clear();
}
isConnectionOpen(connection) {
var _a;
const stream = (_a = connection
.connection) === null || _a === void 0 ? void 0 : _a.stream;
return (stream === null || stream === void 0 ? void 0 : stream.destroyed) !== true;
}
setBrokerNodeState(state, error) {
if (!this.broker) {
return;
}
if (!this.broker.nodeStates) {
this.broker.nodeStates = {};
}
this.broker.nodeStates[this.node.id] = state;
if (!this.broker.lastError) {
this.broker.lastError = {};
}
if (error !== undefined) {
this.broker.lastError[this.node.id] = this.toBrokerNodeError(error);
}
else if (state === 'connected') {
delete this.broker.lastError[this.node.id];
}
}
toBrokerNodeError(error) {
const message = error instanceof Error ? error.message : String(error);
const code = error && typeof error === 'object' ? String(error.code || '') : '';
return {
message,
code: code || undefined,
at: new Date().toISOString(),
};
}
toPublishBuffer(msg) {
if (Buffer.isBuffer(msg)) {
return msg;
}
if (msg instanceof Uint8Array) {
return Buffer.from(msg);
}
if (typeof msg === 'string') {
return Buffer.from(msg);
}
if (msg === undefined) {
return Buffer.from('');
}
try {
const serialized = JSON.stringify(msg);
if (serialized === undefined) {
return Buffer.from('');
}
return Buffer.from(serialized);
}
catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`Could not serialize payload: ${reason}`);
}
}
}
Amqp.connectionPool = new Map();
exports.default = Amqp;