UNPKG

@syntropylog/adapters

Version:
388 lines (379 loc) 15.1 kB
'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