Skip to content

@connectum/events

Universal event adapter layer for Connectum. Provides proto-first pub/sub with pluggable broker adapters, typed event handlers mirroring ConnectRPC's router pattern, and a composable middleware pipeline with built-in retry and dead letter queue (DLQ) support.

Layer: 1 (Events)

Related Guides

Full API Reference

Complete TypeScript API documentation: API Reference

Installation

bash
pnpm add @connectum/events

Peer dependency: @connectum/core

You also need at least one adapter package for production use:

bash
# Choose one (or more) broker adapters:
pnpm add @connectum/events-nats    # NATS JetStream
pnpm add @connectum/events-kafka   # Kafka / Redpanda
pnpm add @connectum/events-redis   # Redis Streams / Valkey
pnpm add @connectum/events-amqp    # AMQP / RabbitMQ

The built-in MemoryAdapter is included in @connectum/events for testing.

Quick Start

typescript
import { createServer } from '@connectum/core';
import { createEventBus, MemoryAdapter } from '@connectum/events';
import type { EventRoute } from '@connectum/events';
import { UserEventHandlers, UserCreatedSchema } from '#gen/user/v1/user_pb.js';

// 1. Define event handlers (mirrors ConnectRPC router pattern)
const userEvents: EventRoute = (events) => {
  events.service(UserEventHandlers, {
    onUserCreated: async (msg, ctx) => {
      console.log(`User created: ${msg.id}, ${msg.email}`);
      await ctx.ack();
    },
  });
};

// 2. Create an EventBus
const eventBus = createEventBus({
  adapter: MemoryAdapter(),
  routes: [userEvents],
  group: 'my-service',
  middleware: {
    retry: { maxRetries: 3, backoff: 'exponential' },
    dlq: { topic: 'my-service.dlq' },
  },
});

// 3. Integrate with Connectum server
const server = createServer({
  services: [routes],
  eventBus,
  shutdown: { autoShutdown: true },
});

await server.start();

// 4. Publish typed events
await eventBus.publish(UserCreatedSchema, {
  id: '123',
  email: '[email protected]',
  name: 'Alice',
});

Core Concepts

EventBus

The central component managing adapter lifecycle, event routes, middleware pipeline, and publishing. Created via createEventBus(), it implements EventBusLike for integration with createServer().

EventAdapter

A minimal interface for message brokers. Each adapter (NATS, Kafka, Redis, AMQP, Memory) implements connect(context?), disconnect(), publish(), and subscribe(). The optional AdapterContext parameter on connect() carries service-level information (like serviceName) derived from registered proto service descriptors, enabling adapters to identify themselves to brokers automatically. Broker-specific configuration is passed to the adapter constructor, not to the interface methods.

The named companion type EventAdapterFactory (() => EventAdapter) is the zero-argument factory shape used where a fresh adapter per consumer is needed -- most notably createBroadcastSubscribers(), where a factory gives each reactor bus its own broker connection. Use it to type dependency-injection seams (a service that accepts EventAdapter | EventAdapterFactory can take a shared instance in tests and a per-use factory in production). Available since 1.3.0.

EventRouter

Mirrors ConnectRPC's ConnectRouter pattern for event handlers. Register typed handlers per proto service:

typescript
const myEvents: EventRoute = (events) => {
  events.service(OrderEventHandlers, {
    onOrderCreated: async (msg, ctx) => { /* ... */ },
    onOrderCancelled: async (msg, ctx) => { /* ... */ },
  });
};

EventContext

Per-event context passed to handlers alongside the deserialized protobuf message. Provides explicit ack() / nack() control, event metadata, and an abort signal for graceful shutdown.

API Reference

createEventBus(options)

Factory function that creates an EventBus instance.

typescript
function createEventBus(options: EventBusOptions): EventBus & EventBusLike;

EventBusOptions

