UNPKG

@citrineos/util

Version:

The OCPP util module which supplies helpful utilities like cache and queue connectors, etc.

155 lines 7.23 kB
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project // // SPDX-License-Identifier: Apache-2.0 import { AbstractMessageHandler, Message, RetryMessageError } from '@citrineos/base'; import * as amqplib from 'amqplib'; import { Logger } from 'tslog'; import { RabbitMQChannelManager } from './ChannelManager.js'; /** * Implementation of a {@link IMessageHandler} using RabbitMQ as the underlying transport. */ export class RabbitMqReceiver extends AbstractMessageHandler { exchange; /** * Constants */ static QUEUE_PREFIX = 'rabbit_queue_'; static CHANNEL_ID = 'receiver'; /** * Fields */ _channelManager; _consumerTags = new Map(); // Map of identifier to consumerTags for unsubscribing constructor(exchange, channelManager, logger, module) { super(logger, module); this.exchange = exchange; this._channelManager = channelManager; } /** * Methods */ /** * Binds queue to an exchange given identifier and optional actions and filter. * Note: Due to the nature of AMQP 0-9-1 model, if you need to filter for the identifier, you **MUST** provide it in the filter object. * * @param {string} identifier - The identifier of the channel to subscribe to. * @param {CallAction[]} actions - Optional. An array of actions to filter the messages. * @param {{ [k: string]: string; }} filter - Optional. An object representing the filter to apply on the messages. * @return {Promise<boolean>} A promise that resolves to true if the subscription is successful, false otherwise. */ async subscribe(identifier, actions, filter) { // If actions are a defined but empty list, it is likely a module // with no available actions and should not have a queue. // // If actions are undefined, it is likely a charger, // which is "allowed" not to have actions. if (actions && actions.length === 0) { this._logger.debug(`Skipping queue binding for module ${identifier} as there are no available actions.`); return true; } const queueName = `${RabbitMqReceiver.QUEUE_PREFIX}${identifier}`; // Ensure that filter includes the x-match header set to all filter = filter ? { 'x-match': 'all', ...filter, } : { 'x-match': 'all' }; const channel = await this._channelManager.getChannel(RabbitMqReceiver.CHANNEL_ID); if (!channel) { throw new Error('RabbitMQ is down: cannot subscribe.'); } // Assert exchange and queue await channel.assertExchange(this.exchange, 'headers', { durable: false }); await channel.assertQueue(queueName, { durable: true, autoDelete: true, exclusive: false, }); // Bind queue based on provided actions and filters if (actions && actions.length > 0) { for (const action of actions) { this._logger.debug(`Bind ${queueName} on ${this.exchange} for ${action} with filter ${JSON.stringify(filter)}.`); await channel.bindQueue(queueName, this.exchange, '', { action, ...filter }); this._logger.info(`Queue ${queueName} bound to exchange ${this.exchange} for action ${action} with filter ${JSON.stringify(filter)}.`); } } else { this._logger.debug(`Bind ${queueName} on ${this.exchange} with filter ${JSON.stringify(filter)}.`); await channel.bindQueue(queueName, this.exchange, '', filter); this._logger.info(`Queue ${queueName} bound to exchange ${this.exchange} with filter ${JSON.stringify(filter)}.`); } // Start consuming messages const consume = await channel.consume(queueName, (msg) => this._onMessage(msg, channel)); const existing = this._consumerTags.get(identifier) ?? []; this._consumerTags.set(identifier, [...existing, consume.consumerTag]); return true; } async unsubscribe(identifier) { const channel = await this._channelManager.getChannel(RabbitMqReceiver.CHANNEL_ID); if (!channel) { this._logger.error('RabbitMQ is down: cannot unsubscribe.'); return false; } const consumerTags = this._consumerTags.get(identifier); if (consumerTags && consumerTags.length > 0) { for (const consumerTag of consumerTags) { await channel.cancel(consumerTag); this._logger.debug(`Unsubscribed from ${identifier} with consumer tag ${consumerTag}.`); } this._consumerTags.delete(identifier); return true; } else { this._logger.warn(`No consumer tag found for ${identifier} during unsubscribe.`); return false; } } async shutdown() { for (const consumerTags of this._consumerTags.values()) { const channel = await this._channelManager.getChannel(RabbitMqReceiver.CHANNEL_ID); if (channel) { for (const consumerTag of consumerTags) { try { await channel.cancel(consumerTag); this._logger.debug(`Cancelled consumer with tag ${consumerTag} during shutdown.`); } catch (error) { this._logger.error(`Error cancelling consumer with tag ${consumerTag} during shutdown.`, error); } } } } } /** * Underlying RabbitMQ message handler. * * @param message The AMQPMessage to process * @param channel */ async _onMessage(message, channel) { if (message) { try { this._logger.debug('_onMessage:Message from broker:', message.properties, message.content.toString()); const messageData = JSON.parse(message.content.toString()); // Create Message instance with generic payload (no type transformation needed) const parsed = new Message(messageData.origin || messageData._origin, messageData.eventGroup || messageData._eventGroup, messageData.action || messageData._action, messageData.state || messageData._state, messageData.context || messageData._context, messageData.payload || messageData._payload, // Keep payload as generic object messageData.protocol || messageData._protocol); await this.handle(parsed, message.properties); } catch (error) { if (error instanceof RetryMessageError) { this._logger.warn('Retrying message: ', error.message); // Retryable error, usually ongoing call with station when trying to send new call channel.nack(message); return; } else { this._logger.error('Error while processing message:', error, message); } } channel.ack(message); } } } //# sourceMappingURL=receiver.js.map