UNPKG

@citrineos/util

Version:

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

157 lines 7.36 kB
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project // // SPDX-License-Identifier: Apache-2.0 import { AbstractMessageSender, MessageState, OcppError } from '@citrineos/base'; import { Logger } from 'tslog'; /** * A decorator around any {@link IMessageSender} that adds resilience when the * underlying message broker is unavailable. * * Behaviour when the broker is **disconnected**: * - **Call messages** (`MessageState.Request`): a `maxCallLengthSeconds` timeout is * started. When it fires the optional {@link onCallTimeout} callback is invoked * (e.g. to close the charger's WebSocket) and the pending entry is removed from * memory to prevent retry accumulation. * - **All other messages** (`MessageState.Response` / `MessageState.Unknown`): the * message is held in an in-memory buffer and replayed in order once the broker * reconnects. * * Behaviour when the broker **reconnects**: * - All buffered non-Call messages are flushed in order through the inner sender. * - In-flight Call timeouts continue to run (they will still close the WS connection * because the Call was never delivered to a module). */ export class BrokerAwareMessageSender extends AbstractMessageSender { _inner; _connectionManager; _maxCallLengthSeconds; /** Pending non-Call messages waiting to be flushed after reconnection. */ _buffer = []; /** * Active Call timeouts keyed by connection identifier (`tenantId:stationId`). * When a timeout fires the entry is deleted and `_onCallTimeout` is invoked. */ _callTimeouts = new Map(); /** * Optional callback invoked when a Call times out while the broker is down. * Typically used to close the corresponding WebSocket connection. * Can be set after construction to avoid circular dependency issues. */ onCallTimeout; constructor(_inner, _connectionManager, _maxCallLengthSeconds, logger) { super(logger); this._inner = _inner; this._connectionManager = _connectionManager; this._maxCallLengthSeconds = _maxCallLengthSeconds; _connectionManager.on('connected', () => { this._flushBuffer().catch((err) => { this._logger.error('BrokerAwareMessageSender: error flushing buffer after reconnect', err); }); }); _connectionManager.on('disconnected', () => { this._logger.warn('BrokerAwareMessageSender: broker disconnected – ' + 'Calls will time out, other messages will be buffered.'); }); } sendRequest(message, payload) { return this.send(message, payload, MessageState.Request); } sendResponse(message, payload) { return this.send(message, payload, MessageState.Response); } async send(message, payload, state) { if (payload) message.payload = payload; if (state) message.state = state; if (this._connectionManager.isConnected()) { return this._inner.send(message); } if (message.state === MessageState.Request) { return this._handleDisconnectedCall(message); } return this._bufferMessage(message); } async shutdown() { this._clearAllCallTimeouts(); this._buffer = []; await this._inner.shutdown(); } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- /** * Starts a `maxCallLengthSeconds` timer for a Call that cannot be delivered * because the broker is down. On expiry the optional {@link onCallTimeout} * callback is invoked and the timeout entry is cleaned up. * * Returns `{ success: true }` so the router does not immediately send a * CallError – the charger will wait until the connection is closed by the timer. */ _handleDisconnectedCall(message) { const { stationId, tenantId } = message.context; const identifier = `${tenantId}:${stationId}`; // If a timeout for this identifier is already running, let it be – a second // Call from the same station while the first is still pending would be // blocked by the router's cache serialisation anyway. if (this._callTimeouts.has(identifier)) { this._logger.debug(`BrokerAwareMessageSender: Call timeout already running for ${identifier}, skipping duplicate.`); return { success: true }; } this._logger.warn(`BrokerAwareMessageSender: broker down – Call for ${identifier} will close WS ` + `in ${this._maxCallLengthSeconds}s if broker does not recover.`); const handle = setTimeout(() => { this._callTimeouts.delete(identifier); this._logger.warn(`BrokerAwareMessageSender: Call timeout expired for ${identifier} – closing connection.`); if (this.onCallTimeout) { this.onCallTimeout(stationId, tenantId).catch((err) => { this._logger.error(`BrokerAwareMessageSender: error closing connection for ${identifier}`, err); }); } }, this._maxCallLengthSeconds * 1000); this._callTimeouts.set(identifier, handle); // Tell the router the message was "accepted" – the timer handles teardown. return { success: true }; } /** Adds a non-Call message to the in-memory buffer. */ _bufferMessage(message) { this._logger.info(`BrokerAwareMessageSender: broker down – buffering message ` + `(state=${message.state}) for ${message.context.stationId}.`); this._buffer.push(message); return { success: true }; } /** * Replays all buffered messages through the inner sender. * If the broker drops again mid-flush the remaining messages are re-buffered. */ async _flushBuffer() { if (this._buffer.length === 0) return; this._logger.info(`BrokerAwareMessageSender: broker reconnected – flushing ${this._buffer.length} buffered message(s).`); const toFlush = this._buffer.splice(0); for (const message of toFlush) { if (!this._connectionManager.isConnected()) { // Broker dropped again – re-buffer the rest and stop. this._logger.warn('BrokerAwareMessageSender: broker disconnected again during flush – re-buffering remainder.'); this._buffer.unshift(...toFlush.slice(toFlush.indexOf(message))); return; } try { const result = await this._inner.send(message); if (!result.success) { this._logger.error(`BrokerAwareMessageSender: failed to flush message for ${message.context.stationId}:`, result.payload); } } catch (err) { this._logger.error(`BrokerAwareMessageSender: error flushing message for ${message.context.stationId}:`, err); } } } _clearAllCallTimeouts() { for (const handle of this._callTimeouts.values()) { clearTimeout(handle); } this._callTimeouts.clear(); } } //# sourceMappingURL=BrokerAwareMessageSender.js.map