@fluojs/event-bus 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -19,8 +19,13 @@ fluo를 위한 인프로세스(In-process) 이벤트 발행 및 구독 패키지
19
19
 
20
20
  ```bash
21
21
  npm install @fluojs/event-bus
22
+
23
+ # @fluojs/event-bus/redis 사용 시 optional peer도 함께 설치
24
+ npm install @fluojs/event-bus ioredis
22
25
  ```
23
26
 
27
+ `@fluojs/event-bus`는 패키지 자체의 지원 계약으로 Node.js `>=24.0.0 <27`을 지원합니다.
28
+
24
29
  ## 사용 시점
25
30
 
26
31
  - 직접적인 서비스 호출 대신 이벤트를 통해 컴포넌트 간의 결합도를 낮추고 싶을 때.
@@ -75,7 +80,9 @@ export class UserService {
75
80
  export class AppModule {}
76
81
  ```
77
82
 
78
- `publish(event, options?)`는 `signal`, `timeoutMs`, `waitForHandlers`를 지원합니다. `waitForHandlers`의 기본값은 `true`이며, 기다리는 로컬 핸들러와 기다리는 트랜스포트 publish는 동일한 timeout 및 cancellation bound를 공유합니다. 이러한 bound가 실제 handler 또는 transport 작업이 끝나기 전에 호출자에게 반환되는 publish promise를 settle하더라도, shutdown은 해당 underlying awaited work가 settle되거나 shutdown drain bound가 만료될 때까지 계속 추적합니다. `waitForHandlers`를 `false`로 설정하면 publish가 즉시 반환되고 timeout bound를 적용하지 않지만, handler와 transport 작업은 background에서 계속 실행되며 shutdown drain 추적 대상에 남습니다. Shutdown 중에는 이벤트 버스가 진행 중인 awaited/background publish 및 inbound transport handler 작업을 drain한 뒤 트랜스포트를 닫고, lifecycle이 stopping에 진입한 뒤의 새 publish 호출과 shutdown 시작 뒤 도착한 inbound transport callback은 무시합니다. Shutdown drain은 기본값이 5000ms인 `EventBusModule.forRoot({ shutdown: { drainTimeoutMs } })`로 제한됩니다. 활성 dispatch 작업이 이 bound 이후에도 멈춰 있으면 bus는 degraded status diagnostic을 기록하고 경고를 남긴 뒤, 애플리케이션 close를 무기한 hang시키지 않고 transport cleanup을 계속합니다.
83
+ `EventPublishOptions`는 일치하는 로컬 핸들러 작업과 선택적 트랜스포트 발행을 모두 제한합니다. `publish(event, options?)`는 `signal`, `timeoutMs`, `waitForHandlers`를 지원합니다. `waitForHandlers`의 기본값은 `true`이며, 기다리는 로컬 핸들러와 기다리는 트랜스포트 publish는 동일한 timeout 및 cancellation bound를 공유합니다. 이러한 bound가 실제 handler 또는 transport 작업이 끝나기 전에 호출자에게 반환되는 publish promise를 settle하더라도, shutdown은 해당 underlying awaited work가 settle되거나 shutdown drain bound가 만료될 때까지 계속 추적합니다. `waitForHandlers`를 `false`로 설정하면 publish가 즉시 반환되고 timeout bound를 적용하지 않지만, handler와 transport 작업은 background에서 계속 실행되며 shutdown drain 추적 대상에 남습니다. Shutdown 중에는 이벤트 버스가 진행 중인 awaited/background publish 및 inbound transport handler 작업을 drain한 뒤 트랜스포트를 닫고, lifecycle이 stopping에 진입한 뒤의 새 publish 호출과 shutdown 시작 뒤 도착한 inbound transport callback은 무시합니다. Drain은 하나의 absolute deadline 아래에서 settle된 snapshot마다 live work set을 다시 확인해 quiescence에 도달하므로, 이미 active인 publish가 늦게 등록한 handler 또는 transport 작업을 transport close 전에 건너뛰지 않습니다. Shutdown drain은 기본값이 5000ms인 `EventBusModule.forRoot({ shutdown: { drainTimeoutMs } })`로 제한됩니다. 활성 dispatch 작업이 이 bound 이후에도 멈춰 있으면 bus는 degraded status diagnostic을 기록하고 경고를 남긴 뒤, 애플리케이션 close를 무기한 hang시키지 않고 transport cleanup을 계속합니다.
84
+
85
+ Handler failure isolation은 publish completion보다 좁은 계약입니다. 일치하는 local listener 실패는 log되고 격리되며, 다른 matching listener는 계속 실행됩니다. Local listener 실패만으로 `publish(...)`를 reject하지 않습니다. Inbound transport listener에는 같은 isolation 규칙이 적용되므로 inbound callback completion은 격리된 listener 실패를 외부로 드러내지 않습니다. Publisher completion은 모든 listener가 성공했음을 증명하지 않습니다. Timeout, cancellation, transport publication, bootstrap 및 그 밖의 publisher 실패는 이 listener-failure 계약의 범위 밖에 있습니다. 해당 실패는 각각 별도로 문서화된 동작을 유지합니다.
79
86
 
80
87
  **마이그레이션 참고:** `waitForHandlers: false`를 사용하는 애플리케이션은 이제 background handler 및 transport 작업을 위해 `app.close()`가 최대 `shutdown.drainTimeoutMs`까지 기다린 뒤 transport cleanup을 계속할 수 있음을 shutdown budget에 반영해야 합니다. 해당 작업을 bounded하게 유지하거나 애플리케이션에 적절한 drain budget을 구성하세요.
81
88
 
@@ -83,19 +90,27 @@ export class AppModule {}
83
90
 
84
91
  ### 분산 팬아웃 (Redis)
85
92
 
86
- 트랜스포트 어댑터를 연결하여 이벤트 버스를 다른 프로세스로 확장할 수 있습니다.
93
+ 트랜스포트 어댑터를 연결하여 이벤트 버스를 다른 프로세스로 확장할 수 있습니다. Redis 서브패스는 optional `ioredis` peer를 사용하므로 transport를 생성하는 애플리케이션에 이를 설치해야 합니다.
87
94
 
88
95
  ```typescript
96
+ import { EventBusModule } from '@fluojs/event-bus';
89
97
  import { RedisEventBusTransport } from '@fluojs/event-bus/redis';
98
+ import Redis from 'ioredis';
99
+
100
+ const redisOptions = { host: 'localhost', port: 6379 };
101
+ const publishClient = new Redis(redisOptions);
102
+ const subscribeClient = new Redis(redisOptions);
90
103
 
91
104
  EventBusModule.forRoot({
92
- transport: new RedisEventBusTransport({
93
- publishClient: redis,
94
- subscribeClient: redisSubscriber
105
+ transport: new RedisEventBusTransport({
106
+ publishClient,
107
+ subscribeClient,
95
108
  }),
96
- })
109
+ });
97
110
  ```
98
111
 
112
+ Transport 전용 `publishClient`와 `subscribeClient`를 서로 다른 instance로 생성하세요. Redis는 구독 연결을 Pub/Sub mode로 전환하므로 subscriber를 publish나 일반 command에도 사용하면 안 됩니다. 두 client는 모두 caller-owned입니다. `RedisEventBusTransport.close()`는 transport의 subscription과 listener를 제거하지만 어느 client도 disconnect하지 않습니다. Event bus teardown이 끝난 뒤 각 lifecycle owner가 해당 client를 닫아야 합니다.
113
+
99
114
  Redis Pub/Sub은 durable work queue가 아니라 fan-out transport입니다. 여러 애플리케이션 인스턴스가 같은 이벤트 채널을 구독하면 각 인스턴스가 같은 published fact를 볼 수 있습니다. 따라서 상태를 변경하거나 알림을 보내거나 외부 시스템을 호출하는 handler는 idempotent해야 합니다. Payload에 안정적인 event identifier 또는 business key를 담고, 이미 적용한 reaction을 기록하며, 반복 전달이 side effect를 두 번 실행하는 대신 같은 결과로 수렴하도록 만드세요.
100
115
 
101
116
  `@OnEvent(...)` handler는 작고 bounded하게 유지하세요. 빠른 local projection, cache invalidation, 가벼운 notification처럼 publish timeout과 shutdown drain window 안에 끝낼 수 있는 reaction에 적합합니다. Reaction이 느리거나, failure-prone이거나, retry 가능하거나, operator-visible dead-letter handling이 필요하다면 해당 작업을 inline으로 수행하지 말고 event handler에서 `@fluojs/queue`의 durable job으로 hand off하세요. Handoff에는 애플리케이션이 소유한 unique claim을 사용하고, `queue.enqueue(...)`가 성공한 뒤에만 handoff를 enqueued로 표시하세요. Enqueue가 실패하면 pending claim을 해제해 이후 duplicate event가 안전하게 다시 시도할 수 있게 합니다.
@@ -162,7 +177,9 @@ class UserRegisteredEvent {
162
177
  - `EventBus`, `EventPublishOptions`, `EventBusModuleOptions`, `EventType`: 발행, 기본값, 트랜스포트, 안정적인 이벤트 키를 위한 타입 전용 계약입니다.
163
178
  - `EventBusLifecycleState`, `EventBusStatusAdapterInput`, `EventBusPlatformStatusSnapshot`: status snapshot 계약입니다.
164
179
 
165
- Transport bootstrap은 unique event channel마다 한 번만 subscribe합니다. `eventKey`가 있으면 transport channel 이름을 제어합니다. Bootstrap 중 이후 transport subscription이 실패하면 이벤트 버스는 이미 열린 channel을 rollback하기 위해 subscription error를 다시 던지기 전에 transport를 닫습니다. 잘못된 JSON transport message는 무시되며, shutdown 시작 뒤 도착한 inbound transport message는 local handler dispatch 전에 무시됩니다.
180
+ Transport bootstrap은 unique event channel마다 한 번만 subscribe합니다. `eventKey`가 있으면 transport channel 이름을 제어합니다. Bootstrap 중 이후 transport subscription이 실패하면 이벤트 버스는 이미 열린 channel을 rollback하기 위해 subscription error를 다시 던지기 전에 transport를 닫습니다. Shutdown 시작 뒤 도착한 inbound transport message는 local handler dispatch 전에 무시됩니다.
181
+
182
+ Handler discovery는 normalized effective singleton provider registration과 controller를 사용하므로, duplicate provider token의 DI winner만 발견되고 factory-provider scope도 canonical DI normalization을 따릅니다. `@OnEvent(...)`는 public instance 메서드에만 적용할 수 있습니다. Handler와 transport 실패는 기록되고 log되지만 `publish()`는 attempt가 settle되면 resolve하며, `waitForHandlers: false`에서는 shutdown-tracked background work를 scheduling한 뒤 resolve합니다.
166
183
 
167
184
  ## 런타임별 및 통합 서브패스
168
185
 
@@ -170,7 +187,7 @@ Transport bootstrap은 unique event channel마다 한 번만 subscribe합니다.
170
187
  | --- | --- | --- |
171
188
  | Redis Pub/Sub 트랜스포트 | `@fluojs/event-bus/redis` | `RedisEventBusTransport`, `RedisEventBusTransportOptions` |
172
189
 
173
- `RedisEventBusTransport`는 명시적인 `@fluojs/event-bus/redis` 서브패스에만 유지되어 루트 `@fluojs/event-bus` 진입점이 모듈 등록, 로컬 발행, 데코레이터, 타입 전용 계약에 집중하도록 합니다. 이 트랜스포트는 shutdown 중 자신이 등록한 채널을 unsubscribe하고 message listener를 분리하지만, 호출자가 소유한 Redis 클라이언트를 disconnect하지 않습니다.
190
+ `RedisEventBusTransport`는 명시적인 `@fluojs/event-bus/redis` 서브패스에만 유지되어 루트 `@fluojs/event-bus` 진입점이 모듈 등록, 로컬 발행, 데코레이터, 타입 전용 계약에 집중하도록 합니다. 이 서브패스를 사용하는 애플리케이션은 optional `ioredis` peer를 설치하고 transport 전용 `publishClient`와 `subscribeClient`를 서로 다른 instance로 제공해야 합니다. 이 Redis adapter는 inbound Redis message를 JSON decode하고 잘못된 JSON은 handler dispatch 전에 버립니다. 이 parsing 규칙은 임의의 `EventBusTransport` 구현에는 적용되지 않습니다. Shutdown adapter는 자신이 등록한 채널을 unsubscribe하고 message listener를 분리하지만, `close()`는 caller-owned client를 disconnect하지 않습니다. Unsubscribe가 실패하면 `close()`는 listener를 계속 분리하면서 등록된 채널을 유지하므로 이후 `close()`가 동일한 cleanup을 다시 시도합니다. 애플리케이션 또는 client-owning module이 event-bus teardown 후 해당 client를 별도로 닫아야 합니다.
174
191
 
175
192
  ## 관련 패키지
176
193
 
@@ -182,4 +199,5 @@ Transport bootstrap은 unique event channel마다 한 번만 subscribe합니다.
182
199
  - `packages/event-bus/src/module.test.ts`: 핸들러 탐색 및 발행/구독 테스트 예제.
183
200
  - `packages/event-bus/src/public-surface.test.ts`: 공개 API 계약 검증 예제.
184
201
  - `packages/event-bus/src/status.test.ts`: status snapshot semantic 테스트 예제.
202
+ - `packages/event-bus/src/shutdown-late-work.test.ts`: 늦은 handler 및 transport 등록 shutdown race 테스트 예제.
185
203
  - `packages/event-bus/src/transports/redis-transport.test.ts`: Redis transport 동작 테스트 예제.
package/README.md CHANGED
@@ -19,8 +19,13 @@ In-process event publishing and subscription for fluo. It features decorator-bas
19
19
 
20
20
  ```bash
21
21
  npm install @fluojs/event-bus
22
+
23
+ # Include the optional peer when using @fluojs/event-bus/redis
24
+ npm install @fluojs/event-bus ioredis
22
25
  ```
23
26
 
27
+ `@fluojs/event-bus` supports Node.js `>=24.0.0 <27` as its package-owned support contract.
28
+
24
29
  ## When to Use
25
30
 
26
31
  - When you need to decouple components by communicating via events instead of direct service calls.
@@ -31,7 +36,7 @@ npm install @fluojs/event-bus
31
36
 
32
37
  ### 1. Define an Event and Handler
33
38
 
34
- Create an event class and a handler method decorated with `@OnEvent`.
39
+ Create an event class and a public instance handler method decorated with `@OnEvent`. Private and static methods are not supported.
35
40
 
36
41
  ```typescript
37
42
  import { OnEvent } from '@fluojs/event-bus';
@@ -75,7 +80,9 @@ export class UserService {
75
80
  export class AppModule {}
76
81
  ```
77
82
 
78
- `publish(event, options?)` supports `signal`, `timeoutMs`, and `waitForHandlers`. `waitForHandlers` defaults to `true`; awaited local handlers and awaited transport publishes share the same timeout and cancellation bounds. When those bounds settle the caller-facing publish promise before the underlying handler or transport work finishes, shutdown still tracks that underlying awaited work until it settles or the shutdown drain bound expires. When `waitForHandlers` is set to `false`, publishing returns immediately and skips timeout bounds, while the handler and transport work continue in the background and remain part of shutdown drain tracking. During shutdown, the event bus drains in-flight awaited and background publish work plus inbound transport handler work before closing the transport, ignores new publish calls after the lifecycle has started stopping, and ignores inbound transport callbacks that arrive after shutdown begins. Shutdown drain is bounded by `EventBusModule.forRoot({ shutdown: { drainTimeoutMs } })`, which defaults to 5000ms; if active dispatch work is still stuck after the bound, the bus records a degraded status diagnostic, logs a warning, and continues transport cleanup instead of hanging application close indefinitely.
83
+ `EventPublishOptions` bounds both matching local handler work and optional transport publication. `publish(event, options?)` supports `signal`, `timeoutMs`, and `waitForHandlers`. `waitForHandlers` defaults to `true`; awaited local handlers and awaited transport publishes share the same timeout and cancellation bounds. When those bounds settle the caller-facing publish promise before the underlying handler or transport work finishes, shutdown still tracks that underlying awaited work until it settles or the shutdown drain bound expires. When `waitForHandlers` is set to `false`, publishing returns immediately and skips timeout bounds, while the handler and transport work continue in the background and remain part of shutdown drain tracking. During shutdown, the event bus drains in-flight awaited and background publish work plus inbound transport handler work before closing the transport, ignores new publish calls after the lifecycle has started stopping, and ignores inbound transport callbacks that arrive after shutdown begins. The drain reaches quiescence by rechecking the live work set after each settled snapshot under one absolute deadline, so handler or transport work registered by an already-active publish cannot be skipped before transport close. Shutdown drain is bounded by `EventBusModule.forRoot({ shutdown: { drainTimeoutMs } })`, which defaults to 5000ms; if active dispatch work is still stuck after the bound, the bus records a degraded status diagnostic, logs a warning, and continues transport cleanup instead of hanging application close indefinitely.
84
+
85
+ Handler failure isolation is narrower than publish completion. Matching local listener failures are logged and isolated, while other matching listeners continue. A local listener failure alone does not reject `publish(...)`. Inbound transport listeners follow the same isolation rule, so inbound callback completion does not surface isolated listener failures. Publisher completion does not prove that every listener succeeded. Timeout, cancellation, transport publication, bootstrap, and other publisher failures are outside this listener-failure contract. Those failures retain their own separately documented behavior.
79
86
 
80
87
  **Migration note:** applications that use `waitForHandlers: false` should now budget for `app.close()` to wait up to `shutdown.drainTimeoutMs` for background handler and transport work before transport cleanup continues. Keep that work bounded or configure a drain budget appropriate for the application.
81
88
 
@@ -83,19 +90,27 @@ export class AppModule {}
83
90
 
84
91
  ### Distributed Fan-out (Redis)
85
92
 
86
- Extend the event bus to other processes by plugging in a transport adapter.
93
+ Extend the event bus to other processes by plugging in a transport adapter. The Redis subpath uses the optional `ioredis` peer, so install it in the application that creates the transport.
87
94
 
88
95
  ```typescript
96
+ import { EventBusModule } from '@fluojs/event-bus';
89
97
  import { RedisEventBusTransport } from '@fluojs/event-bus/redis';
98
+ import Redis from 'ioredis';
99
+
100
+ const redisOptions = { host: 'localhost', port: 6379 };
101
+ const publishClient = new Redis(redisOptions);
102
+ const subscribeClient = new Redis(redisOptions);
90
103
 
91
104
  EventBusModule.forRoot({
92
- transport: new RedisEventBusTransport({
93
- publishClient: redis,
94
- subscribeClient: redisSubscriber
105
+ transport: new RedisEventBusTransport({
106
+ publishClient,
107
+ subscribeClient,
95
108
  }),
96
- })
109
+ });
97
110
  ```
98
111
 
112
+ Create dedicated, separate `publishClient` and `subscribeClient` instances for the transport. Redis puts a subscribed connection into Pub/Sub mode, so the subscriber must not also publish or run ordinary commands. Both clients remain caller-owned: `RedisEventBusTransport.close()` removes the transport subscriptions and listener but does not disconnect either client. Close them from their lifecycle owner after the event bus has finished teardown.
113
+
99
114
  Redis Pub/Sub is a fan-out transport, not a durable work queue. When multiple application instances subscribe to the same event channel, each instance can see the same published fact. Handlers that mutate state, send notifications, or call external systems should therefore be idempotent: carry a stable event identifier or business key in the payload, record which reactions have already been applied, and make repeat deliveries converge to the same result instead of performing the side effect twice.
100
115
 
101
116
  Keep `@OnEvent(...)` handlers small and bounded. They are a good fit for fast local projections, cache invalidation, lightweight notifications, and other reactions that can finish within the publish timeout and shutdown drain window. If a reaction is slow, failure-prone, retryable, or needs operator-visible dead-letter handling, hand off a durable job to `@fluojs/queue` from the event handler instead of doing the work inline. Use an application-owned unique claim for the handoff, then mark the handoff as enqueued only after `queue.enqueue(...)` succeeds; if enqueue fails, release the pending claim so a later duplicate event can retry safely.
@@ -146,14 +161,14 @@ class UserRegisteredEvent {
146
161
  }
147
162
  ```
148
163
 
149
- Handlers are discovered from singleton providers and controllers across imported modules. Discovery keeps distinct singleton provider identities even when multiple providers share the same implementation class; duplicate registration of the same provider token and handler method is invoked only once. Event-bus bootstrap resolves every discovered handler target before reporting ready, and a real handler target resolution failure fails bootstrap instead of silently reporting ready with skipped handlers. Discovery inspects singleton `useValue` instances that already carry handler metadata and singleton `useFactory` providers only when their provider token is the handler class with `@OnEvent(...)` metadata, so unrelated factory providers are not invoked during event-bus bootstrap. Each handler receives an isolated cloned payload, and class inheritance is supported through `instanceof` matching. With an external transport configured, publishing a subclass event fans out to the subclass channel and every inherited event channel in its prototype chain, even when the publisher process has no matching local handlers for those types. A subclass uses its own `static eventKey` only when it declares one directly; otherwise its class name remains the subclass channel while base classes keep their own stable keys.
164
+ Handlers are discovered from normalized effective singleton provider registrations and controllers across imported modules. When duplicate provider tokens are registered, only the DI winner is discovered; factory-provider scope follows the same canonical normalization as container resolution. Event-bus bootstrap resolves every discovered handler target before reporting ready, and a real handler target resolution failure fails bootstrap instead of silently reporting ready with skipped handlers. Discovery inspects singleton `useValue` instances that already carry handler metadata and singleton `useFactory` providers only when their provider token is the handler class with `@OnEvent(...)` metadata, so unrelated factory providers are not invoked during event-bus bootstrap. Each handler receives an isolated cloned payload, and class inheritance is supported through `instanceof` matching. With an external transport configured, publishing a subclass event fans out to the subclass channel and every inherited event channel in its prototype chain, even when the publisher process has no matching local handlers for those types. A subclass uses its own `static eventKey` only when it declares one directly; otherwise its class name remains the subclass channel while base classes keep their own stable keys. `publish()` records and logs handler and transport failures but resolves after attempts settle; with `waitForHandlers: false`, it resolves after scheduling shutdown-tracked background work.
150
165
 
151
166
  ## Public API Overview
152
167
 
153
168
  ### Core
154
169
  - `EventBusModule.forRoot({ global?, publish?, shutdown?, transport? })`: Main entry point for event bus registration. `global` defaults to `true`; set `global: false` to keep event-bus providers visible only through the module that imports the event-bus module.
155
170
  - `EventBusLifecycleService`: Primary service for publishing events (`publish(event, options?)`) and creating platform status snapshots.
156
- - `@OnEvent(EventClass)`: Decorator to mark a method as an event handler.
171
+ - `@OnEvent(EventClass)`: Decorator to mark a public instance method as an event handler.
157
172
  - `EVENT_BUS`: Compatibility injection token for the publish facade.
158
173
  - `createEventBusPlatformStatusSnapshot(...)`: Status snapshot helper used by diagnostics and health surfaces.
159
174
 
@@ -162,7 +177,7 @@ Handlers are discovered from singleton providers and controllers across imported
162
177
  - `EventBus`, `EventPublishOptions`, `EventBusModuleOptions`, `EventType`: Type-only contracts for publishing, defaults, transports, and stable event keys.
163
178
  - `EventBusLifecycleState`, `EventBusStatusAdapterInput`, `EventBusPlatformStatusSnapshot`: Status snapshot contracts.
164
179
 
165
- Transport bootstrap subscribes once per unique event channel. `eventKey` controls the transport channel name when present. If a later transport subscription fails during bootstrap, the event bus closes the transport to roll back any channels that were already opened before rethrowing the subscription error. Invalid JSON transport messages are ignored, and inbound transport messages that arrive after shutdown starts are ignored before local handler dispatch.
180
+ Transport bootstrap subscribes once per unique event channel. `eventKey` controls the transport channel name when present. If a later transport subscription fails during bootstrap, the event bus closes the transport to roll back any channels that were already opened before rethrowing the subscription error. Inbound transport messages that arrive after shutdown starts are ignored before local handler dispatch.
166
181
 
167
182
  ## Runtime-Specific and Integration Subpaths
168
183
 
@@ -170,7 +185,7 @@ Transport bootstrap subscribes once per unique event channel. `eventKey` control
170
185
  | --- | --- | --- |
171
186
  | Redis Pub/Sub transport | `@fluojs/event-bus/redis` | `RedisEventBusTransport`, `RedisEventBusTransportOptions` |
172
187
 
173
- `RedisEventBusTransport` stays on the explicit `@fluojs/event-bus/redis` subpath so the root `@fluojs/event-bus` entrypoint remains focused on module registration, local publishing, decorators, and type-only contracts. The transport unsubscribes the channels it registered and detaches its message listener during shutdown, but it does not disconnect caller-owned Redis clients.
188
+ `RedisEventBusTransport` stays on the explicit `@fluojs/event-bus/redis` subpath so the root `@fluojs/event-bus` entrypoint remains focused on module registration, local publishing, decorators, and type-only contracts. Applications using this subpath must install the optional `ioredis` peer and supply dedicated, separate `publishClient` and `subscribeClient` instances. This Redis adapter JSON-decodes inbound Redis messages and drops malformed JSON before handler dispatch; that parsing rule does not apply to arbitrary `EventBusTransport` implementations. During shutdown, the adapter unsubscribes the channels it registered and detaches its message listener, but `close()` does not disconnect the caller-owned clients. If unsubscribe fails, `close()` still detaches the listener while retaining the registered channels so a later `close()` retries the same cleanup. The application or client-owning module must close those clients separately after event-bus teardown.
174
189
 
175
190
  ## Related Packages
176
191
 
@@ -182,4 +197,5 @@ Transport bootstrap subscribes once per unique event channel. `eventKey` control
182
197
  - `packages/event-bus/src/module.test.ts`: Handler discovery and publish/subscribe tests.
183
198
  - `packages/event-bus/src/public-surface.test.ts`: Public API contract verification.
184
199
  - `packages/event-bus/src/status.test.ts`: Status snapshot semantics.
200
+ - `packages/event-bus/src/shutdown-late-work.test.ts`: Late handler and transport registration shutdown races.
185
201
  - `packages/event-bus/src/transports/redis-transport.test.ts`: Redis transport behavior.
package/dist/module.d.ts CHANGED
@@ -5,7 +5,7 @@ import type { EventBusModuleOptions } from './types.js';
5
5
  */
6
6
  export declare class EventBusModule {
7
7
  /**
8
- * Registers the event-bus providers as a global module.
8
+ * Registers event-bus providers globally by default, or locally when `options.global` is `false`.
9
9
  *
10
10
  * @param options Event bus module options for publish defaults and optional transport integration.
11
11
  * @returns A module definition that exports `EventBusLifecycleService` and the compatibility token `EVENT_BUS`.
package/dist/module.js CHANGED
@@ -19,7 +19,7 @@ function createEventBusProviders(options = {}) {
19
19
  */
20
20
  export class EventBusModule {
21
21
  /**
22
- * Registers the event-bus providers as a global module.
22
+ * Registers event-bus providers globally by default, or locally when `options.global` is `false`.
23
23
  *
24
24
  * @param options Event bus module options for publish defaults and optional transport integration.
25
25
  * @returns A module definition that exports `EventBusLifecycleService` and the compatibility token `EVENT_BUS`.
package/dist/service.d.ts CHANGED
@@ -25,6 +25,7 @@ export declare class EventBusLifecycleService implements EventBus, OnApplication
25
25
  private readonly activeDispatches;
26
26
  private readonly transport;
27
27
  private transportClosed;
28
+ private shutdownDeadlineAtMs;
28
29
  constructor(runtimeContainer: Container, compiledModules: readonly CompiledModule[], logger: ApplicationLogger, moduleOptions: EventBusModuleOptions);
29
30
  onApplicationBootstrap(): Promise<void>;
30
31
  onApplicationShutdown(): Promise<void>;
@@ -38,10 +39,18 @@ export declare class EventBusLifecycleService implements EventBus, OnApplication
38
39
  * Publishes one event to matching local handlers and, when configured, to the external transport.
39
40
  *
40
41
  * @param event Event instance to publish.
41
- * @param options Optional timeout, abort signal, and wait-for-handler controls.
42
- * @returns A promise that resolves once the configured local/transport publication completes.
42
+ * @param options Optional bounds for matching local handlers and transport publication.
43
+ * @returns A promise that resolves after publication attempts settle, or after background work is scheduled when
44
+ * `waitForHandlers` is `false`. Handler and transport failures are recorded without rejecting the caller.
43
45
  */
44
46
  publish(event: object, options?: EventPublishOptions): Promise<void>;
47
+ /**
48
+ * Caps this event bus shutdown drain at a deadline coordinated by an owning integration.
49
+ *
50
+ * @internal
51
+ * @param deadlineAtMs Absolute timestamp in milliseconds.
52
+ */
53
+ adoptShutdownDeadline(deadlineAtMs: number): void;
45
54
  private executePublish;
46
55
  private canPublishInCurrentLifecycle;
47
56
  private drainActiveDispatches;
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAY,MAAM,YAAY,CAAC;AACtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,cAAc,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,iBAAiB,CAAC;AAMxH,OAAO,KAAK,EACV,QAAQ,EACR,qBAAqB,EAGrB,mBAAmB,EAEpB,MAAM,YAAY,CAAC;AA6EpB;;;;;GAKG;AACH,qBACa,wBAAyB,YAAW,QAAQ,EAAE,sBAAsB,EAAE,qBAAqB;IAgBpG,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAlBhC,OAAO,CAAC,WAAW,CAAgC;IACnD,OAAO,CAAC,gBAAgB,CAA4B;IACpD,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,cAAc,CAAsF;IAC5G,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAsC;IACvE,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAqB;IACxD,OAAO,CAAC,sBAAsB,CAAK;IACnC,OAAO,CAAC,wBAAwB,CAAK;IACrC,OAAO,CAAC,0BAA0B,CAAK;IACvC,OAAO,CAAC,qBAAqB,CAAK;IAClC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAA4B;IAC7D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAgC;IAC1D,OAAO,CAAC,eAAe,CAAS;gBAGb,gBAAgB,EAAE,SAAS,EAC3B,eAAe,EAAE,SAAS,cAAc,EAAE,EAC1C,MAAM,EAAE,iBAAiB,EACzB,aAAa,EAAE,qBAAqB;IAKjD,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC;IAavC,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IAiB5C;;;;OAIG;IACH,4BAA4B;IAe5B;;;;;;OAMG;IACG,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;YAY5D,cAAc;IA+B5B,OAAO,CAAC,4BAA4B;YAItB,qBAAqB;YAcrB,mBAAmB;IAUjC,OAAO,CAAC,uBAAuB;YAajB,kBAAkB;IAiBhC,OAAO,CAAC,6BAA6B;IAIrC,OAAO,CAAC,qBAAqB;IAI7B,OAAO,CAAC,qBAAqB;IAW7B,OAAO,CAAC,+BAA+B;IAWvC,OAAO,CAAC,8BAA8B;YAMxB,yBAAyB;YAazB,gBAAgB;IAqB9B,OAAO,CAAC,qBAAqB;IAY7B,OAAO,CAAC,kBAAkB;YAQZ,gBAAgB;IAW9B,OAAO,CAAC,oBAAoB;IAY5B,OAAO,CAAC,2BAA2B;IAkBnC,OAAO,CAAC,gBAAgB;YAiBV,kBAAkB;IAgChC,OAAO,CAAC,0CAA0C;IAOlD,OAAO,CAAC,+BAA+B;YAwBzB,0BAA0B;YAwB1B,mDAAmD;YAInD,6BAA6B;YAa7B,cAAc;YAYd,yBAAyB;IAiCvC,OAAO,CAAC,mCAAmC;YAI7B,gCAAgC;YAmBhC,uBAAuB;YAUvB,uBAAuB;IAmBrC,OAAO,CAAC,iCAAiC;IAOzC,OAAO,CAAC,yBAAyB;YAwBnB,qBAAqB;IAuBnC,OAAO,CAAC,sBAAsB;IAqB9B,OAAO,CAAC,kBAAkB;IAiB1B,OAAO,CAAC,gBAAgB;YAmBV,0BAA0B;IAyBxC,OAAO,CAAC,+BAA+B;IAevC,OAAO,CAAC,8BAA8B;IA4BtC,OAAO,CAAC,uBAAuB;YAejB,mBAAmB;IAoDjC,OAAO,CAAC,iCAAiC;IA+CzC,OAAO,CAAC,0CAA0C;YAmBpC,aAAa;YAwBb,sBAAsB;CAsBrC"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAsB,MAAM,YAAY,CAAC;AAChE,OAAO,KAAK,EACV,iBAAiB,EACjB,cAAc,EACd,sBAAsB,EACtB,qBAAqB,EACtB,MAAM,iBAAiB,CAAC;AAUzB,OAAO,KAAK,EACV,QAAQ,EACR,qBAAqB,EAGrB,mBAAmB,EAEpB,MAAM,YAAY,CAAC;AAoDpB;;;;;GAKG;AACH,qBACa,wBAAyB,YAAW,QAAQ,EAAE,sBAAsB,EAAE,qBAAqB;IAiBpG,OAAO,CAAC,QAAQ,CAAC,gBAAgB;IACjC,OAAO,CAAC,QAAQ,CAAC,eAAe;IAChC,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,aAAa;IAnBhC,OAAO,CAAC,WAAW,CAAgC;IACnD,OAAO,CAAC,gBAAgB,CAA4B;IACpD,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,cAAc,CAAsF;IAC5G,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAsC;IACvE,OAAO,CAAC,QAAQ,CAAC,kBAAkB,CAAqB;IACxD,OAAO,CAAC,sBAAsB,CAAK;IACnC,OAAO,CAAC,wBAAwB,CAAK;IACrC,OAAO,CAAC,0BAA0B,CAAK;IACvC,OAAO,CAAC,qBAAqB,CAAK;IAClC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAA4B;IAC7D,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAgC;IAC1D,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,oBAAoB,CAAqB;gBAG9B,gBAAgB,EAAE,SAAS,EAC3B,eAAe,EAAE,SAAS,cAAc,EAAE,EAC1C,MAAM,EAAE,iBAAiB,EACzB,aAAa,EAAE,qBAAqB;IAKjD,sBAAsB,IAAI,OAAO,CAAC,IAAI,CAAC;IAavC,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IAc5C;;;;OAIG;IACH,4BAA4B;IAe5B;;;;;;;OAOG;IACG,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAY1E;;;;;OAKG;IACH,qBAAqB,CAAC,YAAY,EAAE,MAAM,GAAG,IAAI;YAInC,cAAc;IA+B5B,OAAO,CAAC,4BAA4B;YAItB,qBAAqB;YAarB,mBAAmB;IAUjC,OAAO,CAAC,uBAAuB;YAajB,kBAAkB;IA4BhC,OAAO,CAAC,6BAA6B;IASrC,OAAO,CAAC,qBAAqB;IAI7B,OAAO,CAAC,qBAAqB;IAW7B,OAAO,CAAC,+BAA+B;IAWvC,OAAO,CAAC,8BAA8B;YAMxB,yBAAyB;YAazB,gBAAgB;IAqB9B,OAAO,CAAC,qBAAqB;IAY7B,OAAO,CAAC,kBAAkB;YAQZ,gBAAgB;IAW9B,OAAO,CAAC,oBAAoB;IAY5B,OAAO,CAAC,2BAA2B;IAkBnC,OAAO,CAAC,gBAAgB;YAiBV,kBAAkB;IAgChC,OAAO,CAAC,0CAA0C;IAOlD,OAAO,CAAC,+BAA+B;YAwBzB,0BAA0B;YAwB1B,mDAAmD;YAQnD,6BAA6B;YAY7B,cAAc;YAYd,yBAAyB;IAiCvC,OAAO,CAAC,mCAAmC;YAI7B,gCAAgC;YAmBhC,uBAAuB;YAUvB,uBAAuB;IAmBrC,OAAO,CAAC,iCAAiC;IAOzC,OAAO,CAAC,yBAAyB;YAwBnB,qBAAqB;IAuBnC,OAAO,CAAC,sBAAsB;IAqB9B,OAAO,CAAC,kBAAkB;IAiB1B,OAAO,CAAC,gBAAgB;YAmBV,0BAA0B;IAyBxC,OAAO,CAAC,+BAA+B;IAevC,OAAO,CAAC,8BAA8B;IA4BtC,OAAO,CAAC,uBAAuB;YAejB,mBAAmB;IA8BjC,OAAO,CAAC,iCAAiC;IAkDzC,OAAO,CAAC,0CAA0C;YAsBpC,aAAa;YAwBb,sBAAsB;CAsBrC"}
package/dist/service.js CHANGED
@@ -5,7 +5,7 @@ function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e =
5
5
  function _setFunctionName(e, t, n) { "symbol" == typeof t && (t = (t = t.description) ? "[" + t + "]" : ""); try { Object.defineProperty(e, "name", { configurable: !0, value: n ? n + " " + t : t }); } catch (e) {} return e; }
6
6
  function _checkInRHS(e) { if (Object(e) !== e) throw TypeError("right-hand side of 'in' should be an object, got " + (null !== e ? typeof e : "null")); return e; }
7
7
  import { Inject } from '@fluojs/core';
8
- import { cloneWithFallback, getClassDiMetadata } from '@fluojs/core/internal';
8
+ import { cloneWithFallback } from '@fluojs/core/internal';
9
9
  import { APPLICATION_LOGGER, COMPILED_MODULES, RUNTIME_CONTAINER } from '@fluojs/runtime/internal';
10
10
  import { getEventHandlerMetadataEntries } from './metadata.js';
11
11
  import { createEventBusPlatformStatusSnapshot } from './status.js';
@@ -29,24 +29,9 @@ class EventPublishAbortError extends Error {
29
29
  super('Event publish was aborted.');
30
30
  }
31
31
  }
32
- function scopeFromProvider(provider) {
33
- if (typeof provider === 'function') {
34
- return getClassDiMetadata(provider)?.scope ?? 'singleton';
35
- }
36
- if ('useClass' in provider) {
37
- return provider.scope ?? getClassDiMetadata(provider.useClass)?.scope ?? 'singleton';
38
- }
39
- return 'scope' in provider ? provider.scope ?? 'singleton' : 'singleton';
40
- }
41
32
  function methodKeyToName(methodKey) {
42
33
  return typeof methodKey === 'symbol' ? methodKey.toString() : methodKey;
43
34
  }
44
- function isClassProvider(provider) {
45
- return typeof provider === 'object' && provider !== null && 'useClass' in provider;
46
- }
47
- function isFactoryOrValueProvider(provider) {
48
- return typeof provider === 'object' && provider !== null && ('useFactory' in provider || 'useValue' in provider);
49
- }
50
35
  function hasEventHandlerMetadata(targetType) {
51
36
  return getEventHandlerMetadataEntries(targetType.prototype).length > 0;
52
37
  }
@@ -75,6 +60,7 @@ class EventBusLifecycleService {
75
60
  activeDispatches = new Set();
76
61
  transport;
77
62
  transportClosed = false;
63
+ shutdownDeadlineAtMs;
78
64
  constructor(runtimeContainer, compiledModules, logger, moduleOptions) {
79
65
  this.runtimeContainer = runtimeContainer;
80
66
  this.compiledModules = compiledModules;
@@ -95,16 +81,13 @@ class EventBusLifecycleService {
95
81
  }
96
82
  async onApplicationShutdown() {
97
83
  this.lifecycleState = 'stopping';
98
- let transportClosedCleanly = true;
99
84
  if (this.activeDispatches.size > 0) {
100
85
  await this.drainActiveDispatches();
101
86
  }
102
87
  if (this.transport) {
103
- transportClosedCleanly = await this.closeTransportOrRecordFailure('EventBusTransport failed to close.');
104
- }
105
- if (transportClosedCleanly) {
106
- this.lifecycleState = 'stopped';
88
+ await this.closeTransportOrRecordFailure('EventBusTransport failed to close.');
107
89
  }
90
+ this.lifecycleState = 'stopped';
108
91
  }
109
92
 
110
93
  /**
@@ -131,8 +114,9 @@ class EventBusLifecycleService {
131
114
  * Publishes one event to matching local handlers and, when configured, to the external transport.
132
115
  *
133
116
  * @param event Event instance to publish.
134
- * @param options Optional timeout, abort signal, and wait-for-handler controls.
135
- * @returns A promise that resolves once the configured local/transport publication completes.
117
+ * @param options Optional bounds for matching local handlers and transport publication.
118
+ * @returns A promise that resolves after publication attempts settle, or after background work is scheduled when
119
+ * `waitForHandlers` is `false`. Handler and transport failures are recorded without rejecting the caller.
136
120
  */
137
121
  async publish(event, options) {
138
122
  if (!this.canPublishInCurrentLifecycle()) {
@@ -141,6 +125,16 @@ class EventBusLifecycleService {
141
125
  }
142
126
  await this.trackActiveDispatch(this.executePublish(event, options));
143
127
  }
128
+
129
+ /**
130
+ * Caps this event bus shutdown drain at a deadline coordinated by an owning integration.
131
+ *
132
+ * @internal
133
+ * @param deadlineAtMs Absolute timestamp in milliseconds.
134
+ */
135
+ adoptShutdownDeadline(deadlineAtMs) {
136
+ this.shutdownDeadlineAtMs = Math.min(this.shutdownDeadlineAtMs ?? deadlineAtMs, deadlineAtMs);
137
+ }
144
138
  async executePublish(event, options) {
145
139
  await this.ensureDiscovered();
146
140
  const matchingDescriptors = this.matchEventDescriptors(event);
@@ -167,12 +161,11 @@ class EventBusLifecycleService {
167
161
  return !['failed', 'stopped', 'stopping'].includes(this.lifecycleState);
168
162
  }
169
163
  async drainActiveDispatches() {
170
- const activeDispatches = Array.from(this.activeDispatches);
171
164
  const timeoutMs = this.resolveShutdownDrainTimeoutMs();
172
- const drained = await this.awaitShutdownDrain(activeDispatches, timeoutMs);
165
+ const drained = await this.awaitShutdownDrain(timeoutMs);
173
166
  if (!drained) {
174
167
  this.shutdownDrainTimeouts += 1;
175
- this.logger.warn(`Event bus shutdown drain exceeded ${String(timeoutMs)}ms with ${String(activeDispatches.length)} active dispatch workflow(s); continuing shutdown.`, 'EventBusLifecycleService');
168
+ this.logger.warn(`Event bus shutdown drain exceeded ${String(timeoutMs)}ms with ${String(this.activeDispatches.size)} active dispatch workflow(s); continuing shutdown.`, 'EventBusLifecycleService');
176
169
  }
177
170
  }
178
171
  async trackActiveDispatch(dispatchWorkflow) {
@@ -191,14 +184,23 @@ class EventBusLifecycleService {
191
184
  });
192
185
  return dispatchWork;
193
186
  }
194
- async awaitShutdownDrain(activePublishes, timeoutMs) {
187
+ async awaitShutdownDrain(timeoutMs) {
188
+ if (timeoutMs <= 0) {
189
+ return false;
190
+ }
195
191
  let timeoutId;
196
192
  const timeout = new Promise(resolve => {
197
193
  timeoutId = setTimeout(() => resolve(false), timeoutMs);
198
194
  });
199
- const drain = Promise.allSettled(activePublishes).then(() => true);
200
195
  try {
201
- return await Promise.race([drain, timeout]);
196
+ while (this.activeDispatches.size > 0) {
197
+ const activeDispatches = Array.from(this.activeDispatches);
198
+ const drained = await Promise.race([Promise.allSettled(activeDispatches).then(() => true), timeout]);
199
+ if (!drained) {
200
+ return false;
201
+ }
202
+ }
203
+ return true;
202
204
  } finally {
203
205
  if (timeoutId) {
204
206
  clearTimeout(timeoutId);
@@ -206,7 +208,9 @@ class EventBusLifecycleService {
206
208
  }
207
209
  }
208
210
  resolveShutdownDrainTimeoutMs() {
209
- return this.normalizeTimeoutMs(this.moduleOptions.shutdown?.drainTimeoutMs) ?? DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
211
+ const timeoutMs = this.normalizeTimeoutMs(this.moduleOptions.shutdown?.drainTimeoutMs) ?? DEFAULT_SHUTDOWN_DRAIN_TIMEOUT_MS;
212
+ const remainingTimeoutMs = this.shutdownDeadlineAtMs === undefined ? undefined : Math.max(0, this.shutdownDeadlineAtMs - Date.now());
213
+ return remainingTimeoutMs === undefined ? timeoutMs : Math.min(timeoutMs, remainingTimeoutMs);
210
214
  }
211
215
  matchEventDescriptors(event) {
212
216
  return this.descriptors.filter(descriptor => event instanceof descriptor.eventType);
@@ -366,17 +370,20 @@ class EventBusLifecycleService {
366
370
  }
367
371
  }
368
372
  async rollbackTransportSubscriptionsAfterBootstrapFailure() {
369
- await this.closeTransportOrRecordFailure('EventBusTransport failed to close after bootstrap subscription failure.');
373
+ try {
374
+ await this.closeTransportOrRecordFailure('EventBusTransport failed to close after bootstrap subscription failure.');
375
+ } catch {
376
+ // Preserve the original subscription failure while runtime cleanup retries this incomplete close.
377
+ }
370
378
  }
371
379
  async closeTransportOrRecordFailure(message) {
372
380
  try {
373
381
  await this.closeTransport();
374
- return true;
375
382
  } catch (error) {
376
383
  this.transportCloseFailures += 1;
377
384
  this.lifecycleState = 'failed';
378
385
  this.logger.error(message, error, 'EventBusLifecycleService');
379
- return false;
386
+ throw error;
380
387
  }
381
388
  }
382
389
  async closeTransport() {
@@ -570,62 +577,32 @@ class EventBusLifecycleService {
570
577
  }
571
578
  async discoveryCandidates() {
572
579
  const candidates = [];
573
- const providerCandidates = [];
580
+ const moduleNames = new Map();
581
+ const registrations = this.runtimeContainer.inspectResolutionState().registrations;
574
582
  for (const compiledModule of this.compiledModules) {
575
583
  for (const provider of compiledModule.definition.providers ?? []) {
576
- if (typeof provider === 'function') {
577
- candidates.push({
578
- moduleName: compiledModule.type.name,
579
- scope: scopeFromProvider(provider),
580
- targetType: provider,
581
- token: provider
582
- });
583
- continue;
584
- }
585
- if (isClassProvider(provider)) {
586
- candidates.push({
587
- moduleName: compiledModule.type.name,
588
- scope: scopeFromProvider(provider),
589
- targetType: provider.useClass,
590
- token: provider.provide
591
- });
592
- continue;
593
- }
594
- if (isFactoryOrValueProvider(provider)) {
595
- providerCandidates.push({
596
- moduleName: compiledModule.type.name,
597
- provider
598
- });
599
- }
584
+ const token = typeof provider === 'function' ? provider : provider.provide;
585
+ moduleNames.set(token, compiledModule.type.name);
600
586
  }
601
587
  for (const controller of compiledModule.definition.controllers ?? []) {
602
- candidates.push({
603
- moduleName: compiledModule.type.name,
604
- scope: scopeFromProvider(controller),
605
- targetType: controller,
606
- token: controller
607
- });
588
+ moduleNames.set(controller, compiledModule.type.name);
608
589
  }
609
590
  }
610
- for (const candidate of providerCandidates) {
611
- const resolvedCandidate = await this.resolveProviderDiscoveryCandidate(candidate);
591
+ for (const [token, provider] of registrations) {
592
+ const resolvedCandidate = this.resolveProviderDiscoveryCandidate(moduleNames.get(token) ?? 'BootstrapProviders', provider);
612
593
  if (resolvedCandidate) {
613
594
  candidates.push(resolvedCandidate);
614
595
  }
615
596
  }
616
597
  return candidates;
617
598
  }
618
- resolveProviderDiscoveryCandidate(candidate) {
619
- const provider = candidate.provider;
620
- if (!('provide' in provider)) {
621
- return undefined;
622
- }
623
- const scope = scopeFromProvider(provider);
599
+ resolveProviderDiscoveryCandidate(moduleName, provider) {
600
+ const scope = provider.scope;
624
601
  const token = provider.provide;
625
602
  if (scope !== 'singleton') {
626
- return this.createUnresolvedProviderDiscoveryCandidate(candidate.moduleName, token, scope);
603
+ return this.createUnresolvedProviderDiscoveryCandidate(moduleName, provider);
627
604
  }
628
- if ('useValue' in provider) {
605
+ if (provider.type === 'value') {
629
606
  const instance = provider.useValue;
630
607
  if (typeof instance !== 'object' || instance === null) {
631
608
  return undefined;
@@ -635,32 +612,33 @@ class EventBusLifecycleService {
635
612
  return undefined;
636
613
  }
637
614
  return {
638
- moduleName: candidate.moduleName,
615
+ moduleName,
639
616
  scope,
640
617
  targetType,
641
618
  token
642
619
  };
643
620
  }
644
- if (typeof token !== 'function' || !hasEventHandlerMetadata(token)) {
621
+ const targetType = provider.type === 'class' ? provider.useClass : typeof token === 'function' ? token : undefined;
622
+ if (!targetType || !hasEventHandlerMetadata(targetType)) {
645
623
  return undefined;
646
624
  }
647
625
  return {
648
- moduleName: candidate.moduleName,
626
+ moduleName,
649
627
  scope,
650
- targetType: token,
628
+ targetType,
651
629
  token
652
630
  };
653
631
  }
654
- createUnresolvedProviderDiscoveryCandidate(moduleName, token, scope) {
655
- const tokenType = typeof token === 'function' ? token : undefined;
656
- if (!tokenType) {
632
+ createUnresolvedProviderDiscoveryCandidate(moduleName, provider) {
633
+ const targetType = provider.type === 'class' ? provider.useClass : typeof provider.provide === 'function' ? provider.provide : undefined;
634
+ if (!targetType) {
657
635
  return undefined;
658
636
  }
659
637
  return {
660
638
  moduleName,
661
- scope,
662
- targetType: tokenType,
663
- token
639
+ scope: provider.scope,
640
+ targetType,
641
+ token: provider.provide
664
642
  };
665
643
  }
666
644
  async invokeHandler(descriptor, event) {
package/dist/status.js CHANGED
@@ -110,7 +110,7 @@ export function createEventBusPlatformStatusSnapshot(input) {
110
110
  health: createHealth(input),
111
111
  ownership: {
112
112
  externallyManaged: input.transportConfigured,
113
- ownsResources: false
113
+ ownsResources: input.transportConfigured
114
114
  },
115
115
  readiness: createReadiness(input)
116
116
  };
@@ -1,12 +1,20 @@
1
1
  import type { Redis } from 'ioredis';
2
2
  import type { EventBusTransport } from '../types.js';
3
- /** Clients used by {@link RedisEventBusTransport} for publish and subscribe responsibilities. */
3
+ /** Caller-owned Redis clients used by {@link RedisEventBusTransport}. */
4
4
  export interface RedisEventBusTransportOptions {
5
+ /** Caller-owned client used to publish serialized event payloads. */
5
6
  publishClient: Redis;
7
+ /** Caller-owned, dedicated Pub/Sub client used for event subscriptions. */
6
8
  subscribeClient: Redis;
7
9
  }
8
10
  /**
9
11
  * Redis Pub/Sub transport adapter for cross-process event fan-out.
12
+ * Incoming Redis messages are JSON-decoded by this adapter, and malformed JSON is dropped before
13
+ * handler dispatch.
14
+ *
15
+ * @remarks
16
+ * The publish and subscribe clients remain caller-owned. {@link RedisEventBusTransport.close}
17
+ * removes this transport's subscriptions and listener but does not disconnect either client.
10
18
  *
11
19
  * @example
12
20
  * ```ts
@@ -27,7 +35,7 @@ export declare class RedisEventBusTransport implements EventBusTransport {
27
35
  /**
28
36
  * Creates a Redis-backed event-bus transport.
29
37
  *
30
- * @param options Redis clients dedicated to publish and subscribe operations.
38
+ * @param options Caller-owned Redis clients for publish and subscribe operations.
31
39
  */
32
40
  constructor(options: RedisEventBusTransportOptions);
33
41
  private readonly onMessage;
@@ -49,6 +57,8 @@ export declare class RedisEventBusTransport implements EventBusTransport {
49
57
  subscribe(channel: string, handler: (payload: unknown) => Promise<void>): Promise<void>;
50
58
  /**
51
59
  * Unsubscribes all tracked channels and detaches the Redis message listener.
60
+ * A failed unsubscribe retains the tracked channels so a later close can retry cleanup.
61
+ * The caller-owned publish and subscribe clients remain connected for their owner to close.
52
62
  *
53
63
  * @returns A promise that resolves once the transport cleanup finishes.
54
64
  */
@@ -1 +1 @@
1
- {"version":3,"file":"redis-transport.d.ts","sourceRoot":"","sources":["../../src/transports/redis-transport.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAErC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErD,iGAAiG;AACjG,MAAM,WAAW,6BAA6B;IAC5C,aAAa,EAAE,KAAK,CAAC;IACrB,eAAe,EAAE,KAAK,CAAC;CACxB;AAED;;;;;;;;;;;;;GAaG;AACH,qBAAa,sBAAuB,YAAW,iBAAiB;IAC9D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAQ;IACtC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAQ;IACxC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAA0D;IAC5F,OAAO,CAAC,uBAAuB,CAAS;IAExC;;;;OAIG;gBACS,OAAO,EAAE,6BAA6B;IAKlD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAgBxB;IAEF;;;;;;OAMG;IACG,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAI/D;;;;;;OAMG;IACG,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAU7F;;;;OAIG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAsB7B"}
1
+ {"version":3,"file":"redis-transport.d.ts","sourceRoot":"","sources":["../../src/transports/redis-transport.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAErC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAErD,yEAAyE;AACzE,MAAM,WAAW,6BAA6B;IAC5C,qEAAqE;IACrE,aAAa,EAAE,KAAK,CAAC;IACrB,2EAA2E;IAC3E,eAAe,EAAE,KAAK,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,qBAAa,sBAAuB,YAAW,iBAAiB;IAC9D,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAQ;IACtC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAQ;IACxC,OAAO,CAAC,QAAQ,CAAC,iBAAiB,CAA0D;IAC5F,OAAO,CAAC,uBAAuB,CAAS;IAExC;;;;OAIG;gBACS,OAAO,EAAE,6BAA6B;IAKlD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAgBxB;IAEF;;;;;;OAMG;IACG,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC;IAI/D;;;;;;OAMG;IACG,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC;IAU7F;;;;;;OAMG;IACG,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;CAgB7B"}
@@ -1,7 +1,13 @@
1
- /** Clients used by {@link RedisEventBusTransport} for publish and subscribe responsibilities. */
1
+ /** Caller-owned Redis clients used by {@link RedisEventBusTransport}. */
2
2
 
3
3
  /**
4
4
  * Redis Pub/Sub transport adapter for cross-process event fan-out.
5
+ * Incoming Redis messages are JSON-decoded by this adapter, and malformed JSON is dropped before
6
+ * handler dispatch.
7
+ *
8
+ * @remarks
9
+ * The publish and subscribe clients remain caller-owned. {@link RedisEventBusTransport.close}
10
+ * removes this transport's subscriptions and listener but does not disconnect either client.
5
11
  *
6
12
  * @example
7
13
  * ```ts
@@ -23,7 +29,7 @@ export class RedisEventBusTransport {
23
29
  /**
24
30
  * Creates a Redis-backed event-bus transport.
25
31
  *
26
- * @param options Redis clients dedicated to publish and subscribe operations.
32
+ * @param options Caller-owned Redis clients for publish and subscribe operations.
27
33
  */
28
34
  constructor(options) {
29
35
  this.publishClient = options.publishClient;
@@ -72,27 +78,23 @@ export class RedisEventBusTransport {
72
78
 
73
79
  /**
74
80
  * Unsubscribes all tracked channels and detaches the Redis message listener.
81
+ * A failed unsubscribe retains the tracked channels so a later close can retry cleanup.
82
+ * The caller-owned publish and subscribe clients remain connected for their owner to close.
75
83
  *
76
84
  * @returns A promise that resolves once the transport cleanup finishes.
77
85
  */
78
86
  async close() {
79
- let closeError;
80
87
  const channels = [...this.handlersByChannel.keys()];
81
88
  try {
82
89
  if (channels.length > 0) {
83
90
  await this.subscribeClient.unsubscribe(...channels);
84
91
  }
85
- } catch (error) {
86
- closeError = error;
87
- } finally {
88
92
  this.handlersByChannel.clear();
93
+ } finally {
89
94
  if (this.messageListenerAttached) {
90
95
  this.subscribeClient.off('message', this.onMessage);
91
96
  this.messageListenerAttached = false;
92
97
  }
93
98
  }
94
- if (closeError) {
95
- throw closeError;
96
- }
97
99
  }
98
100
  }
package/dist/types.d.ts CHANGED
@@ -17,10 +17,24 @@ export interface EventHandlerDescriptor {
17
17
  targetName: string;
18
18
  token: Token;
19
19
  }
20
- /** Options that control how one `publish()` call waits for local handlers. */
20
+ /** Per-call bounds for matching local handlers and optional transport publication. */
21
21
  export interface EventPublishOptions {
22
+ /**
23
+ * Cancellation bound for local dispatch and transport publication.
24
+ * An already-aborted signal skips work that has not started. Aborting while awaited work is
25
+ * running settles the caller-facing wait without terminating the underlying shutdown-tracked work.
26
+ */
22
27
  signal?: AbortSignal;
28
+ /**
29
+ * Positive finite timeout applied while awaiting each local handler and transport publication.
30
+ * Non-positive or non-finite values disable the timeout. Ignored when `waitForHandlers` is `false`.
31
+ */
23
32
  timeoutMs?: number;
33
+ /**
34
+ * Whether `publish()` waits for local handlers and transport publication within the configured bounds.
35
+ * Defaults to `true`. When `false`, both kinds of work continue in the background and remain part of
36
+ * shutdown drain tracking.
37
+ */
24
38
  waitForHandlers?: boolean;
25
39
  }
26
40
  /** Transport adapter contract for cross-process event fan-out and inbound subscription wiring. */
@@ -38,7 +52,8 @@ export interface EventBusTransport {
38
52
  */
39
53
  subscribe(channel: string, handler: (payload: unknown) => Promise<void>): Promise<void>;
40
54
  /**
41
- * Tear down any open connections. Called during application shutdown.
55
+ * Release transport-owned subscriptions, listeners, and resources during application shutdown.
56
+ * Adapter-specific ownership rules determine whether injected clients or connections remain open.
42
57
  */
43
58
  close(): Promise<void>;
44
59
  }
@@ -67,8 +82,9 @@ export interface EventBus {
67
82
  * Publishes one event to matching local handlers and the optional external transport.
68
83
  *
69
84
  * @param event Event instance to publish.
70
- * @param options Optional timeout, abort signal, and wait-for-handler controls.
71
- * @returns A promise that resolves once the configured publish workflow completes.
85
+ * @param options Optional bounds for matching local handlers and transport publication.
86
+ * @returns A promise that resolves after the configured workflow completes, or after background work is scheduled
87
+ * when `waitForHandlers` is `false`.
72
88
  */
73
89
  publish(event: object, options?: EventPublishOptions): Promise<void>;
74
90
  }
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAE/D,qGAAqG;AACrG,MAAM,WAAW,SAAS,CAAC,MAAM,SAAS,MAAM,GAAG,MAAM;IACvD,KAAK,GAAG,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,CAAC;IAC/B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,0CAA0C;AAC1C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,SAAS,CAAC;CACtB;AAED,kEAAkE;AAClE,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,SAAS,CAAC;IACrB,SAAS,EAAE,mBAAmB,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,KAAK,CAAC;CACd;AAED,8EAA8E;AAC9E,MAAM,WAAW,mBAAmB;IAClC,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,kGAAkG;AAClG,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE1D;;;;OAIG;IACH,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAExF;;OAEG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,sFAAsF;AACtF,MAAM,WAAW,qBAAqB;IACpC,kFAAkF;IAClF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE;QACR,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,eAAe,CAAC,EAAE,OAAO,CAAC;KAC3B,CAAC;IACF,kEAAkE;IAClE,QAAQ,CAAC,EAAE;QACT,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;IACF;;;;OAIG;IACH,SAAS,CAAC,EAAE,iBAAiB,CAAC;CAC/B;AAED,+DAA+D;AAC/D,MAAM,WAAW,QAAQ;IACvB;;;;;;OAMG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACtE"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAE/D,qGAAqG;AACrG,MAAM,WAAW,SAAS,CAAC,MAAM,SAAS,MAAM,GAAG,MAAM;IACvD,KAAK,GAAG,IAAI,EAAE,KAAK,EAAE,GAAG,MAAM,CAAC;IAC/B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,0CAA0C;AAC1C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,SAAS,CAAC;CACtB;AAED,kEAAkE;AAClE,MAAM,WAAW,sBAAsB;IACrC,SAAS,EAAE,SAAS,CAAC;IACrB,SAAS,EAAE,mBAAmB,CAAC;IAC/B,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,KAAK,CAAC;CACd;AAED,sFAAsF;AACtF,MAAM,WAAW,mBAAmB;IAClC;;;;OAIG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAED,kGAAkG;AAClG,MAAM,WAAW,iBAAiB;IAChC;;;;OAIG;IACH,OAAO,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE1D;;;;OAIG;IACH,SAAS,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAExF;;;OAGG;IACH,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,sFAAsF;AACtF,MAAM,WAAW,qBAAqB;IACpC,kFAAkF;IAClF,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE;QACR,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,eAAe,CAAC,EAAE,OAAO,CAAC;KAC3B,CAAC;IACF,kEAAkE;IAClE,QAAQ,CAAC,EAAE;QACT,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;IACF;;;;OAIG;IACH,SAAS,CAAC,EAAE,iBAAiB,CAAC;CAC/B;AAED,+DAA+D;AAC/D,MAAM,WAAW,QAAQ;IACvB;;;;;;;OAOG;IACH,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACtE"}
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "pubsub",
9
9
  "in-process"
10
10
  ],
11
- "version": "2.0.0",
11
+ "version": "3.0.0",
12
12
  "private": false,
13
13
  "license": "MIT",
14
14
  "repository": {
@@ -17,7 +17,7 @@
17
17
  "directory": "packages/event-bus"
18
18
  },
19
19
  "engines": {
20
- "node": ">=20.0.0"
20
+ "node": ">=24.0.0 <27"
21
21
  },
22
22
  "publishConfig": {
23
23
  "access": "public"
@@ -39,9 +39,9 @@
39
39
  "dist"
40
40
  ],
41
41
  "dependencies": {
42
- "@fluojs/core": "^1.1.0",
43
- "@fluojs/di": "^2.0.0",
44
- "@fluojs/runtime": "^2.0.1"
42
+ "@fluojs/core": "^2.0.0",
43
+ "@fluojs/runtime": "^3.0.0",
44
+ "@fluojs/di": "^3.0.0"
45
45
  },
46
46
  "peerDependencies": {
47
47
  "ioredis": "^5.0.0"
@@ -52,7 +52,7 @@
52
52
  }
53
53
  },
54
54
  "devDependencies": {
55
- "vitest": "^3.2.4"
55
+ "vitest": "^4.1.11"
56
56
  },
57
57
  "scripts": {
58
58
  "prebuild": "node ../../tooling/scripts/clean-dist.mjs",