UNPKG

@proca/queue

Version:

This package provides a **robust RabbitMQ consumer** for processing Proca **action** and **event** messages with strict retry, dead-letter, and crash semantics.

284 lines (278 loc) 9.38 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var src_exports = {}; __export(src_exports, { actionMessageV1to2: () => actionMessageV1to2, connect: () => connect, count: () => count, listenConnection: () => listenConnection, pause: () => pause, syncQueue: () => syncQueue }); module.exports = __toCommonJS(src_exports); // src/queue.ts var import_crypto = require("@proca/crypto"); var import_rabbitmq_client = require("rabbitmq-client"); // src/actionMessage.ts var actionMessageV1to2 = (a1) => { let pii = {}; let personalInfo = null; if (a1.contact.nonce && a1.contact.publicKey && a1.contact.signKey) { personalInfo = { payload: a1.contact.payload, nonce: a1.contact.nonce, encryptKey: { id: 0, // XXX no id info here! public: a1.contact.publicKey }, signKey: { id: 0, // XXX no id info here! public: a1.contact.signKey } }; } else { pii = JSON.parse(a1.contact.payload); } const a2 = { schema: "proca:action:2", stage: a1.stage, actionId: a1.actionId, actionPage: a1.actionPage, actionPageId: a1.actionPageId, campaign: a1.campaign, campaignId: a1.campaignId, orgId: 1, org: { name: "", title: "" }, action: { actionType: a1.action.actionType, createdAt: a1.action.createdAt, customFields: a1.action.fields, testing: false }, contact: { contactRef: a1.contact.ref, firstName: "", email: "", area: a1.contact.area, ...pii }, personalInfo, tracking: a1.tracking, privacy: a1.privacy && { withConsent: true, optIn: a1.privacy.communication, givenAt: a1.privacy.givenAt, emailStatus: null, emailStatusChanged: null } }; return a2; }; // src/queue.ts var import_os = __toESM(require("os")); var connection = null; var consumer = null; var listeners = []; var listenConnection = (fct) => listeners.push(fct); var count = { queued: void 0, ack: 0, nack: 0 }; async function exitHandler(evtOrExitCodeOrError) { try { if (connection) { console.log( "closing after processing", count.ack, "and rejecting", count.nack ); await (consumer == null ? void 0 : consumer.close()); await (connection == null ? void 0 : connection.close()); } console.log("closed, exit now"); process.exit(isNaN(+evtOrExitCodeOrError) ? 0 : +evtOrExitCodeOrError); } catch (e) { console.error("EXIT HANDLER ERROR", e); process.exit(isNaN(+evtOrExitCodeOrError) ? 1 : +evtOrExitCodeOrError); } } var connect = (queueUrl) => { const rabbit = new import_rabbitmq_client.Connection(queueUrl); connection = rabbit; rabbit.on("error", (err) => { console.log("RabbitMQ connection error", err); }); rabbit.on("connection", () => { var _a; console.log( `Connection successfully (re)established, ${(_a = consumer == null ? void 0 : consumer.stats) == null ? void 0 : _a.initialMessageCount} messages in the queue` ); listeners.forEach((d) => d(connection)); }); if (!process.listenerCount("SIGINT")) { process.once("SIGINT", exitHandler); } ["uncaughtException", "unhandledRejection", "SIGTERM"].forEach((evt) => { if (!process.listenerCount(evt)) { process.on(evt, exitHandler); } }); return rabbit; }; var requeueOnceOrDrop = (message, reason = "unknown reason") => { console.error(reason); count.nack++; if (message.redelivered) { console.error("already requeued, push to dead-letter"); return import_rabbitmq_client.ConsumerStatus.DROP; } return import_rabbitmq_client.ConsumerStatus.REQUEUE; }; var isPositiveInt = (value, name = "value") => { if (!Number.isInteger(value) || value <= 0) { throw new Error(`${name} must be a positive integer (> 0), got ${value}`); } return value; }; var syncQueue = async (queueUrl, queueName, syncer, opts) => { const concurrency = (opts == null ? void 0 : opts.concurrency) ? isPositiveInt(opts.concurrency, "concurrency") : isPositiveInt(1, "concurrency"); const prefetch = (opts == null ? void 0 : opts.prefetch) ? isPositiveInt(opts.prefetch, "prefetch") : isPositiveInt(2 * concurrency, "prefetch"); const maxRetries = (opts == null ? void 0 : opts.maxRetries) ? isPositiveInt(opts.maxRetries, "maxRetries") : null; const rabbit = await connect(queueUrl); const tag = import_os.default.hostname() + "." + ((opts == null ? void 0 : opts.tag) ? opts.tag : process.env.npm_package_name); const sub = rabbit.createConsumer( { queue: queueName, requeue: false, noAck: false, queueOptions: { passive: true }, concurrency, consumerTag: tag, qos: { prefetchCount: prefetch } }, async (message) => { var _a, _b, _c; if (maxRetries) { const deaths = ((_c = (_b = (_a = message.headers) == null ? void 0 : _a["x-death"]) == null ? void 0 : _b[0]) == null ? void 0 : _c.count) ?? 0; if (deaths > maxRetries) { console.error( `retry limit exceeded (${deaths} > ${maxRetries}) \u2014 ACK and drop` ); count.ack++; return import_rabbitmq_client.ConsumerStatus.ACK; } } let msg; try { msg = JSON.parse(message.body.toString()); } catch { return requeueOnceOrDrop( message, `invalid JSON payload, cannot parse ${message.body.toString().slice(0, 512)}` ); } if ((msg == null ? void 0 : msg.schema) !== "proca:action:2" && (msg == null ? void 0 : msg.schema) !== "proca:event:2") { return requeueOnceOrDrop( message, `unknown schema "${(msg == null ? void 0 : msg.schema) || JSON.stringify(msg).slice(0, 512)}"` ); } if (msg.schema === "proca:action:2") { if (msg.campaign) msg.campaign.id = msg.campaignId; if (msg.action) msg.action.id = msg.actionId; if (msg.org) msg.org.id = msg.orgId; if (msg.actionPage) msg.actionPage.id = msg.actionPageId; if (msg.personalInfo && (opts == null ? void 0 : opts.keyStore)) { const plainPII = (0, import_crypto.decryptPersonalInfo)(msg.personalInfo, opts.keyStore); msg.contact = { ...msg.contact, ...plainPII }; } } if (msg.schema === "proca:event:2") { if (msg.campaign) msg.campaign.id = msg.campaignId; if (msg.action) msg.action.id = msg.actionId; if (msg.actionPage) msg.actionPage.id = msg.actionPageId; } try { const result = await syncer(msg); if (result === true) { count.ack++; return import_rabbitmq_client.ConsumerStatus.ACK; } if (result === false) { return requeueOnceOrDrop( message, `syncer returned false for message ${(msg == null ? void 0 : msg.actionId) ?? JSON.stringify(msg).slice(0, 512)}` ); } throw new Error( `syncer must return boolean, got: ${JSON.stringify(result)}` ); } catch (e) { console.error("fatal error processing:", e); await exitHandler(e instanceof Error ? e : String(e)); throw e; } } ); sub.on("error", (err) => { console.log("rabbit error", err); }); consumer = sub; return { close: async () => { await sub.close(); await rabbit.close(); } }; }; // src/utils.ts var pause = (time) => { const min = time && time > 2 ? time / 2 : 1; const max = time ? time * 2 : 2; time = Math.floor(Math.random() * (max - min + 1) + min) * 1e3; console.log("wait", time); return new Promise((resolve) => setTimeout(() => resolve(time), time)); }; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { actionMessageV1to2, connect, count, listenConnection, pause, syncQueue });