UNPKG

@frankvdb/node-red-contrib-amqp

Version:
692 lines (691 loc) 28.8 kB
"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;