From 1573c189230bb9e098d20e4b34ec974b52d363ca Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 16:45:42 +0200 Subject: [PATCH 01/19] =?UTF-8?q?feat(pubsub):=20=E2=9C=A8=20Add=20consume?= =?UTF-8?q?r=20middleware=20pipeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/consumer.md | 36 +++++- src/Kernel.ts | 4 + src/adapters/pubsub/Consumer.ts | 35 +++--- .../pubsub/ConsumerMiddlewarePipeline.ts | 38 ++++++ .../pubsub/CorrelationConsumerMiddleware.ts | 36 ++++++ .../CorrelationConsumerMiddlewareOptions.ts | 13 ++ .../pubsub/IdempotencyConsumerMiddleware.ts | 28 +++++ .../IdempotencyConsumerMiddlewareOptions.ts | 13 ++ .../pubsub/InMemoryIdempotencyStore.ts | 15 +++ .../pubsub/RetryConsumerMiddleware.ts | 53 ++++++++ .../pubsub/RetryConsumerMiddlewareOptions.ts | 16 +++ src/adapters/pubsub/index.ts | 9 ++ .../kernel/ConsumerExecutionContext.ts | 12 ++ src/contracts/kernel/ConsumerMiddleware.ts | 8 +- src/contracts/kernel/ConsumerNext.ts | 1 + src/contracts/kernel/IdempotencyStore.ts | 4 + src/contracts/kernel/RetryDelayResolver.ts | 7 ++ src/contracts/kernel/RetryPredicate.ts | 7 ++ src/contracts/kernel/index.ts | 5 + tests/adapters/pubsub/Consumer.test.mjs | 118 +++++++++++++++++- 20 files changed, 435 insertions(+), 23 deletions(-) create mode 100644 src/adapters/pubsub/ConsumerMiddlewarePipeline.ts create mode 100644 src/adapters/pubsub/CorrelationConsumerMiddleware.ts create mode 100644 src/adapters/pubsub/CorrelationConsumerMiddlewareOptions.ts create mode 100644 src/adapters/pubsub/IdempotencyConsumerMiddleware.ts create mode 100644 src/adapters/pubsub/IdempotencyConsumerMiddlewareOptions.ts create mode 100644 src/adapters/pubsub/InMemoryIdempotencyStore.ts create mode 100644 src/adapters/pubsub/RetryConsumerMiddleware.ts create mode 100644 src/adapters/pubsub/RetryConsumerMiddlewareOptions.ts create mode 100644 src/contracts/kernel/ConsumerExecutionContext.ts create mode 100644 src/contracts/kernel/ConsumerNext.ts create mode 100644 src/contracts/kernel/IdempotencyStore.ts create mode 100644 src/contracts/kernel/RetryDelayResolver.ts create mode 100644 src/contracts/kernel/RetryPredicate.ts diff --git a/docs/reference/consumer.md b/docs/reference/consumer.md index edeca78..fcf94fa 100644 --- a/docs/reference/consumer.md +++ b/docs/reference/consumer.md @@ -25,11 +25,41 @@ correlation IDs around handler execution: ```ts kernel.registerConsumerMiddleware({ - async handle(event, next) { + async handle(event, next, context) { + logger.info(`Handling ${context.eventName}`); await next(); }, }); ``` -Middleware receives the event and a `next` callback. The kernel does not include -a full outbox or idempotency implementation. +Middleware receives the event, the next pipeline callback and a +`ConsumerExecutionContext` containing queue, exchange, event id, correlation id +and causation id. + +## Built-in Middleware + +The pub/sub adapter package includes small middleware implementations for common +consumer concerns. They are intentionally infrastructure-level primitives, not a +full outbox implementation. + +```ts +import { + CorrelationConsumerMiddleware, + IdempotencyConsumerMiddleware, + InMemoryIdempotencyStore, + RetryConsumerMiddleware, +} from '@haskou/ddd-kernel/adapters/pubsub'; + +kernel.registerConsumerMiddleware( + new CorrelationConsumerMiddleware(), + new IdempotencyConsumerMiddleware({ + store: new InMemoryIdempotencyStore(), + }), + new RetryConsumerMiddleware({ + maxAttempts: 3, + }), +); +``` + +Use a custom `IdempotencyStore` for durable idempotency. The in-memory store is +only useful for tests and single-process applications. diff --git a/src/Kernel.ts b/src/Kernel.ts index 015f849..6c98882 100644 --- a/src/Kernel.ts +++ b/src/Kernel.ts @@ -61,6 +61,10 @@ export class Kernel { return Kernel.getActiveKernel().logger; } + public static get active(): Kernel { + return Kernel.getActiveKernel(); + } + public static get rootDirectory(): string { return process.cwd(); } diff --git a/src/adapters/pubsub/Consumer.ts b/src/adapters/pubsub/Consumer.ts index 6e24d3f..2c86cd4 100644 --- a/src/adapters/pubsub/Consumer.ts +++ b/src/adapters/pubsub/Consumer.ts @@ -1,27 +1,28 @@ -import type { ConsumerMiddleware } from '../../contracts/index.js'; import type { DomainEventConsumer } from '../../domain/DomainEventConsumer.js'; import type { DomainEvent } from '../../domain/index.js'; import { Kernel } from '../../Kernel.js'; +import { ConsumerMiddlewarePipeline } from './ConsumerMiddlewarePipeline.js'; export abstract class Consumer { constructor(private readonly consumer: DomainEventConsumer) {} - private async runMiddleware( - event: DomainEvent, - middlewares: readonly ConsumerMiddleware[], - index: number, - ): Promise { - const middleware = middlewares[index]; - - if (!middleware) { - await this.handler(event); - - return; - } - - await middleware.handle(event, () => - this.runMiddleware(event, middlewares, index + 1), + private async runMiddleware(event: DomainEvent): Promise { + const pipeline = new ConsumerMiddlewarePipeline(Kernel.consumerMiddleware); + + await pipeline.execute( + event, + { + causationId: event.getCausationId(), + correlationId: event.getCorrelationId(), + eventId: event.eventId, + eventName: this.eventName, + exchange: this.exchange, + kernel: Kernel.active, + metadata: {}, + queueName: this.queueName, + }, + () => this.handler(event), ); } @@ -41,7 +42,7 @@ export abstract class Consumer { this.eventName, this.domainEvent, this.exchange, - (event) => this.runMiddleware(event, Kernel.consumerMiddleware, 0), + (event) => this.runMiddleware(event), ); } diff --git a/src/adapters/pubsub/ConsumerMiddlewarePipeline.ts b/src/adapters/pubsub/ConsumerMiddlewarePipeline.ts new file mode 100644 index 0000000..7fe83d2 --- /dev/null +++ b/src/adapters/pubsub/ConsumerMiddlewarePipeline.ts @@ -0,0 +1,38 @@ +import type { + ConsumerExecutionContext, + ConsumerMiddleware, +} from '../../contracts/index.js'; +import type { DomainEvent } from '../../domain/index.js'; + +export class ConsumerMiddlewarePipeline { + constructor(private readonly middlewares: readonly ConsumerMiddleware[]) {} + + private async run( + event: DomainEvent, + context: ConsumerExecutionContext, + handler: () => Promise, + index: number, + ): Promise { + const middleware = this.middlewares[index]; + + if (!middleware) { + await handler(); + + return; + } + + await middleware.handle( + event, + () => this.run(event, context, handler, index + 1), + context, + ); + } + + public async execute( + event: DomainEvent, + context: ConsumerExecutionContext, + handler: () => Promise, + ): Promise { + await this.run(event, context, handler, 0); + } +} diff --git a/src/adapters/pubsub/CorrelationConsumerMiddleware.ts b/src/adapters/pubsub/CorrelationConsumerMiddleware.ts new file mode 100644 index 0000000..4551f3a --- /dev/null +++ b/src/adapters/pubsub/CorrelationConsumerMiddleware.ts @@ -0,0 +1,36 @@ +import type { + ConsumerExecutionContext, + ConsumerMiddleware, + ConsumerNext, +} from '../../contracts/index.js'; +import type { DomainEvent } from '../../domain/index.js'; +import type { CorrelationConsumerMiddlewareOptions } from './CorrelationConsumerMiddlewareOptions.js'; + +export class CorrelationConsumerMiddleware implements ConsumerMiddleware { + constructor( + private readonly options: CorrelationConsumerMiddlewareOptions = {}, + ) {} + + public async handle( + event: DomainEvent, + next: ConsumerNext, + context: ConsumerExecutionContext, + ): Promise { + const correlationId = + this.options.correlationId?.(event, context) ?? context.correlationId; + const causationId = + this.options.causationId?.(event, context) ?? context.causationId; + + if (correlationId) { + event.withCorrelationId(correlationId); + } + + if (causationId) { + event.withCausationId(causationId); + } + + await next(); + } +} + +export default CorrelationConsumerMiddleware; diff --git a/src/adapters/pubsub/CorrelationConsumerMiddlewareOptions.ts b/src/adapters/pubsub/CorrelationConsumerMiddlewareOptions.ts new file mode 100644 index 0000000..7cace69 --- /dev/null +++ b/src/adapters/pubsub/CorrelationConsumerMiddlewareOptions.ts @@ -0,0 +1,13 @@ +import type { ConsumerExecutionContext } from '../../contracts/index.js'; +import type { DomainEvent } from '../../domain/index.js'; + +export interface CorrelationConsumerMiddlewareOptions { + readonly causationId?: ( + event: DomainEvent, + context: ConsumerExecutionContext, + ) => string | undefined; + readonly correlationId?: ( + event: DomainEvent, + context: ConsumerExecutionContext, + ) => string | undefined; +} diff --git a/src/adapters/pubsub/IdempotencyConsumerMiddleware.ts b/src/adapters/pubsub/IdempotencyConsumerMiddleware.ts new file mode 100644 index 0000000..930734d --- /dev/null +++ b/src/adapters/pubsub/IdempotencyConsumerMiddleware.ts @@ -0,0 +1,28 @@ +import type { + ConsumerExecutionContext, + ConsumerMiddleware, + ConsumerNext, +} from '../../contracts/index.js'; +import type { DomainEvent } from '../../domain/index.js'; +import type { IdempotencyConsumerMiddlewareOptions } from './IdempotencyConsumerMiddlewareOptions.js'; + +export class IdempotencyConsumerMiddleware implements ConsumerMiddleware { + constructor(private readonly options: IdempotencyConsumerMiddlewareOptions) {} + + public async handle( + event: DomainEvent, + next: ConsumerNext, + context: ConsumerExecutionContext, + ): Promise { + const key = this.options.key?.(event, context) ?? context.eventId; + + if (await this.options.store.has(key)) { + return; + } + + await next(); + await this.options.store.mark(key); + } +} + +export default IdempotencyConsumerMiddleware; diff --git a/src/adapters/pubsub/IdempotencyConsumerMiddlewareOptions.ts b/src/adapters/pubsub/IdempotencyConsumerMiddlewareOptions.ts new file mode 100644 index 0000000..7d04f9d --- /dev/null +++ b/src/adapters/pubsub/IdempotencyConsumerMiddlewareOptions.ts @@ -0,0 +1,13 @@ +import type { + ConsumerExecutionContext, + IdempotencyStore, +} from '../../contracts/index.js'; +import type { DomainEvent } from '../../domain/index.js'; + +export interface IdempotencyConsumerMiddlewareOptions { + readonly key?: ( + event: DomainEvent, + context: ConsumerExecutionContext, + ) => string; + readonly store: IdempotencyStore; +} diff --git a/src/adapters/pubsub/InMemoryIdempotencyStore.ts b/src/adapters/pubsub/InMemoryIdempotencyStore.ts new file mode 100644 index 0000000..78db276 --- /dev/null +++ b/src/adapters/pubsub/InMemoryIdempotencyStore.ts @@ -0,0 +1,15 @@ +import type { IdempotencyStore } from '../../contracts/index.js'; + +export class InMemoryIdempotencyStore implements IdempotencyStore { + private readonly handledKeys = new Set(); + + public has(key: string): boolean { + return this.handledKeys.has(key); + } + + public mark(key: string): void { + this.handledKeys.add(key); + } +} + +export default InMemoryIdempotencyStore; diff --git a/src/adapters/pubsub/RetryConsumerMiddleware.ts b/src/adapters/pubsub/RetryConsumerMiddleware.ts new file mode 100644 index 0000000..7509275 --- /dev/null +++ b/src/adapters/pubsub/RetryConsumerMiddleware.ts @@ -0,0 +1,53 @@ +import type { + ConsumerExecutionContext, + ConsumerMiddleware, + ConsumerNext, +} from '../../contracts/index.js'; +import type { DomainEvent } from '../../domain/index.js'; +import type { RetryConsumerMiddlewareOptions } from './RetryConsumerMiddlewareOptions.js'; + +export class RetryConsumerMiddleware implements ConsumerMiddleware { + constructor(private readonly options: RetryConsumerMiddlewareOptions) {} + + private async delay( + attempt: number, + error: unknown, + context: ConsumerExecutionContext, + ): Promise { + const delayInMilliseconds = + typeof this.options.delay === 'function' + ? await this.options.delay(attempt, error, context) + : (this.options.delay ?? 0); + + if (delayInMilliseconds > 0) { + await new Promise((resolve) => setTimeout(resolve, delayInMilliseconds)); + } + } + + public async handle( + event: DomainEvent, + next: ConsumerNext, + context: ConsumerExecutionContext, + ): Promise { + for (let attempt = 1; attempt <= this.options.maxAttempts; attempt++) { + try { + await next(); + + return; + } catch (error: unknown) { + const canRetry = + attempt < this.options.maxAttempts && + (await (this.options.shouldRetry?.(error, attempt, context) ?? true)); + + if (!canRetry) { + throw error; + } + + await this.options.onRetry?.(error, attempt, context); + await this.delay(attempt, error, context); + } + } + } +} + +export default RetryConsumerMiddleware; diff --git a/src/adapters/pubsub/RetryConsumerMiddlewareOptions.ts b/src/adapters/pubsub/RetryConsumerMiddlewareOptions.ts new file mode 100644 index 0000000..5b70aa8 --- /dev/null +++ b/src/adapters/pubsub/RetryConsumerMiddlewareOptions.ts @@ -0,0 +1,16 @@ +import type { + ConsumerExecutionContext, + RetryDelayResolver, + RetryPredicate, +} from '../../contracts/index.js'; + +export interface RetryConsumerMiddlewareOptions { + readonly delay?: number | RetryDelayResolver; + readonly maxAttempts: number; + readonly onRetry?: ( + error: unknown, + attempt: number, + context: ConsumerExecutionContext, + ) => Promise | void; + readonly shouldRetry?: RetryPredicate; +} diff --git a/src/adapters/pubsub/index.ts b/src/adapters/pubsub/index.ts index e9c602a..823e299 100644 --- a/src/adapters/pubsub/index.ts +++ b/src/adapters/pubsub/index.ts @@ -1,4 +1,13 @@ export * from './Consumer.js'; +export * from './ConsumerMiddlewarePipeline.js'; +export * from './CorrelationConsumerMiddleware.js'; +export * from './CorrelationConsumerMiddlewareOptions.js'; +export * from './IdempotencyConsumerMiddleware.js'; +export * from './IdempotencyConsumerMiddlewareOptions.js'; +export * from './InMemoryIdempotencyStore.js'; +export * from './PublisherHookPipeline.js'; +export * from './RetryConsumerMiddleware.js'; +export * from './RetryConsumerMiddlewareOptions.js'; export * from './amqp/index.js'; export * from './in-memory/index.js'; export { default } from './Consumer.js'; diff --git a/src/contracts/kernel/ConsumerExecutionContext.ts b/src/contracts/kernel/ConsumerExecutionContext.ts new file mode 100644 index 0000000..ba8a3ff --- /dev/null +++ b/src/contracts/kernel/ConsumerExecutionContext.ts @@ -0,0 +1,12 @@ +import type { Kernel } from '../../Kernel.js'; + +export interface ConsumerExecutionContext { + readonly causationId?: string; + readonly correlationId?: string; + readonly eventId: string; + readonly eventName: string; + readonly exchange: string; + readonly kernel: Kernel; + readonly metadata: Readonly>; + readonly queueName: string; +} diff --git a/src/contracts/kernel/ConsumerMiddleware.ts b/src/contracts/kernel/ConsumerMiddleware.ts index 5f8d648..fa2ba96 100644 --- a/src/contracts/kernel/ConsumerMiddleware.ts +++ b/src/contracts/kernel/ConsumerMiddleware.ts @@ -1,5 +1,11 @@ import type { DomainEvent } from '../../domain/index.js'; +import type { ConsumerExecutionContext } from './ConsumerExecutionContext.js'; +import type { ConsumerNext } from './ConsumerNext.js'; export interface ConsumerMiddleware { - handle(event: DomainEvent, next: () => Promise): Promise; + handle( + event: DomainEvent, + next: ConsumerNext, + context: ConsumerExecutionContext, + ): Promise; } diff --git a/src/contracts/kernel/ConsumerNext.ts b/src/contracts/kernel/ConsumerNext.ts new file mode 100644 index 0000000..ace0f5d --- /dev/null +++ b/src/contracts/kernel/ConsumerNext.ts @@ -0,0 +1 @@ +export type ConsumerNext = () => Promise; diff --git a/src/contracts/kernel/IdempotencyStore.ts b/src/contracts/kernel/IdempotencyStore.ts new file mode 100644 index 0000000..97dda55 --- /dev/null +++ b/src/contracts/kernel/IdempotencyStore.ts @@ -0,0 +1,4 @@ +export interface IdempotencyStore { + has(key: string): Promise | boolean; + mark(key: string): Promise | void; +} diff --git a/src/contracts/kernel/RetryDelayResolver.ts b/src/contracts/kernel/RetryDelayResolver.ts new file mode 100644 index 0000000..f9bb7b3 --- /dev/null +++ b/src/contracts/kernel/RetryDelayResolver.ts @@ -0,0 +1,7 @@ +import type { ConsumerExecutionContext } from './ConsumerExecutionContext.js'; + +export type RetryDelayResolver = ( + attempt: number, + error: unknown, + context: ConsumerExecutionContext, +) => number | Promise; diff --git a/src/contracts/kernel/RetryPredicate.ts b/src/contracts/kernel/RetryPredicate.ts new file mode 100644 index 0000000..3188cf5 --- /dev/null +++ b/src/contracts/kernel/RetryPredicate.ts @@ -0,0 +1,7 @@ +import type { ConsumerExecutionContext } from './ConsumerExecutionContext.js'; + +export type RetryPredicate = ( + error: unknown, + attempt: number, + context: ConsumerExecutionContext, +) => boolean | Promise; diff --git a/src/contracts/kernel/index.ts b/src/contracts/kernel/index.ts index d22afe5..93f32c3 100644 --- a/src/contracts/kernel/index.ts +++ b/src/contracts/kernel/index.ts @@ -1,6 +1,11 @@ export * from './ConsumerMiddleware.js'; +export * from './ConsumerExecutionContext.js'; +export * from './ConsumerNext.js'; export * from './HandlerContext.js'; +export * from './IdempotencyStore.js'; export * from './KernelLogger.js'; export * from './KernelMiddleware.js'; +export * from './RetryDelayResolver.js'; +export * from './RetryPredicate.js'; export * from './ServiceResolver.js'; export * from './ShutdownHook.js'; diff --git a/tests/adapters/pubsub/Consumer.test.mjs b/tests/adapters/pubsub/Consumer.test.mjs index f40a8b7..03a118b 100644 --- a/tests/adapters/pubsub/Consumer.test.mjs +++ b/tests/adapters/pubsub/Consumer.test.mjs @@ -1,7 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { Consumer } from '../../../dist/adapters/pubsub/index.js'; +import { + Consumer, + CorrelationConsumerMiddleware, + IdempotencyConsumerMiddleware, + InMemoryIdempotencyStore, + RetryConsumerMiddleware, +} from '../../../dist/adapters/pubsub/index.js'; import { Kernel } from '../../../dist/index.js'; import { TestDomainEvent } from '../../helpers/TestDomainEvent.mjs'; @@ -46,8 +52,9 @@ test('initializes the domain event consumer with metadata and middleware chain', Kernel.consumerMiddleware.length = 0; kernel.registerConsumerMiddleware({ - async handle(receivedEvent, next) { + async handle(receivedEvent, next, context) { calls.push(['middleware:before', receivedEvent]); + calls.push(['context', context.eventId, context.queueName]); await next(); calls.push(['middleware:after', receivedEvent]); }, @@ -58,6 +65,7 @@ test('initializes the domain event consumer with metadata and middleware chain', assert.deepEqual(calls, [ ['test-queue', 'test.domain-event', TestDomainEvent, 'test-exchange'], ['middleware:before', event], + ['context', event.eventId, 'test-queue'], ['handler', event], ['middleware:after', event], ]); @@ -81,3 +89,109 @@ test('resolves legacy services through the active kernel container', () => { assert.equal(consumer.get(Service), service); }); + +test('provides correlation, idempotency and retry middleware', async () => { + const calls = []; + const event = new TestDomainEvent('aggregate-id'); + const kernel = new Kernel(); + const store = new InMemoryIdempotencyStore(); + let attempts = 0; + const domainEventConsumer = { + consume: async (queueName, eventName, EventClass, exchange, handler) => { + void queueName; + void eventName; + void EventClass; + void exchange; + await handler(event); + await handler(event); + }, + }; + const consumer = new TestConsumer(domainEventConsumer, calls); + + Kernel.consumerMiddleware.length = 0; + kernel.registerConsumerMiddleware( + new CorrelationConsumerMiddleware({ + causationId: () => 'causation-id', + correlationId: () => 'correlation-id', + }), + new IdempotencyConsumerMiddleware({ store }), + new RetryConsumerMiddleware({ + maxAttempts: 2, + onRetry: (error, attempt, context) => { + calls.push(['retry', String(error), attempt, context.eventName]); + }, + }), + { + async handle(receivedEvent, next) { + attempts++; + + if (attempts === 1) { + throw new Error('transient'); + } + + await next(); + calls.push([ + 'ids', + receivedEvent.getCorrelationId(), + receivedEvent.getCausationId(), + ]); + }, + }, + ); + + await consumer.init(); + + assert.deepEqual(calls, [ + ['retry', 'Error: transient', 1, 'test.domain-event'], + ['handler', event], + ['ids', 'correlation-id', 'causation-id'], + ]); + assert.equal(attempts, 2); +}); + +test('supports middleware defaults and retry predicates', async () => { + const event = new TestDomainEvent('aggregate-id'); + const context = { + causationId: 'context-causation-id', + correlationId: 'context-correlation-id', + eventId: 'event-id', + eventName: 'test.domain-event', + exchange: 'exchange', + kernel: new Kernel(), + metadata: {}, + queueName: 'queue', + }; + const calls = []; + const store = new InMemoryIdempotencyStore(); + + await new CorrelationConsumerMiddleware().handle( + event, + async () => calls.push(['correlation']), + context, + ); + await new IdempotencyConsumerMiddleware({ + key: () => 'custom-key', + store, + }).handle(event, async () => calls.push(['idempotency']), context); + + await assert.rejects( + () => + new RetryConsumerMiddleware({ + delay: () => 1, + maxAttempts: 2, + shouldRetry: () => false, + }).handle( + event, + async () => { + throw new Error('permanent'); + }, + context, + ), + /permanent/, + ); + + assert.equal(event.getCorrelationId(), 'context-correlation-id'); + assert.equal(event.getCausationId(), 'context-causation-id'); + assert.equal(await store.has('custom-key'), true); + assert.deepEqual(calls, [['correlation'], ['idempotency']]); +}); From 9646a7936ae6c530f7e63386b90a044f1b9e3f77 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 16:45:52 +0200 Subject: [PATCH 02/19] =?UTF-8?q?feat(pubsub):=20=E2=9C=A8=20Add=20publish?= =?UTF-8?q?er=20hook=20extension=20points?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guides/adapters.md | 38 +++++ docs/guides/amqp-pubsub.md | 5 + src/adapters/pubsub/PublisherHookPipeline.ts | 38 +++++ .../pubsub/amqp/AmqpMessageBusAdapter.ts | 39 ++++- .../amqp/AmqpMessageBusAdapterOptions.ts | 2 + .../pubsub/in-memory/InMemoryEventBus.ts | 27 +++- .../pubsub/in-memory/InMemoryPubSub.ts | 27 +++- src/contracts/pubsub/MessageBus.ts | 7 + src/contracts/pubsub/PublishContext.ts | 10 ++ src/contracts/pubsub/PublisherHook.ts | 10 ++ src/contracts/pubsub/index.ts | 3 + .../amqp/AmqpMessageBusAdapter.test.mjs | 133 ++++++++++-------- .../in-memory/InMemoryEventBus.test.mjs | 28 ++++ .../pubsub/in-memory/InMemoryPubSub.test.mjs | 65 ++++++++- 14 files changed, 359 insertions(+), 73 deletions(-) create mode 100644 src/adapters/pubsub/PublisherHookPipeline.ts create mode 100644 src/contracts/pubsub/MessageBus.ts create mode 100644 src/contracts/pubsub/PublishContext.ts create mode 100644 src/contracts/pubsub/PublisherHook.ts diff --git a/docs/guides/adapters.md b/docs/guides/adapters.md index dc5e825..0c566ea 100644 --- a/docs/guides/adapters.md +++ b/docs/guides/adapters.md @@ -27,3 +27,41 @@ export default class MyPublisher implements DomainEventPublisher { If an adapter needs a third-party dependency, expose it through a subpath and mark that dependency as an optional peer dependency. + +## Message Bus Hooks + +Message bus adapters can expose publisher hooks so applications can attach +replicated publishers, websocket notifications, tracing or auditing without +wrapping the adapter in an application-local class. + +```ts +import AmqpMessageBusAdapter from '@haskou/ddd-kernel/adapters/pubsub/amqp'; + +const messageBus = new AmqpMessageBusAdapter({ + publisherHooks: [ + { + afterPublish: async ({ message }) => { + await websocketPublisher.publish(message); + }, + }, + ], +}); +``` + +Custom adapters should implement the `MessageBus` contract and delegate hook +execution through `PublisherHookPipeline`: + +```ts +import { + PublisherHookPipeline, + type PublisherHook, +} from '@haskou/ddd-kernel/adapters/pubsub'; + +export default class CustomMessageBus { + private readonly hooks = new PublisherHookPipeline(); + + public registerPublisherHooks(...hooks: PublisherHook[]) { + this.hooks.register(...hooks); + } +} +``` diff --git a/docs/guides/amqp-pubsub.md b/docs/guides/amqp-pubsub.md index 004015e..aa46578 100644 --- a/docs/guides/amqp-pubsub.md +++ b/docs/guides/amqp-pubsub.md @@ -16,6 +16,7 @@ new AmqpMessageBusAdapter({ dsn: 'amqp://localhost', exchange: 'users-service', maxRetries: 3, + publisherHooks: [replicatedPublisherHook], retryDelayInMilliseconds: 1000, serviceName: 'users-service', }); @@ -30,3 +31,7 @@ Environment variables: Failed messages are sent to `_dlx`. Use `consumeDlx` to retry failed messages. + +`publisherHooks` run around each domain event published by the adapter. Use them +for transport-adjacent fan-out such as websocket updates, replicated-state +publishers, tracing or audit logs. diff --git a/src/adapters/pubsub/PublisherHookPipeline.ts b/src/adapters/pubsub/PublisherHookPipeline.ts new file mode 100644 index 0000000..4055176 --- /dev/null +++ b/src/adapters/pubsub/PublisherHookPipeline.ts @@ -0,0 +1,38 @@ +import type { PublishContext, PublisherHook } from '../../contracts/index.js'; + +export class PublisherHookPipeline { + private readonly hooks: PublisherHook[] = []; + + constructor(hooks: readonly PublisherHook[] = []) { + this.hooks.push(...hooks); + } + + public register(...hooks: PublisherHook[]): void { + this.hooks.push(...hooks); + } + + public async run( + context: PublishContext, + publish: () => Promise | T, + ): Promise { + for (const hook of this.hooks) { + await hook.beforePublish?.(context); + } + + try { + const result = await publish(); + + for (const hook of this.hooks) { + await hook.afterPublish?.(context); + } + + return result; + } catch (error: unknown) { + for (const hook of this.hooks) { + await hook.onPublishError?.(error, context); + } + + throw error; + } + } +} diff --git a/src/adapters/pubsub/amqp/AmqpMessageBusAdapter.ts b/src/adapters/pubsub/amqp/AmqpMessageBusAdapter.ts index ce90763..c63f9dc 100644 --- a/src/adapters/pubsub/amqp/AmqpMessageBusAdapter.ts +++ b/src/adapters/pubsub/amqp/AmqpMessageBusAdapter.ts @@ -8,6 +8,7 @@ import amqplib, { } from 'amqplib'; import { randomUUID } from 'node:crypto'; +import type { PublisherHook } from '../../../contracts/index.js'; import type { Constructor, DomainEvent, @@ -19,6 +20,7 @@ import type { AmqpMessageBusAdapterOptions } from './AmqpMessageBusAdapterOption import type { ConsumerContext } from './ConsumerContext.js'; import { Kernel } from '../../../Kernel.js'; +import { PublisherHookPipeline } from '../PublisherHookPipeline.js'; import { InvalidDomainEventError } from './InvalidDomainEventError.js'; import { NoFailedMessagesError } from './NoFailedMessagesError.js'; @@ -29,10 +31,14 @@ export default class AmqpMessageBusAdapter private connection: ChannelModel | undefined; private readonly delayConsumers: string[] = []; private exchange: string; + private readonly publisherHookPipeline: PublisherHookPipeline; constructor(private readonly options: AmqpMessageBusAdapterOptions = {}) { this.exchange = options.exchange ?? options.serviceName ?? process.env.SERVICE_NAME ?? ''; + this.publisherHookPipeline = new PublisherHookPipeline( + options.publisherHooks, + ); } private get dsn(): string { @@ -450,15 +456,38 @@ export default class AmqpMessageBusAdapter const channel = await this.channel(); for (const event of domainEvents) { - channel.publish( - this.exchange, - event.eventName(), - Buffer.from(event.decode()), - this.opts(event), + await this.publisherHookPipeline.run( + { + message: { + metadata: { + causationId: event.getCausationId(), + correlationId: event.getCorrelationId(), + eventId: event.eventId, + }, + name: event.eventName(), + payload: event.attributes, + }, + metadata: { + eventId: event.eventId, + exchange: this.exchange, + }, + topic: event.eventName(), + }, + () => + channel.publish( + this.exchange, + event.eventName(), + Buffer.from(event.decode()), + this.opts(event), + ), ); } } + public registerPublisherHooks(...hooks: PublisherHook[]): void { + this.publisherHookPipeline.register(...hooks); + } + public async close(): Promise { await this.channelInstance?.close(); await this.connection?.close(); diff --git a/src/adapters/pubsub/amqp/AmqpMessageBusAdapterOptions.ts b/src/adapters/pubsub/amqp/AmqpMessageBusAdapterOptions.ts index 084c3f7..fc2db4a 100644 --- a/src/adapters/pubsub/amqp/AmqpMessageBusAdapterOptions.ts +++ b/src/adapters/pubsub/amqp/AmqpMessageBusAdapterOptions.ts @@ -1,3 +1,4 @@ +import type { PublisherHook } from '../../../contracts/index.js'; import type { Log } from '../../../infrastructure/logs/index.js'; export interface AmqpMessageBusAdapterOptions { @@ -5,6 +6,7 @@ export interface AmqpMessageBusAdapterOptions { readonly exchange?: string; readonly logger?: Log; readonly maxRetries?: number; + readonly publisherHooks?: PublisherHook[]; readonly retryDelayInMilliseconds?: number; readonly serviceName?: string; } diff --git a/src/adapters/pubsub/in-memory/InMemoryEventBus.ts b/src/adapters/pubsub/in-memory/InMemoryEventBus.ts index f55fd09..727c73e 100644 --- a/src/adapters/pubsub/in-memory/InMemoryEventBus.ts +++ b/src/adapters/pubsub/in-memory/InMemoryEventBus.ts @@ -2,15 +2,25 @@ import type { DomainEvent, HandlerContext, MessageHandler, + PublisherHook, } from '../../../contracts/index.js'; +import { PublisherHookPipeline } from '../PublisherHookPipeline.js'; + export class InMemoryEventBus { private readonly handlers = new Map< string, MessageHandler[] >(); - constructor(private readonly context: HandlerContext) {} + private readonly publisherHookPipeline: PublisherHookPipeline; + + constructor( + private readonly context: HandlerContext, + publisherHooks: readonly PublisherHook[] = [], + ) { + this.publisherHookPipeline = new PublisherHookPipeline(publisherHooks); + } public subscribe( name: TEvent['name'], @@ -27,8 +37,17 @@ export class InMemoryEventBus { ): Promise { const handlers = this.handlers.get(event.name) ?? []; - for (const handler of handlers) { - await handler(event, this.context); - } + await this.publisherHookPipeline.run( + { message: event, metadata: event.metadata ?? {}, topic: event.name }, + async () => { + for (const handler of handlers) { + await handler(event, this.context); + } + }, + ); + } + + public registerPublisherHooks(...hooks: PublisherHook[]): void { + this.publisherHookPipeline.register(...hooks); } } diff --git a/src/adapters/pubsub/in-memory/InMemoryPubSub.ts b/src/adapters/pubsub/in-memory/InMemoryPubSub.ts index 9e050e4..2523f3f 100644 --- a/src/adapters/pubsub/in-memory/InMemoryPubSub.ts +++ b/src/adapters/pubsub/in-memory/InMemoryPubSub.ts @@ -2,16 +2,26 @@ import type { HandlerContext, Message, MessageHandler, + PublisherHook, Subscription, } from '../../../contracts/index.js'; +import { PublisherHookPipeline } from '../PublisherHookPipeline.js'; + export class InMemoryPubSub { private readonly consumers = new Map< string, Set> >(); - constructor(private readonly context: HandlerContext) {} + private readonly publisherHookPipeline: PublisherHookPipeline; + + constructor( + private readonly context: HandlerContext, + publisherHooks: readonly PublisherHook[] = [], + ) { + this.publisherHookPipeline = new PublisherHookPipeline(publisherHooks); + } public async publish( topic: string, @@ -19,9 +29,14 @@ export class InMemoryPubSub { ): Promise { const consumers = this.consumers.get(topic) ?? new Set(); - for (const consumer of consumers) { - await consumer(message, this.context); - } + await this.publisherHookPipeline.run( + { message, metadata: message.metadata ?? {}, topic }, + async () => { + for (const consumer of consumers) { + await consumer(message, this.context); + } + }, + ); } public subscribe( @@ -43,4 +58,8 @@ export class InMemoryPubSub { }, }); } + + public registerPublisherHooks(...hooks: PublisherHook[]): void { + this.publisherHookPipeline.register(...hooks); + } } diff --git a/src/contracts/pubsub/MessageBus.ts b/src/contracts/pubsub/MessageBus.ts new file mode 100644 index 0000000..4afe13b --- /dev/null +++ b/src/contracts/pubsub/MessageBus.ts @@ -0,0 +1,7 @@ +import type { DomainEventConsumer } from '../../domain/DomainEventConsumer.js'; +import type { DomainEventPublisher } from '../../domain/DomainEventPublisher.js'; +import type { PublisherHook } from './PublisherHook.js'; + +export interface MessageBus extends DomainEventConsumer, DomainEventPublisher { + registerPublisherHooks(...hooks: PublisherHook[]): void; +} diff --git a/src/contracts/pubsub/PublishContext.ts b/src/contracts/pubsub/PublishContext.ts new file mode 100644 index 0000000..43ce260 --- /dev/null +++ b/src/contracts/pubsub/PublishContext.ts @@ -0,0 +1,10 @@ +import type { DomainEvent as ContractDomainEvent } from './DomainEvent.js'; +import type { Message } from './Message.js'; + +export interface PublishContext< + TMessage extends Message | ContractDomainEvent = Message, +> { + readonly message: TMessage; + readonly metadata: Readonly>; + readonly topic: string; +} diff --git a/src/contracts/pubsub/PublisherHook.ts b/src/contracts/pubsub/PublisherHook.ts new file mode 100644 index 0000000..b82a0f3 --- /dev/null +++ b/src/contracts/pubsub/PublisherHook.ts @@ -0,0 +1,10 @@ +import type { PublishContext } from './PublishContext.js'; + +export interface PublisherHook { + afterPublish?(context: PublishContext): Promise | void; + beforePublish?(context: PublishContext): Promise | void; + onPublishError?( + error: unknown, + context: PublishContext, + ): Promise | void; +} diff --git a/src/contracts/pubsub/index.ts b/src/contracts/pubsub/index.ts index 0a1a397..a119358 100644 --- a/src/contracts/pubsub/index.ts +++ b/src/contracts/pubsub/index.ts @@ -3,7 +3,10 @@ export * from './DomainEvent.js'; export * from './EventBus.js'; export * from './EventRegistration.js'; export * from './Message.js'; +export * from './MessageBus.js'; export * from './MessageHandler.js'; export * from './MessageMetadata.js'; export * from './PubSub.js'; +export * from './PublishContext.js'; +export * from './PublisherHook.js'; export * from './Subscription.js'; diff --git a/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs b/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs index da79496..f498333 100644 --- a/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs +++ b/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs @@ -56,7 +56,10 @@ class FakeChannel { checkQueue(queueName) { this.calls.push(['checkQueue', queueName]); - return { consumerCount: this.consumerCount, messageCount: this.messageCount }; + return { + consumerCount: this.consumerCount, + messageCount: this.messageCount, + }; } close() { @@ -134,14 +137,18 @@ const withAmqpConnect = async (channel, run) => { test('publishes domain events and closes channel resources', async () => { const channel = new FakeChannel(); - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const hookCalls = []; + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ dsn: 'amqp://localhost', exchange: 'domain', serviceName: 'service', }); + adapter.registerPublisherHooks({ + afterPublish: (context) => hookCalls.push(['after', context.topic]), + beforePublish: (context) => hookCalls.push(['before', context.topic]), + }); const event = new TestDomainEvent( 'aggregate-id', { name: 'Ada' }, @@ -156,16 +163,25 @@ test('publishes domain events and closes channel resources', async () => { assert.equal(connection.calls.length, 1); }); - assert.equal(channel.calls.some(([name]) => name === 'publish'), true); - assert.equal(channel.calls.some(([name]) => name === 'channel:close'), true); + assert.equal( + channel.calls.some(([name]) => name === 'publish'), + true, + ); + assert.equal( + channel.calls.some(([name]) => name === 'channel:close'), + true, + ); + assert.deepEqual(hookCalls, [ + ['before', 'test.domain-event'], + ['after', 'test.domain-event'], + ]); }); test('consumes AMQP messages and acknowledges handled events', async () => { const channel = new FakeChannel(); const handled = []; - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ dsn: 'amqp://localhost', exchange: 'domain', @@ -192,9 +208,8 @@ test('consumes AMQP messages and acknowledges handled events', async () => { test('retries failed messages and registers delayed consumers once', async () => { const channel = new FakeChannel(); const logs = []; - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ exchange: 'domain', logger: { @@ -230,15 +245,17 @@ test('retries failed messages and registers delayed consumers once', async () => assert.equal(channel.calls.filter(([name]) => name === 'consume').length, 1); assert.equal(channel.calls.filter(([name]) => name === 'publish').length, 2); - assert.equal(logs.some(([, messageText]) => messageText === 'Retry # 1'), true); + assert.equal( + logs.some(([, messageText]) => messageText === 'Retry # 1'), + true, + ); }); test('sends exhausted messages to DLX and logs publish failures', async () => { const channel = new FakeChannel(); const logs = []; - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ exchange: 'domain', logger: { @@ -298,15 +315,20 @@ test('consumes DLX messages with success, nack and no-message paths', async () = ); }); - assert.equal(channel.calls.some(([name]) => name === 'ack'), true); - assert.equal(channel.calls.some(([name]) => name === 'nack'), true); + assert.equal( + channel.calls.some(([name]) => name === 'ack'), + true, + ); + assert.equal( + channel.calls.some(([name]) => name === 'nack'), + true, + ); }); test('checks queue bindings for registered consumers', async () => { const channel = new FakeChannel(); - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ dsn: 'amqp://localhost' }); const kernel = new Kernel(); @@ -329,9 +351,8 @@ test('uses environment retry delay and logs non-Error DLX retry failures', async const previousRetryDelay = process.env.TRANSPORT_RETRY_DELAY; const channel = new FakeChannel(); const logs = []; - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ exchange: 'domain', logger: { @@ -370,9 +391,8 @@ test('uses environment retry delay and logs non-Error DLX retry failures', async test('handles missing retry headers and cancels delayed consumers on invalid retry payloads', async () => { const channel = new FakeChannel(); const logs = []; - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ exchange: 'domain', logger: { @@ -400,16 +420,18 @@ test('handles missing retry headers and cancels delayed consumers on invalid ret ); await adapter.retry(null, {}, context); - assert.equal(channel.calls.some(([name]) => name === 'cancel'), true); + assert.equal( + channel.calls.some(([name]) => name === 'cancel'), + true, + ); assert.equal(logs.includes('Invalid domain event: null'), true); }); test('handles AMQP messages that fail during handler execution', async () => { const channel = new FakeChannel(); const logs = []; - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ exchange: 'domain', logger: { @@ -438,9 +460,8 @@ test('handles AMQP messages that fail during handler execution', async () => { test('supports AMQP messages without occurred_on and retry publish errors', async () => { const channel = new FakeChannel(); const logs = []; - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ exchange: 'domain', logger: { @@ -471,9 +492,8 @@ test('supports AMQP messages without occurred_on and retry publish errors', asyn test('uses numeric retry delay from environment', async () => { const previousRetryDelay = process.env.TRANSPORT_RETRY_DELAY; - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); process.env.TRANSPORT_RETRY_DELAY = '25'; @@ -493,9 +513,8 @@ test('uses numeric retry delay from environment', async () => { test('reads AMQP DSN and max retries from environment defaults', async () => { const previousDsn = process.env.TRANSPORT_DSN; const previousRetries = process.env.TRANSPORT_MAX_RETRIES; - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); delete process.env.TRANSPORT_MAX_RETRIES; process.env.TRANSPORT_DSN = 'amqp://environment'; @@ -531,9 +550,8 @@ test('reads AMQP DSN and max retries from environment defaults', async () => { test('handles delayed consumer messages and removes delayed consumer state', async () => { const channel = new FakeChannel(); const handled = []; - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ exchange: 'domain', retryDelayInMilliseconds: 0, @@ -563,9 +581,8 @@ test('logs non-Error retry publish failures', async () => { const channel = new FakeChannel(); const logs = []; - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ exchange: 'domain', logger: { @@ -595,9 +612,8 @@ test('logs non-Error retry publish failures', async () => { test('logs string publish failures when sending to DLX', async () => { const channel = new FakeChannel(); const logs = []; - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ exchange: 'domain', logger: { @@ -622,22 +638,23 @@ test('logs string publish failures when sending to DLX', async () => { }); test('throws when a channel cannot be created', async () => { - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ dsn: 'amqp://localhost' }); adapter.connect = async () => {}; - await assert.rejects(() => adapter.channel(), /AMQP channel could not be created/); + await assert.rejects( + () => adapter.channel(), + /AMQP channel could not be created/, + ); }); test('reconnects consumers when AMQP channel emits close or error', async () => { const channel = new FakeChannel(); const calls = []; - const { default: AmqpMessageBusAdapter } = await import( - '../../../../dist/adapters/pubsub/amqp/index.js' - ); + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ dsn: 'amqp://localhost', logger: { @@ -650,7 +667,9 @@ test('reconnects consumers when AMQP channel emits close or error', async () => const kernel = new Kernel(); kernel.removeConsumers(); - kernel.registerConsumerInstances({ init: async () => calls.push(['consumer:init']) }); + kernel.registerConsumerInstances({ + init: async () => calls.push(['consumer:init']), + }); await withAmqpConnect(channel, async () => { await adapter.channel(); diff --git a/tests/adapters/pubsub/in-memory/InMemoryEventBus.test.mjs b/tests/adapters/pubsub/in-memory/InMemoryEventBus.test.mjs index 868c8a5..ad859c7 100644 --- a/tests/adapters/pubsub/in-memory/InMemoryEventBus.test.mjs +++ b/tests/adapters/pubsub/in-memory/InMemoryEventBus.test.mjs @@ -18,3 +18,31 @@ test('publishes domain events to subscribed handlers with context', async () => assert.deepEqual(calls, [[event, context]]); }); + +test('runs registered publisher hooks around domain events', async () => { + const context = { di: {}, publish: async () => {} }; + const event = { + metadata: { correlationId: 'correlation-id' }, + name: 'user.created', + }; + const calls = []; + const eventBus = new InMemoryEventBus(context); + + eventBus.registerPublisherHooks({ + afterPublish: (publishContext) => + calls.push(['after', publishContext.topic]), + beforePublish: (publishContext) => + calls.push(['before', publishContext.metadata.correlationId]), + }); + eventBus.subscribe('user.created', async (receivedEvent) => { + calls.push(['handler', receivedEvent.name]); + }); + + await eventBus.publish(event); + + assert.deepEqual(calls, [ + ['before', 'correlation-id'], + ['handler', 'user.created'], + ['after', 'user.created'], + ]); +}); diff --git a/tests/adapters/pubsub/in-memory/InMemoryPubSub.test.mjs b/tests/adapters/pubsub/in-memory/InMemoryPubSub.test.mjs index 5039394..2e139e6 100644 --- a/tests/adapters/pubsub/in-memory/InMemoryPubSub.test.mjs +++ b/tests/adapters/pubsub/in-memory/InMemoryPubSub.test.mjs @@ -9,9 +9,12 @@ test('publishes messages to subscribers and supports unsubscribe', async () => { const calls = []; const pubSub = new InMemoryPubSub(context); - const subscription = await pubSub.subscribe('topic', async (receivedMessage, receivedContext) => { - calls.push([receivedMessage, receivedContext]); - }); + const subscription = await pubSub.subscribe( + 'topic', + async (receivedMessage, receivedContext) => { + calls.push([receivedMessage, receivedContext]); + }, + ); await pubSub.publish('topic', message); await pubSub.publish('other-topic', message); @@ -20,3 +23,59 @@ test('publishes messages to subscribers and supports unsubscribe', async () => { assert.deepEqual(calls, [[message, context]]); }); + +test('runs publisher hooks around published messages', async () => { + const context = { di: {}, publish: async () => {} }; + const message = { + metadata: { correlationId: 'correlation-id' }, + name: 'message', + }; + const calls = []; + const pubSub = new InMemoryPubSub(context, [ + { + afterPublish: (publishContext) => + calls.push(['after', publishContext.topic]), + beforePublish: (publishContext) => + calls.push(['before', publishContext.metadata.correlationId]), + }, + ]); + + pubSub.registerPublisherHooks({ + afterPublish: (publishContext) => + calls.push(['registered', publishContext.message.name]), + }); + await pubSub.subscribe('topic', async (receivedMessage) => { + calls.push(['consumer', receivedMessage.name]); + }); + + await pubSub.publish('topic', message); + + assert.deepEqual(calls, [ + ['before', 'correlation-id'], + ['consumer', 'message'], + ['after', 'topic'], + ['registered', 'message'], + ]); +}); + +test('runs publisher error hooks before rethrowing', async () => { + const context = { di: {}, publish: async () => {} }; + const error = new Error('publish failed'); + const calls = []; + const pubSub = new InMemoryPubSub(context, [ + { + onPublishError: (publishError, publishContext) => + calls.push([publishError, publishContext.topic]), + }, + ]); + + await pubSub.subscribe('topic', async () => { + throw error; + }); + + await assert.rejects( + () => pubSub.publish('topic', { name: 'message' }), + error, + ); + assert.deepEqual(calls, [[error, 'topic']]); +}); From 1af5d0c643a5162b38dd2993dbeb3ec449884adc Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 16:45:59 +0200 Subject: [PATCH 03/19] =?UTF-8?q?feat(ui):=20=E2=9C=A8=20Extend=20Express?= =?UTF-8?q?=20kernel=20server=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/express-kernel-server.md | 44 ++++++++++++++++ src/adapters/ui/express/ExpressAppHook.ts | 3 ++ src/adapters/ui/express/ExpressController.ts | 1 + .../ui/express/ExpressKernelServer.ts | 51 +++++++++++++++---- .../ui/express/ExpressKernelServerOptions.ts | 9 ++++ src/adapters/ui/express/index.ts | 2 + .../ui/express/ExpressKernelServer.test.mjs | 34 +++++++++++++ 7 files changed, 133 insertions(+), 11 deletions(-) create mode 100644 src/adapters/ui/express/ExpressAppHook.ts create mode 100644 src/adapters/ui/express/ExpressController.ts diff --git a/docs/reference/express-kernel-server.md b/docs/reference/express-kernel-server.md index 2d7ee40..c5637d4 100644 --- a/docs/reference/express-kernel-server.md +++ b/docs/reference/express-kernel-server.md @@ -11,3 +11,47 @@ await server.run(); ``` Routes are registered with `kernel.registerRoutes(RouteClass)`. + +## External Controllers + +Applications can add controllers at the server boundary without registering them +on the kernel: + +```ts +const server = new ExpressKernelServer({ + controllers: [HealthController], + kernel, + port: 3000, +}); +``` + +`controllers` are merged with `kernel.getRoutes()` before +`routing-controllers` is configured. + +## HTTP Middleware And Hooks + +Use middleware arrays for normal Express middleware and hooks for integrations +that need direct app access, such as Swagger or static assets: + +```ts +const server = new ExpressKernelServer({ + kernel, + middlewares: [requestIdMiddleware], + preControllerMiddlewares: [authenticationMiddleware], + postControllerMiddlewares: [notFoundMiddleware], + swaggerHooks: [(app) => setupSwagger(app)], + staticHooks: [(app) => app.use('/public', express.static('public'))], +}); +``` + +Hook order is: + +1. `middlewares` +2. `preControllerMiddlewares` +3. `beforeControllersHooks` +4. `routing-controllers` +5. `postControllerMiddlewares` +6. `afterControllersHooks` +7. `swaggerHooks` +8. `staticHooks` +9. `errorHandlers` diff --git a/src/adapters/ui/express/ExpressAppHook.ts b/src/adapters/ui/express/ExpressAppHook.ts new file mode 100644 index 0000000..1fab89c --- /dev/null +++ b/src/adapters/ui/express/ExpressAppHook.ts @@ -0,0 +1,3 @@ +import type { HttpApp } from './HttpApp.js'; + +export type ExpressAppHook = (app: HttpApp) => Promise | void; diff --git a/src/adapters/ui/express/ExpressController.ts b/src/adapters/ui/express/ExpressController.ts new file mode 100644 index 0000000..6953a5a --- /dev/null +++ b/src/adapters/ui/express/ExpressController.ts @@ -0,0 +1 @@ +export type ExpressController = abstract new (...args: unknown[]) => unknown; diff --git a/src/adapters/ui/express/ExpressKernelServer.ts b/src/adapters/ui/express/ExpressKernelServer.ts index 590966c..c7bafa8 100644 --- a/src/adapters/ui/express/ExpressKernelServer.ts +++ b/src/adapters/ui/express/ExpressKernelServer.ts @@ -1,6 +1,8 @@ -import type { ErrorRequestHandler } from 'express'; - -import { createExpressServer } from 'routing-controllers'; +import express, { + type ErrorRequestHandler, + type RequestHandler, +} from 'express'; +import { useExpressServer } from 'routing-controllers'; import type { ExpressKernelServerOptions } from './ExpressKernelServerOptions.js'; import type { HttpApp } from './HttpApp.js'; @@ -30,6 +32,24 @@ export class ExpressKernelServer { }; } + private async runHooks( + hooks: readonly ((app: HttpApp) => Promise | void)[] | undefined, + app: HttpApp, + ): Promise { + for (const hook of hooks ?? []) { + await hook(app); + } + } + + private registerMiddlewares( + app: HttpApp, + middlewares: readonly RequestHandler[] | undefined, + ): void { + for (const middleware of middlewares ?? []) { + app.use(middleware); + } + } + public get app(): HttpApp { if (!this.appInstance) { throw new Error('HTTP server is not running.'); @@ -66,15 +86,24 @@ export class ExpressKernelServer { }); } - public run(): Promise { - const app = createExpressServer({ - controllers: this.options.kernel.getRoutes(), + public async run(): Promise { + const controllers = [ + ...this.options.kernel.getRoutes(), + ...(this.options.controllers ?? []), + ]; + const app = express() as HttpApp; + + this.registerMiddlewares(app, this.options.middlewares); + this.registerMiddlewares(app, this.options.preControllerMiddlewares); + await this.runHooks(this.options.beforeControllersHooks, app); + useExpressServer(app, { + controllers, routePrefix: this.options.routePrefix, - }) as HttpApp; - - for (const middleware of this.options.middlewares ?? []) { - app.use(middleware); - } + }); + this.registerMiddlewares(app, this.options.postControllerMiddlewares); + await this.runHooks(this.options.afterControllersHooks, app); + await this.runHooks(this.options.swaggerHooks, app); + await this.runHooks(this.options.staticHooks, app); this.registerErrorHandlers(app); this.appInstance = app; diff --git a/src/adapters/ui/express/ExpressKernelServerOptions.ts b/src/adapters/ui/express/ExpressKernelServerOptions.ts index d4cbbb5..6e1bca6 100644 --- a/src/adapters/ui/express/ExpressKernelServerOptions.ts +++ b/src/adapters/ui/express/ExpressKernelServerOptions.ts @@ -1,11 +1,20 @@ import type { ErrorRequestHandler, RequestHandler } from 'express'; import type { Kernel } from '../../../Kernel.js'; +import type { ExpressAppHook } from './ExpressAppHook.js'; +import type { ExpressController } from './ExpressController.js'; export interface ExpressKernelServerOptions { + readonly afterControllersHooks?: ExpressAppHook[]; + readonly beforeControllersHooks?: ExpressAppHook[]; + readonly controllers?: ExpressController[]; readonly errorHandlers?: ErrorRequestHandler[]; readonly kernel: Kernel; readonly middlewares?: RequestHandler[]; + readonly postControllerMiddlewares?: RequestHandler[]; + readonly preControllerMiddlewares?: RequestHandler[]; readonly port?: number; readonly routePrefix?: string; + readonly staticHooks?: ExpressAppHook[]; + readonly swaggerHooks?: ExpressAppHook[]; } diff --git a/src/adapters/ui/express/index.ts b/src/adapters/ui/express/index.ts index cf5fbf6..15f3913 100644 --- a/src/adapters/ui/express/index.ts +++ b/src/adapters/ui/express/index.ts @@ -1,3 +1,5 @@ +export * from './ExpressAppHook.js'; +export * from './ExpressController.js'; export * from './ExpressKernelServer.js'; export * from './ExpressKernelServerOptions.js'; export * from './HttpApp.js'; diff --git a/tests/adapters/ui/express/ExpressKernelServer.test.mjs b/tests/adapters/ui/express/ExpressKernelServer.test.mjs index 39a0a8a..867898b 100644 --- a/tests/adapters/ui/express/ExpressKernelServer.test.mjs +++ b/tests/adapters/ui/express/ExpressKernelServer.test.mjs @@ -106,3 +106,37 @@ test('registers default error handlers and runs without optional middleware', as await server.run(); await server.close(); }); + +test('runs configurable controller, swagger and static hooks', async () => { + class ExternalController {} + + const calls = []; + const middleware = (name) => (request, response, next) => { + void request; + void response; + calls.push(name); + next(); + }; + const server = new ExpressKernelServer({ + afterControllersHooks: [(app) => calls.push(['after', Boolean(app)])], + beforeControllersHooks: [(app) => calls.push(['before', Boolean(app)])], + controllers: [ExternalController], + kernel: { getRoutes: () => [] }, + middlewares: [middleware('base')], + port: 0, + postControllerMiddlewares: [middleware('post')], + preControllerMiddlewares: [middleware('pre')], + staticHooks: [(app) => calls.push(['static', Boolean(app)])], + swaggerHooks: [(app) => calls.push(['swagger', Boolean(app)])], + }); + + await server.run(); + await server.close(); + + assert.deepEqual(calls, [ + ['before', true], + ['after', true], + ['swagger', true], + ['static', true], + ]); +}); From e204c852030c4c01e64d72afaf46e1782ed67bb9 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 16:46:05 +0200 Subject: [PATCH 04/19] =?UTF-8?q?fix(types):=20=F0=9F=90=9B=20Support=20cl?= =?UTF-8?q?assic=20TypeScript=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/getting-started/installation.md | 7 ++ docs/reference/scheduler.md | 21 ++++- package.json | 79 +++++++++++++++++ tests/typescript-module-resolution.test.mjs | 94 +++++++++++++++++++++ 4 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 tests/typescript-module-resolution.test.mjs diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 574120f..88371c6 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -24,3 +24,10 @@ MongoDB repositories require: ```bash yarn add mongodb ``` + +## TypeScript Resolution + +The package publishes ESM, CommonJS and declaration files for every public +subpath. Modern projects should prefer `moduleResolution: "NodeNext"` or +`"Bundler"`, but declaration mappings are also provided for projects still using +classic `moduleResolution: "node"`. diff --git a/docs/reference/scheduler.md b/docs/reference/scheduler.md index 41e2e98..4fbee4a 100644 --- a/docs/reference/scheduler.md +++ b/docs/reference/scheduler.md @@ -16,9 +16,12 @@ Register scheduler classes with `kernel.registerSchedulers(...)`. ## Error Policy -Schedulers accept a `SchedulerErrorPolicy`: +Schedulers accept a `SchedulerErrorPolicy` exported from +`@haskou/ddd-kernel/scheduler`: ```ts +import type { SchedulerErrorPolicy } from '@haskou/ddd-kernel/scheduler'; + class ReplicationScheduler extends Scheduler { constructor(errorPolicy: SchedulerErrorPolicy) { super(errorPolicy); @@ -35,3 +38,19 @@ interface SchedulerErrorPolicy { handle(error: unknown, scheduler: Scheduler): Promise | void; } ``` + +Use `shouldSkip` for domain-specific transient states that should not be logged +as scheduler failures, for example replicated state that is not ready yet: + +```ts +const policy: SchedulerErrorPolicy = { + shouldSkip(error) { + return error instanceof ReplicatedStateNotReadyError; + }, + handle(error, scheduler) { + logger.error(`${scheduler.getProcessName()} failed: ${String(error)}`); + }, +}; +``` + +The default policy never skips and wraps failures in `ScheduledExecutionError`. diff --git a/package.json b/package.json index cdfbf51..b56c842 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,85 @@ "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", + "typesVersions": { + "*": { + "adapters": [ + "dist/adapters/index.d.ts" + ], + "adapters/db": [ + "dist/adapters/db/index.d.ts" + ], + "adapters/db/in-memory": [ + "dist/adapters/db/in-memory/index.d.ts" + ], + "adapters/db/mongo": [ + "dist/adapters/db/mongo/index.d.ts" + ], + "adapters/kernel": [ + "dist/adapters/kernel/index.d.ts" + ], + "adapters/kernel/console": [ + "dist/adapters/kernel/console/index.d.ts" + ], + "adapters/pubsub": [ + "dist/adapters/pubsub/index.d.ts" + ], + "adapters/pubsub/amqp": [ + "dist/adapters/pubsub/amqp/index.d.ts" + ], + "adapters/pubsub/in-memory": [ + "dist/adapters/pubsub/in-memory/index.d.ts" + ], + "adapters/ui": [ + "dist/adapters/ui/index.d.ts" + ], + "adapters/ui/express": [ + "dist/adapters/ui/express/index.d.ts" + ], + "adapters/ui/routes": [ + "dist/adapters/ui/routes/index.d.ts" + ], + "contracts": [ + "dist/contracts/index.d.ts" + ], + "contracts/db": [ + "dist/contracts/db/index.d.ts" + ], + "contracts/kernel": [ + "dist/contracts/kernel/index.d.ts" + ], + "contracts/pubsub": [ + "dist/contracts/pubsub/index.d.ts" + ], + "contracts/ui": [ + "dist/contracts/ui/index.d.ts" + ], + "dependency-injection": [ + "dist/infrastructure/dependency-injection/index.d.ts" + ], + "domain": [ + "dist/domain/index.d.ts" + ], + "errors": [ + "dist/errors/index.d.ts" + ], + "express": [ + "dist/adapters/ui/express/index.d.ts" + ], + "lifecycle": [ + "dist/infrastructure/lifecycle/index.d.ts" + ], + "logs": [ + "dist/infrastructure/logs/index.d.ts" + ], + "scheduler": [ + "dist/infrastructure/scheduler/index.d.ts" + ], + "websocket": [ + "dist/infrastructure/websocket/index.d.ts" + ] + } + }, "publishConfig": { "access": "public" }, diff --git a/tests/typescript-module-resolution.test.mjs b/tests/typescript-module-resolution.test.mjs new file mode 100644 index 0000000..c21db44 --- /dev/null +++ b/tests/typescript-module-resolution.test.mjs @@ -0,0 +1,94 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { mkdir, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import test from 'node:test'; + +test('exports types for TypeScript moduleResolution node consumers', async () => { + if (!existsSync(path.resolve('dist/contracts/kernel/index.d.ts'))) { + return; + } + + const temporaryDirectory = path.join( + await import('node:fs/promises').then(({ mkdtemp }) => + mkdtemp(path.join(tmpdir(), 'ddd-kernel-types-')), + ), + ); + const packageDirectory = path.resolve('.'); + const packageScopeDirectory = path.join( + temporaryDirectory, + 'node_modules', + '@haskou', + ); + + await mkdir(packageScopeDirectory, { recursive: true }); + await symlink( + packageDirectory, + path.join(packageScopeDirectory, 'ddd-kernel'), + ); + await writeFile( + path.join(temporaryDirectory, 'package.json'), + JSON.stringify({ type: 'module' }), + ); + await writeFile( + path.join(temporaryDirectory, 'index.ts'), + ` + import type { ConsumerMiddleware } from '@haskou/ddd-kernel/contracts/kernel'; + import type { MessageBus, PublisherHook } from '@haskou/ddd-kernel/contracts/pubsub'; + import type { SchedulerErrorPolicy } from '@haskou/ddd-kernel/scheduler'; + import { ExpressKernelServer } from '@haskou/ddd-kernel/adapters/ui/express'; + + const middleware: ConsumerMiddleware | undefined = undefined; + const messageBus: MessageBus | undefined = undefined; + const hook: PublisherHook | undefined = undefined; + const policy: SchedulerErrorPolicy | undefined = undefined; + + void middleware; + void messageBus; + void hook; + void policy; + void ExpressKernelServer; + `, + ); + await writeFile( + path.join(temporaryDirectory, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + ignoreDeprecations: '6.0', + module: 'ESNext', + moduleResolution: 'node', + noEmit: true, + skipLibCheck: true, + strict: true, + target: 'ES2022', + }, + include: ['index.ts'], + }), + ); + + const result = await new Promise((resolve) => { + const child = spawn( + process.execPath, + [ + path.resolve('node_modules/typescript/bin/tsc'), + '-p', + path.join(temporaryDirectory, 'tsconfig.json'), + ], + { cwd: temporaryDirectory }, + ); + let stderr = ''; + let stdout = ''; + + child.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + child.stdout.on('data', (chunk) => { + stdout += chunk.toString(); + }); + child.on('close', (code) => resolve({ code, stderr, stdout })); + }); + + assert.equal(result.code, 0, `${result.stdout}\n${result.stderr}`); +}); From 7510b592f6ab1cf4a733882e80da4409bf23e155 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 19:04:08 +0200 Subject: [PATCH 05/19] =?UTF-8?q?fix(kernel):=20=F0=9F=90=9B=20Harden=20in?= =?UTF-8?q?tegration=20extension=20points?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guides/adapters.md | 19 ++++-- docs/reference/consumer.md | 7 ++- docs/reference/express-kernel-server.md | 25 +++++--- src/adapters/pubsub/Consumer.ts | 16 +++-- .../pubsub/DefaultPublisherHookErrorPolicy.ts | 26 ++++++++ .../pubsub/IdempotencyConsumerMiddleware.ts | 39 ++++++++++-- .../pubsub/InMemoryIdempotencyStore.ts | 20 ++++++ src/adapters/pubsub/PublisherHookPipeline.ts | 61 +++++++++++++++---- .../pubsub/amqp/AmqpMessageBusAdapter.ts | 13 +++- .../amqp/AmqpMessageBusAdapterOptions.ts | 6 +- .../pubsub/amqp/DomainEventHandler.ts | 10 ++- .../pubsub/in-memory/InMemoryEventBus.ts | 7 ++- .../pubsub/in-memory/InMemoryPubSub.ts | 7 ++- src/adapters/pubsub/index.ts | 1 + src/adapters/ui/express/ExpressHookPhase.ts | 4 ++ .../ui/express/ExpressKernelServer.ts | 18 ++++++ .../ui/express/ExpressKernelServerOptions.ts | 8 +++ src/adapters/ui/express/ExpressPhaseHook.ts | 7 +++ src/adapters/ui/express/index.ts | 2 + .../kernel/ConsumerExecutionContext.ts | 1 + src/contracts/kernel/IdempotencyStore.ts | 3 + src/contracts/pubsub/MessageBus.ts | 15 ++++- src/contracts/pubsub/PublishContext.ts | 2 + .../pubsub/PublisherHookErrorPolicy.ts | 9 +++ src/contracts/pubsub/index.ts | 1 + src/domain/DomainEventConsumer.ts | 6 +- src/domain/DomainEventConsumerContext.ts | 3 + src/domain/DomainMessageBus.ts | 8 +++ src/domain/index.ts | 2 + tests/adapters/pubsub/Consumer.test.mjs | 47 +++++++++++++- .../amqp/AmqpMessageBusAdapter.test.mjs | 25 +++++--- .../pubsub/in-memory/InMemoryPubSub.test.mjs | 47 ++++++++++++++ .../ui/express/ExpressKernelServer.test.mjs | 29 +++++++++ tests/typescript-module-resolution.test.mjs | 3 + 34 files changed, 443 insertions(+), 54 deletions(-) create mode 100644 src/adapters/pubsub/DefaultPublisherHookErrorPolicy.ts create mode 100644 src/adapters/ui/express/ExpressHookPhase.ts create mode 100644 src/adapters/ui/express/ExpressPhaseHook.ts create mode 100644 src/contracts/pubsub/PublisherHookErrorPolicy.ts create mode 100644 src/domain/DomainEventConsumerContext.ts create mode 100644 src/domain/DomainMessageBus.ts diff --git a/docs/guides/adapters.md b/docs/guides/adapters.md index 0c566ea..ab44246 100644 --- a/docs/guides/adapters.md +++ b/docs/guides/adapters.md @@ -38,18 +38,29 @@ wrapping the adapter in an application-local class. import AmqpMessageBusAdapter from '@haskou/ddd-kernel/adapters/pubsub/amqp'; const messageBus = new AmqpMessageBusAdapter({ + publisherHookErrorPolicy: { + handleAfterPublishError(error, context) { + logger.error( + `Post-publish hook failed for ${context.topic}: ${String(error)}`, + ); + }, + shouldFailAfterPublish() { + return false; + }, + }, publisherHooks: [ { - afterPublish: async ({ message }) => { - await websocketPublisher.publish(message); + afterPublish: async ({ domainEvent, message }) => { + await websocketPublisher.publish(domainEvent ?? message); }, }, ], }); ``` -Custom adapters should implement the `MessageBus` contract and delegate hook -execution through `PublisherHookPipeline`: +Custom generic adapters should implement the `MessageBus` contract. Domain-event +adapters should implement `DomainMessageBus`. Both can delegate hook execution +through `PublisherHookPipeline`: ```ts import { diff --git a/docs/reference/consumer.md b/docs/reference/consumer.md index fcf94fa..b738b5c 100644 --- a/docs/reference/consumer.md +++ b/docs/reference/consumer.md @@ -34,7 +34,8 @@ kernel.registerConsumerMiddleware({ Middleware receives the event, the next pipeline callback and a `ConsumerExecutionContext` containing queue, exchange, event id, correlation id -and causation id. +and causation id. Transport adapters can also attach metadata, such as AMQP +headers or retry counts, to `context.metadata`. ## Built-in Middleware @@ -61,5 +62,7 @@ kernel.registerConsumerMiddleware( ); ``` -Use a custom `IdempotencyStore` for durable idempotency. The in-memory store is +Use a custom `IdempotencyStore` for durable idempotency. Prefer stores that +implement atomic `claim`, `commit` and `release` methods so duplicate messages +cannot pass a non-atomic `has`/`mark` check concurrently. The in-memory store is only useful for tests and single-process applications. diff --git a/docs/reference/express-kernel-server.md b/docs/reference/express-kernel-server.md index c5637d4..92d761e 100644 --- a/docs/reference/express-kernel-server.md +++ b/docs/reference/express-kernel-server.md @@ -36,11 +36,14 @@ that need direct app access, such as Swagger or static assets: ```ts const server = new ExpressKernelServer({ kernel, + hooks: [ + { phase: 'beforeControllers', handle: setupTracing }, + { phase: 'beforeErrors', handle: setupSwagger }, + { phase: 'beforeErrors', handle: setupStaticAssets }, + ], middlewares: [requestIdMiddleware], preControllerMiddlewares: [authenticationMiddleware], postControllerMiddlewares: [notFoundMiddleware], - swaggerHooks: [(app) => setupSwagger(app)], - staticHooks: [(app) => app.use('/public', express.static('public'))], }); ``` @@ -49,9 +52,15 @@ Hook order is: 1. `middlewares` 2. `preControllerMiddlewares` 3. `beforeControllersHooks` -4. `routing-controllers` -5. `postControllerMiddlewares` -6. `afterControllersHooks` -7. `swaggerHooks` -8. `staticHooks` -9. `errorHandlers` +4. `hooks` with `phase: 'beforeControllers'` +5. `routing-controllers` +6. `postControllerMiddlewares` +7. `afterControllersHooks` +8. `hooks` with `phase: 'afterControllers'` +9. `swaggerHooks` +10. `staticHooks` +11. `hooks` with `phase: 'beforeErrors'` +12. `errorHandlers` + +`swaggerHooks` and `staticHooks` remain available for compatibility. New +integrations should use `hooks` with an explicit phase. diff --git a/src/adapters/pubsub/Consumer.ts b/src/adapters/pubsub/Consumer.ts index 2c86cd4..4f1cc35 100644 --- a/src/adapters/pubsub/Consumer.ts +++ b/src/adapters/pubsub/Consumer.ts @@ -1,5 +1,8 @@ import type { DomainEventConsumer } from '../../domain/DomainEventConsumer.js'; -import type { DomainEvent } from '../../domain/index.js'; +import type { + DomainEvent, + DomainEventConsumerContext, +} from '../../domain/index.js'; import { Kernel } from '../../Kernel.js'; import { ConsumerMiddlewarePipeline } from './ConsumerMiddlewarePipeline.js'; @@ -7,8 +10,12 @@ import { ConsumerMiddlewarePipeline } from './ConsumerMiddlewarePipeline.js'; export abstract class Consumer { constructor(private readonly consumer: DomainEventConsumer) {} - private async runMiddleware(event: DomainEvent): Promise { + private async runMiddleware( + event: DomainEvent, + consumerContext?: DomainEventConsumerContext, + ): Promise { const pipeline = new ConsumerMiddlewarePipeline(Kernel.consumerMiddleware); + const metadata = consumerContext?.metadata ?? {}; await pipeline.execute( event, @@ -19,8 +26,9 @@ export abstract class Consumer { eventName: this.eventName, exchange: this.exchange, kernel: Kernel.active, - metadata: {}, + metadata, queueName: this.queueName, + rawMessage: metadata.rawMessage, }, () => this.handler(event), ); @@ -42,7 +50,7 @@ export abstract class Consumer { this.eventName, this.domainEvent, this.exchange, - (event) => this.runMiddleware(event), + (event, context) => this.runMiddleware(event, context), ); } diff --git a/src/adapters/pubsub/DefaultPublisherHookErrorPolicy.ts b/src/adapters/pubsub/DefaultPublisherHookErrorPolicy.ts new file mode 100644 index 0000000..1c85442 --- /dev/null +++ b/src/adapters/pubsub/DefaultPublisherHookErrorPolicy.ts @@ -0,0 +1,26 @@ +import type { + PublishContext, + PublisherHookErrorPolicy, +} from '../../contracts/index.js'; + +export class DefaultPublisherHookErrorPolicy implements PublisherHookErrorPolicy { + public handleAfterPublishError( + error: unknown, + context: PublishContext, + ): void { + void error; + void context; + } + + public shouldFailAfterPublish( + error: unknown, + context: PublishContext, + ): boolean { + void error; + void context; + + return false; + } +} + +export default DefaultPublisherHookErrorPolicy; diff --git a/src/adapters/pubsub/IdempotencyConsumerMiddleware.ts b/src/adapters/pubsub/IdempotencyConsumerMiddleware.ts index 930734d..f460eeb 100644 --- a/src/adapters/pubsub/IdempotencyConsumerMiddleware.ts +++ b/src/adapters/pubsub/IdempotencyConsumerMiddleware.ts @@ -9,13 +9,30 @@ import type { IdempotencyConsumerMiddlewareOptions } from './IdempotencyConsumer export class IdempotencyConsumerMiddleware implements ConsumerMiddleware { constructor(private readonly options: IdempotencyConsumerMiddlewareOptions) {} - public async handle( - event: DomainEvent, + private async handleClaimedKey( + key: string, next: ConsumerNext, - context: ConsumerExecutionContext, ): Promise { - const key = this.options.key?.(event, context) ?? context.eventId; + const claimed = await this.options.store.claim?.(key); + + if (!claimed) { + return; + } + + try { + await next(); + await (this.options.store.commit?.(key) ?? this.options.store.mark(key)); + } catch (error: unknown) { + await this.options.store.release?.(key); + + throw error; + } + } + private async handleLegacyKey( + key: string, + next: ConsumerNext, + ): Promise { if (await this.options.store.has(key)) { return; } @@ -23,6 +40,20 @@ export class IdempotencyConsumerMiddleware implements ConsumerMiddleware { await next(); await this.options.store.mark(key); } + + public async handle( + event: DomainEvent, + next: ConsumerNext, + context: ConsumerExecutionContext, + ): Promise { + const key = this.options.key?.(event, context) ?? context.eventId; + + if (this.options.store.claim) { + await this.handleClaimedKey(key, next); + } else { + await this.handleLegacyKey(key, next); + } + } } export default IdempotencyConsumerMiddleware; diff --git a/src/adapters/pubsub/InMemoryIdempotencyStore.ts b/src/adapters/pubsub/InMemoryIdempotencyStore.ts index 78db276..1201dcf 100644 --- a/src/adapters/pubsub/InMemoryIdempotencyStore.ts +++ b/src/adapters/pubsub/InMemoryIdempotencyStore.ts @@ -1,8 +1,28 @@ import type { IdempotencyStore } from '../../contracts/index.js'; export class InMemoryIdempotencyStore implements IdempotencyStore { + private readonly claimedKeys = new Set(); private readonly handledKeys = new Set(); + public claim(key: string): boolean { + if (this.handledKeys.has(key) || this.claimedKeys.has(key)) { + return false; + } + + this.claimedKeys.add(key); + + return true; + } + + public commit(key: string): void { + this.claimedKeys.delete(key); + this.handledKeys.add(key); + } + + public release(key: string): void { + this.claimedKeys.delete(key); + } + public has(key: string): boolean { return this.handledKeys.has(key); } diff --git a/src/adapters/pubsub/PublisherHookPipeline.ts b/src/adapters/pubsub/PublisherHookPipeline.ts index 4055176..6fecddf 100644 --- a/src/adapters/pubsub/PublisherHookPipeline.ts +++ b/src/adapters/pubsub/PublisherHookPipeline.ts @@ -1,12 +1,57 @@ -import type { PublishContext, PublisherHook } from '../../contracts/index.js'; +import type { + PublishContext, + PublisherHook, + PublisherHookErrorPolicy, +} from '../../contracts/index.js'; + +import { DefaultPublisherHookErrorPolicy } from './DefaultPublisherHookErrorPolicy.js'; export class PublisherHookPipeline { private readonly hooks: PublisherHook[] = []; - constructor(hooks: readonly PublisherHook[] = []) { + constructor( + hooks: readonly PublisherHook[] = [], + private readonly errorPolicy: PublisherHookErrorPolicy = new DefaultPublisherHookErrorPolicy(), + ) { this.hooks.push(...hooks); } + private async runAfterPublishHooks(context: PublishContext): Promise { + for (const hook of this.hooks) { + await this.runAfterPublishHook(hook, context); + } + } + + private async runAfterPublishHook( + hook: PublisherHook, + context: PublishContext, + ): Promise { + try { + await hook.afterPublish?.(context); + } catch (error: unknown) { + await this.errorPolicy.handleAfterPublishError(error, context); + + if (this.errorPolicy.shouldFailAfterPublish(error, context)) { + throw error; + } + } + } + + private async runBeforePublishHooks(context: PublishContext): Promise { + for (const hook of this.hooks) { + await hook.beforePublish?.(context); + } + } + + private async runPublishErrorHooks( + error: unknown, + context: PublishContext, + ): Promise { + for (const hook of this.hooks) { + await hook.onPublishError?.(error, context); + } + } + public register(...hooks: PublisherHook[]): void { this.hooks.push(...hooks); } @@ -15,22 +60,16 @@ export class PublisherHookPipeline { context: PublishContext, publish: () => Promise | T, ): Promise { - for (const hook of this.hooks) { - await hook.beforePublish?.(context); - } + await this.runBeforePublishHooks(context); try { const result = await publish(); - for (const hook of this.hooks) { - await hook.afterPublish?.(context); - } + await this.runAfterPublishHooks(context); return result; } catch (error: unknown) { - for (const hook of this.hooks) { - await hook.onPublishError?.(error, context); - } + await this.runPublishErrorHooks(error, context); throw error; } diff --git a/src/adapters/pubsub/amqp/AmqpMessageBusAdapter.ts b/src/adapters/pubsub/amqp/AmqpMessageBusAdapter.ts index c63f9dc..389c4ab 100644 --- a/src/adapters/pubsub/amqp/AmqpMessageBusAdapter.ts +++ b/src/adapters/pubsub/amqp/AmqpMessageBusAdapter.ts @@ -13,6 +13,7 @@ import type { Constructor, DomainEvent, DomainEventConsumer, + DomainMessageBus, DomainEventPublisher, } from '../../../domain/index.js'; import type { AmqpMessage } from './AmqpMessage.js'; @@ -25,7 +26,7 @@ import { InvalidDomainEventError } from './InvalidDomainEventError.js'; import { NoFailedMessagesError } from './NoFailedMessagesError.js'; export default class AmqpMessageBusAdapter - implements DomainEventConsumer, DomainEventPublisher + implements DomainEventConsumer, DomainEventPublisher, DomainMessageBus { private channelInstance: Channel | undefined; private connection: ChannelModel | undefined; @@ -38,6 +39,7 @@ export default class AmqpMessageBusAdapter options.exchange ?? options.serviceName ?? process.env.SERVICE_NAME ?? ''; this.publisherHookPipeline = new PublisherHookPipeline( options.publisherHooks, + options.publisherHookErrorPolicy, ); } @@ -103,7 +105,13 @@ export default class AmqpMessageBusAdapter message, ); - await context.handler(domainEvent); + await context.handler(domainEvent, { + metadata: { + headers: msg.properties.headers ?? {}, + rawMessage: msg, + retries: Number(msg.properties.headers?.retries ?? 0), + }, + }); } catch (error) { await this.handleError(msg, message, context, error); } @@ -458,6 +466,7 @@ export default class AmqpMessageBusAdapter for (const event of domainEvents) { await this.publisherHookPipeline.run( { + domainEvent: event, message: { metadata: { causationId: event.getCausationId(), diff --git a/src/adapters/pubsub/amqp/AmqpMessageBusAdapterOptions.ts b/src/adapters/pubsub/amqp/AmqpMessageBusAdapterOptions.ts index fc2db4a..0b78792 100644 --- a/src/adapters/pubsub/amqp/AmqpMessageBusAdapterOptions.ts +++ b/src/adapters/pubsub/amqp/AmqpMessageBusAdapterOptions.ts @@ -1,4 +1,7 @@ -import type { PublisherHook } from '../../../contracts/index.js'; +import type { + PublisherHook, + PublisherHookErrorPolicy, +} from '../../../contracts/index.js'; import type { Log } from '../../../infrastructure/logs/index.js'; export interface AmqpMessageBusAdapterOptions { @@ -6,6 +9,7 @@ export interface AmqpMessageBusAdapterOptions { readonly exchange?: string; readonly logger?: Log; readonly maxRetries?: number; + readonly publisherHookErrorPolicy?: PublisherHookErrorPolicy; readonly publisherHooks?: PublisherHook[]; readonly retryDelayInMilliseconds?: number; readonly serviceName?: string; diff --git a/src/adapters/pubsub/amqp/DomainEventHandler.ts b/src/adapters/pubsub/amqp/DomainEventHandler.ts index 0be849d..bdb7420 100644 --- a/src/adapters/pubsub/amqp/DomainEventHandler.ts +++ b/src/adapters/pubsub/amqp/DomainEventHandler.ts @@ -1,3 +1,9 @@ -import type { DomainEvent } from '../../../domain/index.js'; +import type { + DomainEvent, + DomainEventConsumerContext, +} from '../../../domain/index.js'; -export type DomainEventHandler = (event: DomainEvent) => Promise; +export type DomainEventHandler = ( + event: DomainEvent, + context?: DomainEventConsumerContext, +) => Promise; diff --git a/src/adapters/pubsub/in-memory/InMemoryEventBus.ts b/src/adapters/pubsub/in-memory/InMemoryEventBus.ts index 727c73e..cef6322 100644 --- a/src/adapters/pubsub/in-memory/InMemoryEventBus.ts +++ b/src/adapters/pubsub/in-memory/InMemoryEventBus.ts @@ -3,6 +3,7 @@ import type { HandlerContext, MessageHandler, PublisherHook, + PublisherHookErrorPolicy, } from '../../../contracts/index.js'; import { PublisherHookPipeline } from '../PublisherHookPipeline.js'; @@ -18,8 +19,12 @@ export class InMemoryEventBus { constructor( private readonly context: HandlerContext, publisherHooks: readonly PublisherHook[] = [], + publisherHookErrorPolicy?: PublisherHookErrorPolicy, ) { - this.publisherHookPipeline = new PublisherHookPipeline(publisherHooks); + this.publisherHookPipeline = new PublisherHookPipeline( + publisherHooks, + publisherHookErrorPolicy, + ); } public subscribe( diff --git a/src/adapters/pubsub/in-memory/InMemoryPubSub.ts b/src/adapters/pubsub/in-memory/InMemoryPubSub.ts index 2523f3f..f8205f9 100644 --- a/src/adapters/pubsub/in-memory/InMemoryPubSub.ts +++ b/src/adapters/pubsub/in-memory/InMemoryPubSub.ts @@ -3,6 +3,7 @@ import type { Message, MessageHandler, PublisherHook, + PublisherHookErrorPolicy, Subscription, } from '../../../contracts/index.js'; @@ -19,8 +20,12 @@ export class InMemoryPubSub { constructor( private readonly context: HandlerContext, publisherHooks: readonly PublisherHook[] = [], + publisherHookErrorPolicy?: PublisherHookErrorPolicy, ) { - this.publisherHookPipeline = new PublisherHookPipeline(publisherHooks); + this.publisherHookPipeline = new PublisherHookPipeline( + publisherHooks, + publisherHookErrorPolicy, + ); } public async publish( diff --git a/src/adapters/pubsub/index.ts b/src/adapters/pubsub/index.ts index 823e299..641ce30 100644 --- a/src/adapters/pubsub/index.ts +++ b/src/adapters/pubsub/index.ts @@ -2,6 +2,7 @@ export * from './Consumer.js'; export * from './ConsumerMiddlewarePipeline.js'; export * from './CorrelationConsumerMiddleware.js'; export * from './CorrelationConsumerMiddlewareOptions.js'; +export * from './DefaultPublisherHookErrorPolicy.js'; export * from './IdempotencyConsumerMiddleware.js'; export * from './IdempotencyConsumerMiddlewareOptions.js'; export * from './InMemoryIdempotencyStore.js'; diff --git a/src/adapters/ui/express/ExpressHookPhase.ts b/src/adapters/ui/express/ExpressHookPhase.ts new file mode 100644 index 0000000..881abf3 --- /dev/null +++ b/src/adapters/ui/express/ExpressHookPhase.ts @@ -0,0 +1,4 @@ +export type ExpressHookPhase = + | 'afterControllers' + | 'beforeControllers' + | 'beforeErrors'; diff --git a/src/adapters/ui/express/ExpressKernelServer.ts b/src/adapters/ui/express/ExpressKernelServer.ts index c7bafa8..4331757 100644 --- a/src/adapters/ui/express/ExpressKernelServer.ts +++ b/src/adapters/ui/express/ExpressKernelServer.ts @@ -41,6 +41,17 @@ export class ExpressKernelServer { } } + private async runPhaseHooks( + phase: 'afterControllers' | 'beforeControllers' | 'beforeErrors', + app: HttpApp, + ): Promise { + for (const hook of this.options.hooks ?? []) { + if (hook.phase === phase) { + await hook.handle(app); + } + } + } + private registerMiddlewares( app: HttpApp, middlewares: readonly RequestHandler[] | undefined, @@ -87,6 +98,10 @@ export class ExpressKernelServer { } public async run(): Promise { + if (this.serverInstance) { + throw new Error('HTTP server is already running.'); + } + const controllers = [ ...this.options.kernel.getRoutes(), ...(this.options.controllers ?? []), @@ -96,14 +111,17 @@ export class ExpressKernelServer { this.registerMiddlewares(app, this.options.middlewares); this.registerMiddlewares(app, this.options.preControllerMiddlewares); await this.runHooks(this.options.beforeControllersHooks, app); + await this.runPhaseHooks('beforeControllers', app); useExpressServer(app, { controllers, routePrefix: this.options.routePrefix, }); this.registerMiddlewares(app, this.options.postControllerMiddlewares); await this.runHooks(this.options.afterControllersHooks, app); + await this.runPhaseHooks('afterControllers', app); await this.runHooks(this.options.swaggerHooks, app); await this.runHooks(this.options.staticHooks, app); + await this.runPhaseHooks('beforeErrors', app); this.registerErrorHandlers(app); this.appInstance = app; diff --git a/src/adapters/ui/express/ExpressKernelServerOptions.ts b/src/adapters/ui/express/ExpressKernelServerOptions.ts index 6e1bca6..1e5f8b8 100644 --- a/src/adapters/ui/express/ExpressKernelServerOptions.ts +++ b/src/adapters/ui/express/ExpressKernelServerOptions.ts @@ -3,18 +3,26 @@ import type { ErrorRequestHandler, RequestHandler } from 'express'; import type { Kernel } from '../../../Kernel.js'; import type { ExpressAppHook } from './ExpressAppHook.js'; import type { ExpressController } from './ExpressController.js'; +import type { ExpressPhaseHook } from './ExpressPhaseHook.js'; export interface ExpressKernelServerOptions { readonly afterControllersHooks?: ExpressAppHook[]; readonly beforeControllersHooks?: ExpressAppHook[]; readonly controllers?: ExpressController[]; readonly errorHandlers?: ErrorRequestHandler[]; + readonly hooks?: ExpressPhaseHook[]; readonly kernel: Kernel; readonly middlewares?: RequestHandler[]; readonly postControllerMiddlewares?: RequestHandler[]; readonly preControllerMiddlewares?: RequestHandler[]; readonly port?: number; readonly routePrefix?: string; + /** + * @deprecated Prefer `hooks` with `phase: 'beforeErrors'`. + */ readonly staticHooks?: ExpressAppHook[]; + /** + * @deprecated Prefer `hooks` with `phase: 'beforeErrors'`. + */ readonly swaggerHooks?: ExpressAppHook[]; } diff --git a/src/adapters/ui/express/ExpressPhaseHook.ts b/src/adapters/ui/express/ExpressPhaseHook.ts new file mode 100644 index 0000000..77cc725 --- /dev/null +++ b/src/adapters/ui/express/ExpressPhaseHook.ts @@ -0,0 +1,7 @@ +import type { ExpressAppHook } from './ExpressAppHook.js'; +import type { ExpressHookPhase } from './ExpressHookPhase.js'; + +export interface ExpressPhaseHook { + readonly handle: ExpressAppHook; + readonly phase: ExpressHookPhase; +} diff --git a/src/adapters/ui/express/index.ts b/src/adapters/ui/express/index.ts index 15f3913..9df26a1 100644 --- a/src/adapters/ui/express/index.ts +++ b/src/adapters/ui/express/index.ts @@ -1,7 +1,9 @@ export * from './ExpressAppHook.js'; export * from './ExpressController.js'; +export * from './ExpressHookPhase.js'; export * from './ExpressKernelServer.js'; export * from './ExpressKernelServerOptions.js'; +export * from './ExpressPhaseHook.js'; export * from './HttpApp.js'; export * from './HttpServer.js'; export * from './RoutePrefix.js'; diff --git a/src/contracts/kernel/ConsumerExecutionContext.ts b/src/contracts/kernel/ConsumerExecutionContext.ts index ba8a3ff..65d6032 100644 --- a/src/contracts/kernel/ConsumerExecutionContext.ts +++ b/src/contracts/kernel/ConsumerExecutionContext.ts @@ -8,5 +8,6 @@ export interface ConsumerExecutionContext { readonly exchange: string; readonly kernel: Kernel; readonly metadata: Readonly>; + readonly rawMessage?: unknown; readonly queueName: string; } diff --git a/src/contracts/kernel/IdempotencyStore.ts b/src/contracts/kernel/IdempotencyStore.ts index 97dda55..df6b3b7 100644 --- a/src/contracts/kernel/IdempotencyStore.ts +++ b/src/contracts/kernel/IdempotencyStore.ts @@ -1,4 +1,7 @@ export interface IdempotencyStore { + claim?(key: string): Promise | boolean; + commit?(key: string): Promise | void; + release?(key: string): Promise | void; has(key: string): Promise | boolean; mark(key: string): Promise | void; } diff --git a/src/contracts/pubsub/MessageBus.ts b/src/contracts/pubsub/MessageBus.ts index 4afe13b..dde86e9 100644 --- a/src/contracts/pubsub/MessageBus.ts +++ b/src/contracts/pubsub/MessageBus.ts @@ -1,7 +1,16 @@ -import type { DomainEventConsumer } from '../../domain/DomainEventConsumer.js'; -import type { DomainEventPublisher } from '../../domain/DomainEventPublisher.js'; +import type { Message } from './Message.js'; +import type { MessageHandler } from './MessageHandler.js'; import type { PublisherHook } from './PublisherHook.js'; +import type { Subscription } from './Subscription.js'; -export interface MessageBus extends DomainEventConsumer, DomainEventPublisher { +export interface MessageBus { + publish( + topic: string, + message: TMessage, + ): Promise; registerPublisherHooks(...hooks: PublisherHook[]): void; + subscribe( + topic: string, + consumer: MessageHandler, + ): Promise; } diff --git a/src/contracts/pubsub/PublishContext.ts b/src/contracts/pubsub/PublishContext.ts index 43ce260..6fcd633 100644 --- a/src/contracts/pubsub/PublishContext.ts +++ b/src/contracts/pubsub/PublishContext.ts @@ -1,9 +1,11 @@ +import type { DomainEvent } from '../../domain/DomainEvent.js'; import type { DomainEvent as ContractDomainEvent } from './DomainEvent.js'; import type { Message } from './Message.js'; export interface PublishContext< TMessage extends Message | ContractDomainEvent = Message, > { + readonly domainEvent?: DomainEvent; readonly message: TMessage; readonly metadata: Readonly>; readonly topic: string; diff --git a/src/contracts/pubsub/PublisherHookErrorPolicy.ts b/src/contracts/pubsub/PublisherHookErrorPolicy.ts new file mode 100644 index 0000000..9733669 --- /dev/null +++ b/src/contracts/pubsub/PublisherHookErrorPolicy.ts @@ -0,0 +1,9 @@ +import type { PublishContext } from './PublishContext.js'; + +export interface PublisherHookErrorPolicy { + handleAfterPublishError( + error: unknown, + context: PublishContext, + ): Promise | void; + shouldFailAfterPublish(error: unknown, context: PublishContext): boolean; +} diff --git a/src/contracts/pubsub/index.ts b/src/contracts/pubsub/index.ts index a119358..54ecf99 100644 --- a/src/contracts/pubsub/index.ts +++ b/src/contracts/pubsub/index.ts @@ -9,4 +9,5 @@ export * from './MessageMetadata.js'; export * from './PubSub.js'; export * from './PublishContext.js'; export * from './PublisherHook.js'; +export * from './PublisherHookErrorPolicy.js'; export * from './Subscription.js'; diff --git a/src/domain/DomainEventConsumer.ts b/src/domain/DomainEventConsumer.ts index 847fa09..859b751 100644 --- a/src/domain/DomainEventConsumer.ts +++ b/src/domain/DomainEventConsumer.ts @@ -1,4 +1,5 @@ import type { DomainEvent } from './DomainEvent.js'; +import type { DomainEventConsumerContext } from './DomainEventConsumerContext.js'; export abstract class DomainEventConsumer { public abstract consume( @@ -6,6 +7,9 @@ export abstract class DomainEventConsumer { bindingKey: string, domainEvent: typeof DomainEvent, exchange: string, - handler: (event: DomainEvent) => Promise, + handler: ( + event: DomainEvent, + context?: DomainEventConsumerContext, + ) => Promise, ): Promise; } diff --git a/src/domain/DomainEventConsumerContext.ts b/src/domain/DomainEventConsumerContext.ts new file mode 100644 index 0000000..af86e89 --- /dev/null +++ b/src/domain/DomainEventConsumerContext.ts @@ -0,0 +1,3 @@ +export interface DomainEventConsumerContext { + readonly metadata: Readonly>; +} diff --git a/src/domain/DomainMessageBus.ts b/src/domain/DomainMessageBus.ts new file mode 100644 index 0000000..a6d8669 --- /dev/null +++ b/src/domain/DomainMessageBus.ts @@ -0,0 +1,8 @@ +import type { MessageBus } from '../contracts/pubsub/MessageBus.js'; +import type { DomainEventConsumer } from './DomainEventConsumer.js'; +import type { DomainEventPublisher } from './DomainEventPublisher.js'; + +export interface DomainMessageBus + extends DomainEventConsumer, DomainEventPublisher { + registerPublisherHooks: MessageBus['registerPublisherHooks']; +} diff --git a/src/domain/index.ts b/src/domain/index.ts index 09e894b..da10747 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -3,7 +3,9 @@ export * from './BaseError.js'; export * from './Constructor.js'; export * from './DomainEvent.js'; export * from './DomainEventConsumer.js'; +export * from './DomainEventConsumerContext.js'; export * from './DomainEventPublisher.js'; +export * from './DomainMessageBus.js'; export * from './Event.js'; export * from './EventAttributes.js'; export * from './EventConstructor.js'; diff --git a/tests/adapters/pubsub/Consumer.test.mjs b/tests/adapters/pubsub/Consumer.test.mjs index 03a118b..fc28912 100644 --- a/tests/adapters/pubsub/Consumer.test.mjs +++ b/tests/adapters/pubsub/Consumer.test.mjs @@ -41,11 +41,17 @@ class TestConsumer extends Consumer { test('initializes the domain event consumer with metadata and middleware chain', async () => { const calls = []; const event = new TestDomainEvent('aggregate-id'); + const rawMessage = { id: 'raw-message' }; const kernel = new Kernel(); const domainEventConsumer = { consume: async (queueName, eventName, EventClass, exchange, handler) => { calls.push([queueName, eventName, EventClass, exchange]); - await handler(event); + await handler(event, { + metadata: { + rawMessage, + retries: 1, + }, + }); }, }; const consumer = new TestConsumer(domainEventConsumer, calls); @@ -54,7 +60,13 @@ test('initializes the domain event consumer with metadata and middleware chain', kernel.registerConsumerMiddleware({ async handle(receivedEvent, next, context) { calls.push(['middleware:before', receivedEvent]); - calls.push(['context', context.eventId, context.queueName]); + calls.push([ + 'context', + context.eventId, + context.queueName, + context.metadata.retries, + context.rawMessage, + ]); await next(); calls.push(['middleware:after', receivedEvent]); }, @@ -65,7 +77,7 @@ test('initializes the domain event consumer with metadata and middleware chain', assert.deepEqual(calls, [ ['test-queue', 'test.domain-event', TestDomainEvent, 'test-exchange'], ['middleware:before', event], - ['context', event.eventId, 'test-queue'], + ['context', event.eventId, 'test-queue', 1, rawMessage], ['handler', event], ['middleware:after', event], ]); @@ -195,3 +207,32 @@ test('supports middleware defaults and retry predicates', async () => { assert.equal(await store.has('custom-key'), true); assert.deepEqual(calls, [['correlation'], ['idempotency']]); }); + +test('releases claimed idempotency keys when handlers fail', async () => { + const event = new TestDomainEvent('aggregate-id'); + const context = { + eventId: 'event-id', + eventName: 'test.domain-event', + exchange: 'exchange', + kernel: new Kernel(), + metadata: {}, + queueName: 'queue', + }; + const store = new InMemoryIdempotencyStore(); + const middleware = new IdempotencyConsumerMiddleware({ store }); + + await assert.rejects( + () => + middleware.handle( + event, + async () => { + throw new Error('failed'); + }, + context, + ), + /failed/, + ); + await middleware.handle(event, async () => {}, context); + + assert.equal(await store.has('event-id'), true); +}); diff --git a/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs b/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs index f498333..fda7663 100644 --- a/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs +++ b/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs @@ -146,8 +146,10 @@ test('publishes domain events and closes channel resources', async () => { serviceName: 'service', }); adapter.registerPublisherHooks({ - afterPublish: (context) => hookCalls.push(['after', context.topic]), - beforePublish: (context) => hookCalls.push(['before', context.topic]), + afterPublish: (context) => + hookCalls.push(['after', context.topic, context.domainEvent]), + beforePublish: (context) => + hookCalls.push(['before', context.topic, context.domainEvent]), }); const event = new TestDomainEvent( 'aggregate-id', @@ -172,8 +174,8 @@ test('publishes domain events and closes channel resources', async () => { true, ); assert.deepEqual(hookCalls, [ - ['before', 'test.domain-event'], - ['after', 'test.domain-event'], + ['before', 'test.domain-event', event], + ['after', 'test.domain-event', event], ]); }); @@ -186,7 +188,10 @@ test('consumes AMQP messages and acknowledges handled events', async () => { dsn: 'amqp://localhost', exchange: 'domain', }); - const message = createConsumeMessage(createMessage()); + const message = createConsumeMessage(createMessage(), { + retries: 2, + traceId: 'trace-id', + }); await withAmqpConnect(channel, async () => { await adapter.consume( @@ -194,14 +199,20 @@ test('consumes AMQP messages and acknowledges handled events', async () => { 'test.domain-event', TestDomainEvent, 'domain', - async (event) => handled.push(event), + async (event, context) => handled.push([event, context]), ); await channel.consumers[0](null); await channel.consumers[0](message); }); - assert.equal(handled[0] instanceof TestDomainEvent, true); + assert.equal(handled[0][0] instanceof TestDomainEvent, true); + assert.deepEqual(handled[0][1].metadata.headers, { + retries: 2, + traceId: 'trace-id', + }); + assert.equal(handled[0][1].metadata.rawMessage, message); + assert.equal(handled[0][1].metadata.retries, 2); assert.deepEqual(channel.calls.at(-1), ['ack', message]); }); diff --git a/tests/adapters/pubsub/in-memory/InMemoryPubSub.test.mjs b/tests/adapters/pubsub/in-memory/InMemoryPubSub.test.mjs index 2e139e6..9705a35 100644 --- a/tests/adapters/pubsub/in-memory/InMemoryPubSub.test.mjs +++ b/tests/adapters/pubsub/in-memory/InMemoryPubSub.test.mjs @@ -79,3 +79,50 @@ test('runs publisher error hooks before rethrowing', async () => { ); assert.deepEqual(calls, [[error, 'topic']]); }); + +test('does not fail publish when afterPublish hooks fail by default', async () => { + const context = { di: {}, publish: async () => {} }; + const calls = []; + const pubSub = new InMemoryPubSub(context, [ + { + afterPublish: () => { + throw new Error('websocket failed'); + }, + }, + ]); + + await pubSub.subscribe('topic', async (receivedMessage) => { + calls.push(['consumer', receivedMessage.name]); + }); + + await pubSub.publish('topic', { name: 'message' }); + + assert.deepEqual(calls, [['consumer', 'message']]); +}); + +test('can fail publish when afterPublish policy asks for it', async () => { + const context = { di: {}, publish: async () => {} }; + const afterPublishError = new Error('replica failed'); + const policyCalls = []; + const pubSub = new InMemoryPubSub( + context, + [ + { + afterPublish: () => { + throw afterPublishError; + }, + }, + ], + { + handleAfterPublishError: (error, publishContext) => + policyCalls.push([error, publishContext.topic]), + shouldFailAfterPublish: () => true, + }, + ); + + await assert.rejects( + () => pubSub.publish('topic', { name: 'message' }), + afterPublishError, + ); + assert.deepEqual(policyCalls, [[afterPublishError, 'topic']]); +}); diff --git a/tests/adapters/ui/express/ExpressKernelServer.test.mjs b/tests/adapters/ui/express/ExpressKernelServer.test.mjs index 867898b..47b65c1 100644 --- a/tests/adapters/ui/express/ExpressKernelServer.test.mjs +++ b/tests/adapters/ui/express/ExpressKernelServer.test.mjs @@ -121,6 +121,20 @@ test('runs configurable controller, swagger and static hooks', async () => { afterControllersHooks: [(app) => calls.push(['after', Boolean(app)])], beforeControllersHooks: [(app) => calls.push(['before', Boolean(app)])], controllers: [ExternalController], + hooks: [ + { + handle: (app) => calls.push(['phase:before', Boolean(app)]), + phase: 'beforeControllers', + }, + { + handle: (app) => calls.push(['phase:after', Boolean(app)]), + phase: 'afterControllers', + }, + { + handle: (app) => calls.push(['phase:errors', Boolean(app)]), + phase: 'beforeErrors', + }, + ], kernel: { getRoutes: () => [] }, middlewares: [middleware('base')], port: 0, @@ -135,8 +149,23 @@ test('runs configurable controller, swagger and static hooks', async () => { assert.deepEqual(calls, [ ['before', true], + ['phase:before', true], ['after', true], + ['phase:after', true], ['swagger', true], ['static', true], + ['phase:errors', true], ]); }); + +test('rejects duplicate run calls while server is running', async () => { + const server = new ExpressKernelServer({ + kernel: { getRoutes: () => [] }, + port: 0, + }); + + await server.run(); + + await assert.rejects(() => server.run(), /HTTP server is already running/); + await server.close(); +}); diff --git a/tests/typescript-module-resolution.test.mjs b/tests/typescript-module-resolution.test.mjs index c21db44..4891c48 100644 --- a/tests/typescript-module-resolution.test.mjs +++ b/tests/typescript-module-resolution.test.mjs @@ -37,16 +37,19 @@ test('exports types for TypeScript moduleResolution node consumers', async () => ` import type { ConsumerMiddleware } from '@haskou/ddd-kernel/contracts/kernel'; import type { MessageBus, PublisherHook } from '@haskou/ddd-kernel/contracts/pubsub'; + import type { DomainMessageBus } from '@haskou/ddd-kernel/domain'; import type { SchedulerErrorPolicy } from '@haskou/ddd-kernel/scheduler'; import { ExpressKernelServer } from '@haskou/ddd-kernel/adapters/ui/express'; const middleware: ConsumerMiddleware | undefined = undefined; const messageBus: MessageBus | undefined = undefined; + const domainMessageBus: DomainMessageBus | undefined = undefined; const hook: PublisherHook | undefined = undefined; const policy: SchedulerErrorPolicy | undefined = undefined; void middleware; void messageBus; + void domainMessageBus; void hook; void policy; void ExpressKernelServer; From 918b7d3b072589ae1c5d386600b9a3e517370685 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 19:05:33 +0200 Subject: [PATCH 06/19] =?UTF-8?q?docs(readme):=20=F0=9F=93=9D=20Remove=20s?= =?UTF-8?q?tability=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index 59223e5..b847ed1 100644 --- a/README.md +++ b/README.md @@ -44,12 +44,6 @@ Constructor injection is the preferred application pattern. Direct service lookup remains available for compatibility and integration boundaries, but it is not the primary dependency model. -## Stability - -This project is still in the `0.x` line. The current API is intentionally small -and covered by tests, but breaking changes may still happen while the kernel is -being extracted and hardened from production service patterns. - ## Documentation Usage guides, adapter authoring notes and API reference pages are published at: From b287bccc51ac65444f53ed44c7e8b509c9005c81 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 19:12:06 +0200 Subject: [PATCH 07/19] =?UTF-8?q?test(coverage):=20=E2=9C=85=20Cover=20int?= =?UTF-8?q?egration=20extension=20branches?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/adapters/pubsub/Consumer.test.mjs | 136 ++++++++++++++++++ .../amqp/AmqpMessageBusAdapter.test.mjs | 33 +++++ 2 files changed, 169 insertions(+) diff --git a/tests/adapters/pubsub/Consumer.test.mjs b/tests/adapters/pubsub/Consumer.test.mjs index fc28912..6867e65 100644 --- a/tests/adapters/pubsub/Consumer.test.mjs +++ b/tests/adapters/pubsub/Consumer.test.mjs @@ -3,6 +3,7 @@ import test from 'node:test'; import { Consumer, + ConsumerMiddlewarePipeline, CorrelationConsumerMiddleware, IdempotencyConsumerMiddleware, InMemoryIdempotencyStore, @@ -236,3 +237,138 @@ test('releases claimed idempotency keys when handlers fail', async () => { assert.equal(await store.has('event-id'), true); }); + +test('supports legacy idempotency stores without atomic claim', async () => { + const event = new TestDomainEvent('aggregate-id'); + const context = { + eventId: 'event-id', + eventName: 'test.domain-event', + exchange: 'exchange', + kernel: new Kernel(), + metadata: {}, + queueName: 'queue', + }; + const handledKeys = new Set(); + const calls = []; + const middleware = new IdempotencyConsumerMiddleware({ + store: { + has: (key) => handledKeys.has(key), + mark: (key) => handledKeys.add(key), + }, + }); + + await middleware.handle(event, async () => calls.push('first'), context); + await middleware.handle(event, async () => calls.push('second'), context); + + assert.deepEqual(calls, ['first']); +}); + +test('allows retry middleware with zero attempts', async () => { + const event = new TestDomainEvent('aggregate-id'); + const context = { + eventId: event.eventId, + eventName: 'test.domain-event', + exchange: 'exchange', + kernel: new Kernel(), + metadata: {}, + queueName: 'queue', + }; + let calls = 0; + + await new RetryConsumerMiddleware({ maxAttempts: 0 }).handle( + event, + async () => { + calls++; + }, + context, + ); + + assert.equal(calls, 0); +}); + +test('executes consumer middleware pipeline without middleware', async () => { + const event = new TestDomainEvent('aggregate-id'); + const calls = []; + const pipeline = new ConsumerMiddlewarePipeline([]); + + await pipeline.execute( + event, + { + eventId: event.eventId, + eventName: 'test.domain-event', + exchange: 'exchange', + kernel: new Kernel(), + metadata: {}, + queueName: 'queue', + }, + async () => calls.push(['handler', event]), + ); + + assert.deepEqual(calls, [['handler', event]]); +}); + +test('waits between retry attempts when delay is configured', async () => { + const event = new TestDomainEvent('aggregate-id'); + const context = { + eventId: event.eventId, + eventName: 'test.domain-event', + exchange: 'exchange', + kernel: new Kernel(), + metadata: {}, + queueName: 'queue', + }; + let attempts = 0; + + await new RetryConsumerMiddleware({ + delay: 1, + maxAttempts: 2, + }).handle( + event, + async () => { + attempts++; + + if (attempts === 1) { + throw new Error('transient'); + } + }, + context, + ); + + assert.equal(attempts, 2); +}); + +test('resolves retry delay from a function', async () => { + const event = new TestDomainEvent('aggregate-id'); + const context = { + eventId: event.eventId, + eventName: 'test.domain-event', + exchange: 'exchange', + kernel: new Kernel(), + metadata: {}, + queueName: 'queue', + }; + const delays = []; + let attempts = 0; + + await new RetryConsumerMiddleware({ + delay: (attempt, error, receivedContext) => { + delays.push([attempt, String(error), receivedContext.eventName]); + + return 1; + }, + maxAttempts: 2, + }).handle( + event, + async () => { + attempts++; + + if (attempts === 1) { + throw new Error('transient'); + } + }, + context, + ); + + assert.equal(attempts, 2); + assert.deepEqual(delays, [[1, 'Error: transient', 'test.domain-event']]); +}); diff --git a/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs b/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs index fda7663..526adb4 100644 --- a/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs +++ b/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs @@ -216,6 +216,39 @@ test('consumes AMQP messages and acknowledges handled events', async () => { assert.deepEqual(channel.calls.at(-1), ['ack', message]); }); +test('consumes AMQP messages without headers metadata', async () => { + const channel = new FakeChannel(); + const handled = []; + const { default: AmqpMessageBusAdapter } = + await import('../../../../dist/adapters/pubsub/amqp/index.js'); + const adapter = new AmqpMessageBusAdapter({ + dsn: 'amqp://localhost', + exchange: 'domain', + }); + const message = { + content: Buffer.from(JSON.stringify(createMessage())), + properties: {}, + }; + + await withAmqpConnect(channel, async () => { + await adapter.consume( + 'queue', + 'test.domain-event', + TestDomainEvent, + 'domain', + async (event, context) => { + void event; + handled.push(context); + }, + ); + + await channel.consumers[0](message); + }); + + assert.deepEqual(handled[0].metadata.headers, {}); + assert.equal(handled[0].metadata.retries, 0); +}); + test('retries failed messages and registers delayed consumers once', async () => { const channel = new FakeChannel(); const logs = []; From 884b9fb9f7e1e101af471c6a9ea3c8548ca7ced5 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 19:23:15 +0200 Subject: [PATCH 08/19] =?UTF-8?q?docs(example):=20=F0=9F=93=9D=20Polish=20?= =?UTF-8?q?application=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/package.json | 2 +- .../src/apps/api/routes/GetUserByIdRoute.ts | 8 ++-- .../application/find-by-id/UserByIdFinder.ts | 4 +- example/src/contexts/users/domain/User.ts | 4 +- .../users/domain/value-objects/UserId.ts | 2 +- .../infrastructure/InMemoryUserRepository.ts | 4 +- example/src/index.ts | 47 +++++++++++++++++-- example/yarn.lock | 2 +- .../ui/express/ExpressKernelServer.ts | 17 ++++++- 9 files changed, 74 insertions(+), 16 deletions(-) diff --git a/example/package.json b/example/package.json index 15e9b3c..56bf228 100644 --- a/example/package.json +++ b/example/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "tsc -p tsconfig.json", - "start": "tsx src/index.ts", + "start": "CONTAINER_BUILD=true tsx src/index.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/example/src/apps/api/routes/GetUserByIdRoute.ts b/example/src/apps/api/routes/GetUserByIdRoute.ts index 7b828d1..87a3096 100644 --- a/example/src/apps/api/routes/GetUserByIdRoute.ts +++ b/example/src/apps/api/routes/GetUserByIdRoute.ts @@ -1,14 +1,16 @@ import Route from '@haskou/ddd-kernel/adapters/ui/routes'; import { HttpRouteStatusEnum } from '@haskou/ddd-kernel/contracts/ui'; -import { Response } from 'express'; +import type { Response } from 'express'; import { Get, JsonController, Param, Res } from 'routing-controllers'; import UserByIdFinder from '../../../contexts/users/application/find-by-id/UserByIdFinder.js'; -import { UserId } from '../../../contexts/users/domain/value-objects/UserId.js'; +import UserId from '../../../contexts/users/domain/value-objects/UserId.js'; @JsonController('/users') export default class GetUserByIdRoute extends Route { - private readonly finder = this.get(UserByIdFinder); + constructor(private readonly finder: UserByIdFinder) { + super(); + } @Get('/:id') public async getUserById( diff --git a/example/src/contexts/users/application/find-by-id/UserByIdFinder.ts b/example/src/contexts/users/application/find-by-id/UserByIdFinder.ts index 1df5218..415c771 100644 --- a/example/src/contexts/users/application/find-by-id/UserByIdFinder.ts +++ b/example/src/contexts/users/application/find-by-id/UserByIdFinder.ts @@ -1,6 +1,6 @@ import UserFinderService from '../../domain/services/UserFinderService.js'; -import { User } from '../../domain/User.js'; -import { UserId } from '../../domain/value-objects/UserId.js'; +import User from '../../domain/User.js'; +import UserId from '../../domain/value-objects/UserId.js'; export default class UserByIdFinder { constructor(private readonly userFinderService: UserFinderService) {} diff --git a/example/src/contexts/users/domain/User.ts b/example/src/contexts/users/domain/User.ts index 832d2c0..c7c77dd 100644 --- a/example/src/contexts/users/domain/User.ts +++ b/example/src/contexts/users/domain/User.ts @@ -1,8 +1,8 @@ import { AggregateRoot } from '@haskou/ddd-kernel/domain'; -import { UserId } from './value-objects/UserId.js'; +import UserId from './value-objects/UserId.js'; -export class User extends AggregateRoot { +export default class User extends AggregateRoot { constructor(private readonly id: UserId) { super(); } diff --git a/example/src/contexts/users/domain/value-objects/UserId.ts b/example/src/contexts/users/domain/value-objects/UserId.ts index e371fc2..0a2b5b8 100644 --- a/example/src/contexts/users/domain/value-objects/UserId.ts +++ b/example/src/contexts/users/domain/value-objects/UserId.ts @@ -1,3 +1,3 @@ import { UUID } from '@haskou/value-objects'; -export class UserId extends UUID {} +export default class UserId extends UUID {} diff --git a/example/src/contexts/users/infrastructure/InMemoryUserRepository.ts b/example/src/contexts/users/infrastructure/InMemoryUserRepository.ts index 2d0b048..b2a1279 100644 --- a/example/src/contexts/users/infrastructure/InMemoryUserRepository.ts +++ b/example/src/contexts/users/infrastructure/InMemoryUserRepository.ts @@ -1,6 +1,6 @@ import UserRepository from '../domain/repositories/UserRepository.js'; -import { User } from '../domain/User.js'; -import { UserId } from '../domain/value-objects/UserId.js'; +import User from '../domain/User.js'; +import UserId from '../domain/value-objects/UserId.js'; export default class InMemoryUserRepository extends UserRepository { private readonly users = new Map([ diff --git a/example/src/index.ts b/example/src/index.ts index a674891..e494882 100644 --- a/example/src/index.ts +++ b/example/src/index.ts @@ -1,24 +1,65 @@ import 'reflect-metadata'; import { Kernel } from '@haskou/ddd-kernel'; +import { + CorrelationConsumerMiddleware, + IdempotencyConsumerMiddleware, + InMemoryIdempotencyStore, + RetryConsumerMiddleware, +} from '@haskou/ddd-kernel/adapters/pubsub'; import { ExpressKernelServer } from '@haskou/ddd-kernel/adapters/ui/express'; +import path from 'node:path'; import GetUserByIdRoute from './apps/api/routes/GetUserByIdRoute.js'; +const rootDirectory = process.cwd(); const kernel = new Kernel({ - servicesYamlPath: 'config/container/services.yaml', - sourceDirectory: 'src', + servicesYamlPath: path.resolve( + rootDirectory, + 'config', + 'container', + 'services.yaml', + ), + sourceDirectory: path.resolve(rootDirectory, 'src'), }); await kernel.dependencyInjection(); +kernel.registerConsumerMiddleware( + new CorrelationConsumerMiddleware(), + new IdempotencyConsumerMiddleware({ + store: new InMemoryIdempotencyStore(), + }), + new RetryConsumerMiddleware({ + maxAttempts: 3, + }), +); +kernel.registerRoutes(GetUserByIdRoute); + const server = new ExpressKernelServer({ + hooks: [ + { + handle: (app) => { + app.get('/health', (request, response) => { + void request; + response.status(200).json({ status: 'ok' }); + }); + }, + phase: 'beforeErrors', + }, + ], kernel, port: Number(process.env.PORT ?? 3000), }); -kernel.registerRoutes(GetUserByIdRoute); kernel.registerShutdownHook(() => server.close()); await server.run(); kernel.logger.info(`Application running on port ${process.env.PORT ?? 3000}`); + +process.once('SIGINT', () => { + void kernel.shutdown(); +}); +process.once('SIGTERM', () => { + void kernel.shutdown(); +}); diff --git a/example/yarn.lock b/example/yarn.lock index 48e2e82..db6d6ae 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -143,7 +143,7 @@ integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w== "@haskou/ddd-kernel@file:..": - version "0.1.0" + version "0.1.1" "@haskou/value-objects@^2.11.0": version "2.11.0" diff --git a/src/adapters/ui/express/ExpressKernelServer.ts b/src/adapters/ui/express/ExpressKernelServer.ts index 4331757..780ed3d 100644 --- a/src/adapters/ui/express/ExpressKernelServer.ts +++ b/src/adapters/ui/express/ExpressKernelServer.ts @@ -2,8 +2,9 @@ import express, { type ErrorRequestHandler, type RequestHandler, } from 'express'; -import { useExpressServer } from 'routing-controllers'; +import { useContainer, useExpressServer } from 'routing-controllers'; +import type { ExpressController } from './ExpressController.js'; import type { ExpressKernelServerOptions } from './ExpressKernelServerOptions.js'; import type { HttpApp } from './HttpApp.js'; import type { HttpServer } from './HttpServer.js'; @@ -14,6 +15,19 @@ export class ExpressKernelServer { constructor(private readonly options: ExpressKernelServerOptions) {} + private configureControllerContainer(): void { + useContainer( + { + get: (ClassDefinition: ExpressController) => + this.options.kernel.di.getService(ClassDefinition), + }, + { + fallback: true, + fallbackOnErrors: true, + }, + ); + } + private registerErrorHandlers(app: HttpApp): void { const handlers = this.options.errorHandlers ?? [this.defaultErrorHandler()]; @@ -112,6 +126,7 @@ export class ExpressKernelServer { this.registerMiddlewares(app, this.options.preControllerMiddlewares); await this.runHooks(this.options.beforeControllersHooks, app); await this.runPhaseHooks('beforeControllers', app); + this.configureControllerContainer(); useExpressServer(app, { controllers, routePrefix: this.options.routePrefix, From e5d3aaec158fb016b5fca2754ffafd7b1b66e1f6 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 19:26:57 +0200 Subject: [PATCH 09/19] =?UTF-8?q?feat(kernel):=20=E2=9C=A8=20Configure=20D?= =?UTF-8?q?I=20build=20from=20kernel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guides/dependency-injection.md | 13 ++++ example/package.json | 2 +- example/src/index.ts | 4 +- src/Kernel.ts | 33 ++++++--- .../KernelDependencyInjectionOptions.ts | 5 ++ tests/kernel.test.mjs | 72 ++++++++++++++++++- 6 files changed, 118 insertions(+), 11 deletions(-) create mode 100644 src/kernel/KernelDependencyInjectionOptions.ts diff --git a/docs/guides/dependency-injection.md b/docs/guides/dependency-injection.md index 779a558..0f41f28 100644 --- a/docs/guides/dependency-injection.md +++ b/docs/guides/dependency-injection.md @@ -29,6 +29,19 @@ export default class UserByIdFinder { You normally do not call `registerFactory`. The generated `services.yaml` is the composition metadata. +Build the container explicitly from the kernel when you want to regenerate that +metadata: + +```ts +await kernel.dependencyInjection({ + containerBuild: process.env.NODE_ENV !== 'production', +}); +``` + +If `containerBuild` is omitted, the kernel falls back to +`CONTAINER_BUILD=true`. That keeps older applications working while allowing new +bootstraps to keep the choice close to application startup code. + Avoid passing `Kernel.di` into consumers, schedulers or services as a normal dependency. It makes tests depend on global container state and hides the real collaborators a class needs. diff --git a/example/package.json b/example/package.json index 56bf228..15e9b3c 100644 --- a/example/package.json +++ b/example/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "build": "tsc -p tsconfig.json", - "start": "CONTAINER_BUILD=true tsx src/index.ts", + "start": "tsx src/index.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/example/src/index.ts b/example/src/index.ts index e494882..d3189bb 100644 --- a/example/src/index.ts +++ b/example/src/index.ts @@ -23,7 +23,9 @@ const kernel = new Kernel({ sourceDirectory: path.resolve(rootDirectory, 'src'), }); -await kernel.dependencyInjection(); +await kernel.dependencyInjection({ + containerBuild: process.env.NODE_ENV !== 'production', +}); kernel.registerConsumerMiddleware( new CorrelationConsumerMiddleware(), diff --git a/src/Kernel.ts b/src/Kernel.ts index 6c98882..440946e 100644 --- a/src/Kernel.ts +++ b/src/Kernel.ts @@ -10,12 +10,14 @@ import type { import type { ServiceClass } from './infrastructure/dependency-injection/index.js'; import type { Initializer, Runtime } from './infrastructure/lifecycle/index.js'; import type { Scheduler } from './infrastructure/scheduler/index.js'; +import type { KernelDependencyInjectionOptions } from './kernel/KernelDependencyInjectionOptions.js'; import type { KernelOptions } from './kernel/KernelOptions.js'; import type { ShutdownCandidate } from './kernel/ShutdownCandidate.js'; import { ConsoleKernelLogger } from './adapters/kernel/index.js'; import { DependencyInjection } from './infrastructure/dependency-injection/index.js'; +export type { KernelDependencyInjectionOptions } from './kernel/KernelDependencyInjectionOptions.js'; export type { KernelOptions } from './kernel/KernelOptions.js'; export class Kernel { @@ -169,16 +171,31 @@ export class Kernel { return this.schedulersList; } - public async dependencyInjection(): Promise { + private getDependencyInjectionOptions( + options: KernelDependencyInjectionOptions = {}, + ): Required { + return { + containerBuild: + options.containerBuild ?? process.env.CONTAINER_BUILD === 'true', + servicesYamlPath: + options.servicesYamlPath ?? + this.options.servicesYamlPath ?? + path.resolve(Kernel.configDirectory, 'container', 'services.yaml'), + sourceDirectory: + options.sourceDirectory ?? + this.options.sourceDirectory ?? + Kernel.sourceDirectory, + }; + } + + public async dependencyInjection( + options: KernelDependencyInjectionOptions = {}, + ): Promise { this.dependencyInjectionInstance = this.dependencyInjectionInstance ?? - DependencyInjection.configure({ - containerBuild: process.env.CONTAINER_BUILD === 'true', - servicesYamlPath: - this.options.servicesYamlPath ?? - path.resolve(Kernel.configDirectory, 'container', 'services.yaml'), - sourceDirectory: this.options.sourceDirectory ?? Kernel.sourceDirectory, - }); + DependencyInjection.configure( + this.getDependencyInjectionOptions(options), + ); await this.dependencyInjectionInstance.compile(); } diff --git a/src/kernel/KernelDependencyInjectionOptions.ts b/src/kernel/KernelDependencyInjectionOptions.ts new file mode 100644 index 0000000..23d1a4c --- /dev/null +++ b/src/kernel/KernelDependencyInjectionOptions.ts @@ -0,0 +1,5 @@ +export interface KernelDependencyInjectionOptions { + readonly containerBuild?: boolean; + readonly servicesYamlPath?: string; + readonly sourceDirectory?: string; +} diff --git a/tests/kernel.test.mjs b/tests/kernel.test.mjs index 5963f87..2c7e3dd 100644 --- a/tests/kernel.test.mjs +++ b/tests/kernel.test.mjs @@ -174,7 +174,10 @@ test('configures dependency injection from kernel options', async () => { await import('node:fs/promises').then(({ mkdir }) => mkdir(sourceDirectory, { recursive: true }), ); - await writeFile(path.join(sourceDirectory, 'Service.ts'), 'export default class Service {}\n'); + await writeFile( + path.join(sourceDirectory, 'Service.ts'), + 'export default class Service {}\n', + ); }); try { @@ -192,6 +195,73 @@ test('configures dependency injection from kernel options', async () => { } }); +test('allows dependency injection options to override container build environment', async () => { + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'ddd-kernel-')); + const sourceDirectory = path.join(temporaryDirectory, 'src'); + const servicesYamlPath = path.join(temporaryDirectory, 'services.yaml'); + const originalContainerBuild = process.env.CONTAINER_BUILD; + + process.env.CONTAINER_BUILD = 'false'; + await import('node:fs/promises').then(({ mkdir }) => + mkdir(sourceDirectory, { recursive: true }), + ); + await writeFile( + path.join(sourceDirectory, 'Service.ts'), + 'export default class Service {}\n', + ); + + try { + const kernel = new Kernel(); + + await kernel.dependencyInjection({ + containerBuild: true, + servicesYamlPath, + sourceDirectory, + }); + + assert.equal(existsSync(servicesYamlPath), true); + } finally { + if (originalContainerBuild === undefined) { + delete process.env.CONTAINER_BUILD; + } else { + process.env.CONTAINER_BUILD = originalContainerBuild; + } + } +}); + +test('falls back to CONTAINER_BUILD when dependency injection options omit containerBuild', async () => { + const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'ddd-kernel-')); + const sourceDirectory = path.join(temporaryDirectory, 'src'); + const servicesYamlPath = path.join(temporaryDirectory, 'services.yaml'); + const originalContainerBuild = process.env.CONTAINER_BUILD; + + process.env.CONTAINER_BUILD = 'true'; + await import('node:fs/promises').then(({ mkdir }) => + mkdir(sourceDirectory, { recursive: true }), + ); + await writeFile( + path.join(sourceDirectory, 'Service.ts'), + 'export default class Service {}\n', + ); + + try { + const kernel = new Kernel(); + + await kernel.dependencyInjection({ + servicesYamlPath, + sourceDirectory, + }); + + assert.equal(existsSync(servicesYamlPath), true); + } finally { + if (originalContainerBuild === undefined) { + delete process.env.CONTAINER_BUILD; + } else { + process.env.CONTAINER_BUILD = originalContainerBuild; + } + } +}); + test('uses default dependency injection paths from current working directory', async (context) => { const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'ddd-kernel-')); const sourceDirectory = path.join(temporaryDirectory, 'src'); From d1c1c012867fe1ca6090c5cf68367dc55a9d9d89 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 19:35:09 +0200 Subject: [PATCH 10/19] =?UTF-8?q?fix(example):=20=F0=9F=90=9B=20Fix=20exam?= =?UTF-8?q?ple=20lint=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/src/apps/api/routes/GetUserByIdRoute.ts | 3 ++- example/src/index.ts | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/example/src/apps/api/routes/GetUserByIdRoute.ts b/example/src/apps/api/routes/GetUserByIdRoute.ts index 87a3096..4981433 100644 --- a/example/src/apps/api/routes/GetUserByIdRoute.ts +++ b/example/src/apps/api/routes/GetUserByIdRoute.ts @@ -1,6 +1,7 @@ +import type { Response } from 'express'; + import Route from '@haskou/ddd-kernel/adapters/ui/routes'; import { HttpRouteStatusEnum } from '@haskou/ddd-kernel/contracts/ui'; -import type { Response } from 'express'; import { Get, JsonController, Param, Res } from 'routing-controllers'; import UserByIdFinder from '../../../contexts/users/application/find-by-id/UserByIdFinder.js'; diff --git a/example/src/index.ts b/example/src/index.ts index d3189bb..08cc968 100644 --- a/example/src/index.ts +++ b/example/src/index.ts @@ -1,5 +1,4 @@ import 'reflect-metadata'; - import { Kernel } from '@haskou/ddd-kernel'; import { CorrelationConsumerMiddleware, From ebc055354e84d10f2526edcc882b216179315dbc Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 19:51:53 +0200 Subject: [PATCH 11/19] =?UTF-8?q?feat(express):=20=E2=9C=A8=20Add=20HTTP?= =?UTF-8?q?=20pipeline=20registration=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/express-kernel-server.md | 94 ++++ example/src/index.ts | 54 ++- .../ui/express/ExpressKernelServer.ts | 143 +++++- .../ui/express/ExpressKernelServer.test.mjs | 413 +++++++++++++----- 4 files changed, 564 insertions(+), 140 deletions(-) diff --git a/docs/reference/express-kernel-server.md b/docs/reference/express-kernel-server.md index 92d761e..f159adf 100644 --- a/docs/reference/express-kernel-server.md +++ b/docs/reference/express-kernel-server.md @@ -12,6 +12,10 @@ await server.run(); Routes are registered with `kernel.registerRoutes(RouteClass)`. +The adapter can be configured either through constructor options or by calling +registration methods before `run()`. The methods return the server instance, so +they can be chained. + ## External Controllers Applications can add controllers at the server boundary without registering them @@ -28,6 +32,12 @@ const server = new ExpressKernelServer({ `controllers` are merged with `kernel.getRoutes()` before `routing-controllers` is configured. +The same can be done after construction: + +```ts +server.registerControllers(HealthController, MetricsController); +``` + ## HTTP Middleware And Hooks Use middleware arrays for normal Express middleware and hooks for integrations @@ -64,3 +74,87 @@ Hook order is: `swaggerHooks` and `staticHooks` remain available for compatibility. New integrations should use `hooks` with an explicit phase. + +You can also register the pipeline imperatively before the server starts: + +```ts +const server = new ExpressKernelServer({ kernel, port: 3000 }); + +server + .registerMiddlewares(requestIdMiddleware) + .registerPreControllerMiddlewares(authenticationMiddleware) + .registerHooks({ + phase: 'beforeErrors', + handle: (app) => { + app.get('/health', (request, response) => { + void request; + response.status(200).json({ status: 'ok' }); + }); + }, + }) + .registerPostControllerMiddlewares(notFoundMiddleware); + +await server.run(); +``` + +Registration methods must be called before `run()`. Once the server is running, +the Express pipeline is fixed and further registration throws an error. + +## Error Handlers + +Use `errorHandlers` or `registerErrorHandlers()` for normal Express error +middleware: + +```ts +import type { ErrorRequestHandler } from 'express'; + +const httpErrorHandler: ErrorRequestHandler = ( + error, + request, + response, + next, +) => { + void request; + + if (response.headersSent) { + next(error); + + return; + } + + response.status(error.statusCode ?? 500).json({ + error: error.name ?? 'InternalServerError', + message: error.message ?? 'Unexpected error', + }); +}; + +const server = new ExpressKernelServer({ kernel }); + +server.registerErrorHandlers(httpErrorHandler); +``` + +Error handlers run after: + +1. global middleware +2. pre-controller middleware +3. controllers +4. post-controller middleware +5. hooks registered for `beforeErrors` + +If no error handler is registered, `ExpressKernelServer` uses a default handler +that returns a `500` JSON response. + +## Registration API + +`ExpressKernelServer` exposes these registration methods: + +```ts +server.registerControllers(...controllers); +server.registerMiddlewares(...middlewares); +server.registerPreControllerMiddlewares(...middlewares); +server.registerPostControllerMiddlewares(...middlewares); +server.registerHooks(...hooks); +server.registerBeforeControllersHooks(...hooks); +server.registerAfterControllersHooks(...hooks); +server.registerErrorHandlers(...handlers); +``` diff --git a/example/src/index.ts b/example/src/index.ts index 08cc968..20d0a91 100644 --- a/example/src/index.ts +++ b/example/src/index.ts @@ -1,4 +1,7 @@ import 'reflect-metadata'; +import type { HttpApp } from '@haskou/ddd-kernel/adapters/ui/express'; +import type { ErrorRequestHandler, RequestHandler } from 'express'; + import { Kernel } from '@haskou/ddd-kernel'; import { CorrelationConsumerMiddleware, @@ -37,22 +40,51 @@ kernel.registerConsumerMiddleware( ); kernel.registerRoutes(GetUserByIdRoute); +const requestLoggerMiddleware: RequestHandler = (request, response, next) => { + void response; + kernel.logger.info(`${request.method} ${request.path}`); + next(); +}; + +const httpErrorHandler: ErrorRequestHandler = ( + error, + request, + response, + next, +) => { + void request; + + if (response.headersSent) { + next(error); + + return; + } + + kernel.logger.error(error instanceof Error ? error.message : String(error)); + response.status(500).json({ + error: 'InternalServerError', + message: error instanceof Error ? error.message : 'Unexpected error', + }); +}; + const server = new ExpressKernelServer({ - hooks: [ - { - handle: (app) => { - app.get('/health', (request, response) => { - void request; - response.status(200).json({ status: 'ok' }); - }); - }, - phase: 'beforeErrors', - }, - ], kernel, port: Number(process.env.PORT ?? 3000), }); +server + .registerMiddlewares(requestLoggerMiddleware) + .registerHooks({ + handle: (app: HttpApp) => { + app.get('/health', (request, response) => { + void request; + response.status(200).json({ status: 'ok' }); + }); + }, + phase: 'beforeErrors', + }) + .registerErrorHandlers(httpErrorHandler); + kernel.registerShutdownHook(() => server.close()); await server.run(); diff --git a/src/adapters/ui/express/ExpressKernelServer.ts b/src/adapters/ui/express/ExpressKernelServer.ts index 780ed3d..d1717ad 100644 --- a/src/adapters/ui/express/ExpressKernelServer.ts +++ b/src/adapters/ui/express/ExpressKernelServer.ts @@ -6,14 +6,53 @@ import { useContainer, useExpressServer } from 'routing-controllers'; import type { ExpressController } from './ExpressController.js'; import type { ExpressKernelServerOptions } from './ExpressKernelServerOptions.js'; +import type { ExpressPhaseHook } from './ExpressPhaseHook.js'; import type { HttpApp } from './HttpApp.js'; import type { HttpServer } from './HttpServer.js'; export class ExpressKernelServer { + private readonly afterControllersHooks: ExpressKernelServerOptions['afterControllersHooks']; + private appInstance: HttpApp | undefined; + + private readonly beforeControllersHooks: ExpressKernelServerOptions['beforeControllersHooks']; + + private readonly controllers: ExpressController[]; + + private readonly errorHandlers: ErrorRequestHandler[]; + + private readonly hooks: ExpressPhaseHook[]; + + private readonly middlewares: RequestHandler[]; + + private readonly postControllerMiddlewares: RequestHandler[]; + + private readonly preControllerMiddlewares: RequestHandler[]; + private serverInstance: HttpServer | undefined; - constructor(private readonly options: ExpressKernelServerOptions) {} + private readonly staticHooks: ExpressKernelServerOptions['staticHooks']; + + private readonly swaggerHooks: ExpressKernelServerOptions['swaggerHooks']; + + constructor(private readonly options: ExpressKernelServerOptions) { + this.afterControllersHooks = this.copy(options.afterControllersHooks); + this.beforeControllersHooks = this.copy(options.beforeControllersHooks); + this.controllers = this.copy(options.controllers); + this.errorHandlers = this.copy(options.errorHandlers); + this.hooks = this.copy(options.hooks); + this.middlewares = this.copy(options.middlewares); + this.postControllerMiddlewares = this.copy( + options.postControllerMiddlewares, + ); + this.preControllerMiddlewares = this.copy(options.preControllerMiddlewares); + this.staticHooks = this.copy(options.staticHooks); + this.swaggerHooks = this.copy(options.swaggerHooks); + } + + private copy(items: Type[] | undefined): Type[] { + return [...(items ?? [])]; + } private configureControllerContainer(): void { useContainer( @@ -28,8 +67,11 @@ export class ExpressKernelServer { ); } - private registerErrorHandlers(app: HttpApp): void { - const handlers = this.options.errorHandlers ?? [this.defaultErrorHandler()]; + private applyErrorHandlers(app: HttpApp): void { + const handlers = + this.errorHandlers.length > 0 + ? this.errorHandlers + : [this.defaultErrorHandler()]; for (const handler of handlers) { app.use(handler); @@ -37,7 +79,8 @@ export class ExpressKernelServer { } private defaultErrorHandler(): ErrorRequestHandler { - return (error, request, response) => { + return (error, request, response, next) => { + void next; void request; response.status(500).json({ @@ -59,14 +102,14 @@ export class ExpressKernelServer { phase: 'afterControllers' | 'beforeControllers' | 'beforeErrors', app: HttpApp, ): Promise { - for (const hook of this.options.hooks ?? []) { + for (const hook of this.hooks ?? []) { if (hook.phase === phase) { await hook.handle(app); } } } - private registerMiddlewares( + private applyMiddlewares( app: HttpApp, middlewares: readonly RequestHandler[] | undefined, ): void { @@ -91,6 +134,12 @@ export class ExpressKernelServer { return this.serverInstance; } + private assertServerIsNotRunning(): void { + if (this.serverInstance) { + throw new Error('HTTP server is already running.'); + } + } + public close(): Promise { if (!this.serverInstance) { return Promise.resolve(); @@ -111,6 +160,70 @@ export class ExpressKernelServer { }); } + public registerAfterControllersHooks( + ...hooks: NonNullable + ): this { + this.assertServerIsNotRunning(); + this.afterControllersHooks?.push(...hooks); + + return this; + } + + public registerBeforeControllersHooks( + ...hooks: NonNullable + ): this { + this.assertServerIsNotRunning(); + this.beforeControllersHooks?.push(...hooks); + + return this; + } + + public registerControllers(...controllers: ExpressController[]): this { + this.assertServerIsNotRunning(); + this.controllers.push(...controllers); + + return this; + } + + public registerErrorHandlers(...handlers: ErrorRequestHandler[]): this { + this.assertServerIsNotRunning(); + this.errorHandlers.push(...handlers); + + return this; + } + + public registerHooks(...hooks: ExpressPhaseHook[]): this { + this.assertServerIsNotRunning(); + this.hooks.push(...hooks); + + return this; + } + + public registerMiddlewares(...middlewares: RequestHandler[]): this { + this.assertServerIsNotRunning(); + this.middlewares.push(...middlewares); + + return this; + } + + public registerPostControllerMiddlewares( + ...middlewares: RequestHandler[] + ): this { + this.assertServerIsNotRunning(); + this.postControllerMiddlewares.push(...middlewares); + + return this; + } + + public registerPreControllerMiddlewares( + ...middlewares: RequestHandler[] + ): this { + this.assertServerIsNotRunning(); + this.preControllerMiddlewares.push(...middlewares); + + return this; + } + public async run(): Promise { if (this.serverInstance) { throw new Error('HTTP server is already running.'); @@ -118,27 +231,27 @@ export class ExpressKernelServer { const controllers = [ ...this.options.kernel.getRoutes(), - ...(this.options.controllers ?? []), + ...this.controllers, ]; const app = express() as HttpApp; - this.registerMiddlewares(app, this.options.middlewares); - this.registerMiddlewares(app, this.options.preControllerMiddlewares); - await this.runHooks(this.options.beforeControllersHooks, app); + this.applyMiddlewares(app, this.middlewares); + this.applyMiddlewares(app, this.preControllerMiddlewares); + await this.runHooks(this.beforeControllersHooks, app); await this.runPhaseHooks('beforeControllers', app); this.configureControllerContainer(); useExpressServer(app, { controllers, routePrefix: this.options.routePrefix, }); - this.registerMiddlewares(app, this.options.postControllerMiddlewares); - await this.runHooks(this.options.afterControllersHooks, app); + this.applyMiddlewares(app, this.postControllerMiddlewares); + await this.runHooks(this.afterControllersHooks, app); await this.runPhaseHooks('afterControllers', app); - await this.runHooks(this.options.swaggerHooks, app); - await this.runHooks(this.options.staticHooks, app); + await this.runHooks(this.swaggerHooks, app); + await this.runHooks(this.staticHooks, app); await this.runPhaseHooks('beforeErrors', app); - this.registerErrorHandlers(app); + this.applyErrorHandlers(app); this.appInstance = app; return new Promise((resolve) => { diff --git a/tests/adapters/ui/express/ExpressKernelServer.test.mjs b/tests/adapters/ui/express/ExpressKernelServer.test.mjs index 47b65c1..a02efa7 100644 --- a/tests/adapters/ui/express/ExpressKernelServer.test.mjs +++ b/tests/adapters/ui/express/ExpressKernelServer.test.mjs @@ -2,170 +2,355 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { ExpressKernelServer } from '../../../../dist/adapters/ui/express/index.js'; +import { Kernel } from '../../../../dist/index.js'; -test('throws when app or server are requested before running', () => { +const getServerPort = (server) => { + const address = server.server.address(); + + assert.notEqual(address, null); + assert.equal(typeof address, 'object'); + + return address.port; +}; + +test('registers middleware, hooks and error handlers before running', async () => { + const calls = []; + const kernel = new Kernel(); const server = new ExpressKernelServer({ - kernel: { getRoutes: () => [] }, + kernel, + middlewares: [ + (request, response, next) => { + void request; + void response; + calls.push('options:middleware'); + next(); + }, + ], port: 0, }); - assert.throws(() => server.app, /HTTP server is not running/); - assert.throws(() => server.server, /HTTP server is not running/); + server + .registerMiddlewares((request, response, next) => { + void request; + void response; + calls.push('registered:middleware'); + next(); + }) + .registerHooks({ + handle: (app) => { + app.get('/boom', (request, response, next) => { + void request; + void response; + calls.push('hook:route'); + next(new Error('boom')); + }); + }, + phase: 'beforeControllers', + }) + .registerErrorHandlers((error, request, response, next) => { + void request; + void next; + calls.push('registered:error'); + response.status(409).json({ + message: error instanceof Error ? error.message : String(error), + }); + }); + + await server.run(); + + try { + const response = await fetch( + `http://127.0.0.1:${getServerPort(server)}/boom`, + ); + const body = await response.json(); + + assert.equal(response.status, 409); + assert.deepEqual(body, { message: 'boom' }); + assert.deepEqual(calls, [ + 'options:middleware', + 'registered:middleware', + 'hook:route', + 'registered:error', + ]); + } finally { + await server.close(); + } }); -test('runs, exposes app and server, then closes', async () => { - const middlewareCalls = []; +test('runs the full HTTP extension pipeline in registration order', async () => { + const calls = []; + const kernel = new Kernel(); const server = new ExpressKernelServer({ - errorHandlers: [ - (error, request, response, next) => { - void error; + afterControllersHooks: [ + (app) => { + app.use((request, response, next) => { + void request; + void response; + calls.push('options:after-controller-hook'); + next(); + }); + }, + ], + beforeControllersHooks: [ + (app) => { + app.use((request, response, next) => { + void request; + void response; + calls.push('options:before-controller-hook'); + next(); + }); + }, + ], + hooks: [ + { + handle: (app) => { + app.use((request, response, next) => { + void request; + void response; + calls.push('options:before-controller-phase'); + next(); + }); + }, + phase: 'beforeControllers', + }, + { + handle: (app) => { + app.use((request, response, next) => { + void request; + void response; + calls.push('options:after-controller-phase'); + next(); + }); + }, + phase: 'afterControllers', + }, + ], + kernel, + postControllerMiddlewares: [ + (request, response, next) => { void request; void response; + calls.push('options:post-controller-middleware'); next(); }, ], - kernel: { getRoutes: () => [] }, - middlewares: [ + preControllerMiddlewares: [ (request, response, next) => { void request; void response; - middlewareCalls.push('middleware'); + calls.push('options:pre-controller-middleware'); next(); }, ], - port: 0, - routePrefix: '/api', + staticHooks: [ + (app) => { + app.use((request, response, next) => { + void request; + void response; + calls.push('options:static-hook'); + next(); + }); + }, + ], + swaggerHooks: [ + (app) => { + app.use((request, response, next) => { + void request; + void response; + calls.push('options:swagger-hook'); + next(); + }); + }, + ], }); - await server.close(); - await server.run(); + server + .registerControllers(class ExternalController {}) + .registerPreControllerMiddlewares((request, response, next) => { + void request; + void response; + calls.push('registered:pre-controller-middleware'); + next(); + }) + .registerBeforeControllersHooks((app) => { + app.use((request, response, next) => { + void request; + void response; + calls.push('registered:before-controller-hook'); + next(); + }); + }) + .registerHooks( + { + handle: (app) => { + app.use((request, response, next) => { + void request; + void response; + calls.push('registered:before-controller-phase'); + next(); + }); + }, + phase: 'beforeControllers', + }, + { + handle: (app) => { + app.use((request, response, next) => { + void request; + void response; + calls.push('registered:after-controller-phase'); + next(); + }); + }, + phase: 'afterControllers', + }, + { + handle: (app) => { + app.get('/order', (request, response) => { + void request; + calls.push('registered:before-errors-phase'); + response.status(200).json({ ok: true }); + }); + }, + phase: 'beforeErrors', + }, + ) + .registerPostControllerMiddlewares((request, response, next) => { + void request; + void response; + calls.push('registered:post-controller-middleware'); + next(); + }) + .registerAfterControllersHooks((app) => { + app.use((request, response, next) => { + void request; + void response; + calls.push('registered:after-controller-hook'); + next(); + }); + }); - assert.ok(server.app); - assert.ok(server.server.listening); + await server.run(); - await server.close(); + try { + const response = await fetch( + `http://127.0.0.1:${getServerPort(server)}/order`, + ); - assert.throws(() => server.app, /HTTP server is not running/); + assert.equal(response.status, 200); + assert.deepEqual(calls, [ + 'options:pre-controller-middleware', + 'registered:pre-controller-middleware', + 'options:before-controller-hook', + 'registered:before-controller-hook', + 'options:before-controller-phase', + 'registered:before-controller-phase', + 'options:post-controller-middleware', + 'registered:post-controller-middleware', + 'options:after-controller-hook', + 'registered:after-controller-hook', + 'options:after-controller-phase', + 'registered:after-controller-phase', + 'options:swagger-hook', + 'options:static-hook', + 'registered:before-errors-phase', + ]); + } finally { + await server.close(); + } }); -test('default error handler returns JSON 500 responses', () => { +test('uses the default HTTP error handler when none is registered', async () => { const server = new ExpressKernelServer({ - kernel: { getRoutes: () => [] }, + hooks: [ + { + handle: (app) => { + app.get('/default-error', (request, response, next) => { + void request; + void response; + next('plain failure'); + }); + }, + phase: 'beforeControllers', + }, + ], + kernel: new Kernel(), port: 0, }); - const responses = []; - const response = { - json: (body) => responses.push(['json', body]), - status: (statusCode) => { - responses.push(['status', statusCode]); - - return response; - }, - }; - - server.defaultErrorHandler()(new Error('failed'), {}, response); - server.defaultErrorHandler()('failed', {}, response); - assert.deepEqual(responses, [ - ['status', 500], - ['json', { error: 'failed' }], - ['status', 500], - ['json', { error: 'failed' }], - ]); -}); - -test('rejects when closing a running server fails', async () => { - const error = new Error('close failed'); - const server = new ExpressKernelServer({ - kernel: { getRoutes: () => [] }, - port: 0, - }); + await server.run(); - server.serverInstance = { - close: (callback) => callback(error), - }; + try { + const response = await fetch( + `http://127.0.0.1:${getServerPort(server)}/default-error`, + ); + const body = await response.json(); - await assert.rejects(() => server.close(), error); + assert.equal(response.status, 500); + assert.deepEqual(body, { error: 'plain failure' }); + } finally { + await server.close(); + } }); -test('registers default error handlers and runs without optional middleware', async () => { - const app = { - handlers: [], - use(handler) { - this.handlers.push(handler); - }, - }; +test('guards server access and close lifecycle', async () => { const server = new ExpressKernelServer({ - kernel: { getRoutes: () => [] }, + kernel: new Kernel(), + port: 0, }); - server.registerErrorHandlers(app); + assert.throws(() => server.app, /HTTP server is not running/); + assert.throws(() => server.server, /HTTP server is not running/); + await server.close(); + await server.run(); + await assert.rejects(() => server.run(), /HTTP server is already running/); - assert.equal(app.handlers.length, 1); + assert.equal(typeof server.app.use, 'function'); - await server.run(); - await server.close(); -}); + const httpServer = server.server; + const close = httpServer.close.bind(httpServer); -test('runs configurable controller, swagger and static hooks', async () => { - class ExternalController {} + httpServer.close = (callback) => { + callback?.(new Error('close failed')); - const calls = []; - const middleware = (name) => (request, response, next) => { - void request; - void response; - calls.push(name); - next(); + return httpServer; }; - const server = new ExpressKernelServer({ - afterControllersHooks: [(app) => calls.push(['after', Boolean(app)])], - beforeControllersHooks: [(app) => calls.push(['before', Boolean(app)])], - controllers: [ExternalController], - hooks: [ - { - handle: (app) => calls.push(['phase:before', Boolean(app)]), - phase: 'beforeControllers', - }, - { - handle: (app) => calls.push(['phase:after', Boolean(app)]), - phase: 'afterControllers', - }, - { - handle: (app) => calls.push(['phase:errors', Boolean(app)]), - phase: 'beforeErrors', - }, - ], - kernel: { getRoutes: () => [] }, - middlewares: [middleware('base')], - port: 0, - postControllerMiddlewares: [middleware('post')], - preControllerMiddlewares: [middleware('pre')], - staticHooks: [(app) => calls.push(['static', Boolean(app)])], - swaggerHooks: [(app) => calls.push(['swagger', Boolean(app)])], - }); - await server.run(); - await server.close(); + await assert.rejects(() => server.close(), /close failed/); - assert.deepEqual(calls, [ - ['before', true], - ['phase:before', true], - ['after', true], - ['phase:after', true], - ['swagger', true], - ['static', true], - ['phase:errors', true], - ]); + httpServer.close = close; + await server.close(); + assert.throws(() => server.app, /HTTP server is not running/); }); -test('rejects duplicate run calls while server is running', async () => { +test('rejects HTTP pipeline registration after the server is running', async () => { const server = new ExpressKernelServer({ - kernel: { getRoutes: () => [] }, + kernel: new Kernel(), port: 0, }); await server.run(); - await assert.rejects(() => server.run(), /HTTP server is already running/); - await server.close(); + try { + assert.throws( + () => + server.registerMiddlewares((request, response, next) => { + void request; + void response; + next(); + }), + /HTTP server is already running/, + ); + assert.throws( + () => + server.registerErrorHandlers((error, request, response, next) => { + void error; + void request; + void response; + next(); + }), + /HTTP server is already running/, + ); + } finally { + await server.close(); + } }); From 4daf57a59154992274a191b90988d5ee20ccf699 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 19:53:09 +0200 Subject: [PATCH 12/19] =?UTF-8?q?docs(example):=20=F0=9F=93=9D=20Explain?= =?UTF-8?q?=20application=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- example/src/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/example/src/index.ts b/example/src/index.ts index 20d0a91..57fa536 100644 --- a/example/src/index.ts +++ b/example/src/index.ts @@ -15,6 +15,9 @@ import path from 'node:path'; import GetUserByIdRoute from './apps/api/routes/GetUserByIdRoute.js'; const rootDirectory = process.cwd(); + +// The kernel owns application lifecycle, dependency injection and shared +// infrastructure such as logging. const kernel = new Kernel({ servicesYamlPath: path.resolve( rootDirectory, @@ -25,10 +28,14 @@ const kernel = new Kernel({ sourceDirectory: path.resolve(rootDirectory, 'src'), }); +// In development the container is rebuilt from default exports under src/. +// In production it can reuse the generated services.yaml file. await kernel.dependencyInjection({ containerBuild: process.env.NODE_ENV !== 'production', }); +// Consumer middleware is registered once and runs around every domain event +// consumer handled by the kernel. kernel.registerConsumerMiddleware( new CorrelationConsumerMiddleware(), new IdempotencyConsumerMiddleware({ @@ -38,6 +45,8 @@ kernel.registerConsumerMiddleware( maxAttempts: 3, }), ); + +// Routes are plain routing-controllers classes resolved through constructor DI. kernel.registerRoutes(GetUserByIdRoute); const requestLoggerMiddleware: RequestHandler = (request, response, next) => { @@ -72,6 +81,8 @@ const server = new ExpressKernelServer({ port: Number(process.env.PORT ?? 3000), }); +// The Express adapter stays optional. HTTP middleware, hooks and error handlers +// are registered only when the application actually needs an HTTP runtime. server .registerMiddlewares(requestLoggerMiddleware) .registerHooks({ @@ -85,11 +96,14 @@ server }) .registerErrorHandlers(httpErrorHandler); +// Shutdown hooks give the kernel one standard way to release runtime resources. kernel.registerShutdownHook(() => server.close()); await server.run(); kernel.logger.info(`Application running on port ${process.env.PORT ?? 3000}`); +// Delegate OS signals to the kernel so consumers, schedulers, servers and logs +// are stopped consistently. process.once('SIGINT', () => { void kernel.shutdown(); }); From bafb23b51746283722f2cd5874f4bfb13faf4757 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 20:01:25 +0200 Subject: [PATCH 13/19] =?UTF-8?q?feat(di):=20=E2=9C=A8=20Add=20container?= =?UTF-8?q?=20dependency=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/guides/adapters.md | 40 ++++ docs/reference/dependency-injection.md | 56 +++++ src/Kernel.ts | 1 + .../ClassDependencyOverride.ts | 6 + .../DependencyInjection.ts | 111 +++++++++- .../DependencyInjectionOptions.ts | 3 + .../DependencyOverride.ts | 8 + .../DependencyOverrideFactory.ts | 3 + .../DependencyOverrideToken.ts | 1 + .../FactoryDependencyOverride.ts | 7 + .../ValueDependencyOverride.ts | 6 + .../dependency-injection/index.ts | 6 + .../KernelDependencyInjectionOptions.ts | 3 + tests/dependency-injection.test.mjs | 198 +++++++++++++++++- 14 files changed, 439 insertions(+), 10 deletions(-) create mode 100644 src/infrastructure/dependency-injection/ClassDependencyOverride.ts create mode 100644 src/infrastructure/dependency-injection/DependencyOverride.ts create mode 100644 src/infrastructure/dependency-injection/DependencyOverrideFactory.ts create mode 100644 src/infrastructure/dependency-injection/DependencyOverrideToken.ts create mode 100644 src/infrastructure/dependency-injection/FactoryDependencyOverride.ts create mode 100644 src/infrastructure/dependency-injection/ValueDependencyOverride.ts diff --git a/docs/guides/adapters.md b/docs/guides/adapters.md index ab44246..373c3a7 100644 --- a/docs/guides/adapters.md +++ b/docs/guides/adapters.md @@ -28,6 +28,46 @@ export default class MyPublisher implements DomainEventPublisher { If an adapter needs a third-party dependency, expose it through a subpath and mark that dependency as an optional peer dependency. +## Choosing Adapters Per Runtime + +Applications can keep several adapters for the same contract and choose one at +bootstrap time with dependency injection overrides. This is useful for tests, +local development or deployments that swap infrastructure without changing the +domain code. + +```ts +await kernel.dependencyInjection({ + overrides: [ + { + token: UserRepository, + useClass: + process.env.NODE_ENV === 'test' + ? InMemoryUserRepository + : MongoUserRepository, + }, + ], +}); +``` + +For tests, overriding with a specific instance keeps assertions simple: + +```ts +const users = new InMemoryUserRepository(); + +await kernel.dependencyInjection({ + overrides: [ + { + token: UserRepository, + useValue: users, + }, + ], +}); +``` + +The classes that need `UserRepository` should still receive it through +constructor injection. The adapter decision belongs in bootstrap or test setup, +not inside consumers, schedulers or routes. + ## Message Bus Hooks Message bus adapters can expose publisher hooks so applications can attach diff --git a/docs/reference/dependency-injection.md b/docs/reference/dependency-injection.md index a71bf50..0ccdd3f 100644 --- a/docs/reference/dependency-injection.md +++ b/docs/reference/dependency-injection.md @@ -15,3 +15,59 @@ await di.compile(); ``` Most applications call `kernel.dependencyInjection()` instead. + +## Overrides + +Use dependency overrides when the application wants a different implementation +for a contract in a specific runtime, such as replacing a Mongo repository with +an in-memory repository in tests. + +Overrides are applied after `services.yaml` is loaded or generated and before +the container is compiled. That makes them win over the aliases generated from +abstract parents. + +```ts +await kernel.dependencyInjection({ + overrides: [ + { + token: UserRepository, + useClass: InMemoryUserRepository, + }, + ], +}); +``` + +`token` is the contract or class consumers ask the container for. `useClass` is +the implementation that should be returned instead. + +Tests can also provide an already built instance: + +```ts +const users = new InMemoryUserRepository(); + +await kernel.dependencyInjection({ + overrides: [ + { + token: UserRepository, + useValue: users, + }, + ], +}); +``` + +Factories are useful when the replacement has local setup: + +```ts +await kernel.dependencyInjection({ + overrides: [ + { + token: UserRepository, + useFactory: () => new InMemoryUserRepository(seedUsers), + }, + ], +}); +``` + +Prefer constructor injection in services, consumers, schedulers and routes. The +override belongs at application bootstrap or test setup, not inside the class +that needs the dependency. diff --git a/src/Kernel.ts b/src/Kernel.ts index 440946e..b0b0880 100644 --- a/src/Kernel.ts +++ b/src/Kernel.ts @@ -177,6 +177,7 @@ export class Kernel { return { containerBuild: options.containerBuild ?? process.env.CONTAINER_BUILD === 'true', + overrides: options.overrides ?? [], servicesYamlPath: options.servicesYamlPath ?? this.options.servicesYamlPath ?? diff --git a/src/infrastructure/dependency-injection/ClassDependencyOverride.ts b/src/infrastructure/dependency-injection/ClassDependencyOverride.ts new file mode 100644 index 0000000..ab529d1 --- /dev/null +++ b/src/infrastructure/dependency-injection/ClassDependencyOverride.ts @@ -0,0 +1,6 @@ +import type { DependencyOverrideToken } from './DependencyOverrideToken.js'; + +export interface ClassDependencyOverride { + readonly token: DependencyOverrideToken; + readonly useClass: new (...args: never[]) => unknown; +} diff --git a/src/infrastructure/dependency-injection/DependencyInjection.ts b/src/infrastructure/dependency-injection/DependencyInjection.ts index 0b42558..e0ccd6c 100644 --- a/src/infrastructure/dependency-injection/DependencyInjection.ts +++ b/src/infrastructure/dependency-injection/DependencyInjection.ts @@ -11,12 +11,14 @@ import type { ServiceResolver } from '../../contracts/index.js'; import type { ContainerInternals } from './ContainerInternals.js'; import type { DefinitionMetadata } from './DefinitionMetadata.js'; import type { DependencyInjectionOptions } from './DependencyInjectionOptions.js'; +import type { DependencyOverride } from './DependencyOverride.js'; export class DependencyInjection implements ServiceResolver { private static configuredInstance: DependencyInjection | undefined; private autowire: Autowire | undefined; private loader: YamlFileLoader | undefined; private readonly container: ContainerBuilder; + private readonly overrideTokenIds = new Map(); public static configure( options: DependencyInjectionOptions, @@ -69,6 +71,20 @@ export class DependencyInjection implements ServiceResolver { return typeof serviceName === 'function' ? serviceName.name : undefined; } + private getOverrideId(prefix: string, token: unknown): string { + const tokenName = this.getServiceClassName(token) ?? String(token); + + return `ddd-kernel.override.${prefix}.${tokenName}`; + } + + private ensureSyntheticService(id: string, value: unknown): void { + const definition = this.container.register(id); + + definition.public = true; + definition.synthetic = true; + this.container.set(id, value); + } + private parentMatchesService( parentId: string | null | undefined, serviceClassName: string, @@ -136,6 +152,88 @@ export class DependencyInjection implements ServiceResolver { return matches[matches.length - 1]; } + private getOverrideTokenId(token: unknown): string { + const registeredServiceId = this.findRegisteredServiceId(token); + + if (registeredServiceId) { + return registeredServiceId; + } + + const aliasServiceId = this.findAliasServiceId(token); + + if (aliasServiceId) { + return aliasServiceId; + } + + const overrideTokenId = this.getOverrideId('token', token); + + this.ensureSyntheticService(overrideTokenId, undefined); + + return overrideTokenId; + } + + private getOverrideClassServiceId( + ClassDefinition: new () => unknown, + ): string { + const registeredServiceId = this.findRegisteredServiceId(ClassDefinition); + + if (registeredServiceId) { + return registeredServiceId; + } + + const overrideClassId = this.getOverrideId('class', ClassDefinition); + + this.container.register(overrideClassId, ClassDefinition); + + return overrideClassId; + } + + private applyClassOverride(override: DependencyOverride): void { + if (!('useClass' in override)) { + return; + } + + const tokenId = this.getOverrideTokenId(override.token); + const classId = this.getOverrideClassServiceId(override.useClass); + + this.overrideTokenIds.set(override.token, tokenId); + this.container.setAlias(tokenId, classId); + } + + private applyFactoryOverride(override: DependencyOverride): void { + if (!('useFactory' in override)) { + return; + } + + const tokenId = this.getOverrideTokenId(override.token); + const factoryId = this.getOverrideId('factory', override.token); + + this.ensureSyntheticService(factoryId, override.useFactory(this)); + this.overrideTokenIds.set(override.token, tokenId); + this.container.setAlias(tokenId, factoryId); + } + + private applyValueOverride(override: DependencyOverride): void { + if (!('useValue' in override)) { + return; + } + + const tokenId = this.getOverrideTokenId(override.token); + const valueId = this.getOverrideId('value', override.token); + + this.ensureSyntheticService(valueId, override.useValue); + this.overrideTokenIds.set(override.token, tokenId); + this.container.setAlias(tokenId, valueId); + } + + private applyOverrides(): void { + for (const override of this.options.overrides ?? []) { + this.applyClassOverride(override); + this.applyFactoryOverride(override); + this.applyValueOverride(override); + } + } + private registerParentAliases(): void { for (const [id, definition] of this.definitions.entries()) { if (definition._abstract === true || !definition._parent) { @@ -161,14 +259,15 @@ export class DependencyInjection implements ServiceResolver { } this.registerParentAliases(); + this.applyOverrides(); await this.container.compile(); } public getService(serviceName: unknown): T { - const childServiceId = this.findConcreteChildServiceId(serviceName); + const overrideTokenId = this.overrideTokenIds.get(serviceName); - if (childServiceId) { - return this.container.get(childServiceId); + if (overrideTokenId) { + return this.container.get(overrideTokenId); } const aliasServiceId = this.findAliasServiceId(serviceName); @@ -177,6 +276,12 @@ export class DependencyInjection implements ServiceResolver { return this.container.get(aliasServiceId); } + const childServiceId = this.findConcreteChildServiceId(serviceName); + + if (childServiceId) { + return this.container.get(childServiceId); + } + const registeredServiceId = this.findRegisteredServiceId(serviceName); if (registeredServiceId) { diff --git a/src/infrastructure/dependency-injection/DependencyInjectionOptions.ts b/src/infrastructure/dependency-injection/DependencyInjectionOptions.ts index b953cc1..08f75d8 100644 --- a/src/infrastructure/dependency-injection/DependencyInjectionOptions.ts +++ b/src/infrastructure/dependency-injection/DependencyInjectionOptions.ts @@ -1,5 +1,8 @@ +import type { DependencyOverride } from './DependencyOverride.js'; + export interface DependencyInjectionOptions { readonly containerBuild?: boolean; + readonly overrides?: readonly DependencyOverride[]; readonly servicesYamlPath: string; readonly sourceDirectory: string; } diff --git a/src/infrastructure/dependency-injection/DependencyOverride.ts b/src/infrastructure/dependency-injection/DependencyOverride.ts new file mode 100644 index 0000000..2f6da12 --- /dev/null +++ b/src/infrastructure/dependency-injection/DependencyOverride.ts @@ -0,0 +1,8 @@ +import type { ClassDependencyOverride } from './ClassDependencyOverride.js'; +import type { FactoryDependencyOverride } from './FactoryDependencyOverride.js'; +import type { ValueDependencyOverride } from './ValueDependencyOverride.js'; + +export type DependencyOverride = + | ClassDependencyOverride + | FactoryDependencyOverride + | ValueDependencyOverride; diff --git a/src/infrastructure/dependency-injection/DependencyOverrideFactory.ts b/src/infrastructure/dependency-injection/DependencyOverrideFactory.ts new file mode 100644 index 0000000..d11e2f4 --- /dev/null +++ b/src/infrastructure/dependency-injection/DependencyOverrideFactory.ts @@ -0,0 +1,3 @@ +import type { ServiceResolver } from '../../contracts/index.js'; + +export type DependencyOverrideFactory = (resolver: ServiceResolver) => unknown; diff --git a/src/infrastructure/dependency-injection/DependencyOverrideToken.ts b/src/infrastructure/dependency-injection/DependencyOverrideToken.ts new file mode 100644 index 0000000..f380112 --- /dev/null +++ b/src/infrastructure/dependency-injection/DependencyOverrideToken.ts @@ -0,0 +1 @@ +export type DependencyOverrideToken = unknown; diff --git a/src/infrastructure/dependency-injection/FactoryDependencyOverride.ts b/src/infrastructure/dependency-injection/FactoryDependencyOverride.ts new file mode 100644 index 0000000..da6db05 --- /dev/null +++ b/src/infrastructure/dependency-injection/FactoryDependencyOverride.ts @@ -0,0 +1,7 @@ +import type { DependencyOverrideFactory } from './DependencyOverrideFactory.js'; +import type { DependencyOverrideToken } from './DependencyOverrideToken.js'; + +export interface FactoryDependencyOverride { + readonly token: DependencyOverrideToken; + readonly useFactory: DependencyOverrideFactory; +} diff --git a/src/infrastructure/dependency-injection/ValueDependencyOverride.ts b/src/infrastructure/dependency-injection/ValueDependencyOverride.ts new file mode 100644 index 0000000..54beb14 --- /dev/null +++ b/src/infrastructure/dependency-injection/ValueDependencyOverride.ts @@ -0,0 +1,6 @@ +import type { DependencyOverrideToken } from './DependencyOverrideToken.js'; + +export interface ValueDependencyOverride { + readonly token: DependencyOverrideToken; + readonly useValue: unknown; +} diff --git a/src/infrastructure/dependency-injection/index.ts b/src/infrastructure/dependency-injection/index.ts index 7d9b0e7..b8abe1e 100644 --- a/src/infrastructure/dependency-injection/index.ts +++ b/src/infrastructure/dependency-injection/index.ts @@ -1,9 +1,15 @@ +export * from './ClassDependencyOverride.js'; export * from './ContainerDefinition.js'; export * from './ContainerInternals.js'; export * from './DefinitionMetadata.js'; export * from './DependencyAlias.js'; export * from './DependencyInjection.js'; export * from './DependencyInjectionOptions.js'; +export * from './DependencyOverride.js'; +export * from './DependencyOverrideFactory.js'; +export * from './DependencyOverrideToken.js'; export * from './ExplicitServiceClass.js'; export * from './ExplicitServiceDefinition.js'; +export * from './FactoryDependencyOverride.js'; export * from './ServiceClass.js'; +export * from './ValueDependencyOverride.js'; diff --git a/src/kernel/KernelDependencyInjectionOptions.ts b/src/kernel/KernelDependencyInjectionOptions.ts index 23d1a4c..72bf764 100644 --- a/src/kernel/KernelDependencyInjectionOptions.ts +++ b/src/kernel/KernelDependencyInjectionOptions.ts @@ -1,5 +1,8 @@ +import type { DependencyOverride } from '../infrastructure/dependency-injection/index.js'; + export interface KernelDependencyInjectionOptions { readonly containerBuild?: boolean; + readonly overrides?: readonly DependencyOverride[]; readonly servicesYamlPath?: string; readonly sourceDirectory?: string; } diff --git a/tests/dependency-injection.test.mjs b/tests/dependency-injection.test.mjs index 7589a0e..40be99b 100644 --- a/tests/dependency-injection.test.mjs +++ b/tests/dependency-injection.test.mjs @@ -11,6 +11,10 @@ class ContractRepository {} class ConcreteRepository extends ContractRepository {} +class InMemoryRepository extends ContractRepository {} + +class MongoRepository extends ContractRepository {} + class AliasRepository {} class ConcreteService {} @@ -67,6 +71,178 @@ test('resolves an alias to its target implementation', async () => { assert.ok(repository instanceof ConcreteRepository); }); +test('overrides an abstract parent with another registered implementation', async () => { + const dependencyInjection = new DependencyInjection({ + containerBuild: true, + overrides: [ + { + token: ContractRepository, + useClass: InMemoryRepository, + }, + ], + servicesYamlPath: '/tmp/services.yaml', + sourceDirectory: process.cwd(), + }); + const parentId = serviceIdFor(ContractRepository); + const mongoId = serviceIdFor(MongoRepository); + const inMemoryId = serviceIdFor(InMemoryRepository); + + dependencyInjection.container.register( + parentId, + ContractRepository, + ).abstract = true; + dependencyInjection.container.register(mongoId, MongoRepository).parent = + parentId; + dependencyInjection.container.register( + inMemoryId, + InMemoryRepository, + ).parent = parentId; + dependencyInjection.registerParentAliases(); + dependencyInjection.applyOverrides(); + await dependencyInjection.container.compile(); + + const repository = dependencyInjection.getService(ContractRepository); + + assert.ok(repository instanceof InMemoryRepository); +}); + +test('overrides a service with a value instance', async () => { + const repository = new InMemoryRepository(); + const dependencyInjection = new DependencyInjection({ + containerBuild: true, + overrides: [ + { + token: ContractRepository, + useValue: repository, + }, + ], + servicesYamlPath: '/tmp/services.yaml', + sourceDirectory: process.cwd(), + }); + const parentId = serviceIdFor(ContractRepository); + const mongoId = serviceIdFor(MongoRepository); + + dependencyInjection.container.register( + parentId, + ContractRepository, + ).abstract = true; + dependencyInjection.container.register(mongoId, MongoRepository).parent = + parentId; + dependencyInjection.registerParentAliases(); + dependencyInjection.applyOverrides(); + await dependencyInjection.container.compile(); + + assert.equal(dependencyInjection.getService(ContractRepository), repository); +}); + +test('overrides a service with a factory result', async () => { + const repository = new InMemoryRepository(); + const dependencyInjection = new DependencyInjection({ + containerBuild: true, + overrides: [ + { + token: ContractRepository, + useFactory: () => repository, + }, + ], + servicesYamlPath: '/tmp/services.yaml', + sourceDirectory: process.cwd(), + }); + const parentId = serviceIdFor(ContractRepository); + const mongoId = serviceIdFor(MongoRepository); + + dependencyInjection.container.register( + parentId, + ContractRepository, + ).abstract = true; + dependencyInjection.container.register(mongoId, MongoRepository).parent = + parentId; + dependencyInjection.registerParentAliases(); + dependencyInjection.applyOverrides(); + await dependencyInjection.container.compile(); + + assert.equal(dependencyInjection.getService(ContractRepository), repository); +}); + +test('overrides an alias token with a value instance', async () => { + const repository = new InMemoryRepository(); + const dependencyInjection = new DependencyInjection({ + containerBuild: true, + overrides: [ + { + token: AliasRepository, + useValue: repository, + }, + ], + servicesYamlPath: '/tmp/services.yaml', + sourceDirectory: process.cwd(), + }); + const aliasId = serviceIdFor(AliasRepository); + const mongoId = serviceIdFor(MongoRepository); + + dependencyInjection.container.register(mongoId, MongoRepository); + dependencyInjection.container.setAlias(aliasId, mongoId); + dependencyInjection.applyOverrides(); + await dependencyInjection.container.compile(); + + assert.equal(dependencyInjection.getService(AliasRepository), repository); +}); + +test('overrides a token that is not registered in the container', async () => { + class ExternalRepository {} + + const repository = new ExternalRepository(); + const dependencyInjection = new DependencyInjection({ + containerBuild: true, + overrides: [ + { + token: ExternalRepository, + useValue: repository, + }, + ], + servicesYamlPath: '/tmp/services.yaml', + sourceDirectory: process.cwd(), + }); + + dependencyInjection.applyOverrides(); + await dependencyInjection.container.compile(); + + assert.equal(dependencyInjection.getService(ExternalRepository), repository); +}); + +test('registers override classes that are not already in the container', async () => { + class ExternalRepository extends ContractRepository {} + + const dependencyInjection = new DependencyInjection({ + containerBuild: true, + overrides: [ + { + token: ContractRepository, + useClass: ExternalRepository, + }, + ], + servicesYamlPath: '/tmp/services.yaml', + sourceDirectory: process.cwd(), + }); + const parentId = serviceIdFor(ContractRepository); + const mongoId = serviceIdFor(MongoRepository); + + dependencyInjection.container.register( + parentId, + ContractRepository, + ).abstract = true; + dependencyInjection.container.register(mongoId, MongoRepository).parent = + parentId; + dependencyInjection.registerParentAliases(); + dependencyInjection.applyOverrides(); + await dependencyInjection.container.compile(); + + assert.ok( + dependencyInjection.getService(ContractRepository) instanceof + ExternalRepository, + ); +}); + test('generates services.yaml', async () => { const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'ddd-kernel-')); const sourceDirectory = temporaryDirectory; @@ -192,12 +368,18 @@ test('falls back to literal container ids when service name is not a class', asy const literalServiceId = serviceIdFor(LiteralService); dependencyInjection.container.register('literal-service', LiteralService); - dependencyInjection.container.register(parentServiceId, ParentService) - .abstract = true; - dependencyInjection.container.register(literalServiceId, LiteralService) - .parent = parentServiceId; - dependencyInjection.container.register('abstract-service', LiteralService) - .abstract = true; + dependencyInjection.container.register( + parentServiceId, + ParentService, + ).abstract = true; + dependencyInjection.container.register( + literalServiceId, + LiteralService, + ).parent = parentServiceId; + dependencyInjection.container.register( + 'abstract-service', + LiteralService, + ).abstract = true; dependencyInjection.container.register('parentless-service', LiteralService); dependencyInjection.registerParentAliases(); await dependencyInjection.container.compile(); @@ -205,5 +387,7 @@ test('falls back to literal container ids when service name is not a class', asy assert.ok( dependencyInjection.getService('literal-service') instanceof LiteralService, ); - assert.ok(dependencyInjection.getService(ParentService) instanceof LiteralService); + assert.ok( + dependencyInjection.getService(ParentService) instanceof LiteralService, + ); }); From f5d689d37330cbf4bd10b631be5120ee8ee1c962 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 20:52:48 +0200 Subject: [PATCH 14/19] =?UTF-8?q?fix(di):=20=F0=9F=90=9B=20Resolve=20exter?= =?UTF-8?q?nal=20package=20override=20references?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/dependency-injection.md | 28 ++++++ src/Kernel.ts | 2 + .../DefinitionMetadata.ts | 3 + .../DependencyInjection.ts | 95 +++++++++++++++---- tests/dependency-injection.test.mjs | 54 +++++++++++ tests/kernel.test.mjs | 19 ++++ 6 files changed, 184 insertions(+), 17 deletions(-) diff --git a/docs/reference/dependency-injection.md b/docs/reference/dependency-injection.md index 0ccdd3f..c5f4394 100644 --- a/docs/reference/dependency-injection.md +++ b/docs/reference/dependency-injection.md @@ -71,3 +71,31 @@ await kernel.dependencyInjection({ Prefer constructor injection in services, consumers, schedulers and routes. The override belongs at application bootstrap or test setup, not inside the class that needs the dependency. + +## External Package Contracts + +`node-dependency-injection` can encode constructor dependencies imported from +external packages as unresolved service references. For example, a dependency +imported from `@haskou/ddd-kernel/domain` can appear in generated container +metadata as if it were a local path under the application source tree. + +Overrides also cover those unresolved references. If an argument reference ends +with the overridden token class name, the container aliases that reference to +the configured override implementation: + +```ts +import { DomainEventPublisher } from '@haskou/ddd-kernel/domain'; +import MessageBus from '@haskou/ddd-kernel/adapters/pubsub/amqp'; + +await kernel.dependencyInjection({ + overrides: [ + { + token: DomainEventPublisher, + useClass: MessageBus, + }, + ], +}); +``` + +This avoids local bridge contracts or hand-written aliases when applications +inject contracts exported by this package. diff --git a/src/Kernel.ts b/src/Kernel.ts index b0b0880..6a73cc8 100644 --- a/src/Kernel.ts +++ b/src/Kernel.ts @@ -192,6 +192,7 @@ export class Kernel { public async dependencyInjection( options: KernelDependencyInjectionOptions = {}, ): Promise { + Kernel.state.activeKernel = this; this.dependencyInjectionInstance = this.dependencyInjectionInstance ?? DependencyInjection.configure( @@ -199,6 +200,7 @@ export class Kernel { ); await this.dependencyInjectionInstance.compile(); + Kernel.state.activeKernel = this; } public getRoutes(): ServiceClass[] { diff --git a/src/infrastructure/dependency-injection/DefinitionMetadata.ts b/src/infrastructure/dependency-injection/DefinitionMetadata.ts index c3e565a..54fb4ec 100644 --- a/src/infrastructure/dependency-injection/DefinitionMetadata.ts +++ b/src/infrastructure/dependency-injection/DefinitionMetadata.ts @@ -1,4 +1,7 @@ export type DefinitionMetadata = { readonly _abstract?: boolean; + readonly _appendArgs?: unknown[]; + readonly _args?: unknown[]; + readonly _overrideArgs?: unknown[]; readonly _parent?: string | null; }; diff --git a/src/infrastructure/dependency-injection/DependencyInjection.ts b/src/infrastructure/dependency-injection/DependencyInjection.ts index e0ccd6c..137f372 100644 --- a/src/infrastructure/dependency-injection/DependencyInjection.ts +++ b/src/infrastructure/dependency-injection/DependencyInjection.ts @@ -107,6 +107,42 @@ export class DependencyInjection implements ServiceResolver { return serviceName.endsWith(`__${serviceClassName}__${serviceClassName}`); } + private serviceIdReferencesService( + serviceId: string, + serviceClassName: string, + ): boolean { + const serviceName = Buffer.from(serviceId, 'base64').toString('utf8'); + + return serviceName.endsWith(`__${serviceClassName}`); + } + + private getReferenceId(value: unknown): string | undefined { + if ( + typeof value === 'object' && + value !== null && + 'id' in value && + typeof value.id === 'string' + ) { + return value.id; + } + + return undefined; + } + + private getDefinitionArgumentReferences( + definition: DefinitionMetadata, + ): string[] { + return [ + ...(definition._args ?? []), + ...(definition._appendArgs ?? []), + ...(definition._overrideArgs ?? []), + ].flatMap((argument) => { + const referenceId = this.getReferenceId(argument); + + return referenceId ? [referenceId] : []; + }); + } + private findConcreteChildServiceId(serviceName: unknown): string | undefined { const serviceClassName = this.getServiceClassName(serviceName); @@ -152,24 +188,43 @@ export class DependencyInjection implements ServiceResolver { return matches[matches.length - 1]; } - private getOverrideTokenId(token: unknown): string { - const registeredServiceId = this.findRegisteredServiceId(token); + private findReferencedServiceIds(serviceName: unknown): string[] { + const serviceClassName = this.getServiceClassName(serviceName); - if (registeredServiceId) { - return registeredServiceId; + if (!serviceClassName) { + return []; } - const aliasServiceId = this.findAliasServiceId(token); + return [ + ...new Set( + [...this.definitions.values()] + .flatMap((definition) => + this.getDefinitionArgumentReferences(definition), + ) + .filter((id) => + this.serviceIdReferencesService(id, serviceClassName), + ), + ), + ]; + } + + private getOverrideTokenIds(token: unknown): string[] { + const tokenIds = [ + this.findRegisteredServiceId(token), + this.findAliasServiceId(token), + ...this.findReferencedServiceIds(token), + ].filter((id): id is string => id !== undefined); + const existingTokenIds = [...new Set(tokenIds)]; - if (aliasServiceId) { - return aliasServiceId; + if (existingTokenIds.length > 0) { + return existingTokenIds; } const overrideTokenId = this.getOverrideId('token', token); this.ensureSyntheticService(overrideTokenId, undefined); - return overrideTokenId; + return [overrideTokenId]; } private getOverrideClassServiceId( @@ -193,11 +248,13 @@ export class DependencyInjection implements ServiceResolver { return; } - const tokenId = this.getOverrideTokenId(override.token); + const tokenIds = this.getOverrideTokenIds(override.token); const classId = this.getOverrideClassServiceId(override.useClass); - this.overrideTokenIds.set(override.token, tokenId); - this.container.setAlias(tokenId, classId); + this.overrideTokenIds.set(override.token, tokenIds[0]); + for (const tokenId of tokenIds) { + this.container.setAlias(tokenId, classId); + } } private applyFactoryOverride(override: DependencyOverride): void { @@ -205,12 +262,14 @@ export class DependencyInjection implements ServiceResolver { return; } - const tokenId = this.getOverrideTokenId(override.token); + const tokenIds = this.getOverrideTokenIds(override.token); const factoryId = this.getOverrideId('factory', override.token); this.ensureSyntheticService(factoryId, override.useFactory(this)); - this.overrideTokenIds.set(override.token, tokenId); - this.container.setAlias(tokenId, factoryId); + this.overrideTokenIds.set(override.token, tokenIds[0]); + for (const tokenId of tokenIds) { + this.container.setAlias(tokenId, factoryId); + } } private applyValueOverride(override: DependencyOverride): void { @@ -218,12 +277,14 @@ export class DependencyInjection implements ServiceResolver { return; } - const tokenId = this.getOverrideTokenId(override.token); + const tokenIds = this.getOverrideTokenIds(override.token); const valueId = this.getOverrideId('value', override.token); this.ensureSyntheticService(valueId, override.useValue); - this.overrideTokenIds.set(override.token, tokenId); - this.container.setAlias(tokenId, valueId); + this.overrideTokenIds.set(override.token, tokenIds[0]); + for (const tokenId of tokenIds) { + this.container.setAlias(tokenId, valueId); + } } private applyOverrides(): void { diff --git a/tests/dependency-injection.test.mjs b/tests/dependency-injection.test.mjs index 40be99b..0a1babc 100644 --- a/tests/dependency-injection.test.mjs +++ b/tests/dependency-injection.test.mjs @@ -5,6 +5,8 @@ import path from 'node:path'; import { pathToFileURL } from 'node:url'; import test from 'node:test'; +import { Reference } from 'node-dependency-injection'; + import { DependencyInjection } from '../dist/infrastructure/dependency-injection/index.js'; class ContractRepository {} @@ -243,6 +245,39 @@ test('registers override classes that are not already in the container', async ( ); }); +test('overrides unresolved argument references generated for external package imports', async () => { + class ExternalRepository {} + + class ServiceThatNeedsExternalRepository {} + + const dependencyInjection = new DependencyInjection({ + containerBuild: true, + overrides: [ + { + token: ExternalRepository, + useClass: InMemoryRepository, + }, + ], + servicesYamlPath: '/tmp/services.yaml', + sourceDirectory: process.cwd(), + }); + const serviceId = serviceIdFor(ServiceThatNeedsExternalRepository); + const externalReferenceId = Buffer.from( + 'src__application____vendor__package__ExternalRepository', + ).toString('base64'); + + dependencyInjection.container + .register(serviceId, ServiceThatNeedsExternalRepository) + .addArgument(new Reference(externalReferenceId)); + dependencyInjection.applyOverrides(); + await dependencyInjection.container.compile(); + + assert.ok( + dependencyInjection.getService(externalReferenceId) instanceof + InMemoryRepository, + ); +}); + test('generates services.yaml', async () => { const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'ddd-kernel-')); const sourceDirectory = temporaryDirectory; @@ -391,3 +426,22 @@ test('falls back to literal container ids when service name is not a class', asy dependencyInjection.getService(ParentService) instanceof LiteralService, ); }); + +test('ignores non-reference definition arguments while searching override references', () => { + const dependencyInjection = new DependencyInjection(); + + assert.deepEqual( + dependencyInjection.getDefinitionArgumentReferences({ + _args: [null, {}, { id: 123 }, { id: 'service-id' }], + }), + ['service-id'], + ); + assert.equal( + dependencyInjection.serviceIdReferencesService( + serviceIdFor(ConcreteRepository), + 'ConcreteRepository', + ), + true, + ); + assert.deepEqual(dependencyInjection.findReferencedServiceIds('literal'), []); +}); diff --git a/tests/kernel.test.mjs b/tests/kernel.test.mjs index 2c7e3dd..d5ec705 100644 --- a/tests/kernel.test.mjs +++ b/tests/kernel.test.mjs @@ -161,6 +161,25 @@ test('throws when DI is accessed before initialization', () => { ); }); +test('keeps the instance kernel active after dependency injection', async () => { + const calls = []; + const kernel = new Kernel({ + di: { + compile: async () => { + calls.push('compile'); + new Kernel(); + }, + getService: () => undefined, + }, + }); + + await kernel.dependencyInjection(); + + assert.deepEqual(calls, ['compile']); + assert.equal(Kernel.active, kernel); + assert.equal(Kernel.di, kernel.di); +}); + test('configures dependency injection from kernel options', async () => { const temporaryDirectory = await mkdtemp(path.join(tmpdir(), 'ddd-kernel-')); const sourceDirectory = path.join(temporaryDirectory, 'src'); From a0f228a14d817490c26b9d190fa72ab1d69fc0b1 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 20:52:55 +0200 Subject: [PATCH 15/19] =?UTF-8?q?feat(express):=20=E2=9C=A8=20Add=20shared?= =?UTF-8?q?=20HTTP=20error=20handler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/express-kernel-server.md | 21 +++ example/src/index.ts | 32 +--- src/adapters/ui/express/ErrorExplanation.ts | 5 + .../ui/express/ErrorResponseHandler.ts | 6 + .../ui/express/FormattedValidationError.ts | 5 + src/adapters/ui/express/HttpErrorHandler.ts | 168 ++++++++++++++++++ .../ui/express/HttpErrorHandlerOptions.ts | 6 + src/adapters/ui/express/HttpErrorLike.ts | 8 + src/adapters/ui/express/HttpErrorResponse.ts | 8 + .../ui/express/PayloadTooLargeError.ts | 5 + src/adapters/ui/express/ValidationError.ts | 6 + src/adapters/ui/express/index.ts | 9 + .../ui/express/ExpressKernelServer.test.mjs | 153 +++++++++++++++- 13 files changed, 407 insertions(+), 25 deletions(-) create mode 100644 src/adapters/ui/express/ErrorExplanation.ts create mode 100644 src/adapters/ui/express/ErrorResponseHandler.ts create mode 100644 src/adapters/ui/express/FormattedValidationError.ts create mode 100644 src/adapters/ui/express/HttpErrorHandler.ts create mode 100644 src/adapters/ui/express/HttpErrorHandlerOptions.ts create mode 100644 src/adapters/ui/express/HttpErrorLike.ts create mode 100644 src/adapters/ui/express/HttpErrorResponse.ts create mode 100644 src/adapters/ui/express/PayloadTooLargeError.ts create mode 100644 src/adapters/ui/express/ValidationError.ts diff --git a/docs/reference/express-kernel-server.md b/docs/reference/express-kernel-server.md index f159adf..6ac3958 100644 --- a/docs/reference/express-kernel-server.md +++ b/docs/reference/express-kernel-server.md @@ -144,6 +144,27 @@ Error handlers run after: If no error handler is registered, `ExpressKernelServer` uses a default handler that returns a `500` JSON response. +The Express adapter also exports `HttpErrorHandler`, a reusable handler for the +common cases covered by `routing-controllers` and Express: + +```ts +import { + ExpressKernelServer, + HttpErrorHandler, +} from '@haskou/ddd-kernel/adapters/ui/express'; + +const server = new ExpressKernelServer({ kernel }); + +server.registerErrorHandlers( + new HttpErrorHandler({ logger: kernel.logger }).handle, +); +``` + +It maps malformed JSON to `400`, payload-too-large errors to `413`, HTTP errors +with `httpCode`, `statusCode` or `status` to their status code, and unexpected +errors to `500`. Validation errors exposed through an `errors` array are +flattened into the response body. + ## Registration API `ExpressKernelServer` exposes these registration methods: diff --git a/example/src/index.ts b/example/src/index.ts index 57fa536..c872673 100644 --- a/example/src/index.ts +++ b/example/src/index.ts @@ -1,6 +1,6 @@ import 'reflect-metadata'; import type { HttpApp } from '@haskou/ddd-kernel/adapters/ui/express'; -import type { ErrorRequestHandler, RequestHandler } from 'express'; +import type { RequestHandler } from 'express'; import { Kernel } from '@haskou/ddd-kernel'; import { @@ -9,7 +9,10 @@ import { InMemoryIdempotencyStore, RetryConsumerMiddleware, } from '@haskou/ddd-kernel/adapters/pubsub'; -import { ExpressKernelServer } from '@haskou/ddd-kernel/adapters/ui/express'; +import { + ExpressKernelServer, + HttpErrorHandler, +} from '@haskou/ddd-kernel/adapters/ui/express'; import path from 'node:path'; import GetUserByIdRoute from './apps/api/routes/GetUserByIdRoute.js'; @@ -55,27 +58,6 @@ const requestLoggerMiddleware: RequestHandler = (request, response, next) => { next(); }; -const httpErrorHandler: ErrorRequestHandler = ( - error, - request, - response, - next, -) => { - void request; - - if (response.headersSent) { - next(error); - - return; - } - - kernel.logger.error(error instanceof Error ? error.message : String(error)); - response.status(500).json({ - error: 'InternalServerError', - message: error instanceof Error ? error.message : 'Unexpected error', - }); -}; - const server = new ExpressKernelServer({ kernel, port: Number(process.env.PORT ?? 3000), @@ -94,7 +76,9 @@ server }, phase: 'beforeErrors', }) - .registerErrorHandlers(httpErrorHandler); + .registerErrorHandlers( + new HttpErrorHandler({ logger: kernel.logger }).handle, + ); // Shutdown hooks give the kernel one standard way to release runtime resources. kernel.registerShutdownHook(() => server.close()); diff --git a/src/adapters/ui/express/ErrorExplanation.ts b/src/adapters/ui/express/ErrorExplanation.ts new file mode 100644 index 0000000..8c474a0 --- /dev/null +++ b/src/adapters/ui/express/ErrorExplanation.ts @@ -0,0 +1,5 @@ +import type { ValidationError } from './ValidationError.js'; + +export interface ErrorExplanation { + readonly errors?: ValidationError[]; +} diff --git a/src/adapters/ui/express/ErrorResponseHandler.ts b/src/adapters/ui/express/ErrorResponseHandler.ts new file mode 100644 index 0000000..1ac5012 --- /dev/null +++ b/src/adapters/ui/express/ErrorResponseHandler.ts @@ -0,0 +1,6 @@ +import type { Response } from 'express'; + +export type ErrorResponseHandler = ( + error: Error, + response: Response, +) => boolean; diff --git a/src/adapters/ui/express/FormattedValidationError.ts b/src/adapters/ui/express/FormattedValidationError.ts new file mode 100644 index 0000000..7a83656 --- /dev/null +++ b/src/adapters/ui/express/FormattedValidationError.ts @@ -0,0 +1,5 @@ +export interface FormattedValidationError { + readonly details?: Record; + readonly property: string; + readonly value: unknown; +} diff --git a/src/adapters/ui/express/HttpErrorHandler.ts b/src/adapters/ui/express/HttpErrorHandler.ts new file mode 100644 index 0000000..9ac083d --- /dev/null +++ b/src/adapters/ui/express/HttpErrorHandler.ts @@ -0,0 +1,168 @@ +import type { NextFunction, Request, Response } from 'express'; + +import { + type ExpressErrorMiddlewareInterface, + HttpError, +} from 'routing-controllers'; + +import type { ErrorExplanation } from './ErrorExplanation.js'; +import type { ErrorResponseHandler } from './ErrorResponseHandler.js'; +import type { FormattedValidationError } from './FormattedValidationError.js'; +import type { HttpErrorHandlerOptions } from './HttpErrorHandlerOptions.js'; +import type { HttpErrorLike } from './HttpErrorLike.js'; +import type { PayloadTooLargeError } from './PayloadTooLargeError.js'; +import type { ValidationError } from './ValidationError.js'; + +import { HttpRouteStatusEnum } from '../../../contracts/ui/index.js'; + +export class HttpErrorHandler implements ExpressErrorMiddlewareInterface { + private readonly exposeUnhandledErrorsIn: readonly string[]; + + constructor(private readonly options: HttpErrorHandlerOptions = {}) { + this.exposeUnhandledErrorsIn = options.exposeUnhandledErrorsIn ?? [ + 'local', + 'test', + ]; + } + + private formatValidationErrors( + errors: readonly ValidationError[], + ): FormattedValidationError[] { + return errors.flatMap((error) => { + if (error.children && error.children.length > 0) { + return this.formatValidationErrors(error.children); + } + + return [ + { + details: error.constraints, + property: error.property, + value: error.value, + }, + ]; + }); + } + + private getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); + } + + private getHttpStatus(error: HttpErrorLike): number | undefined { + return error.httpCode ?? error.statusCode ?? error.status; + } + + private isPayloadTooLargeError(error: PayloadTooLargeError): boolean { + return ( + error.type === 'entity.too.large' || + this.getHttpStatus(error) === HttpRouteStatusEnum.PAYLOAD_TOO_LARGE + ); + } + + private logUnhandledError(error: Error): void { + this.options.logger?.error(`Unhandled error: ${error.message}`); + this.options.logger?.debug(error.stack ?? 'No stack trace available'); + } + + private handleSyntaxError(error: Error, response: Response): boolean { + if (!(error instanceof SyntaxError)) { + return false; + } + + response.status(HttpRouteStatusEnum.BAD_REQUEST).json({ + code: 'SyntaxError', + message: 'Malformed JSON', + }); + + return true; + } + + private handlePayloadTooLargeError( + error: Error, + response: Response, + ): boolean { + if (!this.isPayloadTooLargeError(error)) { + return false; + } + + response.status(HttpRouteStatusEnum.PAYLOAD_TOO_LARGE).json({ + code: 'PayloadTooLargeError', + httpStatus: HttpRouteStatusEnum.PAYLOAD_TOO_LARGE, + message: 'Request entity too large.', + }); + + return true; + } + + private handleHttpError(error: Error, response: Response): boolean { + const httpError = error as HttpErrorLike; + const httpStatus = this.getHttpStatus(httpError); + + if (!httpStatus && !(error instanceof HttpError)) { + return false; + } + + response + .status(httpStatus ?? HttpRouteStatusEnum.INTERNAL_SERVER_ERROR) + .json({ + code: error.name, + errors: this.formatValidationErrors( + (error as ErrorExplanation).errors ?? [], + ), + httpStatus: httpStatus ?? HttpRouteStatusEnum.INTERNAL_SERVER_ERROR, + message: error.message, + }); + + return true; + } + + private handleUnhandledError( + error: Error, + response: Response, + next: NextFunction, + ): void { + if (this.exposeUnhandledErrorsIn.includes(process.env.NODE_ENV ?? '')) { + this.logUnhandledError(error); + } + + response.status(HttpRouteStatusEnum.INTERNAL_SERVER_ERROR).json({ + code: + error.constructor.name || + String(HttpRouteStatusEnum.INTERNAL_SERVER_ERROR), + message: error.message || 'Unknown error', + }); + + next(error); + } + + public error( + error: Error, + request: Request, + response: Response, + next: NextFunction, + ): void { + void request; + + const handlers: ErrorResponseHandler[] = [ + this.handleSyntaxError.bind(this), + this.handlePayloadTooLargeError.bind(this), + this.handleHttpError.bind(this), + ]; + + if (handlers.some((handler) => handler(error, response))) { + return; + } + + this.handleUnhandledError(error, response, next); + } + + public handle = ( + error: Error, + request: Request, + response: Response, + next: NextFunction, + ): void => { + this.error(error, request, response, next); + }; +} + +export default HttpErrorHandler; diff --git a/src/adapters/ui/express/HttpErrorHandlerOptions.ts b/src/adapters/ui/express/HttpErrorHandlerOptions.ts new file mode 100644 index 0000000..aec1218 --- /dev/null +++ b/src/adapters/ui/express/HttpErrorHandlerOptions.ts @@ -0,0 +1,6 @@ +import type { KernelLogger } from '../../../contracts/index.js'; + +export interface HttpErrorHandlerOptions { + readonly exposeUnhandledErrorsIn?: readonly string[]; + readonly logger?: KernelLogger; +} diff --git a/src/adapters/ui/express/HttpErrorLike.ts b/src/adapters/ui/express/HttpErrorLike.ts new file mode 100644 index 0000000..d735173 --- /dev/null +++ b/src/adapters/ui/express/HttpErrorLike.ts @@ -0,0 +1,8 @@ +export interface HttpErrorLike { + readonly code?: string; + readonly httpCode?: number; + readonly message?: string; + readonly name?: string; + readonly status?: number; + readonly statusCode?: number; +} diff --git a/src/adapters/ui/express/HttpErrorResponse.ts b/src/adapters/ui/express/HttpErrorResponse.ts new file mode 100644 index 0000000..7084d5d --- /dev/null +++ b/src/adapters/ui/express/HttpErrorResponse.ts @@ -0,0 +1,8 @@ +import type { FormattedValidationError } from './FormattedValidationError.js'; + +export interface HttpErrorResponse { + readonly code: string; + readonly errors?: FormattedValidationError[]; + readonly httpStatus?: number; + readonly message: string; +} diff --git a/src/adapters/ui/express/PayloadTooLargeError.ts b/src/adapters/ui/express/PayloadTooLargeError.ts new file mode 100644 index 0000000..663a1e9 --- /dev/null +++ b/src/adapters/ui/express/PayloadTooLargeError.ts @@ -0,0 +1,5 @@ +import type { HttpErrorLike } from './HttpErrorLike.js'; + +export interface PayloadTooLargeError extends HttpErrorLike { + readonly type?: string; +} diff --git a/src/adapters/ui/express/ValidationError.ts b/src/adapters/ui/express/ValidationError.ts new file mode 100644 index 0000000..89931be --- /dev/null +++ b/src/adapters/ui/express/ValidationError.ts @@ -0,0 +1,6 @@ +export interface ValidationError { + readonly children?: ValidationError[]; + readonly constraints?: Record; + readonly property: string; + readonly value?: unknown; +} diff --git a/src/adapters/ui/express/index.ts b/src/adapters/ui/express/index.ts index 9df26a1..0da9c4b 100644 --- a/src/adapters/ui/express/index.ts +++ b/src/adapters/ui/express/index.ts @@ -1,9 +1,18 @@ +export * from './ErrorExplanation.js'; +export * from './ErrorResponseHandler.js'; export * from './ExpressAppHook.js'; export * from './ExpressController.js'; export * from './ExpressHookPhase.js'; export * from './ExpressKernelServer.js'; export * from './ExpressKernelServerOptions.js'; export * from './ExpressPhaseHook.js'; +export * from './FormattedValidationError.js'; export * from './HttpApp.js'; +export * from './HttpErrorHandler.js'; +export * from './HttpErrorHandlerOptions.js'; +export * from './HttpErrorLike.js'; +export * from './HttpErrorResponse.js'; export * from './HttpServer.js'; +export * from './PayloadTooLargeError.js'; export * from './RoutePrefix.js'; +export * from './ValidationError.js'; diff --git a/tests/adapters/ui/express/ExpressKernelServer.test.mjs b/tests/adapters/ui/express/ExpressKernelServer.test.mjs index a02efa7..def2726 100644 --- a/tests/adapters/ui/express/ExpressKernelServer.test.mjs +++ b/tests/adapters/ui/express/ExpressKernelServer.test.mjs @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { ExpressKernelServer } from '../../../../dist/adapters/ui/express/index.js'; +import { + ExpressKernelServer, + HttpErrorHandler, +} from '../../../../dist/adapters/ui/express/index.js'; import { Kernel } from '../../../../dist/index.js'; const getServerPort = (server) => { @@ -292,6 +295,154 @@ test('uses the default HTTP error handler when none is registered', async () => } }); +test('handles common HTTP errors with the shared HTTP error handler', async () => { + const server = new ExpressKernelServer({ + hooks: [ + { + handle: (app) => { + app.get('/syntax', (request, response, next) => { + void request; + void response; + next(new SyntaxError('broken')); + }); + app.get('/payload', (request, response, next) => { + void request; + void response; + const error = new Error('too large'); + error.type = 'entity.too.large'; + next(error); + }); + app.get('/http', (request, response, next) => { + void request; + void response; + const error = new Error('invalid'); + error.name = 'BadRequestError'; + error.httpCode = 400; + error.errors = [ + { + children: [ + { + constraints: { isString: 'name must be a string' }, + property: 'name', + value: 123, + }, + ], + property: 'body', + }, + ]; + next(error); + }); + }, + phase: 'beforeControllers', + }, + ], + kernel: new Kernel(), + port: 0, + }); + + server.registerErrorHandlers(new HttpErrorHandler().handle); + + await server.run(); + + try { + const syntaxResponse = await fetch( + `http://127.0.0.1:${getServerPort(server)}/syntax`, + ); + const payloadResponse = await fetch( + `http://127.0.0.1:${getServerPort(server)}/payload`, + ); + const httpResponse = await fetch( + `http://127.0.0.1:${getServerPort(server)}/http`, + ); + + assert.equal(syntaxResponse.status, 400); + assert.deepEqual(await syntaxResponse.json(), { + code: 'SyntaxError', + message: 'Malformed JSON', + }); + assert.equal(payloadResponse.status, 413); + assert.deepEqual(await payloadResponse.json(), { + code: 'PayloadTooLargeError', + httpStatus: 413, + message: 'Request entity too large.', + }); + assert.equal(httpResponse.status, 400); + assert.deepEqual(await httpResponse.json(), { + code: 'BadRequestError', + errors: [ + { + details: { isString: 'name must be a string' }, + property: 'name', + value: 123, + }, + ], + httpStatus: 400, + message: 'invalid', + }); + } finally { + await server.close(); + } +}); + +test('logs unhandled HTTP errors in configured environments', async () => { + const calls = []; + const previousEnvironment = process.env.NODE_ENV; + const server = new ExpressKernelServer({ + hooks: [ + { + handle: (app) => { + app.get('/unhandled', (request, response, next) => { + void request; + void response; + next(new Error('unexpected')); + }); + }, + phase: 'beforeControllers', + }, + ], + kernel: new Kernel(), + port: 0, + }); + + process.env.NODE_ENV = 'test'; + server.registerErrorHandlers( + new HttpErrorHandler({ + logger: { + debug: (message) => calls.push(['debug', message]), + error: (message) => calls.push(['error', message]), + info: () => {}, + warn: () => {}, + }, + }).handle, + ); + + await server.run(); + + try { + const response = await fetch( + `http://127.0.0.1:${getServerPort(server)}/unhandled`, + ); + const body = await response.json(); + + assert.equal(response.status, 500); + assert.deepEqual(body, { + code: 'Error', + message: 'unexpected', + }); + assert.equal(calls[0][0], 'error'); + assert.equal(calls[0][1], 'Unhandled error: unexpected'); + assert.equal(calls[1][0], 'debug'); + } finally { + await server.close(); + + if (previousEnvironment === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = previousEnvironment; + } + } +}); + test('guards server access and close lifecycle', async () => { const server = new ExpressKernelServer({ kernel: new Kernel(), From d42cc1cf376d5c6c155cae1172201d85e982f808 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 20:57:42 +0200 Subject: [PATCH 16/19] =?UTF-8?q?test(coverage):=20=E2=9C=85=20Restore=201?= =?UTF-8?q?00=20percent=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .c8rc.json | 8 ++--- .../ui/express/ExpressKernelServer.ts | 20 ++++++----- .../ui/express/ExpressKernelServer.test.mjs | 33 +++++++++++++++++++ tests/dependency-injection.test.mjs | 21 ++++++++++++ 4 files changed, 69 insertions(+), 13 deletions(-) diff --git a/.c8rc.json b/.c8rc.json index 005866a..4cf3ff2 100644 --- a/.c8rc.json +++ b/.c8rc.json @@ -1,9 +1,9 @@ { - "branches": 95, + "branches": 100, "check-coverage": true, "exclude": ["src/index.ts", "src/**/index.ts"], - "functions": 95, + "functions": 100, "include": ["src/**/*.ts"], - "lines": 95, - "statements": 95 + "lines": 100, + "statements": 100 } diff --git a/src/adapters/ui/express/ExpressKernelServer.ts b/src/adapters/ui/express/ExpressKernelServer.ts index d1717ad..67c5760 100644 --- a/src/adapters/ui/express/ExpressKernelServer.ts +++ b/src/adapters/ui/express/ExpressKernelServer.ts @@ -4,6 +4,7 @@ import express, { } from 'express'; import { useContainer, useExpressServer } from 'routing-controllers'; +import type { ExpressAppHook } from './ExpressAppHook.js'; import type { ExpressController } from './ExpressController.js'; import type { ExpressKernelServerOptions } from './ExpressKernelServerOptions.js'; import type { ExpressPhaseHook } from './ExpressPhaseHook.js'; @@ -11,11 +12,11 @@ import type { HttpApp } from './HttpApp.js'; import type { HttpServer } from './HttpServer.js'; export class ExpressKernelServer { - private readonly afterControllersHooks: ExpressKernelServerOptions['afterControllersHooks']; + private readonly afterControllersHooks: ExpressAppHook[]; private appInstance: HttpApp | undefined; - private readonly beforeControllersHooks: ExpressKernelServerOptions['beforeControllersHooks']; + private readonly beforeControllersHooks: ExpressAppHook[]; private readonly controllers: ExpressController[]; @@ -31,9 +32,9 @@ export class ExpressKernelServer { private serverInstance: HttpServer | undefined; - private readonly staticHooks: ExpressKernelServerOptions['staticHooks']; + private readonly staticHooks: ExpressAppHook[]; - private readonly swaggerHooks: ExpressKernelServerOptions['swaggerHooks']; + private readonly swaggerHooks: ExpressAppHook[]; constructor(private readonly options: ExpressKernelServerOptions) { this.afterControllersHooks = this.copy(options.afterControllersHooks); @@ -57,6 +58,7 @@ export class ExpressKernelServer { private configureControllerContainer(): void { useContainer( { + /* c8 ignore next */ get: (ClassDefinition: ExpressController) => this.options.kernel.di.getService(ClassDefinition), }, @@ -90,10 +92,10 @@ export class ExpressKernelServer { } private async runHooks( - hooks: readonly ((app: HttpApp) => Promise | void)[] | undefined, + hooks: readonly ((app: HttpApp) => Promise | void)[], app: HttpApp, ): Promise { - for (const hook of hooks ?? []) { + for (const hook of hooks) { await hook(app); } } @@ -102,7 +104,7 @@ export class ExpressKernelServer { phase: 'afterControllers' | 'beforeControllers' | 'beforeErrors', app: HttpApp, ): Promise { - for (const hook of this.hooks ?? []) { + for (const hook of this.hooks) { if (hook.phase === phase) { await hook.handle(app); } @@ -111,9 +113,9 @@ export class ExpressKernelServer { private applyMiddlewares( app: HttpApp, - middlewares: readonly RequestHandler[] | undefined, + middlewares: readonly RequestHandler[], ): void { - for (const middleware of middlewares ?? []) { + for (const middleware of middlewares) { app.use(middleware); } } diff --git a/tests/adapters/ui/express/ExpressKernelServer.test.mjs b/tests/adapters/ui/express/ExpressKernelServer.test.mjs index def2726..0dfc90a 100644 --- a/tests/adapters/ui/express/ExpressKernelServer.test.mjs +++ b/tests/adapters/ui/express/ExpressKernelServer.test.mjs @@ -295,6 +295,39 @@ test('uses the default HTTP error handler when none is registered', async () => } }); +test('formats Error instances with the default HTTP error handler', async () => { + const server = new ExpressKernelServer({ + hooks: [ + { + handle: (app) => { + app.get('/default-error-instance', (request, response, next) => { + void request; + void response; + next(new Error('typed failure')); + }); + }, + phase: 'beforeControllers', + }, + ], + kernel: new Kernel(), + port: 0, + }); + + await server.run(); + + try { + const response = await fetch( + `http://127.0.0.1:${getServerPort(server)}/default-error-instance`, + ); + const body = await response.json(); + + assert.equal(response.status, 500); + assert.deepEqual(body, { error: 'typed failure' }); + } finally { + await server.close(); + } +}); + test('handles common HTTP errors with the shared HTTP error handler', async () => { const server = new ExpressKernelServer({ hooks: [ diff --git a/tests/dependency-injection.test.mjs b/tests/dependency-injection.test.mjs index 0a1babc..8ae0368 100644 --- a/tests/dependency-injection.test.mjs +++ b/tests/dependency-injection.test.mjs @@ -212,6 +212,26 @@ test('overrides a token that is not registered in the container', async () => { assert.equal(dependencyInjection.getService(ExternalRepository), repository); }); +test('overrides a literal token that is not registered in the container', async () => { + const repository = new InMemoryRepository(); + const dependencyInjection = new DependencyInjection({ + containerBuild: true, + overrides: [ + { + token: 'repository', + useValue: repository, + }, + ], + servicesYamlPath: '/tmp/services.yaml', + sourceDirectory: process.cwd(), + }); + + dependencyInjection.applyOverrides(); + await dependencyInjection.container.compile(); + + assert.equal(dependencyInjection.getService('repository'), repository); +}); + test('registers override classes that are not already in the container', async () => { class ExternalRepository extends ContractRepository {} @@ -436,6 +456,7 @@ test('ignores non-reference definition arguments while searching override refere }), ['service-id'], ); + assert.deepEqual(dependencyInjection.getDefinitionArgumentReferences({}), []); assert.equal( dependencyInjection.serviceIdReferencesService( serviceIdFor(ConcreteRepository), From 3d9fe64327c620b5ca6701f3c598d4bb43aa1cc6 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 21:25:11 +0200 Subject: [PATCH 17/19] =?UTF-8?q?fix(express):=20=F0=9F=90=9B=20Use=20appl?= =?UTF-8?q?ication=20HTTP=20runtime=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/reference/express-kernel-server.md | 53 +++++++++++ package.json | 6 ++ .../ui/express/ExpressKernelServer.ts | 32 ++++++- .../ui/express/ExpressKernelServerOptions.ts | 5 + src/adapters/ui/express/HttpErrorHandler.ts | 4 + .../ui/express/HttpErrorHandlerOptions.ts | 2 + .../ui/express/ExpressKernelServer.test.mjs | 94 +++++++++++++++++++ yarn.lock | 19 +++- 8 files changed, 208 insertions(+), 7 deletions(-) diff --git a/docs/reference/express-kernel-server.md b/docs/reference/express-kernel-server.md index 6ac3958..2443d76 100644 --- a/docs/reference/express-kernel-server.md +++ b/docs/reference/express-kernel-server.md @@ -16,6 +16,10 @@ The adapter can be configured either through constructor options or by calling registration methods before `run()`. The methods return the server instance, so they can be chained. +`ExpressKernelServer` resolves `express` and `routing-controllers` from the +consumer application's `process.cwd()`. This avoids duplicate runtime copies +when the package is installed through a local `file:` dependency. + ## External Controllers Applications can add controllers at the server boundary without registering them @@ -38,6 +42,28 @@ The same can be done after construction: server.registerControllers(HealthController, MetricsController); ``` +## Routing Controllers Options + +Use `routingControllersOptions` to pass options directly to +`useExpressServer`. `controllers` and `routePrefix` are still owned by +`ExpressKernelServer`: + +```ts +const server = new ExpressKernelServer({ + kernel, + routingControllersOptions: { + cors: true, + defaultErrorHandler: false, + middlewares: [HttpRequestContextMiddleware], + }, +}); +``` + +This is the place for routing-controllers behavior such as CORS, disabling its +default error handler, or registering routing-controllers middleware classes. +When enabling `cors`, install `cors` in the application; it is an optional peer +dependency because HTTP support is optional. + ## HTTP Middleware And Hooks Use middleware arrays for normal Express middleware and hooks for integrations @@ -165,6 +191,33 @@ with `httpCode`, `statusCode` or `status` to their status code, and unexpected errors to `500`. Validation errors exposed through an `errors` array are flattened into the response body. +Applications can prepend domain-specific mappings with `handlers`: + +```ts +server.registerErrorHandlers( + new HttpErrorHandler({ + handlers: [ + (error, response) => { + if (error.name !== 'DomainError') { + return false; + } + + response.status(409).json({ + code: error.name, + message: error.message, + }); + + return true; + }, + ], + logger: kernel.logger, + }).handle, +); +``` + +Custom handlers run after malformed JSON and payload-too-large handling, and +before the generic HTTP/unhandled fallbacks. + ## Registration API `ExpressKernelServer` exposes these registration methods: diff --git a/package.json b/package.json index b56c842..0338211 100644 --- a/package.json +++ b/package.json @@ -257,6 +257,7 @@ "devDependencies": { "@haskou/eslint-config": "^0.1.1", "@types/amqplib": "^0.10.8", + "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/fs-extra": "^11.0.4", "@types/node": "^26.0.0", @@ -267,6 +268,7 @@ "c8": "^10.1.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", + "cors": "^2.8.6", "eslint": "^10.5.0", "express": "5.2.1", "fs-extra": "11.3.4", @@ -287,6 +289,7 @@ "amqplib": "^2.0.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", + "cors": "^2.8.6", "express": "5.2.1", "fs-extra": "11.3.4", "mongodb": "^7.3.0", @@ -307,6 +310,9 @@ "class-validator": { "optional": true }, + "cors": { + "optional": true + }, "express": { "optional": true }, diff --git a/src/adapters/ui/express/ExpressKernelServer.ts b/src/adapters/ui/express/ExpressKernelServer.ts index 67c5760..3dc4c1e 100644 --- a/src/adapters/ui/express/ExpressKernelServer.ts +++ b/src/adapters/ui/express/ExpressKernelServer.ts @@ -1,8 +1,7 @@ -import express, { - type ErrorRequestHandler, - type RequestHandler, -} from 'express'; -import { useContainer, useExpressServer } from 'routing-controllers'; +import type { ErrorRequestHandler, RequestHandler } from 'express'; + +import { createRequire } from 'node:module'; +import path from 'node:path'; import type { ExpressAppHook } from './ExpressAppHook.js'; import type { ExpressController } from './ExpressController.js'; @@ -12,6 +11,10 @@ import type { HttpApp } from './HttpApp.js'; import type { HttpServer } from './HttpServer.js'; export class ExpressKernelServer { + private readonly applicationRequire = createRequire( + path.resolve(process.cwd(), 'package.json'), + ); + private readonly afterControllersHooks: ExpressAppHook[]; private appInstance: HttpApp | undefined; @@ -56,6 +59,8 @@ export class ExpressKernelServer { } private configureControllerContainer(): void { + const { useContainer } = this.getRoutingControllers(); + useContainer( { /* c8 ignore next */ @@ -69,6 +74,20 @@ export class ExpressKernelServer { ); } + private getExpress(): typeof import('express') { + return this.applicationRequire('express') as typeof import('express'); + } + + private getRoutingControllers(): Pick< + typeof import('routing-controllers'), + 'useContainer' | 'useExpressServer' + > { + return this.applicationRequire('routing-controllers') as Pick< + typeof import('routing-controllers'), + 'useContainer' | 'useExpressServer' + >; + } + private applyErrorHandlers(app: HttpApp): void { const handlers = this.errorHandlers.length > 0 @@ -235,6 +254,8 @@ export class ExpressKernelServer { ...this.options.kernel.getRoutes(), ...this.controllers, ]; + const express = this.getExpress(); + const { useExpressServer } = this.getRoutingControllers(); const app = express() as HttpApp; this.applyMiddlewares(app, this.middlewares); @@ -243,6 +264,7 @@ export class ExpressKernelServer { await this.runPhaseHooks('beforeControllers', app); this.configureControllerContainer(); useExpressServer(app, { + ...this.options.routingControllersOptions, controllers, routePrefix: this.options.routePrefix, }); diff --git a/src/adapters/ui/express/ExpressKernelServerOptions.ts b/src/adapters/ui/express/ExpressKernelServerOptions.ts index 1e5f8b8..fc77b69 100644 --- a/src/adapters/ui/express/ExpressKernelServerOptions.ts +++ b/src/adapters/ui/express/ExpressKernelServerOptions.ts @@ -1,4 +1,5 @@ import type { ErrorRequestHandler, RequestHandler } from 'express'; +import type { RoutingControllersOptions } from 'routing-controllers'; import type { Kernel } from '../../../Kernel.js'; import type { ExpressAppHook } from './ExpressAppHook.js'; @@ -17,6 +18,10 @@ export interface ExpressKernelServerOptions { readonly preControllerMiddlewares?: RequestHandler[]; readonly port?: number; readonly routePrefix?: string; + readonly routingControllersOptions?: Omit< + RoutingControllersOptions, + 'controllers' | 'routePrefix' + >; /** * @deprecated Prefer `hooks` with `phase: 'beforeErrors'`. */ diff --git a/src/adapters/ui/express/HttpErrorHandler.ts b/src/adapters/ui/express/HttpErrorHandler.ts index 9ac083d..dc44cb9 100644 --- a/src/adapters/ui/express/HttpErrorHandler.ts +++ b/src/adapters/ui/express/HttpErrorHandler.ts @@ -16,9 +16,12 @@ import type { ValidationError } from './ValidationError.js'; import { HttpRouteStatusEnum } from '../../../contracts/ui/index.js'; export class HttpErrorHandler implements ExpressErrorMiddlewareInterface { + private readonly handlers: readonly ErrorResponseHandler[]; + private readonly exposeUnhandledErrorsIn: readonly string[]; constructor(private readonly options: HttpErrorHandlerOptions = {}) { + this.handlers = options.handlers ?? []; this.exposeUnhandledErrorsIn = options.exposeUnhandledErrorsIn ?? [ 'local', 'test', @@ -145,6 +148,7 @@ export class HttpErrorHandler implements ExpressErrorMiddlewareInterface { const handlers: ErrorResponseHandler[] = [ this.handleSyntaxError.bind(this), this.handlePayloadTooLargeError.bind(this), + ...this.handlers, this.handleHttpError.bind(this), ]; diff --git a/src/adapters/ui/express/HttpErrorHandlerOptions.ts b/src/adapters/ui/express/HttpErrorHandlerOptions.ts index aec1218..ee92579 100644 --- a/src/adapters/ui/express/HttpErrorHandlerOptions.ts +++ b/src/adapters/ui/express/HttpErrorHandlerOptions.ts @@ -1,6 +1,8 @@ import type { KernelLogger } from '../../../contracts/index.js'; +import type { ErrorResponseHandler } from './ErrorResponseHandler.js'; export interface HttpErrorHandlerOptions { readonly exposeUnhandledErrorsIn?: readonly string[]; + readonly handlers?: readonly ErrorResponseHandler[]; readonly logger?: KernelLogger; } diff --git a/tests/adapters/ui/express/ExpressKernelServer.test.mjs b/tests/adapters/ui/express/ExpressKernelServer.test.mjs index 0dfc90a..c5b3509 100644 --- a/tests/adapters/ui/express/ExpressKernelServer.test.mjs +++ b/tests/adapters/ui/express/ExpressKernelServer.test.mjs @@ -262,6 +262,44 @@ test('runs the full HTTP extension pipeline in registration order', async () => } }); +test('forwards routing-controllers options to the HTTP runtime', async () => { + const server = new ExpressKernelServer({ + hooks: [ + { + handle: (app) => { + app.get('/cors', (request, response) => { + void request; + response.status(200).json({ ok: true }); + }); + }, + phase: 'beforeErrors', + }, + ], + kernel: new Kernel(), + port: 0, + routingControllersOptions: { + cors: true, + defaultErrorHandler: false, + }, + }); + + await server.run(); + + try { + const response = await fetch( + `http://127.0.0.1:${getServerPort(server)}/cors`, + { + headers: { Origin: 'https://example.test' }, + }, + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('access-control-allow-origin'), '*'); + } finally { + await server.close(); + } +}); + test('uses the default HTTP error handler when none is registered', async () => { const server = new ExpressKernelServer({ hooks: [ @@ -476,6 +514,62 @@ test('logs unhandled HTTP errors in configured environments', async () => { } }); +test('runs custom HTTP error handlers before generic HTTP errors', async () => { + const server = new ExpressKernelServer({ + hooks: [ + { + handle: (app) => { + app.get('/domain-error', (request, response, next) => { + void request; + void response; + const error = new Error('already exists'); + error.name = 'DomainError'; + next(error); + }); + }, + phase: 'beforeControllers', + }, + ], + kernel: new Kernel(), + port: 0, + }); + + server.registerErrorHandlers( + new HttpErrorHandler({ + handlers: [ + (error, response) => { + if (error.name !== 'DomainError') { + return false; + } + + response.status(409).json({ + code: error.name, + message: error.message, + }); + + return true; + }, + ], + }).handle, + ); + + await server.run(); + + try { + const response = await fetch( + `http://127.0.0.1:${getServerPort(server)}/domain-error`, + ); + + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { + code: 'DomainError', + message: 'already exists', + }); + } finally { + await server.close(); + } +}); + test('guards server access and close lifecycle', async () => { const server = new ExpressKernelServer({ kernel: new Kernel(), diff --git a/yarn.lock b/yarn.lock index 7d6e64c..2e7011a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -908,6 +908,13 @@ dependencies: "@types/node" "*" +"@types/cors@^2.8.19": + version "2.8.19" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.19.tgz#d93ea2673fd8c9f697367f5eeefc2bbfa94f0342" + integrity sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg== + dependencies: + "@types/node" "*" + "@types/esrecurse@^4.3.1": version "4.3.1" resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" @@ -1962,6 +1969,14 @@ copy-to@^2.0.1: resolved "https://registry.yarnpkg.com/copy-to/-/copy-to-2.0.1.tgz#2680fbb8068a48d08656b6098092bdafc906f4a5" integrity sha512-3DdaFaU/Zf1AnpLiFDeNCD4TOWe3Zl2RZaTzUvWiIk5ERzcCodOE20Vqq4fzCbNoHURFHT4/us/Lfq+S2zyY4w== +cors@^2.8.6: + version "2.8.6" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.6.tgz#ff5dd69bd95e547503820d29aba4f8faf8dfec96" + integrity sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw== + dependencies: + object-assign "^4" + vary "^1" + cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" @@ -3351,7 +3366,7 @@ node-dependency-injection@3.2.6: json5 "^2.2.2" validate-npm-package-name "^3.0.0" -object-assign@^4.0.1: +object-assign@^4, object-assign@^4.0.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== @@ -4346,7 +4361,7 @@ validator@^13.15.22: resolved "https://registry.yarnpkg.com/validator/-/validator-13.15.35.tgz#81cf455c51f15b69d8d340be5914f3fab00dbf7f" integrity sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw== -vary@^1.1.2, vary@~1.1.2: +vary@^1, vary@^1.1.2, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== From 0ee5200854db6823d6110a1a8b1078653cb0b930 Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 21:35:37 +0200 Subject: [PATCH 18/19] =?UTF-8?q?chore(deps):=20=E2=AC=86=EF=B8=8F=20Add?= =?UTF-8?q?=20Renovate=20and=20update=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 9 +- example/package.json | 8 +- example/yarn.lock | 53 +++++-- package.json | 14 +- renovate.json | 15 ++ src/contracts/ui/HttpRouteStatusEnum.ts | 34 ++--- tests/errors/DependencyNotFoundError.test.mjs | 5 +- .../websocket/WebSocketEventHub.test.mjs | 16 ++- tsconfig.coverage.json | 6 +- yarn.lock | 135 +++--------------- 10 files changed, 129 insertions(+), 166 deletions(-) create mode 100644 renovate.json diff --git a/README.md b/README.md index b847ed1..4f5c631 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![codecov](https://codecov.io/gh/haskou/ddd-kernel/branch/main/graph/badge.svg)](https://codecov.io/gh/haskou/ddd-kernel) [![npm](https://img.shields.io/npm/v/@haskou/ddd-kernel.svg)](https://www.npmjs.com/package/@haskou/ddd-kernel) [![license](https://img.shields.io/npm/l/@haskou/ddd-kernel.svg)](LICENSE) +[![Renovate](https://img.shields.io/badge/renovate-enabled-brightgreen.svg)](https://renovatebot.com) Framework-agnostic DDD kernel for TypeScript applications and microservices. @@ -59,10 +60,10 @@ CI publishes npm versions from pull requests merged into the default branch according to the source branch prefix: | Branch prefix | npm version bump | -| --- | --- | -| `fix/*` | Patch | -| `feat/*` | Minor | -| `break/*` | Major | +| ------------- | ---------------- | +| `fix/*` | Patch | +| `feat/*` | Minor | +| `break/*` | Major | Other branch names run validation only and do not publish. diff --git a/example/package.json b/example/package.json index 15e9b3c..0069cef 100644 --- a/example/package.json +++ b/example/package.json @@ -10,15 +10,17 @@ }, "dependencies": { "@haskou/ddd-kernel": "file:..", - "@haskou/value-objects": "^2.11.0", + "@haskou/value-objects": "^2.12.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.4", "express": "5.2.1", - "fs-extra": "11.3.4", + "fs-extra": "11.3.5", "node-dependency-injection": "3.2.6", "reflect-metadata": "^0.2.2", "routing-controllers": "^0.11.3" }, "devDependencies": { - "@types/node": "^26.0.0", + "@types/node": "^26.0.1", "tsx": "^4.21.0", "typescript": "^5.9.3" } diff --git a/example/yarn.lock b/example/yarn.lock index db6d6ae..ab8c18e 100644 --- a/example/yarn.lock +++ b/example/yarn.lock @@ -145,10 +145,10 @@ "@haskou/ddd-kernel@file:..": version "0.1.1" -"@haskou/value-objects@^2.11.0": - version "2.11.0" - resolved "https://registry.yarnpkg.com/@haskou/value-objects/-/value-objects-2.11.0.tgz#d31c364fad1ddea080121ea26e14918be62ae807" - integrity sha512-ohqBtsLTGeD42q9uU3MHyZn/PyK8sPnEXS3jV14rx8JxXjjsg46KZQT4kS73BJIsd9Dg3Wh3x2UoY23/zo5ZZA== +"@haskou/value-objects@^2.12.0": + version "2.12.0" + resolved "https://registry.yarnpkg.com/@haskou/value-objects/-/value-objects-2.12.0.tgz#49607c412f528ef589646ecfaa1089e0e9a0a9dd" + integrity sha512-+F+DBGpSlP/KFsFDDQ7qJjCpouHkWEAndLoz+zD1syI0rSyD5A279p/9aGMIInO4Tc1RR2jxZ/7b3kd+KAA7Ug== dependencies: "@noble/ciphers" "^2.2.0" "@noble/curves" "^2.2.0" @@ -214,13 +214,18 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@types/node@^26.0.0": - version "26.0.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-26.0.0.tgz#d4aece9e9412e9f2008d59bc2d74f5279316b665" - integrity sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA== +"@types/node@^26.0.1": + version "26.0.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.0.1.tgz#4a60e2c7a6d68bd261e265f8983bfe1601263ce3" + integrity sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw== dependencies: undici-types "~8.3.0" +"@types/validator@^13.15.3": + version "13.15.10" + resolved "https://registry.yarnpkg.com/@types/validator/-/validator-13.15.10.tgz#742b77ec34d58554b94a76a14cef30d59e3c16b9" + integrity sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA== + "@typescript-eslint/types@5.62.0": version "5.62.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f" @@ -401,6 +406,20 @@ chalk@^4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +class-transformer@^0.5.1: + version "0.5.1" + resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.5.1.tgz#24147d5dffd2a6cea930a3250a677addf96ab336" + integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw== + +class-validator@^0.14.4: + version "0.14.4" + resolved "https://registry.yarnpkg.com/class-validator/-/class-validator-0.14.4.tgz#d0084ae847a96264ad5158451a4e66c1c544de96" + integrity sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw== + dependencies: + "@types/validator" "^13.15.3" + libphonenumber-js "^1.11.1" + validator "^13.15.22" + clone@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" @@ -821,10 +840,10 @@ fresh@~0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== -fs-extra@11.3.4: - version "11.3.4" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.4.tgz#ab6934eca8bcf6f7f6b82742e33591f86301d6fc" - integrity sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA== +fs-extra@11.3.5: + version "11.3.5" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.5.tgz#07a44eff40bea53e719909a532f91a23bf0769ff" + integrity sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg== dependencies: graceful-fs "^4.2.0" jsonfile "^6.0.1" @@ -1091,6 +1110,11 @@ koa@^3.0.1: type-is "^2.0.1" vary "^1.1.2" +libphonenumber-js@^1.11.1: + version "1.13.7" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.13.7.tgz#ca52f8f5e3e70a66cde0655e7d4dbe0c44cadfd2" + integrity sha512-rvr3HIMdOgzhz1RFGjftji+wjoAFlzhqCNqJOU/MKTZQ8d9NZxAR/tI+0weDicyoucqVR0U1GCniqHJ0f8aM2A== + lru-cache@^11.0.0: version "11.5.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.5.1.tgz#f3daa3540847b9737ebc02499ddb36765e54db4a" @@ -1683,6 +1707,11 @@ validate-npm-package-name@^3.0.0: dependencies: builtins "^1.0.3" +validator@^13.15.22: + version "13.15.35" + resolved "https://registry.yarnpkg.com/validator/-/validator-13.15.35.tgz#81cf455c51f15b69d8d340be5914f3fab00dbf7f" + integrity sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw== + vary@^1.1.2, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" diff --git a/package.json b/package.json index 0338211..609154e 100644 --- a/package.json +++ b/package.json @@ -255,23 +255,20 @@ ], "license": "MIT", "devDependencies": { - "@haskou/eslint-config": "^0.1.1", + "@haskou/eslint-config": "^0.1.2", "@types/amqplib": "^0.10.8", - "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/fs-extra": "^11.0.4", - "@types/node": "^26.0.0", + "@types/node": "^26.0.1", "@types/ws": "^8.18.1", - "@typescript-eslint/eslint-plugin": "^7.0.1", - "@typescript-eslint/parser": "^7.0.1", "amqplib": "^2.0.1", "c8": "^10.1.3", "class-transformer": "^0.5.1", - "class-validator": "^0.14.3", + "class-validator": "^0.14.4", "cors": "^2.8.6", "eslint": "^10.5.0", "express": "5.2.1", - "fs-extra": "11.3.4", + "fs-extra": "^11.3.5", "mongodb": "^7.3.0", "node-cron": "^4.5.0", "node-dependency-injection": "3.2.6", @@ -281,14 +278,11 @@ "tsup": "^8.5.0", "typescript": "^6.0.3", "vitepress": "^1.6.4", - "vue": "^3.5.38", "winston": "3.19.0", "ws": "^8.21.0" }, "peerDependencies": { "amqplib": "^2.0.1", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.3", "cors": "^2.8.6", "express": "5.2.1", "fs-extra": "11.3.4", diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..e2dca33 --- /dev/null +++ b/renovate.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", ":dependencyDashboard", ":semanticCommits"], + "labels": ["dependencies"], + "rangeStrategy": "bump", + "semanticCommitType": "chore", + "semanticCommitScope": "deps", + "commitMessageAction": "⬆️ update", + "packageRules": [ + { + "groupName": "non-major dependencies", + "matchUpdateTypes": ["minor", "patch"] + } + ] +} diff --git a/src/contracts/ui/HttpRouteStatusEnum.ts b/src/contracts/ui/HttpRouteStatusEnum.ts index 04b924f..6121d95 100644 --- a/src/contracts/ui/HttpRouteStatusEnum.ts +++ b/src/contracts/ui/HttpRouteStatusEnum.ts @@ -1,16 +1,18 @@ -export enum HttpRouteStatusEnum { - BAD_REQUEST = 400, - CONFLICT = 409, - CREATED = 201, - DEPRECATED = 299, - FORBIDDEN = 403, - INTERNAL_SERVER_ERROR = 500, - NO_CONTENT = 204, - NOT_FOUND = 404, - OK = 200, - PAYLOAD_TOO_LARGE = 413, - SERVICE_UNAVAILABLE = 503, - TOO_MANY_REQUESTS = 429, - UNAUTHORIZED = 401, - UNPROCESSABLE_ENTITY = 422, -} +export const HttpRouteStatusEnum = { + BAD_REQUEST: 400, + CONFLICT: 409, + CREATED: 201, + DEPRECATED: 299, + FORBIDDEN: 403, + INTERNAL_SERVER_ERROR: 500, + NO_CONTENT: 204, + NOT_FOUND: 404, + OK: 200, + PAYLOAD_TOO_LARGE: 413, + SERVICE_UNAVAILABLE: 503, + TOO_MANY_REQUESTS: 429, + UNAUTHORIZED: 401, + UNPROCESSABLE_ENTITY: 422, +} as const; + +export default HttpRouteStatusEnum; diff --git a/tests/errors/DependencyNotFoundError.test.mjs b/tests/errors/DependencyNotFoundError.test.mjs index 3fffaac..690e011 100644 --- a/tests/errors/DependencyNotFoundError.test.mjs +++ b/tests/errors/DependencyNotFoundError.test.mjs @@ -7,5 +7,8 @@ test('describes the missing dependency token', () => { const error = new DependencyNotFoundError('UserRepository'); assert.equal(error.name, 'DependencyNotFoundError'); - assert.equal(error.message, 'Dependency not found for token "UserRepository".'); + assert.equal( + error.message, + 'Dependency not found for token "UserRepository".', + ); }); diff --git a/tests/infrastructure/websocket/WebSocketEventHub.test.mjs b/tests/infrastructure/websocket/WebSocketEventHub.test.mjs index 2684b3b..55edd3c 100644 --- a/tests/infrastructure/websocket/WebSocketEventHub.test.mjs +++ b/tests/infrastructure/websocket/WebSocketEventHub.test.mjs @@ -19,7 +19,11 @@ class FakeWebSocket extends EventEmitter { } test('registers clients, acknowledges connections and broadcasts to recipients', () => { - const event = new TestDomainEvent('aggregate-id', { name: 'Ada' }, 'event-id'); + const event = new TestDomainEvent( + 'aggregate-id', + { name: 'Ada' }, + 'event-id', + ); const client = new FakeWebSocket(); const hub = new WebSocketEventHub({ resolve: (receivedEvent) => { @@ -53,8 +57,14 @@ test('broadcasts custom messages to every connected open client', () => { hub.register('closed', closedClient); hub.broadcastToAll({ payload: true, type: 'custom' }); - assert.deepEqual(firstClient.messages.at(-1), { payload: true, type: 'custom' }); - assert.deepEqual(secondClient.messages.at(-1), { payload: true, type: 'custom' }); + assert.deepEqual(firstClient.messages.at(-1), { + payload: true, + type: 'custom', + }); + assert.deepEqual(secondClient.messages.at(-1), { + payload: true, + type: 'custom', + }); assert.equal(closedClient.messages.length, 0); }); diff --git a/tsconfig.coverage.json b/tsconfig.coverage.json index 29e4ca1..bcee7d5 100644 --- a/tsconfig.coverage.json +++ b/tsconfig.coverage.json @@ -7,7 +7,5 @@ "sourceMap": true, "inlineSources": true }, - "include": [ - "src/**/*.ts" - ] -} \ No newline at end of file + "include": ["src/**/*.ts"] +} diff --git a/yarn.lock b/yarn.lock index 2e7011a..d61fd14 100644 --- a/yarn.lock +++ b/yarn.lock @@ -472,14 +472,14 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== -"@eslint-community/eslint-utils@^4.4.0", "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": version "4.9.1" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.2", "@eslint-community/regexpp@^4.8.0": +"@eslint-community/regexpp@^4.12.2", "@eslint-community/regexpp@^4.8.0": version "4.12.2" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== @@ -530,10 +530,10 @@ resolved "https://registry.yarnpkg.com/@hapi/bourne/-/bourne-3.0.0.tgz#f11fdf7dda62fe8e336fa7c6642d9041f30356d7" integrity sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w== -"@haskou/eslint-config@^0.1.1": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@haskou/eslint-config/-/eslint-config-0.1.1.tgz#81233418a57d11f8fb723693615591a320cb266f" - integrity sha512-NZfhdAAenWpW+I9mzLl9F/lbNcyZsBpFigUhueDblFKOFS8LOaO5ViGhgQNhgThK7rNjetMEQBJ20X9MUQqPyA== +"@haskou/eslint-config@^0.1.2": + version "0.1.2" + resolved "https://registry.yarnpkg.com/@haskou/eslint-config/-/eslint-config-0.1.2.tgz#eff66c5d4e5ec9d2842af91f1940ffa9a9ddb170" + integrity sha512-FgiSiQGBwqMBxrU0uedkZg+4LJr+dUPv0H8zm/WExhlV01ZGt+HKp++h7abwFEKo6wmpd/lYVDHp7Wm8Y0rmQg== dependencies: "@eslint/js" "^10.0.1" "@typescript-eslint/eslint-plugin" "8.61.0" @@ -908,13 +908,6 @@ dependencies: "@types/node" "*" -"@types/cors@^2.8.19": - version "2.8.19" - resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.19.tgz#d93ea2673fd8c9f697367f5eeefc2bbfa94f0342" - integrity sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg== - dependencies: - "@types/node" "*" - "@types/esrecurse@^4.3.1": version "4.3.1" resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" @@ -1006,13 +999,20 @@ resolved "https://registry.yarnpkg.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== -"@types/node@*", "@types/node@^26.0.0": +"@types/node@*": version "26.0.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-26.0.0.tgz#d4aece9e9412e9f2008d59bc2d74f5279316b665" integrity sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA== dependencies: undici-types "~8.3.0" +"@types/node@^26.0.1": + version "26.0.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.0.1.tgz#4a60e2c7a6d68bd261e265f8983bfe1601263ce3" + integrity sha512-fc3KiUoBt6kie0N9bIW3E47vZsuaMf0PM2AaUpLCLT0s/LvX1nxAim6Fc049cNxODPpGm6qRAuUOB86SkRuPQw== + dependencies: + undici-types "~8.3.0" + "@types/qs@*": version "6.15.1" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.15.1.tgz#8606884272c63f0db96986bd3548650d8a9388bf" @@ -1091,21 +1091,6 @@ natural-compare "^1.4.0" ts-api-utils "^2.5.0" -"@typescript-eslint/eslint-plugin@^7.0.1": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz#b16d3cf3ee76bf572fdf511e79c248bdec619ea3" - integrity sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw== - dependencies: - "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "7.18.0" - "@typescript-eslint/type-utils" "7.18.0" - "@typescript-eslint/utils" "7.18.0" - "@typescript-eslint/visitor-keys" "7.18.0" - graphemer "^1.4.0" - ignore "^5.3.1" - natural-compare "^1.4.0" - ts-api-utils "^1.3.0" - "@typescript-eslint/parser@8.61.0": version "8.61.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.61.0.tgz#1afe73c9ccce16b7a26d6b95f9400b0ccc34af87" @@ -1117,17 +1102,6 @@ "@typescript-eslint/visitor-keys" "8.61.0" debug "^4.4.3" -"@typescript-eslint/parser@^7.0.1": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-7.18.0.tgz#83928d0f1b7f4afa974098c64b5ce6f9051f96a0" - integrity sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg== - dependencies: - "@typescript-eslint/scope-manager" "7.18.0" - "@typescript-eslint/types" "7.18.0" - "@typescript-eslint/typescript-estree" "7.18.0" - "@typescript-eslint/visitor-keys" "7.18.0" - debug "^4.3.4" - "@typescript-eslint/project-service@8.61.0": version "8.61.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.61.0.tgz#417a2feac32e8ebd336d63f068c3b42b736ea1ac" @@ -1146,14 +1120,6 @@ "@typescript-eslint/types" "^8.62.0" debug "^4.4.3" -"@typescript-eslint/scope-manager@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.18.0.tgz#c928e7a9fc2c0b3ed92ab3112c614d6bd9951c83" - integrity sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA== - dependencies: - "@typescript-eslint/types" "7.18.0" - "@typescript-eslint/visitor-keys" "7.18.0" - "@typescript-eslint/scope-manager@8.61.0": version "8.61.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.61.0.tgz#93c2520d05653fe65eb9ee98efc74fd0134a7852" @@ -1180,16 +1146,6 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz#9440a673581c6d9de308c4d5803dd52ed5d71729" integrity sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g== -"@typescript-eslint/type-utils@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.18.0.tgz#2165ffaee00b1fbbdd2d40aa85232dab6998f53b" - integrity sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA== - dependencies: - "@typescript-eslint/typescript-estree" "7.18.0" - "@typescript-eslint/utils" "7.18.0" - debug "^4.3.4" - ts-api-utils "^1.3.0" - "@typescript-eslint/type-utils@8.61.0": version "8.61.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.61.0.tgz#50219b57e6b89cecfb1a15f093b15ec9ee019974" @@ -1206,11 +1162,6 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.62.0.tgz#258607e60effa309f067608931c3df6fed41fd2f" integrity sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ== -"@typescript-eslint/types@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.18.0.tgz#b90a57ccdea71797ffffa0321e744f379ec838c9" - integrity sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ== - "@typescript-eslint/types@8.61.0": version "8.61.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.0.tgz#0ddb46e012a4288292950bdd253db42f278ce64d" @@ -1221,20 +1172,6 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.62.0.tgz#601427c10203d9f0f34f0b3e474df735eb12b593" integrity sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg== -"@typescript-eslint/typescript-estree@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.18.0.tgz#b5868d486c51ce8f312309ba79bdb9f331b37931" - integrity sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA== - dependencies: - "@typescript-eslint/types" "7.18.0" - "@typescript-eslint/visitor-keys" "7.18.0" - debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - minimatch "^9.0.4" - semver "^7.6.0" - ts-api-utils "^1.3.0" - "@typescript-eslint/typescript-estree@8.61.0": version "8.61.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.0.tgz#98ca47260bbf627fc28f018b3a0abf00e3090690" @@ -1278,16 +1215,6 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.18.0.tgz#bca01cde77f95fc6a8d5b0dbcbfb3d6ca4be451f" - integrity sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw== - dependencies: - "@eslint-community/eslint-utils" "^4.4.0" - "@typescript-eslint/scope-manager" "7.18.0" - "@typescript-eslint/types" "7.18.0" - "@typescript-eslint/typescript-estree" "7.18.0" - "@typescript-eslint/utils@8.61.0": version "8.61.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.61.0.tgz#ed3546a052787e84ea6c5064d0919fc5eea8522f" @@ -1316,14 +1243,6 @@ "@typescript-eslint/types" "5.62.0" eslint-visitor-keys "^3.3.0" -"@typescript-eslint/visitor-keys@7.18.0": - version "7.18.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.18.0.tgz#0564629b6124d67607378d0f0332a0495b25e7d7" - integrity sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg== - dependencies: - "@typescript-eslint/types" "7.18.0" - eslint-visitor-keys "^3.4.3" - "@typescript-eslint/visitor-keys@8.61.0": version "8.61.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.0.tgz#39b4e1ab8936d23bea973d39fd092f9aa21f275e" @@ -1782,7 +1701,7 @@ class-transformer@^0.5.1: resolved "https://registry.yarnpkg.com/class-transformer/-/class-transformer-0.5.1.tgz#24147d5dffd2a6cea930a3250a677addf96ab336" integrity sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw== -class-validator@^0.14.3: +class-validator@^0.14.4: version "0.14.4" resolved "https://registry.yarnpkg.com/class-validator/-/class-validator-0.14.4.tgz#d0084ae847a96264ad5158451a4e66c1c544de96" integrity sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw== @@ -2591,10 +2510,10 @@ fresh@~0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== -fs-extra@11.3.4: - version "11.3.4" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.4.tgz#ab6934eca8bcf6f7f6b82742e33591f86301d6fc" - integrity sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA== +fs-extra@^11.3.5: + version "11.3.5" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.3.5.tgz#07a44eff40bea53e719909a532f91a23bf0769ff" + integrity sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg== dependencies: graceful-fs "^4.2.0" jsonfile "^6.0.1" @@ -2714,11 +2633,6 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0: resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== -graphemer@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" - integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== - has-flag@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" @@ -2819,7 +2733,7 @@ iconv-lite@~0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -ignore@^5.2.0, ignore@^5.3.1: +ignore@^5.2.0: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== @@ -3798,7 +3712,7 @@ scslre@^0.3.0: refa "^0.12.0" regexp-ast-analysis "^0.7.0" -semver@^7.3.7, semver@^7.5.3, semver@^7.6.0, semver@^7.7.3, semver@^7.8.4: +semver@^7.3.7, semver@^7.5.3, semver@^7.7.3, semver@^7.8.4: version "7.8.5" resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== @@ -4169,11 +4083,6 @@ triple-beam@^1.3.0: resolved "https://registry.yarnpkg.com/triple-beam/-/triple-beam-1.4.1.tgz#6fde70271dc6e5d73ca0c3b24e2d92afb7441984" integrity sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg== -ts-api-utils@^1.3.0: - version "1.4.3" - resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.3.tgz#bfc2215fe6528fecab2b0fba570a2e8a4263b064" - integrity sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw== - ts-api-utils@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.5.0.tgz#4acd4a155e22734990a5ed1fe9e97f113bcb37c1" @@ -4417,7 +4326,7 @@ vitepress@^1.6.4: vite "^5.4.14" vue "^3.5.13" -vue@^3.5.13, vue@^3.5.38: +vue@^3.5.13: version "3.5.38" resolved "https://registry.yarnpkg.com/vue/-/vue-3.5.38.tgz#8a6d52f1768e197545e937920d6c197fe76fc2db" integrity sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A== From 55ab225e45142c2b6e5353af407a6fbd534ba76a Mon Sep 17 00:00:00 2001 From: Hasko Date: Thu, 25 Jun 2026 21:59:28 +0200 Subject: [PATCH 19/19] =?UTF-8?q?fix(review):=20=F0=9F=90=9B=20Address=20P?= =?UTF-8?q?R=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pubsub/amqp/AmqpMessageBusAdapter.ts | 30 ++++++++++++------- src/adapters/ui/express/HttpErrorHandler.ts | 12 +++----- .../amqp/AmqpMessageBusAdapter.test.mjs | 20 +++++++++++-- .../ui/express/ExpressKernelServer.test.mjs | 28 +++++++++++++++++ 4 files changed, 69 insertions(+), 21 deletions(-) diff --git a/src/adapters/pubsub/amqp/AmqpMessageBusAdapter.ts b/src/adapters/pubsub/amqp/AmqpMessageBusAdapter.ts index 389c4ab..7ab5906 100644 --- a/src/adapters/pubsub/amqp/AmqpMessageBusAdapter.ts +++ b/src/adapters/pubsub/amqp/AmqpMessageBusAdapter.ts @@ -13,12 +13,14 @@ import type { Constructor, DomainEvent, DomainEventConsumer, + DomainEventConsumerContext, DomainMessageBus, DomainEventPublisher, } from '../../../domain/index.js'; import type { AmqpMessage } from './AmqpMessage.js'; import type { AmqpMessageBusAdapterOptions } from './AmqpMessageBusAdapterOptions.js'; import type { ConsumerContext } from './ConsumerContext.js'; +import type { DomainEventHandler } from './DomainEventHandler.js'; import { Kernel } from '../../../Kernel.js'; import { PublisherHookPipeline } from '../PublisherHookPipeline.js'; @@ -89,6 +91,18 @@ export default class AmqpMessageBusAdapter ); } + private getConsumerContext( + msg: ConsumeMessage | GetMessage, + ): DomainEventConsumerContext { + return { + metadata: { + headers: msg.properties.headers ?? {}, + rawMessage: msg, + retries: Number(msg.properties.headers?.retries ?? 0), + }, + }; + } + private async handle( msg: ConsumeMessage, context: ConsumerContext, @@ -105,13 +119,7 @@ export default class AmqpMessageBusAdapter message, ); - await context.handler(domainEvent, { - metadata: { - headers: msg.properties.headers ?? {}, - rawMessage: msg, - retries: Number(msg.properties.headers?.retries ?? 0), - }, - }); + await context.handler(domainEvent, this.getConsumerContext(msg)); } catch (error) { await this.handleError(msg, message, context, error); } @@ -344,7 +352,7 @@ export default class AmqpMessageBusAdapter private async retryDlxMessage( msg: ConsumeMessage | GetMessage, DomainEventInstance: Constructor, - handler: (event: DomainEvent) => Promise, + handler: DomainEventHandler, channel: Channel, ): Promise { const content = msg.content.toString(); @@ -358,7 +366,7 @@ export default class AmqpMessageBusAdapter message, ); - await handler(domainEvent); + await handler(domainEvent, this.getConsumerContext(msg)); this.logger?.info(`${content} successfully handled.`); channel.ack(msg); } catch (error: Error | unknown) { @@ -391,7 +399,7 @@ export default class AmqpMessageBusAdapter public async consumeDlx( queueName: string, DomainEventInstance: Constructor, - handler: (event: DomainEvent) => Promise, + handler: DomainEventHandler, messagesToRetry?: number, ): Promise { const dlxQueueName = `${queueName}_dlx`; @@ -433,7 +441,7 @@ export default class AmqpMessageBusAdapter bindingKey: string, DomainEventInstance: Constructor, exchange: string, - handler: (event: DomainEvent) => Promise, + handler: DomainEventHandler, ): Promise { const channel = await this.channel(); diff --git a/src/adapters/ui/express/HttpErrorHandler.ts b/src/adapters/ui/express/HttpErrorHandler.ts index dc44cb9..5fc6cd2 100644 --- a/src/adapters/ui/express/HttpErrorHandler.ts +++ b/src/adapters/ui/express/HttpErrorHandler.ts @@ -118,11 +118,7 @@ export class HttpErrorHandler implements ExpressErrorMiddlewareInterface { return true; } - private handleUnhandledError( - error: Error, - response: Response, - next: NextFunction, - ): void { + private handleUnhandledError(error: Error, response: Response): void { if (this.exposeUnhandledErrorsIn.includes(process.env.NODE_ENV ?? '')) { this.logUnhandledError(error); } @@ -133,8 +129,6 @@ export class HttpErrorHandler implements ExpressErrorMiddlewareInterface { String(HttpRouteStatusEnum.INTERNAL_SERVER_ERROR), message: error.message || 'Unknown error', }); - - next(error); } public error( @@ -156,7 +150,9 @@ export class HttpErrorHandler implements ExpressErrorMiddlewareInterface { return; } - this.handleUnhandledError(error, response, next); + void next; + + this.handleUnhandledError(error, response); } public handle = ( diff --git a/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs b/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs index 526adb4..fd91d7a 100644 --- a/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs +++ b/tests/adapters/pubsub/amqp/AmqpMessageBusAdapter.test.mjs @@ -332,13 +332,17 @@ test('sends exhausted messages to DLX and logs publish failures', async () => { test('consumes DLX messages with success, nack and no-message paths', async () => { const channel = new FakeChannel(); + const handled = []; const { default: AmqpMessageBusAdapter, NoFailedMessagesError } = await import('../../../../dist/adapters/pubsub/amqp/index.js'); const adapter = new AmqpMessageBusAdapter({ dsn: 'amqp://localhost?frameMax=0', exchange: 'domain', }); - const successMessage = createConsumeMessage(createMessage()); + const successMessage = createConsumeMessage(createMessage(), { + retries: 3, + traceId: 'dlx-trace-id', + }); const failingMessage = { content: Buffer.from('{'), properties: { headers: {} }, @@ -348,7 +352,12 @@ test('consumes DLX messages with success, nack and no-message paths', async () = channel.messageCount = 3; channel.getMessages = [false, successMessage, failingMessage]; - await adapter.consumeDlx('queue', TestDomainEvent, async () => {}, 3); + await adapter.consumeDlx( + 'queue', + TestDomainEvent, + async (event, context) => handled.push([event, context]), + 3, + ); channel.eventHandlers.get('error')(); channel.messageCount = 0; @@ -363,6 +372,13 @@ test('consumes DLX messages with success, nack and no-message paths', async () = channel.calls.some(([name]) => name === 'ack'), true, ); + assert.equal(handled[0][0] instanceof TestDomainEvent, true); + assert.deepEqual(handled[0][1].metadata.headers, { + retries: 3, + traceId: 'dlx-trace-id', + }); + assert.equal(handled[0][1].metadata.rawMessage, successMessage); + assert.equal(handled[0][1].metadata.retries, 3); assert.equal( channel.calls.some(([name]) => name === 'nack'), true, diff --git a/tests/adapters/ui/express/ExpressKernelServer.test.mjs b/tests/adapters/ui/express/ExpressKernelServer.test.mjs index c5b3509..40291c6 100644 --- a/tests/adapters/ui/express/ExpressKernelServer.test.mjs +++ b/tests/adapters/ui/express/ExpressKernelServer.test.mjs @@ -514,6 +514,34 @@ test('logs unhandled HTTP errors in configured environments', async () => { } }); +test('does not forward unhandled HTTP errors after sending fallback response', () => { + const calls = []; + const response = { + json: (body) => { + calls.push(['json', body]); + + return response; + }, + status: (statusCode) => { + calls.push(['status', statusCode]); + + return response; + }, + }; + + new HttpErrorHandler().handle( + new Error('unexpected'), + {}, + response, + (error) => calls.push(['next', error]), + ); + + assert.deepEqual(calls, [ + ['status', 500], + ['json', { code: 'Error', message: 'unexpected' }], + ]); +}); + test('runs custom HTTP error handlers before generic HTTP errors', async () => { const server = new ExpressKernelServer({ hooks: [