OptionTypeDefaultDescription
adapterEventAdapterrequiredAdapter instance (e.g., NatsAdapter, KafkaAdapter, MemoryAdapter)
routesEventRoute[][]Event routes to register
groupstringundefinedConsumer group name for load-balanced consumption
signalAbortSignalundefinedAbort signal for graceful shutdown
handlerTimeoutnumber30000Timeout in ms for event handler execution
drainTimeoutnumber30000Max ms to wait for in-flight handlers during stop()
drainPublishTimeoutnumberundefinedOpt-in: max ms to wait for in-flight publish() promises during stop(), before the adapter disconnects. Runs concurrently with the handler drain (slower-of, never the sum). undefined/0 = disabled. Available since 1.3.0
middlewareMiddlewareConfigundefinedMiddleware configuration (retry, DLQ, custom)
publishesDescService[][]Event service descriptors this process publishes to (publisher-only, no subscription)
strictTopicsbooleanfalseThrow on an unresolved publish topic instead of silently falling back to the message typeName. Available since 1.1.0.

EventBus

MethodDescription
start()Connect adapter, set up subscriptions
stop()Drain subscriptions, disconnect adapter
publish(schema, data, options?)Publish a typed protobuf event

PublishOptions

OptionTypeDefaultDescription
topicstringschema.typeNameOverride topic name
keystringundefinedPartition/routing key for ordered delivery
groupstringundefinedNamed group tag for workflow grouping
metadataRecord<string, string>undefinedAdditional metadata / headers
messageIdstringundefinedCaller-supplied message id the adapter sets on the wire where supported (AMQP messageId property; other adapters ignore it). Mainly for external-contract publishing, where the adapter does not auto-generate one. Available since 1.1.0.
timestampnumberundefinedCaller-supplied message timestamp in Unix epoch seconds, set on the wire where supported (AMQP timestamp property; other adapters ignore it). Mainly for external-contract publishing. Available since 1.1.0.

EventContext

Property/MethodTypeDescription
signalAbortSignalAborted when server is shutting down
eventIdstringUnique event identifier
eventTypestringEvent type / topic name
publishedAtDateWhen the event was published
attemptnumberDelivery attempt number (1-based)
metadataReadonlyMap<string, string>Event metadata (headers)
ack()Promise<void>Acknowledge successful processing
nack(requeue?)Promise<void>Negative acknowledge -- request redelivery or send to DLQ

MiddlewareConfig

OptionTypeDescription
retryRetryOptionsRetry middleware configuration
dlqDlqOptionsDead letter queue configuration
customEventMiddleware[]Custom user middleware (executed outermost)

RetryOptions

OptionTypeDefaultDescription
maxRetriesnumber3Maximum retry attempts
backoff"exponential" | "linear" | "fixed""exponential"Backoff strategy
initialDelaynumber1000Initial delay in ms
maxDelaynumber30000Maximum delay in ms
multipliernumber2Multiplier for exponential backoff
retryableErrors(error: unknown) => booleanundefinedFilter: only retry matching errors

DlqOptions

OptionTypeDefaultDescription
topicstringrequiredDLQ topic name
errorSerializer(error: unknown) => stringerror.nameCustom error serializer for DLQ metadata

createBroadcastSubscribers(options)

Builds 1→N fan-out wiring: one EventBus per reactor, each on its own consumer group, so a single published event is delivered to every reactor independently.

Available since 1.1.0.

typescript
function createBroadcastSubscribers(
  options: BroadcastSubscribersOptions,
): Array<EventBus & EventBusLike>;

Delivering one published event to N independent reactors requires one EventBus per reactor, each with its own consumer group:

  • The per-bus duplicate-topic guard rejects two routes resolving to the same topic on one bus, so reactors cannot share a bus.
  • On a real broker, a shared group load-balances (one reactor "steals" each event), while distinct groups give each reactor its own durable consumer.

