UNPKG

kafka-pub-sub

Version:

Enterprise-grade Kafka publish/subscribe library for Node.js — producer pool, batch sending, DLQ, SSL/SASL, multi-broker, and real-world industry examples.

224 lines (197 loc) 7.5 kB
const { CompressionTypes, Partitioners } = require('kafkajs'); const kafka = require('./config/kafka'); const config = require('./config/config'); const validateTopic = require('./validation/validateTopic'); // ── Internal helpers ────────────────────────────────────────────────────────── const sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }); /** * Decode all header values from Buffer to string. */ const decodeHeaders = (raw) => Object.keys(raw).reduce((acc, key) => { acc[key] = Buffer.isBuffer(raw[key]) ? raw[key].toString() : raw[key]; return acc; }, {}); /** * Parse a raw KafkaJS message into a clean data object. */ const parseMessage = (topic, partition, message) => ({ topic, partition, offset: message.offset, timestamp: message.timestamp, key: message.key ? message.key.toString() : null, value: message.value ? JSON.parse(message.value.toString()) : null, headers: message.headers ? decodeHeaders(message.headers) : {}, }); // ── DLQ producer (module-level singleton shared across all consumers) ───────── // // The DLQ producer is intentionally shared so we keep a single broker connection // regardless of how many consumers are running. It is only disconnected on // process exit — never inside an individual consumer's stop() — so that stopping // one consumer does not break DLQ routing for the others. let dlqProducer = null; let dlqConnected = false; const getDlqProducer = async () => { if (!dlqProducer) { dlqProducer = kafka.producer({ createPartitioner: Partitioners.DefaultPartitioner, allowAutoTopicCreation: true, }); } if (!dlqConnected) { await dlqProducer.connect(); dlqConnected = true; } return dlqProducer; }; /** * Cleanly disconnect the shared DLQ producer. * Called once at process exit, not inside individual consumer stop() functions. */ const disconnectDlqProducer = async () => { if (dlqConnected && dlqProducer) { await dlqProducer.disconnect(); dlqConnected = false; dlqProducer = null; } }; const sendToDLQ = async (originalTopic, rawMessage, error) => { try { const producer = await getDlqProducer(); await producer.send({ topic: `${originalTopic}.dlq`, compression: CompressionTypes.None, messages: [ { key: rawMessage.key, value: rawMessage.value, headers: { ...(rawMessage.headers || {}), 'dlq-original-topic': originalTopic, 'dlq-failed-at': new Date().toISOString(), 'dlq-error': error.message || String(error), }, }, ], }); } catch (dlqError) { // DLQ failure must not crash the consumer — log and continue // eslint-disable-next-line no-console console.error('[kafka-pub-sub] Failed to send message to DLQ:', dlqError.message); } }; // Disconnect the DLQ producer when the process exits process.once('SIGTERM', disconnectDlqProducer); process.once('SIGINT', disconnectDlqProducer); // ── ConsumeEvent ───────────────────────────────────────────────────────────── /** * Subscribes to a Kafka topic and invokes a handler for every incoming message. * * Key improvements over v1: * - Callback-based: handler is called for every message (not just the first). * - Automatic retry with exponential back-off on handler failure. * - Dead-letter queue (DLQ): messages that exhaust retries are routed to * `<topic>.dlq` so they are never silently dropped. * - Returns a `stop()` function for graceful, orderly shutdown. * - Heartbeat is sent after each message to prevent consumer group rebalancing. * - Multiple consumers can run in parallel safely — each has its own connection, * but they share a single DLQ producer for efficiency. * * @param {string} topic - Kafka topic name to subscribe to. * @param {Function} handler - `async (message) => void`. Receives a parsed message object. * @param {Object} [options={}] * @param {boolean} [options.fromBeginning=false] - Consume from earliest offset. * @param {number} [options.retry=3] - Max handler retries before DLQ. * @param {boolean} [options.dlq=true] - Route exhausted messages to DLQ. * @param {number} [options.sessionTimeout] - Override consumer session timeout (ms). * @param {number} [options.heartbeatInterval] - Override heartbeat interval (ms). * @param {string} [options.groupId] - Override consumer group ID. * @return {Promise<Function>} Resolves to a `stop()` async function. * * @example — basic usage * const stop = await ConsumeEvent('order.placed', async (msg) => { * console.log('New order:', msg.value.data); * }); * // Later, during shutdown: * await stop(); * * @example — multiple consumers in parallel (safe) * const [stopA, stopB] = await Promise.all([ * ConsumeEvent('order.placed', handlerA, { groupId: 'svc-a' }), * ConsumeEvent('payment.transaction', handlerB, { groupId: 'svc-b' }), * ]); * // Stopping one does NOT affect the other's DLQ routing * await stopA(); */ const ConsumeEvent = async (topic, handler, options = {}) => { if (typeof handler !== 'function') { throw new Error('ConsumeEvent requires a handler function as the second argument'); } validateTopic(topic); const { fromBeginning = false, retry = 3, dlq = true, sessionTimeout = config.kafka_session_timeout, heartbeatInterval = config.kafka_heartbeat_interval, groupId = config.kafka_group_id, } = options; const consumer = kafka.consumer({ groupId, sessionTimeout, heartbeatInterval, maxWaitTimeInMs: config.kafka_max_wait_time, }); await consumer.connect(); await consumer.subscribe({ topics: [topic], fromBeginning }); // eslint-disable-next-line no-shadow await consumer.run({ // eslint-disable-next-line no-shadow eachMessage: async ({ topic: msgTopic, partition, message, heartbeat }) => { const parsed = parseMessage(msgTopic, partition, message); let attempt = 0; let lastError; while (attempt < retry) { try { // eslint-disable-next-line no-await-in-loop await handler(parsed); lastError = null; break; } catch (err) { attempt += 1; lastError = err; if (attempt < retry) { // Exponential back-off: 100ms, 200ms, 400ms, … // eslint-disable-next-line no-await-in-loop await sleep(100 * 2 ** (attempt - 1)); } } } if (lastError) { if (dlq) { await sendToDLQ(msgTopic, message, lastError); } else { throw lastError; // surface to KafkaJS — it will pause and retry at broker level } } await heartbeat(); }, }); /** * Gracefully disconnect THIS consumer only. * The shared DLQ producer stays connected until process exit. * @return {Promise<void>} */ const stop = async () => { await consumer.disconnect(); }; // Auto-stop on process signals process.once('SIGTERM', stop); process.once('SIGINT', stop); return stop; }; module.exports = ConsumeEvent;