import { EventBusLike } from '@connectum/core'; import { DescService, MessageShape, DescMessage, DescMethod } from '@bufbuild/protobuf'; /** * Type definitions for the event adapter layer. * * @module types */ /** * Raw event data delivered by the adapter */ interface RawEvent { /** Unique event identifier */ readonly eventId: string; /** Event type / topic name */ readonly eventType: string; /** Serialized protobuf payload */ readonly payload: Uint8Array; /** When the event was published */ readonly publishedAt: Date; /** Delivery attempt number (1-based) */ readonly attempt: number; /** Event metadata (headers) */ readonly metadata: ReadonlyMap; } /** * Raw event handler function type. * * Adapters call this with the deserialized event and broker-specific * ack/nack callbacks. The EventBus wires these into the EventContext * for end-user handlers. */ type RawEventHandler = (event: RawEvent, ack: () => Promise, nack: (requeue?: boolean) => Promise) => Promise; /** * Subscription handle returned by adapter.subscribe() */ interface EventSubscription { /** Unsubscribe and clean up */ unsubscribe(): Promise; } /** * Options for raw subscribe */ interface RawSubscribeOptions { /** Consumer group name for load-balanced consumption */ group?: string; } /** * Options for publishing events */ interface PublishOptions { /** Override topic name (default: schema.typeName) */ topic?: string; /** Named group tag for workflow grouping */ group?: string; /** Additional metadata / headers */ metadata?: Record; /** Message key for partitioning (Kafka: partition key, others: ignored) */ key?: string; /** * Caller-supplied message identifier the adapter sets on the wire where * supported (AMQP: the `messageId` property; other adapters ignore it). * Primarily for external-contract publishing — in `@connectum/events-amqp` * `externalContract` mode the adapter does not auto-generate a `messageId`, * so set this when the contract requires one. */ messageId?: string; /** * Caller-supplied message timestamp in **Unix epoch seconds**, set on the * wire where supported (AMQP: the `timestamp` property; other adapters * ignore it). Like {@link messageId}, mainly for external-contract * publishing where the adapter does not auto-populate it. */ timestamp?: number; } /** * Context provided to adapters by the EventBus before connect(). * * Contains service-level information derived from registered proto * service descriptors. Adapters may use this for broker-level * identification (e.g., Kafka clientId, NATS connection name, * Redis connectionName). */ interface AdapterContext { /** * Service identifier derived from proto service names. * * Format: `{packageNames}@{hostname}` * * Examples: * - `"order.v1@pod-abc123"` (single service) * - `"order.v1/payment.v1@pod-abc123"` (multiple services) */ readonly serviceName?: string; } /** * Minimal adapter interface for message brokers. * * Inspired by Watermill (Go): minimal surface, broker-specific * config in constructor, not in interface methods. */ interface EventAdapter { /** Adapter name for identification (e.g., "nats", "kafka", "redis", "memory") */ readonly name: string; /** * Connect to the message broker. * * @param context - Optional adapter context with service-level information * derived from proto service descriptors. Adapters may use * `context.serviceName` for broker-level client identification. */ connect(context?: AdapterContext): Promise; /** Disconnect from the message broker */ disconnect(): Promise; /** Publish a serialized event to a topic */ publish(eventType: string, payload: Uint8Array, options?: PublishOptions): Promise; /** Subscribe to event patterns with a raw handler */ subscribe(patterns: string[], handler: RawEventHandler, options?: RawSubscribeOptions): Promise; } /** * Per-event context with explicit ack/nack control. * * Passed to event handlers alongside the deserialized message. * Supports explicit ack/nack control. If the handler completes * without calling either, the event is automatically acknowledged. */ interface EventContext { /** Abort signal (aborted when server is shutting down) */ readonly signal: AbortSignal; /** Unique event identifier */ readonly eventId: string; /** Event type / topic name */ readonly eventType: string; /** When the event was published */ readonly publishedAt: Date; /** Delivery attempt number (1-based) */ readonly attempt: number; /** Event metadata (headers) */ readonly metadata: ReadonlyMap; /** Acknowledge successful processing */ ack(): Promise; /** Negative acknowledge -- request redelivery or send to DLQ */ nack(requeue?: boolean): Promise; } /** * Initialization data for creating an EventContext */ interface EventContextInit { readonly raw: RawEvent; readonly signal: AbortSignal; readonly onAck: () => Promise; readonly onNack: (requeue: boolean) => Promise; } /** * Typed event handler for a specific message type */ type TypedEventHandler = (event: I, ctx: EventContext) => Promise; /** * Per-handler middleware configuration. * * Overrides global EventBus middleware for this specific handler. * When present, the global middleware pipeline is bypassed entirely * and only the per-handler middleware array is applied. */ interface EventHandlerConfig { /** Event handler function */ readonly handler: TypedEventHandler; /** Per-handler middleware array (overrides global middleware for this handler) */ readonly middleware?: EventMiddleware[]; } /** * Maps service methods to typed event handlers. * * Each handler can be either: * - A simple function (uses global middleware) * - An object with `handler` and optional `middleware` (per-handler override) */ type ServiceEventHandlers = { [K in keyof S["method"]]: TypedEventHandler> | EventHandlerConfig>; }; /** * Registered event route (internal use) */ interface EventRouteEntry { /** Topic pattern to subscribe to */ readonly topic: string; /** Method descriptor for deserialization */ readonly method: DescMethod; /** Typed handler function */ readonly handler: TypedEventHandler; /** Per-handler middleware (overrides global when present) */ readonly middleware?: EventMiddleware[]; } /** * Event router for registering service event handlers. * * Mirrors ConnectRPC's ConnectRouter pattern: * `events.service(UserEventHandlers, { ... })` mirrors `router.service(UserService, { ... })` */ interface EventRouter { /** Register event handlers for a service */ service(serviceDesc: S, handlers: ServiceEventHandlers): void; } /** * Event route function -- mirrors ServiceRoute from @connectum/core */ type EventRoute = (events: EventRouter) => void; /** * Event middleware next function. * * Optionally accepts an updated event to replace the current one * in the pipeline (e.g., retry middleware sets a new attempt number * without mutating the readonly original). */ type EventMiddlewareNext = (updatedEvent?: RawEvent) => Promise; /** * Event middleware function */ type EventMiddleware = (event: RawEvent, ctx: EventContext, next: EventMiddlewareNext) => Promise; /** * Retry middleware options */ interface RetryOptions { /** Maximum retry attempts (default: 3) */ maxRetries?: number; /** Backoff strategy */ backoff?: "exponential" | "linear" | "fixed"; /** Initial delay in ms (default: 1000) */ initialDelay?: number; /** Maximum delay in ms (default: 30000) */ maxDelay?: number; /** Multiplier for exponential backoff (default: 2) */ multiplier?: number; /** Filter: only retry for these error types */ retryableErrors?: (error: unknown) => boolean; } /** * Dead letter queue middleware options */ interface DlqOptions { /** DLQ topic name */ topic: string; /** * Custom error serializer for DLQ metadata. * Defaults to `error.name` only (e.g. "TypeError") to prevent credential leaks. * For production, provide a custom serializer that redacts sensitive data * (connection strings, tokens) before including error details. */ errorSerializer?: (error: unknown) => string; } /** * Built-in middleware configuration */ interface MiddlewareConfig { /** Retry configuration */ retry?: RetryOptions; /** Dead letter queue configuration */ dlq?: DlqOptions; /** Custom user middleware (executed outermost) */ custom?: EventMiddleware[]; } /** * EventBus configuration options for createEventBus() */ interface EventBusOptions { /** Adapter instance (e.g., NatsAdapter, KafkaAdapter, MemoryAdapter) */ adapter: EventAdapter; /** Event routes to register */ routes?: EventRoute[]; /** * Event service descriptors this bus publishes to (publish-only, no subscription). * * A process that only PUBLISHES events has no `routes`, so its publish-topic * lookup would be empty and `publish()` would fall back to the message * `typeName` — silently emitting to the wrong topic whenever the event * declares a custom `(connectum.events.v1.event).topic`. List the event * service descriptors here to populate the publish-topic lookup from their * proto options, so the declared topic is used end-to-end without * hand-maintaining raw topic strings. Subscribers still register via `routes`. */ publishes?: DescService[]; /** Consumer group name */ group?: string; /** Middleware configuration */ middleware?: MiddlewareConfig; /** * Abort signal for graceful shutdown. * * When provided, per-event signals are composed via `AbortSignal.any()` * so that server shutdown aborts in-flight event processing. * Automatically set when used with `createServer({ eventBus })`. */ signal?: AbortSignal; /** * Per-event handler timeout in milliseconds. * * Each event handler invocation gets an AbortSignal that fires after * this duration. Default: 30000 (30 seconds). */ handlerTimeout?: number; /** * Maximum time in milliseconds to wait for in-flight event handlers * to complete during shutdown. After this timeout, remaining handlers * are force-aborted via AbortSignal. * * Default: 30000 (30 seconds). Set to 0 for immediate abort. */ drainTimeout?: number; /** * Reject a `publish()` whose topic cannot be resolved instead of silently * falling back to the message `typeName`. * * By default, when no explicit `publishOptions.topic` is given and the event * type is covered by neither `routes` nor `publishes`, `publish()` emits to * the raw `schema.typeName` — a silent misconfiguration (the event may never * reach subscribers expecting the proto-declared `(event).topic`). With * `strictTopics: true`, that case throws so the misconfiguration surfaces at * the call site. * * Default: `false` (backward-compatible silent fallback). */ strictTopics?: boolean; } /** * EventBus interface -- manages adapter, routes, and middleware */ interface EventBus { /** * Start the event bus: connect adapter, set up subscriptions. * * An optional `signal` can be passed for graceful shutdown. * If provided, it **overrides** the construction-time `EventBusOptions.signal`. * The active signal is then composed with `AbortSignal.timeout(handlerTimeout)` * via `AbortSignal.any()` for each event handler invocation, so either * shutdown or per-event timeout will abort in-flight processing. */ start(options?: { signal?: AbortSignal; }): Promise; /** Stop the event bus: drain subscriptions, disconnect adapter */ stop(): Promise; /** Publish a typed event */ publish(schema: Desc, data: MessageShape, options?: PublishOptions): Promise; } /** * Broadcast / fan-out helper. * * To deliver ONE published event to N INDEPENDENT reactors (each reacting on its * own), each reactor must live on its OWN EventBus with its OWN consumer group: * * - the per-bus duplicate-topic guard rejects two routes resolving to the same * topic on one bus, and * - on a real broker, a SHARED group load-balances (one reactor "steals" each * event) while DISTINCT groups give each reactor its own durable consumer. * * {@link createBroadcastSubscribers} builds that one-bus-per-reactor wiring from * a list of reactors, so callers do not hand-roll N `createEventBus` calls. * * @module broadcast */ /** One independent broadcast reactor: its consumer group + routes. */ interface BroadcastReactor { /** Consumer group — MUST be DISTINCT per reactor for true fan-out (a shared group load-balances). */ readonly group: string; /** The event routes (handlers) this reactor subscribes with. */ readonly routes: EventRoute[]; /** Optional per-reactor middleware (retry/DLQ/custom). */ readonly middleware?: MiddlewareConfig; } /** Options for {@link createBroadcastSubscribers}. */ interface BroadcastSubscribersOptions { /** * The broker adapter. Pass ONE shared instance (fine for `MemoryAdapter` in * tests, where all buses share the in-memory registry) OR a factory invoked * once per reactor (use this for real brokers so each reactor bus gets its * own connection / durable consumer). */ readonly adapter: EventAdapter | (() => EventAdapter); /** The independent reactors — each becomes its own EventBus with its own group. */ readonly reactors: BroadcastReactor[]; /** Shared per-bus handler timeout (ms). */ readonly handlerTimeout?: number; /** Shared per-bus drain timeout (ms). */ readonly drainTimeout?: number; /** Shared abort signal for graceful shutdown. */ readonly signal?: AbortSignal; } /** * Build one `EventBus` per reactor (each with its own consumer group) so a * single published event fans out to ALL reactors independently. * * The returned buses are NOT started — start them yourself (e.g. * `await Promise.all(buses.map((b) => b.start()))`) and stop them on shutdown. * * Throws if two reactors share a consumer group (that would load-balance / steal * instead of fanning out). * * @example * ```typescript * const buses = createBroadcastSubscribers({ * adapter: () => new NatsAdapter({ servers, stream: 'orders' }), * reactors: [ * { group: 'pricing', routes: [pricingRoutes] }, * { group: 'audit', routes: [auditRoutes] }, * { group: 'notify', routes: [notifyRoutes] }, * ], * }); * await Promise.all(buses.map((bus) => bus.start())); * ``` */ declare function createBroadcastSubscribers(options: BroadcastSubscribersOptions): Array; /** * EventBus implementation. * * Central component managing adapter, routes, middleware pipeline, * and event publishing. Implements EventBusLike for server integration. * * @module EventBus */ /** * Derive a service identifier from registered proto service type names. * * Extracts unique package names and appends the hostname for * replica disambiguation. * * @returns Service name in format `"{packages}@{hostname}"`, or undefined if no services registered */ declare function deriveServiceName(serviceNames: readonly string[]): string | undefined; /** * Create an EventBus instance. * * @param options - EventBus configuration * @returns EventBus instance implementing EventBusLike for server integration * * @example * ```typescript * import { createEventBus, MemoryAdapter } from '@connectum/events'; * * const eventBus = createEventBus({ * adapter: MemoryAdapter(), * routes: [myEventRoutes], * middleware: { * retry: { maxRetries: 3, backoff: 'exponential' }, * dlq: { topic: 'my-service.dlq' }, * }, * }); * * await eventBus.start(); * await eventBus.publish(UserCreatedSchema, { id: '1', email: 'a@b.c', name: 'Test' }); * await eventBus.stop(); * ``` */ declare function createEventBus(options: EventBusOptions): EventBus & EventBusLike; /** * EventContext implementation. * * Wraps a RawEvent with idempotent ack/nack operations * and exposes structured metadata. * * @module EventContext */ /** * Create an EventContext from raw event data. * * The ack/nack operations are idempotent -- calling either * multiple times has no effect after the first call. */ declare function createEventContext(init: EventContextInit): EventContext; /** * EventRouter implementation. * * Mirrors ConnectRPC's ConnectRouter pattern for event handlers. * Iterates service methods, resolves topics, creates entries. * * @module EventRouter */ /** * EventRouter implementation that collects route entries. */ declare class EventRouterImpl implements EventRouter { readonly entries: EventRouteEntry[]; readonly serviceNames: string[]; service(serviceDesc: S, handlers: ServiceEventHandlers): void; } /** * Typed error classes for event processing control flow. * * Use NonRetryableError to skip retry middleware entirely. * Use RetryableError to force retry regardless of the retryableErrors predicate. * * Both use Symbol.for() branding for cross-realm compatibility. * * @module errors */ declare const NON_RETRYABLE: unique symbol; declare const RETRYABLE: unique symbol; /** * Error that should never be retried. * * When thrown inside an event handler, the retry middleware * skips all retries and re-throws immediately — regardless * of the `retryableErrors` predicate. * * @example * ```typescript * throw new NonRetryableError("Invalid payload", { cause: validationError }); * ``` */ declare class NonRetryableError extends Error { readonly [NON_RETRYABLE] = true; constructor(message: string, options?: ErrorOptions); /** * Check if an error is branded as non-retryable. * Works across realms (Symbol.for is global). */ static isNonRetryable(error: unknown): error is { [NON_RETRYABLE]: true; }; } /** * Error that should always be retried. * * When thrown inside an event handler, the retry middleware * retries the handler — even if the `retryableErrors` predicate * would otherwise reject it. * * @example * ```typescript * throw new RetryableError("Temporary DB connection lost", { cause: dbError }); * ``` */ declare class RetryableError extends Error { readonly [RETRYABLE] = true; constructor(message: string, options?: ErrorOptions); /** * Check if an error is branded as retryable. * Works across realms (Symbol.for is global). */ static isRetryable(error: unknown): error is { [RETRYABLE]: true; }; } /** * In-memory event adapter for testing. * * Supports wildcard patterns (* and >) and synchronous * delivery. No external broker required. * * @module MemoryAdapter */ /** * Create an in-memory adapter for testing event flows * without an external message broker. */ declare function MemoryAdapter(): EventAdapter; /** * Dead Letter Queue (DLQ) middleware for event processing. * * When a handler fails after all retries are exhausted, * publishes the event to a configured DLQ topic and * acknowledges the original event. * * @module middleware/dlq */ /** * Create a DLQ middleware that catches errors from inner middleware * (retry), publishes to DLQ topic, and acks the original. */ declare function dlqMiddleware(options: DlqOptions, adapter: EventAdapter): EventMiddleware; /** * Retry middleware for event processing. * * Retries failed event handlers with configurable backoff strategy. * Integrates with the middleware pipeline. * * @module middleware/retry */ /** * Create a retry middleware with configurable options. * * On handler failure, retries up to `maxRetries` times with * the configured backoff strategy. If all retries exhaust, * the error is re-thrown for the next middleware (e.g., DLQ). */ declare function retryMiddleware(options?: RetryOptions): EventMiddleware; /** * Middleware composition for event processing pipeline. * * Uses a dispatch pattern (similar to Koa) to build an onion model * where each middleware wraps the next, with double-call protection. * * @module middleware */ /** * Compose an array of middleware into a single handler. * * Middleware is applied from left to right (outer to inner). * The innermost function is the actual event handler. * * Uses a dispatch pattern that guards against double next() invocation. * * @param middlewares - Middleware functions to compose * @param handler - The final handler (innermost) * @returns Composed handler function */ declare function composeMiddleware(middlewares: EventMiddleware[], handler: (event: RawEvent, ctx: EventContext) => Promise): (event: RawEvent, ctx: EventContext) => Promise; /** * Topic name resolution from proto method descriptors. * * Resolves the event topic for a method by checking the custom * `(event).topic` proto option, falling back to the input * message's typeName. * * @module topic */ /** * Resolve the topic name for an event handler method. * * Priority: * 1. Custom option: `option (connectum.events.v1.event).topic = "custom"` * 2. Default: `method.input.typeName` (e.g., "mypackage.UserCreated") */ declare function resolveTopicName(method: DescMethod): string; /** * Wildcard pattern matching for event topics. * * Supports two wildcard tokens: * - `*` matches exactly one segment (dot-separated) * - `>` matches one or more trailing segments * * @module wildcard */ /** * Match a topic against a wildcard pattern. * * @param pattern - Pattern with optional `*` and `>` wildcards * @param topic - Concrete topic name to match * @returns true if the topic matches the pattern * * @example * ```typescript * matchPattern("user.*", "user.created") // true * matchPattern("user.*", "user.created.v2") // false * matchPattern("user.>", "user.created") // true * matchPattern("user.>", "user.created.v2") // true * matchPattern("user.created", "user.created") // true * ``` */ declare function matchPattern(pattern: string, topic: string): boolean; export { type AdapterContext, type BroadcastReactor, type BroadcastSubscribersOptions, type DlqOptions, type EventAdapter, type EventBus, type EventBusOptions, type EventContext, type EventContextInit, type EventHandlerConfig, type EventMiddleware, type EventMiddlewareNext, type EventRoute, type EventRouteEntry, type EventRouter, EventRouterImpl, type EventSubscription, MemoryAdapter, type MiddlewareConfig, NonRetryableError, type PublishOptions, type RawEvent, type RawEventHandler, type RawSubscribeOptions, type RetryOptions, RetryableError, type ServiceEventHandlers, type TypedEventHandler, composeMiddleware, createBroadcastSubscribers, createEventBus, createEventContext, deriveServiceName, dlqMiddleware, matchPattern, resolveTopicName, retryMiddleware };