createBroadcastSubscribers() constructs that one-bus-per-reactor wiring from a list of reactors, so callers do not hand-roll N createEventBus() calls. It throws if two reactors share a consumer group.

WARNING

The returned buses are not started -- start (and later stop) them yourself.

typescript
import { createBroadcastSubscribers } from '@connectum/events';
import { NatsAdapter } from '@connectum/events-nats';

// Per-bus adapter factory: each reactor bus gets its own connection / durable consumer
const buses = createBroadcastSubscribers({
  adapter: () => NatsAdapter({ servers: 'nats://localhost:4222' }),
  reactors: [
    { group: 'pricing', routes: [pricingRoutes] },
    { group: 'audit', routes: [auditRoutes] },
    { group: 'notify', routes: [notifyRoutes] },
  ],
});

await Promise.all(buses.map((bus) => bus.start()));

// On shutdown:
await Promise.all(buses.map((bus) => bus.stop()));

For in-process tests, pass a single shared MemoryAdapter() instance instead of a factory (all buses share the in-memory registry):

typescript
import { createBroadcastSubscribers, MemoryAdapter } from '@connectum/events';

const buses = createBroadcastSubscribers({
  adapter: MemoryAdapter(), // one shared instance
  reactors: [
    { group: 'pricing', routes: [pricingRoutes] },
    { group: 'audit', routes: [auditRoutes] },
  ],
});

await Promise.all(buses.map((bus) => bus.start()));

BroadcastSubscribersOptions

OptionTypeDefaultDescription
adapterEventAdapter | EventAdapterFactoryrequiredOne shared adapter instance (fine for MemoryAdapter in tests) or an EventAdapterFactory invoked once per reactor (use for real brokers so each bus gets its own connection / durable consumer). The named factory type is available since 1.3.0
reactorsBroadcastReactor[]requiredThe independent reactors -- each becomes its own EventBus with its own group
handlerTimeoutnumber30000Shared per-bus handler timeout in ms
drainTimeoutnumber30000Shared per-bus drain timeout in ms
drainPublishTimeoutnumberundefinedShared per-bus opt-in publish drain budget at stop() (ms). Available since 1.3.0
signalAbortSignalundefinedShared abort signal for graceful shutdown

BroadcastReactor

OptionTypeDefaultDescription
groupstringrequiredConsumer group -- MUST be distinct per reactor for true fan-out (a shared group load-balances)
routesEventRoute[]requiredThe event routes (handlers) this reactor subscribes with
middlewareMiddlewareConfigundefinedOptional per-reactor middleware (retry / DLQ / custom)

Middleware

The middleware pipeline uses an onion model (outer to inner):

Custom → DLQ → Retry → Handler
  • Custom middleware runs outermost, wrapping everything
  • DLQ catches errors after all retries are exhausted and publishes to a dead letter topic
  • Retry catches handler errors and retries with configurable backoff

See Middleware Guide for detailed configuration and custom middleware examples.

Configuration

Integration with createServer

Pass the eventBus to createServer() for automatic lifecycle management:

typescript
const server = createServer({
  services: [routes],
  eventBus,
  shutdown: { autoShutdown: true },
});

The server calls eventBus.start() on startup and eventBus.stop() on shutdown.

Dependency Injection and Testing

Primary pattern -- inject an EventAdapter instance. Construct the adapter at your composition root and pass it in; a test swaps it for a double without touching the wiring:

typescript
// Composition root (production):
const adapter = NatsAdapter({ servers: process.env.NATS_URL! });
const bus = createEventBus({ adapter, routes: [eventRoutes] });
typescript
// Test: the same wiring, a different instance.
const bus = createEventBus({ adapter: MemoryAdapter(), routes: [eventRoutes] });

Secondary pattern -- EventAdapterFactory (() => EventAdapter, exported since 1.3.0): a zero-argument factory for the places where each consumer needs its own broker connection -- createBroadcastSubscribers() invokes it once per reactor. Prefer the instance elsewhere: a test double with its own configuration does not fit a zero-argument factory signature without a wrapper closure.

