@syntropylog/adapters
Version:
External adapters for SyntropyLog framework
388 lines (379 loc) • 15.1 kB
JavaScript
'use strict';
var amqplib = require('amqplib');
var nats = require('nats');
function _interopNamespaceDefault(e) {
var n = Object.create(null);
if (e) {
Object.keys(e).forEach(function (k) {
if (k !== 'default') {
var d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: function () { return e[k]; }
});
}
});
}
n.default = e;
return Object.freeze(n);
}
var amqplib__namespace = /*#__PURE__*/_interopNamespaceDefault(amqplib);
/**
* Utility class for handling payload serialization/deserialization
* across different broker adapters.
*/
class PayloadSerializer {
/**
* Serializes a BrokerMessage payload for sending to a broker.
* Handles Buffer objects by extracting their JSON content.
*/
static serializeForBroker(message) {
let payloadToSend;
if (Buffer.isBuffer(message.payload)) {
// If it's already a Buffer, decode it as JSON and re-encode
try {
const jsonString = message.payload.toString();
payloadToSend = JSON.parse(jsonString);
}
catch {
// If it's not valid JSON, send as string
payloadToSend = message.payload.toString();
}
}
else {
payloadToSend = message.payload;
}
return JSON.stringify(payloadToSend);
}
/**
* Deserializes a payload received from a broker into a Buffer
* that the SyntropyLog framework expects.
*/
static deserializeFromBroker(brokerPayload) {
if (!brokerPayload) {
return Buffer.alloc(0);
}
try {
// Convert to string if it's a Buffer
const jsonString = Buffer.isBuffer(brokerPayload)
? brokerPayload.toString()
: brokerPayload;
// Parse the JSON
const parsedPayload = JSON.parse(jsonString);
// Return as Buffer with JSON stringified content
return Buffer.from(JSON.stringify(parsedPayload));
}
catch {
// If it's not valid JSON, return as Buffer
return Buffer.isBuffer(brokerPayload)
? brokerPayload
: Buffer.from(brokerPayload);
}
}
/**
* Creates a BrokerMessage with properly deserialized payload
*/
static createBrokerMessage(brokerPayload, headers) {
return {
payload: this.deserializeFromBroker(brokerPayload),
headers: headers || {},
};
}
}
/**
* Helper function to normalize Kafka's complex IHeaders object into
* the simple Record<string, string | Buffer> that our framework expects.
* @param headers The headers object from a Kafka message.
* @returns A normalized headers object.
*/
function normalizeKafkaHeaders(headers) {
if (!headers) {
return undefined;
}
const normalized = {};
for (const key in headers) {
if (Object.prototype.hasOwnProperty.call(headers, key)) {
const value = headers[key];
// We only accept string or Buffer, and we discard undefined or arrays for simplicity.
if (typeof value === 'string' || Buffer.isBuffer(value)) {
normalized[key] = value;
}
}
}
return normalized;
}
class KafkaAdapter {
// The constructor now receives the Kafka instance already created.
// This makes it more flexible and easier to test.
constructor(kafkaInstance, groupId) {
this.producer = kafkaInstance.producer();
this.consumer = kafkaInstance.consumer({ groupId });
}
async connect() {
await this.producer.connect();
await this.consumer.connect();
}
async disconnect() {
await this.producer.disconnect();
await this.consumer.disconnect();
}
async publish(topic, message) {
const serializedPayload = PayloadSerializer.serializeForBroker(message);
await this.producer.send({
topic,
messages: [{ value: serializedPayload, headers: message.headers }],
});
}
async subscribe(topic, handler) {
await this.consumer.subscribe({ topic, fromBeginning: true });
await this.consumer.run({
eachMessage: async ({ topic, partition, message }) => {
try {
const brokerMessage = PayloadSerializer.createBrokerMessage(message.value, normalizeKafkaHeaders(message.headers));
const controls = {
ack: async () => {
await this.consumer.commitOffsets([
{
topic,
partition,
offset: (Number(message.offset) + 1).toString(),
},
]);
},
nack: async () => {
// Nacking in Kafka is complex. For now, we just log.
// A real implementation might move the message to a dead-letter queue.
console.log(`NACK received for message on topic ${topic}.`);
},
};
await handler(brokerMessage, controls);
}
catch (err) {
// If there's an error (e.g., JSON parsing), we can't process the message,
// but we don't want to crash the whole service. We'll log it.
// A more robust implementation might publish to a dead-letter queue.
console.error(`Failed to process message from topic ${topic}`, err);
}
},
});
}
}
class RabbitMQAdapter {
constructor(connectionString, exchangeName = 'topic_logs') {
this.connection = null;
this.channel = null;
this.consumerTags = new Map();
this.connectionString = connectionString;
this.exchangeName = exchangeName;
}
async connect() {
this.connection = await amqplib__namespace.connect(this.connectionString);
if (!this.connection) {
throw new Error('Failed to connect to RabbitMQ');
}
this.channel = await this.connection.createChannel();
if (!this.channel) {
throw new Error('Failed to create RabbitMQ channel');
}
await this.channel.assertExchange(this.exchangeName, 'topic', { durable: true });
}
async disconnect() {
try {
// Cancel all active consumers first
if (this.channel && this.consumerTags.size > 0) {
for (const [topic, consumerTag] of this.consumerTags) {
try {
await this.channel.cancel(consumerTag);
console.log(`✅ Cancelled consumer for topic: ${topic}`);
}
catch (error) {
console.warn(`⚠️ Error cancelling consumer for topic ${topic}:`, error);
}
}
this.consumerTags.clear();
}
// Close channel
if (this.channel) {
await this.channel.close();
}
// Close connection
if (this.connection) {
await this.connection.close();
}
}
catch (error) {
console.error('Error during RabbitMQ disconnection:', error);
}
finally {
this.channel = null;
this.connection = null;
}
}
async publish(topic, message) {
if (!this.channel) {
throw new Error('RabbitMQ channel is not available. Please connect first.');
}
const routingKey = topic;
const serializedPayload = PayloadSerializer.serializeForBroker(message);
const content = Buffer.from(serializedPayload);
const options = {
headers: message.headers || {},
persistent: true,
};
this.channel.publish(this.exchangeName, routingKey, content, options);
}
async subscribe(topic, handler) {
if (!this.channel) {
throw new Error('RabbitMQ channel is not available. Please connect first.');
}
const q = await this.channel.assertQueue('', { exclusive: true });
await this.channel.bindQueue(q.queue, this.exchangeName, topic);
const { consumerTag } = await this.channel.consume(q.queue, async (msg) => {
if (msg && this.channel) {
try {
const brokerMessage = PayloadSerializer.createBrokerMessage(msg.content, msg.properties.headers);
const ack = async () => this.channel.ack(msg);
const nack = async (requeue = false) => this.channel.nack(msg, false, requeue);
await handler(brokerMessage, { ack, nack });
}
catch (err) {
// If there's an error (e.g., JSON parsing), we can't process the message,
// but we don't want to crash the whole service. We'll log it.
// A more robust implementation might publish to a dead-letter queue.
console.error(`Failed to process message from topic ${topic}`, err);
// Nack the message to prevent infinite retries
this.channel.nack(msg, false, false);
}
}
}, { noAck: false });
this.consumerTags.set(topic, consumerTag);
}
async unsubscribe(topic) {
if (!this.channel) {
throw new Error('RabbitMQ channel is not available.');
}
const consumerTag = this.consumerTags.get(topic);
if (consumerTag) {
await this.channel.cancel(consumerTag);
this.consumerTags.delete(topic);
}
else {
console.warn(`No active subscription found for topic: ${topic}`);
}
}
}
class NatsAdapter {
constructor(natsServers = ['nats://localhost:4222']) {
this.natsConnection = null;
this.codec = nats.JSONCodec();
this.subscriptions = new Map();
this.natsServers = natsServers;
}
async connect() {
this.natsConnection = await nats.connect({
servers: this.natsServers,
});
}
async disconnect() {
if (this.natsConnection) {
// Unsubscribe from all topics first
if (this.subscriptions.size > 0) {
for (const [topic, subscription] of this.subscriptions) {
try {
subscription.unsubscribe();
console.log(`✅ Cancelled NATS subscription for topic: ${topic}`);
}
catch (error) {
console.warn(`⚠️ Error cancelling subscription for topic ${topic}:`, error);
}
}
this.subscriptions.clear();
}
await this.natsConnection.drain();
this.natsConnection.close();
this.natsConnection = null;
}
}
async publish(topic, message) {
if (!this.natsConnection) {
throw new Error('NATS connection is not available. Please connect first.');
}
const serializedPayload = PayloadSerializer.serializeForBroker(message);
const natsHeaders = this.recordToNatsHeaders(message.headers);
await this.natsConnection.publish(topic, this.codec.encode(JSON.parse(serializedPayload)), { headers: natsHeaders });
}
async subscribe(topic, handler) {
if (!this.natsConnection) {
throw new Error('NATS connection is not available. Please connect first.');
}
const subscription = this.natsConnection.subscribe(topic);
(async () => {
for await (const msg of subscription) {
try {
// Decode the JSON payload from NATS
const decodedPayload = this.codec.decode(msg.data);
const headers = this.natsHeadersToRecord(msg.headers);
const brokerMessage = PayloadSerializer.createBrokerMessage(Buffer.from(JSON.stringify(decodedPayload)), headers);
const controls = {
ack: async () => {
// NATS doesn't require explicit ack for most use cases
// but we can implement it if needed
},
nack: async () => {
// NATS doesn't have a built-in nack mechanism
// but we can implement custom logic if needed
console.log(`NACK received for message on topic ${topic}.`);
},
};
await handler(brokerMessage, controls);
}
catch (err) {
// If there's an error (e.g., JSON parsing), we can't process the message,
// but we don't want to crash the whole service. We'll log it.
// A more robust implementation might publish to a dead-letter queue.
console.error(`Failed to process message from topic ${topic}`, err);
}
}
})().catch(console.error);
this.subscriptions.set(topic, subscription);
}
async unsubscribe(topic) {
if (!this.natsConnection) {
throw new Error('NATS connection is not available.');
}
const subscription = this.subscriptions.get(topic);
if (subscription) {
subscription.unsubscribe();
this.subscriptions.delete(topic);
console.log(`✅ Unsubscribed from NATS topic: ${topic}`);
}
else {
console.warn(`No active subscription found for topic: ${topic}`);
}
}
natsHeadersToRecord(natsHeaders) {
if (!natsHeaders) {
return undefined;
}
const record = {};
// NATS headers are iterable but don't have .entries() method
for (const [key, value] of natsHeaders) {
record[key] = value;
}
return record;
}
recordToNatsHeaders(record) {
if (!record) {
return undefined;
}
const natsHeaders = nats.headers();
for (const [key, value] of Object.entries(record)) {
natsHeaders.set(key, String(value));
}
return natsHeaders;
}
}
exports.KafkaAdapter = KafkaAdapter;
exports.NatsAdapter = NatsAdapter;
exports.RabbitMQAdapter = RabbitMQAdapter;
//# sourceMappingURL=index.cjs.map