@eco-foundation/eco-messaging

中文文档
3.0.4发布于 2026/09/26MITTypeScriptCommonJS零依赖近 30 天下载 2013

Hardened NestJS AMQP transport: confirms, outbox, idempotent consume, topology as code

原始使用文档
文档目录9 节
  1. The guarantee
  2. Wiring it up
  3. Publishing
  4. Consuming
  5. Readiness
  6. Observability
  7. Subpath exports
  8. Upgrading from 1.x
  9. Documentation

@eco-foundation/eco-messaging

A hardened NestJS AMQP transport for Eco's services: connection lifecycle, topology as code, a confirm-based publisher, a transactional outbox with relay, and an idempotent consumer host. Extracted so that correct publish/consume behaviour is the default rather than something each service re-derives and re-reviews on its own.

pnpm add @eco-foundation/eco-messaging @eco-foundation/eco-message-contracts

@eco-foundation/eco-message-contracts is a peer, not a dependency, so installing this package alone leaves an unmet peer — install both explicitly. The remaining peers are things a NestJS service already has, and are peers so this library never pulls a second copy of your framework or AMQP client into the tree: @nestjs/common (^10 || ^11), @nestjs/core (^10.2 || ^11), amqplib (^0.10), amqp-connection-manager (^5), and zod (^3.25 || ^4). @opentelemetry/api and testcontainers are optional peers, needed only if you wire OTel or import the /testing subpath.

Note the zod floor: ^3.25.0. A service declaring an older range needs to raise the declared range even if its lockfile already resolves something newer.

The guarantee

At-least-once delivery plus idempotent consumption. This library does not provide exactly-once delivery — that primitive does not exist here — so your handler must be safe to run twice. What the library provides is that a message is not acknowledged until your handler has resolved, that a duplicate delivery of the same message ID is skipped rather than reapplied, and that a failure retries through broker topology rather than an in-process sleep.

Wiring it up

import { Module } from '@nestjs/common';
import {
  EcoMessagingModule,
  MongoOutbox,
  MongoIdempotencyLedger,
} from '@eco-foundation/eco-messaging';

@Module({
  imports: [
    EcoMessagingModule.forRoot({
      url: process.env.RABBITMQ_URL!,
      exchange: 'my-service.events',
      sourceService: 'my-service',
      // Host-supplied, already-connected. The library never opens its own datastore
      // connection — it piggybacks on yours and owns only its own collections.
      outbox: MongoOutbox.forConnection(mongooseConnection),
      ledger: MongoIdempotencyLedger.forConnection(mongooseConnection),
    }),
  ],
})
export class AppModule {}

forRoot/forRootAsync (not register) on purpose: there is exactly one messaging connection per service, and the method name says so. The module is @Global — import it once at the app root. Importing it per feature module is how a service ends up with two connections, and heartbeats are per-connection, so a publisher on one can look healthy while a consumer on the other is dead.

A publisher-only service can omit outbox/ledger entirely; an in-memory outbox is wired automatically. Registering a consumer with no ledger is a refused configuration — consuming without dedup is not allowed to boot silently degraded.

Persistence is a port with adapters, and the host chooses: MemoryOutbox/MongoOutbox and MemoryLedger/MongoIdempotencyLedger/RedisIdempotencyLedger. The library never opens its own datastore connection, never calls mongoose.connect(), and has no mongoose peer dependency — which is what lets services on different Mongoose majors share it.

Publishing

A producer publishes through the outbox by default, and for a required contract that is not merely recommended — it is the only admitted path:

import { obligationFromContract } from '@eco-foundation/eco-messaging';

await outbox.record(
  obligationFromContract(InventorySnapshot, payload, {
    now: new Date(),
    sourceService: 'my-service',
  }),
  dbSession, // your own transaction handle, so the record commits atomically with the event
);

publisher.publishNowOrThrow(contract, payload) is the fail-closed direct path: it awaits the confirm and throws on nack, return, timeout, or disconnect rather than queuing anything. It admits a required contract only when that contract declares durability: 'caller-ledger'.

On failure, EcoPublishError answers two orthogonal questions. isRetryablePublicationFailure asks "can retrying this exact message ever succeed?"; wireOutcome asks "did the broker ever see it?" — which is what decides whether a failure should spend a finite attempt budget. A caller reducing the three wire outcomes to two must cut not-attempted vs. everything-else, never broker-verdict vs. everything-else; the latter lets an ambiguous publish retry without bound.

If you lease an obligation before publishing, size the lease TTL above maxPublishWallTimeMs(options). Past that bound a slow-but-live publish has its lease stolen mid-flight and the lease starts producing duplicates.

Consuming

import { Injectable } from '@nestjs/common';
import type { HandlerContext } from '@eco-foundation/eco-messaging';
import {
  EcoConsumer,
  EcoMessageHandlerProvider,
  TransientError,
} from '@eco-foundation/eco-messaging';

@Injectable()
@EcoConsumer({ queue: 'my-service.snapshots', contracts: [InventorySnapshot] })
export class SnapshotHandler implements EcoMessageHandlerProvider {
  async handle(payload: unknown, context: HandlerContext): Promise<void> {
    // Your resolve is your durability attestation: the ack happens only after this resolves.
    // Throw TransientError to retry via topology-based backoff, or PermanentError to park
    // immediately in the DLQ.
    if (somethingRecoverable) throw new TransientError('downstream unavailable, retry me');
  }
}

