@suissa/universal-queues
Version:
Factory universal para mensageria (RabbitMQ, Kafka, SQS) para sistemas distribuídos.
262 lines (232 loc) • 10.5 kB
text/typescript
// 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) ----------
(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 {}
}
}