@easyweb/events

中文文档
2.22.0发布于 2026/09/20MITTypeScriptESM + CJS零依赖近 30 天下载 3018

Shared RabbitMQ event layer for Easyweb microservices: resilient connection manager, typed publisher/subscriber base classes, exchange and routing-key registry

原始使用文档
文档目录12 节
  1. Installation
  2. Connection
  3. Options
  4. Handle contract
  5. Publishing
  6. Subscribing
  7. Event registry
  8. Money on the wire
  9. Changes in 2.8.0
  10. Breaking changes in 2.0.0
  11. Row versioning
  12. Development

@easyweb/events

Shared RabbitMQ event layer for Easyweb microservices — a resilient connection manager, typed publisher/subscriber base classes, and the exchange/routing-key registry that keeps event names consistent across services.

Installation

amqplib and amqp-connection-manager are peer dependencies — the consuming service owns them, so only one copy exists in the tree:

npm install @easyweb/events amqplib amqp-connection-manager

Two copies of amqp-connection-manager would mean two TCP connections per service, two heartbeat timers, and two uncoordinated reconnect loops. Keep them at the top level.

Connection

createRabbitMQConnection owns one connection per process. Call it once, in a module the rest of the service imports:

// src/lib/rabbitmq.ts
import { createRabbitMQConnection } from "@easyweb/events";
import config from "../config";

const { host, port, user, pass, vhost } = config.rabbitmq;

const rabbitmq = createRabbitMQConnection({
  urls: [{ protocol: "amqp", hostname: host, port, username: user, password: pass, vhost }],
  connectionName: "auth-service",
});

export const connectRabbitMQ = rabbitmq.connectRabbitMQ;
export const getChannel = rabbitmq.getChannel;
export const isRabbitMQConnected = rabbitmq.isConnected;
export const onRabbitMQReady = rabbitmq.onReady;
export const closeRabbitMQ = rabbitmq.close;

Options

OptionDefaultNotes
urls—Passed straight to amqp-connection-manager. Strings or Options.Connect objects.
connectionNamenoneSurfaces in the RabbitMQ management UI under Connections. Set it — it is how you tell services apart.
heartbeatIntervalInSeconds120
onChannelSetupnoneRe-runs on every (re)connect. Declare exchanges, queues, and bindings here so topology survives a channel recreation.

Handle contract

MethodBehaviour
connectRabbitMQ()Resolves once, on first successful connect. Never rejects — but never resolves while the broker is down either. Do not await it ahead of server.listen(), or a broker outage holds your service off its port. Fire it with void after listening.
getChannel()Throws "RabbitMQ not initialized" if called before the first connect.
isConnected()Current state. Useful for health endpoints.
onReady(cb)Fires on first connect and again after every reconnect. Fires immediately if already connected. Use this to rebind subscribers.
close()Closes channel then connection.

Reconnects use exponential backoff with full jitter, capped at 30s, injected via the findServers hook. The jitter is deliberate: it stops every service reconnecting in lockstep after a broker restart.

Publishing

Define the event shape once, then a Publisher per event:

import { Publisher, Exchanges, RoutingKeys, UserCreatedEvent } from "@easyweb/events";
import { getChannel } from "../lib/rabbitmq";

class UserCreatedPublisher extends Publisher<UserCreatedEvent> {
  exchange = Exchanges.AuthService;
  routingKey = RoutingKeys.USER_CREATED;
}

await new UserCreatedPublisher(getChannel()).publish(
  { userId, name, email },
  requestId, // optional — travels as the x-request-id header
);

publish asserts the exchange (durable, topic by default), sends persistent messages, and throws if the channel buffer is full.

Subscribing

import { Subscriber, Exchanges, RoutingKeys, UserCreatedEvent } from "@easyweb/events";
import { onRabbitMQReady } from "../lib/rabbitmq";

class UserCreatedSubscriber extends Subscriber<UserCreatedEvent> {
  exchange = Exchanges.AuthService;
  routingKey = RoutingKeys.USER_CREATED;

  async onMessage(data: UserCreatedEvent["data"], _msg, requestId: string) {
    await createProfile(data.userId, requestId);
  }
}

// Re-subscribe on every reconnect, not just at boot.
onRabbitMQReady((channel) => {
  void new UserCreatedSubscriber(channel).subscribe();
});

subscribe() gives you, per subscriber:

  • a durable queue named <exchange>.<routingKey>, overridable via protected queueName
  • a dead-letter exchange <exchange>.dlx and queue <queueName>.dead
  • a delay queue <queueName>.retry, which nothing consumes
  • prefetch(1)
  • retry on failure by parking the message in <queueName>.retry with an incremented x-retry-count and an expiration of EVENT_RETRY_DELAY_MS (default 2000). It expires, dead-letters back into <queueName> alone, and is tried again — up to 3 attempts, then the message is acked and dropped to stop poison messages looping.

A retry goes back to this queue, never to the exchange. Five services bind user.account.provisioned; republishing to the exchange re-ran the handler in the four that had already succeeded. See docs/platform/ADR-0005.

"Event handler failed" is logged at warn while retries remain and at error on the delivery that discards the message. The message string is fixed — a Loki recording rule keys on it.

requestId is read from the x-request-id header, falling back to a fresh UUID so every message is traceable.

Event registry

Exchanges and routing keys live in this package so no service invents its own string.

