@message-queue-toolkit/core
Version:
Useful utilities, interfaces and base classes for message queue handling. Supports AMQP and SQS with a common abstraction on top currently
711 lines (557 loc) • 20.3 kB
Markdown
# -queue-toolkit/core
Core library for message-queue-toolkit. Provides foundational abstractions, utilities, and base classes for building message queue publishers and consumers.
## Table of Contents
- [Installation](#installation)
- [Overview](#overview)
- [Core Concepts](#core-concepts)
- [Message Schemas](#message-schemas)
- [Message Type Resolution](#message-type-resolution)
- [Handler Configuration](#handler-configuration)
- [Pre-handlers and Barriers](#pre-handlers-and-barriers)
- [Handler Spies](#handler-spies)
- [Key Classes](#key-classes)
- [AbstractQueueService](#abstractqueueservice)
- [MessageHandlerConfigBuilder](#messagehandlerconfigbuilder)
- [HandlerContainer](#handlercontainer)
- [MessageSchemaContainer](#messageschemacontainer)
- [AbstractPublisherManager](#abstractpublishermanager)
- [DomainEventEmitter](#domaineventemitter)
- [Utilities](#utilities)
- [Error Classes](#error-classes)
- [Message Deduplication](#message-deduplication)
- [Payload Offloading](#payload-offloading)
- [API Reference](#api-reference)
- [Links](#links)
## Installation
```bash
npm install -queue-toolkit/core zod
```
**Peer Dependencies:**
- `zod` - Schema validation
## Overview
The core package provides the foundational building blocks used by all protocol-specific implementations (SQS, SNS, AMQP, Kafka, GCP Pub/Sub). It includes:
- **Base Classes**: Abstract classes for publishers and consumers
- **Handler System**: Type-safe message routing and handling
- **Validation**: Zod schema validation and message parsing
- **Utilities**: Retry logic, date handling, environment utilities
- **Testing**: Handler spies for testing async message flows
- **Extensibility**: Interfaces for payload stores, deduplication stores, and metrics
## Core Concepts
### Message Schemas
Messages are validated using Zod schemas. The library uses configurable field names:
- **`messageTypeResolver`**: Configuration for resolving the message type discriminator (see [Message Type Resolution](#message-type-resolution))
- **`messageIdField`** (default: `'id'`): Field containing the message ID
- **`messageTimestampField`** (default: `'timestamp'`): Field containing the timestamp
```typescript
import { z } from 'zod'
const UserCreatedSchema = z.object({
id: z.string(),
type: z.literal('user.created'), // Used for routing
timestamp: z.string().datetime(),
userId: z.string(),
email: z.string().email(),
})
type UserCreated = z.infer<typeof UserCreatedSchema>
```
### Message Type Resolution
#### What is Message Type?
The **message type** is a discriminator field that identifies what kind of event or command a message represents. It's used for:
1. **Routing**: Directing messages to the appropriate handler based on their type
2. **Schema validation**: Selecting the correct Zod schema to validate the message
3. **Observability**: Tracking metrics and logs per message type
In a typical event-driven architecture, a single queue or topic may receive multiple types of messages. For example, a `user-events` queue might receive `user.created`, `user.updated`, and `user.deleted` events. The message type tells the consumer which handler should process each message.
#### Configuration Options
The `messageTypeResolver` configuration supports three modes:
##### Mode 1: Field Path (Simple)
Use when the message type is a field in the parsed message body. Supports dot notation for nested paths:
```typescript
{
messageTypeResolver: { messageTypePath: 'type' }, // Extracts from message.type
}
// Nested path example
{
messageTypeResolver: { messageTypePath: 'metadata.eventType' }, // Extracts from message.metadata.eventType
}
```
##### Mode 2: Literal (Constant)
Use when all messages are of the same type:
```typescript
{
messageTypeResolver: { literal: 'order.created' }, // All messages treated as this type
}
```
##### Mode 3: Custom Resolver (Flexible)
Use for complex scenarios where the type needs to be extracted from message attributes, nested fields, or requires transformation:
```typescript
import type { MessageTypeResolverConfig } from '@message-queue-toolkit/core'
const resolverConfig: MessageTypeResolverConfig = {
resolver: ({ messageData, messageAttributes }) => {
// Your custom logic here
return 'resolved.type'
},
}
```
**Important:** The resolver function must always return a valid string. If the type cannot be determined, either return a default type or throw an error with a descriptive message.
#### Real-World Examples by Platform
##### AWS SQS (Plain)
When publishing your own events directly to SQS, you control the message format:
```typescript
// Message format you control
{
"id": "msg-123",
"type": "order.created", // Your type field
"timestamp": "2024-01-15T10:30:00Z",
"payload": {
"orderId": "order-456",
"amount": 99.99
}
}
// Configuration
{
messageTypeResolver: { messageTypePath: 'type' },
}
```
##### AWS EventBridge → SQS
EventBridge events have a specific structure with `detail-type`:
```typescript
// EventBridge event structure delivered to SQS
{
"version": "0",
"id": "12345678-1234-1234-1234-123456789012",
"detail-type": "Order Created", // EventBridge uses detail-type
"source": "com.myapp.orders",
"account": "123456789012",
"time": "2024-01-15T10:30:00Z",
"region": "us-east-1",
"detail": {
"orderId": "order-456",
"amount": 99.99
}
}
// Configuration
{
messageTypeResolver: { messageTypePath: 'detail-type' },
}
// Or with resolver for normalization
{
messageTypeResolver: {
resolver: ({ messageData }) => {
const data = messageData as { 'detail-type'?: string; source?: string }
const detailType = data['detail-type']
if (!detailType) throw new Error('detail-type is required')
// Optionally normalize: "Order Created" → "order.created"
return detailType.toLowerCase().replace(/ /g, '.')
},
},
}
```
##### AWS SNS → SQS
SNS messages wrapped in SQS have the actual payload in the `Message` field (handled automatically by the library after unwrapping):
```typescript
// After SNS envelope unwrapping, you get your original message
{
"id": "msg-123",
"type": "user.signup.completed",
"userId": "user-789",
"email": "user@example.com"
}
// Configuration
{
messageTypeResolver: { messageTypePath: 'type' },
}
```
##### Apache Kafka
Kafka typically uses topic-based routing, but you may still need message types within a topic:
```typescript
// Kafka message value (JSON)
{
"eventType": "inventory.reserved",
"eventId": "evt-123",
"timestamp": 1705312200000,
"data": {
"sku": "PROD-001",
"quantity": 5
}
}
// Configuration
{
messageTypeResolver: { messageTypePath: 'eventType' },
}
// Or using Kafka headers (via custom resolver)
{
messageTypeResolver: {
resolver: ({ messageData, messageAttributes }) => {
// Kafka headers are passed as messageAttributes
if (messageAttributes?.['ce_type']) {
return messageAttributes['ce_type'] as string // CloudEvents header
}
const data = messageData as { eventType?: string }
if (!data.eventType) throw new Error('eventType required')
return data.eventType
},
},
}
```
##### Google Cloud Pub/Sub (Your Own Events)
When you control the message format in Pub/Sub:
```typescript
// Your message (base64-decoded from data field)
{
"type": "payment.processed",
"paymentId": "pay-123",
"amount": 150.00,
"currency": "USD"
}
// Configuration
{
messageTypeResolver: { messageTypePath: 'type' },
}
```
##### Google Cloud Pub/Sub (Cloud Storage Notifications)
Cloud Storage notifications put the event type in message **attributes**, not the data payload:
```typescript
// Pub/Sub message structure for Cloud Storage notifications
{
"data": "eyJraW5kIjoic3RvcmFnZSMgb2JqZWN0In0=", // Base64-encoded object metadata
"attributes": {
"eventType": "OBJECT_FINALIZE", // Type is HERE, not in data!
"bucketId": "my-bucket",
"objectId": "path/to/file.jpg",
"objectGeneration": "1705312200000"
},
"messageId": "123456789",
"publishTime": "2024-01-15T10:30:00Z"
}
// Configuration - must use resolver to access attributes
{
messageTypeResolver: {
resolver: ({ messageAttributes }) => {
const eventType = messageAttributes?.eventType as string
if (!eventType) {
throw new Error('eventType attribute required for Cloud Storage notifications')
}
// Map GCS event types to your internal types
const typeMap: Record<string, string> = {
'OBJECT_FINALIZE': 'storage.object.created',
'OBJECT_DELETE': 'storage.object.deleted',
'OBJECT_ARCHIVE': 'storage.object.archived',
'OBJECT_METADATA_UPDATE': 'storage.object.metadataUpdated',
}
return typeMap[eventType] ?? eventType
},
},
}
```
##### Google Cloud Pub/Sub (Eventarc / CloudEvents)
Eventarc delivers events in CloudEvents format:
```typescript
// CloudEvents structured format
{
"specversion": "1.0",
"type": "google.cloud.storage.object.v1.finalized", // CloudEvents type
"source": "//storage.googleapis.com/projects/_/buckets/my-bucket",
"id": "1234567890",
"time": "2024-01-15T10:30:00Z",
"datacontenttype": "application/json",
"data": {
"bucket": "my-bucket",
"name": "path/to/file.jpg",
"contentType": "image/jpeg"
}
}
// Configuration
{
messageTypeResolver: { messageTypePath: 'type' }, // CloudEvents type is at root level
}
// Or with mapping to simpler types
{
messageTypeResolver: {
resolver: ({ messageData }) => {
const data = messageData as { type?: string }
const ceType = data.type
if (!ceType) throw new Error('CloudEvents type required')
// Map verbose CloudEvents types to simpler names
if (ceType.includes('storage.object') && ceType.includes('finalized')) {
return 'storage.object.created'
}
if (ceType.includes('storage.object') && ceType.includes('deleted')) {
return 'storage.object.deleted'
}
return ceType
},
},
}
```
##### Single-Type Queues (Any Platform)
When a queue/subscription only ever receives one type of message, use `literal`:
```typescript
// Dedicated queue for order.created events only
{
messageTypeResolver: {
literal: 'order.created',
},
}
```
This is useful for:
- Dedicated queues/subscriptions filtered to a single event type
- Legacy systems where messages don't have a type field
- Simple integrations where you know exactly what you're receiving
### Handler Configuration
Use `MessageHandlerConfigBuilder` to configure handlers for different message types:
```typescript
import { MessageHandlerConfigBuilder } from '@message-queue-toolkit/core'
const handlers = new MessageHandlerConfigBuilder<SupportedMessages, ExecutionContext>()
.addConfig(
UserCreatedSchema,
async (message, context, preHandlingOutputs) => {
await context.userService.createUser(message.userId)
return { result: 'success' }
}
)
.addConfig(
UserUpdatedSchema,
async (message, context, preHandlingOutputs) => {
await context.userService.updateUser(message.userId, message.changes)
return { result: 'success' }
}
)
.build()
```
### Pre-handlers and Barriers
**Pre-handlers** are middleware functions that run before the main handler:
```typescript
const preHandlers = [
(message, context, output, next) => {
// Enrich context, validate prerequisites, etc.
output.logger = context.logger.child({ messageId: message.id })
next({ result: 'success' })
},
]
```
**Barriers** control whether a message should be processed or retried later:
```typescript
const preHandlerBarrier = async (message, context, preHandlerOutput) => {
const prerequisiteMet = await checkPrerequisite(message)
return {
isPassing: prerequisiteMet,
output: { ready: true },
}
}
```
### Handler Spies
Handler spies enable testing of async message flows:
```typescript
// Enable in consumer/publisher options
{ handlerSpy: true }
// Wait for specific messages in tests
const result = await consumer.handlerSpy.waitForMessageWithId('msg-123', 'consumed', 5000)
expect(result.userId).toBe('user-456')
```
## Key Classes
### AbstractQueueService
Base class for all queue services. Provides:
- Message serialization/deserialization
- Schema validation
- Retry logic with exponential backoff
- Payload offloading support
- Message deduplication primitives
### MessageHandlerConfigBuilder
Fluent builder for configuring message handlers:
```typescript
import { MessageHandlerConfigBuilder } from '@message-queue-toolkit/core'
const handlers = new MessageHandlerConfigBuilder<
SupportedMessages,
ExecutionContext,
PrehandlerOutput
>()
.addConfig(Schema1, handler1)
.addConfig(Schema2, handler2, {
preHandlers: [preHandler1, preHandler2],
preHandlerBarrier: barrierFn,
messageLogFormatter: (msg) => ({ id: msg.id }),
})
.build()
```
#### Handler Configuration Options
The third parameter to `addConfig` accepts these options:
| Option | Type | Description |
|--------|------|-------------|
| `messageType` | `string` | Explicit message type for routing. Required when using custom resolver. |
| `messageLogFormatter` | `(message) => unknown` | Custom formatter for logging |
| `preHandlers` | `Prehandler[]` | Middleware functions run before the handler |
| `preHandlerBarrier` | `BarrierCallback` | Barrier function for out-of-order message handling |
#### Explicit Message Type
When using a custom resolver function (`messageTypeResolver: { resolver: fn }`), the message type cannot be automatically extracted from schemas at registration time. You must provide an explicit `messageType` for each handler:
```typescript
const handlers = new MessageHandlerConfigBuilder<SupportedMessages, Context>()
.addConfig(
STORAGE_OBJECT_SCHEMA,
handleObjectCreated,
{ messageType: 'storage.object.created' } // Required for custom resolver
)
.addConfig(
STORAGE_DELETE_SCHEMA,
handleObjectDeleted,
{ messageType: 'storage.object.deleted' } // Required for custom resolver
)
.build()
const container = new HandlerContainer({
messageHandlers: handlers,
messageTypeResolver: {
resolver: ({ messageAttributes }) => {
// Map external event types to your internal types
const eventType = messageAttributes?.eventType as string
if (eventType === 'OBJECT_FINALIZE') return 'storage.object.created'
if (eventType === 'OBJECT_DELETE') return 'storage.object.deleted'
throw new Error(`Unknown event type: ${eventType}`)
},
},
})
```
**Priority for determining handler message type:**
1. Explicit `messageType` in handler options (highest priority)
2. Literal type from `messageTypeResolver: { literal: 'type' }`
3. Extract from schema's literal field using `messageTypePath`
If the message type cannot be determined, an error is thrown during container construction.
### HandlerContainer
Routes messages to appropriate handlers based on message type:
```typescript
import { HandlerContainer } from '@message-queue-toolkit/core'
const container = new HandlerContainer({
messageHandlers: handlers,
messageTypeResolver: { messageTypePath: 'type' },
})
const handler = container.resolveHandler(message.type)
```
### MessageSchemaContainer
Manages Zod schemas and validates messages:
```typescript
import { MessageSchemaContainer } from '@message-queue-toolkit/core'
const container = new MessageSchemaContainer({
messageSchemas: [{ schema: Schema1 }, { schema: Schema2 }],
messageDefinitions: [],
messageTypeResolver: { messageTypePath: 'type' },
})
const result = container.resolveSchema(message)
if ('error' in result) {
// Handle error
} else {
const schema = result.result
}
```
### AbstractPublisherManager
Factory pattern for spawning publishers on demand:
```typescript
import { AbstractPublisherManager } from '@message-queue-toolkit/core'
// Automatically spawns publishers and fills metadata
await publisherManager.publish('user-events-topic', {
type: 'user.created',
userId: 'user-123',
})
```
### DomainEventEmitter
Event emitter for domain events:
```typescript
import { DomainEventEmitter } from '@message-queue-toolkit/core'
const emitter = new DomainEventEmitter()
emitter.on('user.created', async (event) => {
console.log('User created:', event.userId)
})
await emitter.emit('user.created', { userId: 'user-123' })
```
## Utilities
### Error Classes
```typescript
import {
MessageValidationError,
MessageInvalidFormatError,
DoNotProcessMessageError,
RetryMessageLaterError,
} from '-queue-toolkit/core'
// Validation failed
throw new MessageValidationError(zodError.issues)
// Message format is invalid (cannot parse)
throw new MessageInvalidFormatError({ message: 'Invalid JSON' })
// Do not process this message (skip without retry)
throw new DoNotProcessMessageError({ message: 'Duplicate message' })
// Retry this message later
throw new RetryMessageLaterError({ message: 'Dependency not ready' })
```
### Message Deduplication
Interfaces for implementing deduplication stores:
```typescript
import type { MessageDeduplicationStore, ReleasableLock } from '@message-queue-toolkit/core'
// Implement custom deduplication store
class MyDeduplicationStore implements MessageDeduplicationStore {
async keyExists(key: string): Promise<boolean> { /* ... */ }
async setKey(key: string, ttlSeconds: number): Promise<void> { /* ... */ }
async acquireLock(key: string, options: AcquireLockOptions): Promise<ReleasableLock> { /* ... */ }
}
```
### Payload Offloading
Interfaces for implementing payload stores:
```typescript
import type { PayloadStore, PayloadStoreConfig } from '@message-queue-toolkit/core'
// Implement custom payload store
class MyPayloadStore implements PayloadStore {
async storePayload(payload: Buffer, messageId: string): Promise<PayloadRef> { /* ... */ }
async retrievePayload(ref: PayloadRef): Promise<Buffer> { /* ... */ }
}
```
## API Reference
### Types
```typescript
// Handler result type
type HandlerResult = Either<'retryLater', 'success'>
// Pre-handler signature
type Prehandler<Message, Context, Output> = (
message: Message,
context: Context,
output: Output,
next: (result: PrehandlerResult) => void
) => void
// Barrier signature
type BarrierCallback<Message, Context, PrehandlerOutput, BarrierOutput> = (
message: Message,
context: Context,
preHandlerOutput: PrehandlerOutput
) => Promise<BarrierResult<BarrierOutput>>
// Barrier result
type BarrierResult<Output> =
| { isPassing: true; output: Output }
| { isPassing: false; output?: never }
// Message type resolver context
type MessageTypeResolverContext = {
messageData: unknown
messageAttributes?: Record<string, unknown>
}
// Message type resolver function
type MessageTypeResolverFn = (context: MessageTypeResolverContext) => string
// Message type resolver configuration
type MessageTypeResolverConfig =
| { messageTypePath: string } // Extract from field at root of message data
| { literal: string } // Constant type for all messages
| { resolver: MessageTypeResolverFn } // Custom resolver function
```
### Utility Functions
```typescript
// Environment utilities
isProduction(): boolean
reloadConfig(): void
// Date utilities
isRetryDateExceeded(timestamp: string | Date, maxRetryDuration: number): boolean
// Message parsing
parseMessage<T>(data: unknown, schema: ZodSchema<T>): ParseMessageResult<T>
// Wait utilities
waitAndRetry<T>(fn: () => Promise<T>, options: WaitAndRetryOptions): Promise<T>
// Object utilities
objectMatches(obj: unknown, pattern: unknown): boolean
isShallowSubset(subset: object, superset: object): boolean
```
## Links
- [Main Repository](https://github.com/kibertoad/message-queue-toolkit)
- [SQS Package](https://www.npmjs.com/package/@message-queue-toolkit/sqs)
- [SNS Package](https://www.npmjs.com/package/@message-queue-toolkit/sns)
- [AMQP Package](https://www.npmjs.com/package/@message-queue-toolkit/amqp)
- [GCP Pub/Sub Package](https://www.npmjs.com/package/@message-queue-toolkit/gcp-pubsub)
- [Kafka Package](https://www.npmjs.com/package/@message-queue-toolkit/kafka)