UNPKG

@suissa/universal-queues

Version:

Factory universal para mensageria (RabbitMQ, Kafka, SQS) para sistemas distribuídos.

262 lines (232 loc) 10.5 kB
// src/rabbitmq.ts import * as amqp from 'amqplib'; import { IMessaging } from './interfaces/IMessaging'; import { Retry } from './decorators/retry'; import { promises as fs } from 'fs'; import { createHash, randomUUID } from 'crypto'; // ---------- utils mínimos ---------- class TimeoutError extends Error { constructor(msg = 'Operation timed out') { super(msg); this.name = 'TimeoutError'; } } function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T> { return new Promise<T>((resolve, reject) => { const t = setTimeout(() => reject(new TimeoutError(`${label} timeout after ${ms}ms`)), ms); p.then(v => { clearTimeout(t); resolve(v); }, e => { clearTimeout(t); reject(e); }); }); } function enrichHeaders(exchange: string, routingKey: string, message: object, headers: Record<string, any> = {}) { const eventId = headers['x-event-id'] ?? randomUUID(); const hash = createHash('sha256').update(JSON.stringify(message)).digest('hex'); const origin = `${exchange}.${routingKey}`; return { headers: { ...headers, 'x-event-id': eventId, 'x-event-hash': hash, 'x-origin': origin }, eventId, hash, origin }; } // ---------- opções (ENV opcionais) ---------- type RabbitClientOpts = { prefetch?: number; operationTimeoutMs?: number; uris?: string[]; confirm?: boolean; forensicPath?: string; // arquivo NDJSON p/ ZoombieQ resolveOnFallback?: boolean; // se true, publish NÃO lança ao cair no fallback (ainda retorna void) }; const envURIs = process.env.RABBITMQ_URIS?.split(',').map(s => s.trim()).filter(Boolean); const envTimeout = process.env.UNIVERSAL_QUEUES_OPERATION_TIMEOUT_MS ? Number(process.env.UNIVERSAL_QUEUES_OPERATION_TIMEOUT_MS) : undefined; const envConfirm = process.env.RABBITMQ_CONFIRM ? /^(1|true|yes)$/i.test(process.env.RABBITMQ_CONFIRM) : undefined; const envResolveOnFallback = process.env.UNIVERSAL_QUEUES_RESOLVE_ON_FALLBACK ? /^(1|true|yes)$/i.test(process.env.UNIVERSAL_QUEUES_RESOLVE_ON_FALLBACK) : undefined; export class RabbitMQClient implements IMessaging { private connection!: amqp.Connection; private channel!: amqp.Channel | amqp.ConfirmChannel; private prefetch: number; private operationTimeoutMs: number; private uris: string[]; private confirm: boolean; private forensicPath?: string; private resolveOnFallback: boolean; constructor(opts: RabbitClientOpts = {}) { this.prefetch = opts.prefetch ?? 0; this.operationTimeoutMs = opts.operationTimeoutMs ?? envTimeout ?? 5000; this.uris = opts.uris ?? envURIs ?? []; this.confirm = opts.confirm ?? envConfirm ?? true; this.forensicPath = opts.forensicPath ?? process.env.UNIVERSAL_QUEUES_FORENSIC_PATH; this.resolveOnFallback = opts.resolveOnFallback ?? envResolveOnFallback ?? false; } // ---------- CONNECT (timeout + failover) ---------- async connect(uri: string): Promise<void> { const candidates = this.uris.length ? this.uris : [uri]; let lastErr: unknown; for (const candidate of candidates) { try { // Alguns setups de TS “deduzem” ChannelModel aqui. Forço a assinatura correta com um cast. const connectFn = amqp.connect as unknown as (url: string) => Promise<amqp.Connection>; this.connection = await withTimeout( connectFn(candidate), this.operationTimeoutMs, `connect(${candidate})` ); // Guard de existência para createConfirmChannel (algumas definições antigas não expõem) const hasConfirm = this.confirm && typeof (this.connection as any).createConfirmChannel === 'function'; if (hasConfirm) { this.channel = await withTimeout( (this.connection as any).createConfirmChannel() as Promise<amqp.ConfirmChannel>, this.operationTimeoutMs, 'createConfirmChannel' ); } else { this.channel = await withTimeout( this.connection.createChannel(), this.operationTimeoutMs, 'createChannel' ); } if (this.prefetch > 0) { await withTimeout(this.channel.prefetch(this.prefetch), this.operationTimeoutMs, 'prefetch'); } const close = async () => { try { await this.close(); } catch {} process.exit(0); }; process.once('SIGINT', close); process.once('SIGTERM', close); return; // conectado } catch (e) { lastErr = e; } } throw lastErr; } // ---------- PUBLISH (timeout + confirm + ZoombieQ) ---------- @Retry(2, 250) async publishEvent( exchange: string, routingKey: string, message: object, headers: Record<string, any> = {} ): Promise<void> { const env = enrichHeaders(exchange, routingKey, message, headers); try { await withTimeout( this.channel.assertExchange(exchange, 'topic', { durable: true }), this.operationTimeoutMs, 'assertExchange' ); const ok = this.channel.publish( exchange, routingKey, Buffer.from(JSON.stringify(message)), { headers: env.headers, persistent: true } ); const waitForConfirms = (this.channel as amqp.ConfirmChannel).waitForConfirms; if (typeof waitForConfirms === 'function') { await withTimeout( waitForConfirms.call(this.channel), this.operationTimeoutMs, 'waitForConfirms' ); } else if (!ok) { throw new TimeoutError('publish backpressure (no confirm)'); } } catch (error) { await this.runFallback('ZoombieQ', { exchange, routingKey, message, headers: env.headers, error }); if (this.resolveOnFallback) return; // contrato IMessaging: Promise<void> throw error; } } // ---------- SUBSCRIBE ---------- async subscribeToEvent( exchange: string, queue: string, routingKey: string, handler: (msg: any) => void ): Promise<void> { await this.channel.assertExchange(exchange, 'topic', { durable: true }); await this.channel.assertQueue(queue, { durable: true, deadLetterExchange: `${exchange}.dlq` }); await this.channel.bindQueue(queue, exchange, routingKey); this.channel.consume(queue, (msg: amqp.ConsumeMessage | null) => { if (!msg) return; try { const content = JSON.parse(msg.content.toString()); handler(content); this.ackMessage(msg); } catch { this.nackMessage(msg); } }); } // ---------- FANOUT ---------- async publishToFanout(exchange: string, message: object): Promise<void> { await this.channel.assertExchange(exchange, 'fanout', { durable: true }); this.channel.publish(exchange, '', Buffer.from(JSON.stringify(message)), { persistent: true }); const waitForConfirms = (this.channel as amqp.ConfirmChannel).waitForConfirms; if (typeof waitForConfirms === 'function') { await withTimeout( waitForConfirms.call(this.channel), this.operationTimeoutMs, 'waitForConfirms(fanout)' ); } } async subscribeToFanout(exchange: string, handler: (msg: any) => void): Promise<void> { await this.channel.assertExchange(exchange, 'fanout', { durable: true }); const q = await this.channel.assertQueue('', { exclusive: true }); await this.channel.bindQueue(q.queue, exchange, ''); this.channel.consume(q.queue, (msg: amqp.ConsumeMessage | null) => { if (!msg) return; handler(JSON.parse(msg.content.toString())); this.ackMessage(msg); }); } // ---------- OUTBOX + DLQ ---------- async publishToOutbox(event: object): Promise<void> { // TODO: DB transacional + UNIQUE(eventId,hash) p/ dedup console.error('[OUTBOX]', JSON.stringify(event)); } async handleDeadLetter(dlqExchange: string, dlqQueue: string, handler: (msg: any) => void): Promise<void> { await this.channel.assertExchange(dlqExchange, 'fanout', { durable: true }); await this.channel.assertQueue(dlqQueue, { durable: true }); await this.channel.bindQueue(dlqQueue, dlqExchange, ''); this.channel.consume(dlqQueue, (msg: amqp.ConsumeMessage | null) => { if (!msg) return; handler(JSON.parse(msg.content.toString())); this.ackMessage(msg); }); } // ---------- ACK/NACK ---------- ackMessage(msg: amqp.ConsumeMessage): void { this.channel.ack(msg); } nackMessage(msg: amqp.ConsumeMessage, requeue = false): void { this.channel.nack(msg, false, requeue); } // ---------- CLOSE ---------- async close(): Promise<void> { try { if (this.channel) await this.channel.close(); } catch {} try { if (this.connection) await this.connection.close(); } catch {} } // ---------- Fallback genérico (ZoombieQ = DLQ + Outbox + Log) ---------- private async runFallback( mode: 'DLQ' | 'Outbox' | 'ZoombieQ', ctx: { exchange: string; routingKey: string; message: object; headers: Record<string, any>; error: unknown; } ): Promise<void> { const record = { eventId: ctx.headers['x-event-id'], hash: ctx.headers['x-event-hash'], origin: ctx.headers['x-origin'], reason: ctx.error instanceof Error ? `${ctx.error.name}:${ctx.error.message}` : String(ctx.error), ts: new Date().toISOString(), payload: ctx.message, headers: ctx.headers, }; const toDLQ = async () => { const dlq = `${ctx.exchange}.dlq`; try { await this.publishToFanout(dlq, record); } catch (e) { await this.appendForensic({ ...record, sink: 'DLQ_ERROR', err: String(e) }); } }; const toOutbox = async () => { try { await this.publishToOutbox(record); } catch (e) { await this.appendForensic({ ...record, sink: 'OUTBOX_ERROR', err: String(e) }); } }; const toForensic = async () => { await this.appendForensic({ ...record, sink: 'FORENSIC' }); }; if (mode === 'DLQ') await toDLQ(); else if (mode === 'Outbox') await toOutbox(); else await Promise.allSettled([toDLQ(), toOutbox(), toForensic()]); } private async appendForensic(line: object): Promise<void> { if (!this.forensicPath) return; try { await fs.appendFile(this.forensicPath, JSON.stringify(line) + '\n', 'utf8'); } catch {} } }