enum Exchanges {
  AuthService = "auth",
  UserService = "user",
  SubscriptionService = "subscription",
  BillingService = "billing",
  PaymentService = "payment",
}

enum RoutingKeys {
  USER_CREATED = "auth.user.created",
  USER_ACCOUNT_PROVISIONED = "user.account.provisioned",
  USER_PROFILE_UPDATED = "user.profile.updated",
  SUBSCRIPTION_PLAN_PUBLISHED = "subscription.plan.published",
  BILLING_INVOICE_ISSUED = "billing.invoice.issued",
  PAYMENT_CHARGE_SUCCEEDED = "payment.charge.succeeded",
}

Routing keys follow <service>.<entity>.<action>. Adding an event means adding the routing key, the event interface under src/events/types/, and publishing a new version of this package.

One exchange per owning service, and only that service publishes on it. RoutingKeys is a single flat enum shared by all of them, so member names carry the owner as a prefix — USER_CREATED is auth's auth.user.created, while user-service's keys are USER_PROFILE_*, USER_ADDRESS_* and so on.

PaymentService has no publisher yet; payment-service is not built. Its keys are pinned here so billing can name them at compile time and so the strings cannot drift when it does land.

Money on the wire

Money is a decimal string of minor units, because the columns behind it are BigInt and JSON.stringify throws on one. Parse to BigInt, not Number. The minor unit depends on the currency, which is always carried in a field beside the amount:

CurrencyMinor unit"199000" means
IDRRp 1 (zero-decimal)Rp 199.000
USDone cent$1,990.00

IDR is zero-decimal because that is what Xendit and Stripe both settle in. Carrying sen would mean rounding every invoice total to a rupiah before it could be charged, and the remainder would have to be stored somewhere and reconciled.

Changes in 2.8.0

subscription.subscription.activated gained required priceAmount, currency and billingInterval.

Additive for every consumer, and a minor rather than a major for that reason — but it is required at the publish site, which is safe only because subscription.activated has exactly one publisher in the platform: subscription-service's trial-conversion sweep.

It was a bug that they were ever absent. Billing's subscription mirror merges partial payloads, so an activated naming no price left the row holding what subscription.created wrote when the account was provisioned on free — a zero price on a LIFETIME interval. Both of billing's cycle-planner queries exclude precisely that shape, so a converted trial was never planned, never invoiced and never charged, and the customer heard nothing about it because notification stays silent on a converted trial in the expectation that the invoice speaks for it.

subscription.trial-started deliberately does not carry them, and must not be made to. Billing's planner reads no trial column; the stale zero price is the only thing keeping it from invoicing a trial that is still running.

Breaking changes in 2.0.0

  • IDR amounts are zero-decimal. They were sen (exponent 2) in 1.x, where "9900000" meant Rp 99.000.
  • subscription.subscription.{renewed,past-due,paused,resumed} moved to the billing exchange as billing.subscription.*. Billing owns the clock, so only billing knows that a period was paid for and rolled over, or that dunning ran out. The four payloads lost entitlementsVersion — billing cannot know it, and subscription-service computes it when it applies the event — and gained invoiceId, which is what makes each transition traceable to the document that caused it.
  • The nine remaining subscription.subscription.* payloads gained version, the subscription row's version, so mirrors can guard them like every other replicated row.

Row versioning

Every payload for a row that crosses a service boundary carries a version. It starts at 1 and advances by exactly 1 per published change, so a mirror can tell a stale redelivery from the expected next one. The full rule, and the VersionGuardOutcome type consumers apply it with, live in src/events/types/version-contract.ts.

This matters because subscribe() retries by parking the message in a delay queue and letting it expire back into the tail of its own queue (see above) — out-of-order delivery is normal operation, not an anomaly.

Publishers must not advance version for writes that touch only unreplicated columns. A login-timestamp bump that increments the version puts a permanent gap in the sequence, and every consumer stalls behind it.

A row's first version is announced by whichever event creates it — user.account.provisioned for the profile, preference and notification rows, user.address.created for an address — so a mirror starts at version 1 and the first edit applies cleanly as 1 → 2. Consumers should still tolerate a snapshot arriving for a row they have never seen: publishing is best-effort, and a creation event lost to a broker outage would otherwise strand the row forever.

Development

npm install
npm run build       # cleans dist, then tsc
npm pack --dry-run  # verify tarball contents before publishing
npm publish

版本与发布记录

@easyweb/events 共发布 34 个版本,首次发布于 2026/08/06;过去 12 个月发布了 34 个版本。

版本发布日期距今
2.22.0latest2026/09/201 个月前
2.21.02026/09/201 个月前
2.20.02026/08/272 个月前
2.19.02026/08/142 个月前
2.18.02026/08/142 个月前
2.17.02026/08/133 个月前

关于 @easyweb/events 的常见问题

@easyweb/events 是什么?

Shared RabbitMQ event layer for Easyweb microservices: resilient connection manager, typed publisher/subscriber base classes, exchange and routing-key registry

如何安装 @easyweb/events?

在项目目录运行 npm install @easyweb/events,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:amqplib(>=2.0.0 <3)、@easyweb/logging(^1.0.0)、amqp-connection-manager(^5.0.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@easyweb/events 支持 TypeScript 吗?

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

@easyweb/events 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@easyweb/events 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18.0.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@easyweb/events 可以商用吗?

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

@easyweb/events 的下载量有多少?

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

@easyweb/events 还在维护吗?

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