Registering a consumer materializes a trio of queues at boot and on every reconnect: X.v2 (the quorum work queue), X.v2.retry (TTL, dead-lettering back to work), and X.v2.dlq (the parking lot, no further dead-lettering). Subscribing without that trio asserted is a refused configuration.

For a producer-owned, un-enveloped, latest-value feed you do not control, EcoExternalSnapshotConsumer reads it on the shared connection — a bounded x-max-length: 1 queue where a failure requeues once and then drops, because the message being retried is guaranteed stale.

Readiness

Wire EcoMessagingHealth.isReady() into your probe, not EcoConnectionManager.isConnected(): a connected client whose queues do not exist is not ready to consume, and reporting ready in that state is how a deploy silently black-holes traffic.

Readiness is the conjunction of named gates. The library owns topology; a service that rebuilds in-memory state on boot registers its own, so "running" and "rehydrated" stay distinguishable:

this.rehydration = health.registerGate('rehydration'); // registered closed
// ...later
this.rehydration.open(`rebuilt ${restored} obligations`);

A closed gate withholds readiness only — never consumption, and never the outbox relay's drain. Both are deliberate: a service whose rehydration source is the bus would deadlock otherwise, and an obligation committed before a crash must publish regardless of how long the rebuild takes.

EcoMessagingStatus is the separate, evented view of connection and publisher state for host lifecycle adapters (watchdogs, readiness latches, worker gates). It gates nothing, and credentials are redacted before any detail reaches a listener.

Observability

ALERT exports five structured log-line literals. Eco alerting is log-based — this library's custom OTLP metrics do not reach Datadog — so those five strings are the only thing that can page an on-call engineer about a messaging failure. They are pinned byte-for-byte by tests; renaming one is a breaking change.

METRIC and METRIC_KINDS name the instruments, covering both halves of durability: the transport (publish.*, consume.*, outbox.*) and the caller-ledger obligation lifecycle (obligation.state, obligation.pending, obligation.oldest_age_ms). A gauge with no measurement reports nothing rather than zero.

Subpath exports

SubpathContents
@eco-foundation/eco-messagingthe transport, module, ports, and adapters
@eco-foundation/eco-messaging/testingconformance suites and fixtures for your own integration tests — framework-agnostic: the test API (describe/it/expect) is injected as a parameter, so it works under Jest as well as Vitest
@eco-foundation/eco-messaging/otelOpenTelemetry metrics and tracing adapters
@eco-foundation/eco-messaging/dashboards/grafanathe Grafana dashboard JSON, versioned with the metric contract

If you wire OTel, read the two ordering traps in the repository README first — registering the MeterProvider after the metrics sink is built silently produces no metrics.

Upgrading from 1.x

2.0.0 is a major for topology-surface reasons; nothing existing changes behaviour and the new bounds default to what hosts already had. There is one breaking source change: RawPublishInput.envelope is now Omit<Envelope, 'correlationId'>, because publishRawOrThrow has always minted a fresh correlation ID per attempt and discarded whatever was passed. A caller passing a full Envelope object literal must drop that field. See CHANGELOG.md for the full entry.

Documentation

These packages are the reference implementation of the standard, not its definition. The definition is two language-agnostic documents, both machine-checked against this code:

版本与发布记录

@eco-foundation/eco-messaging 共发布 8 个版本,首次发布于 2026/08/24;过去 12 个月发布了 8 个版本。

版本发布日期距今
3.0.4latest2026/09/261 个月前
3.0.32026/09/151 个月前
3.0.22026/09/092 个月前
3.0.12026/09/092 个月前
3.0.02026/09/012 个月前
2.1.02026/08/262 个月前

关于 @eco-foundation/eco-messaging 的常见问题

@eco-foundation/eco-messaging 是什么?

Hardened NestJS AMQP transport: confirms, outbox, idempotent consume, topology as code

如何安装 @eco-foundation/eco-messaging?

在项目目录运行 npm install @eco-foundation/eco-messaging,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:zod(^3.25.0 || ^4.0.0)、amqplib(^0.10.0)、@nestjs/core(^10.2.0 || ^11.0.0)、@nestjs/common(^10.0.0 || ^11.0.0)、amqp-connection-manager(^5.0.0)、@eco-foundation/eco-message-contracts(^2.3.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@eco-foundation/eco-messaging 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@eco-foundation/eco-messaging 支持 ESM 还是 CommonJS?

以 CommonJS 形式发布,可以直接 require();在 ES Module 项目中也可以用 import 引入(Node.js 会把 module.exports 作为默认导出)。

@eco-foundation/eco-messaging 可以商用吗?

@eco-foundation/eco-messaging 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@eco-foundation/eco-messaging 的下载量有多少?

根据 npm 官方下载统计,@eco-foundation/eco-messaging 在 2026/10/13 – 2026/11/11 这 30 天内被下载 2,013 次,日均约 67 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@eco-foundation/eco-messaging 还在维护吗?

最新版本 3.0.4 发布于 2026/09/26(1 个月前);至今共发布 8 个版本,其中过去 12 个月发布了 8 个;首次发布于 2026/08/24。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。