@citrineos/util
Version:
The OCPP util module which supplies helpful utilities like cache and queue connectors, etc.
157 lines • 7.36 kB
JavaScript
// 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