Test doubles: MemoryAdapter covers the generic happy path (routing, handlers, middleware, DLQ flows). Broker-specific failure semantics (typed AMQP error taxonomy, recovery/lifecycle behavior) cannot be modeled generically -- use the programmable FakeAmqpAdapter from the @connectum/events-amqp/testing subpath (since 1.3.0; see events-amqp Testing). For real-broker integration semantics, see each adapter package's testing notes.

Consumer Groups

Set group to enable load-balanced consumption across multiple service instances:

typescript
const eventBus = createEventBus({
  adapter: KafkaAdapter({ brokers: ['localhost:9092'] }),
  routes: [orderEvents],
  group: 'order-service', // All instances share this group
});

Graceful Shutdown

stop() closes subscriptions, waits for in-flight consumer handlers up to drainTimeout (default 30s), force-aborts the rest via AbortSignal, then disconnects the adapter. Set drainTimeout: 0 for immediate abort.

In-flight publish() promises are not tracked by the bus, and drainTimeout does not cover them. An at-least-once producer must settle its publishes before stopping:

typescript
// Track publishes you must not lose:
const pending = new Set<Promise<void>>();

const p = bus.publish(OrderCreatedSchema, order);
pending.add(p);
p.catch(() => {}).finally(() => pending.delete(p));

// On shutdown — settle them BEFORE stop():
await Promise.allSettled([...pending]);
await bus.stop();

Two related boundaries:

  • Publishing from a draining handler is rejected — once stop() begins, publish() throws, including from handlers that are still draining. Relay topologies (consume → transform → publish) lose the in-flight tail at shutdown; the design discussion is tracked in connectum#212.
  • An opt-in symmetric publish drain ships since 1.3.0: set drainPublishTimeout and stop() waits (up to that budget, concurrently with the handler drain) for publishes started before stop() to settle, before the adapter disconnects. A post-deadline settlement never becomes an unhandledRejection. Publishes issued from draining handlers stay uncovered (the stopping gate; design tracked in connectum#212). Note: createServer's shutdown.timeout does not bound the bus-stopping hook — size the budget below your orchestrator's kill grace.

Exports Summary

ExportDescription
createEventBusEventBus factory function
createBroadcastSubscribers1→N fan-out factory: one EventBus per reactor, each with its own consumer group, so every reactor receives every event (available since 1.1.0)
deriveServiceNameDerives a service identifier from proto service type names (format: {packages}@{hostname})
createEventContextEventContext factory (advanced)
EventRouterImplEventRouter implementation class
MemoryAdapterIn-memory adapter for testing
retryMiddlewareRetry middleware factory
dlqMiddlewareDLQ middleware factory
composeMiddlewareMiddleware composition utility
resolveTopicNameTopic resolution from proto method descriptors
matchPatternWildcard pattern matching for topics
NonRetryableErrorError class that skips retry middleware
RetryableErrorError class that forces retry regardless of predicate

Type Exports

TypeDescription
EventBusEventBus interface
EventBusOptionsEventBus configuration
BroadcastSubscribersOptionsOptions for createBroadcastSubscribers() (available since 1.1.0)
BroadcastReactorOne broadcast reactor: group + routes + optional middleware (available since 1.1.0)
AdapterContextContext passed to adapters on connect (contains serviceName)
EventAdapterAdapter interface
EventRouterRouter interface
EventRouteRoute function type
EventContextPer-event context
EventMiddlewareMiddleware function type
PublishOptionsPublish options
RetryOptionsRetry middleware options
DlqOptionsDLQ middleware options
MiddlewareConfigBuilt-in middleware configuration
RawEventRaw event data from adapter
ServiceEventHandlersTyped handler map for a service
TypedEventHandlerTyped handler function

Learn More