@zudojs/messaging 0.1.0 → 1.0.1

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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +85 -7
  3. package/dist/dispatcher/dispatcherCore.d.ts +38 -3
  4. package/dist/dispatcher/dispatcherCore.js +173 -30
  5. package/dist/dispatcher/dispatcherType.type.d.ts +16 -5
  6. package/dist/handlerRegistry/handlerRegistryStore.d.ts +10 -0
  7. package/dist/handlerRegistry/handlerRegistryStore.js +38 -2
  8. package/dist/handlerRegistry/handlerRegistryType.type.d.ts +6 -3
  9. package/dist/message/index.d.ts +1 -1
  10. package/dist/message/index.js +1 -1
  11. package/dist/message/messageFactory.d.ts +17 -1
  12. package/dist/message/messageFactory.js +33 -0
  13. package/dist/messageBus/messageBusCore.d.ts +4 -3
  14. package/dist/messageBus/messageBusCore.js +20 -25
  15. package/dist/messageBus/messageBusType.type.d.ts +12 -4
  16. package/dist/messageMiddleware/messageMiddlewarePipeline.js +18 -7
  17. package/dist/messageMiddleware/messageMiddlewareType.type.d.ts +11 -0
  18. package/package.json +25 -13
  19. package/dist/.tsbuildinfo +0 -1
  20. package/dist/dispatcher/dispatcherCore.d.ts.map +0 -1
  21. package/dist/dispatcher/dispatcherCore.js.map +0 -1
  22. package/dist/dispatcher/dispatcherType.type.d.ts.map +0 -1
  23. package/dist/dispatcher/dispatcherType.type.js.map +0 -1
  24. package/dist/dispatcher/index.d.ts.map +0 -1
  25. package/dist/dispatcher/index.js.map +0 -1
  26. package/dist/errors/index.d.ts.map +0 -1
  27. package/dist/errors/index.js.map +0 -1
  28. package/dist/handlerRegistry/handlerRegistryStore.d.ts.map +0 -1
  29. package/dist/handlerRegistry/handlerRegistryStore.js.map +0 -1
  30. package/dist/handlerRegistry/handlerRegistryType.type.d.ts.map +0 -1
  31. package/dist/handlerRegistry/handlerRegistryType.type.js.map +0 -1
  32. package/dist/handlerRegistry/index.d.ts.map +0 -1
  33. package/dist/handlerRegistry/index.js.map +0 -1
  34. package/dist/index.d.ts.map +0 -1
  35. package/dist/index.js.map +0 -1
  36. package/dist/message/index.d.ts.map +0 -1
  37. package/dist/message/index.js.map +0 -1
  38. package/dist/message/messageFactory.d.ts.map +0 -1
  39. package/dist/message/messageFactory.js.map +0 -1
  40. package/dist/message/messageType.type.d.ts.map +0 -1
  41. package/dist/message/messageType.type.js.map +0 -1
  42. package/dist/messageBus/index.d.ts.map +0 -1
  43. package/dist/messageBus/index.js.map +0 -1
  44. package/dist/messageBus/messageBusCore.d.ts.map +0 -1
  45. package/dist/messageBus/messageBusCore.js.map +0 -1
  46. package/dist/messageBus/messageBusType.type.d.ts.map +0 -1
  47. package/dist/messageBus/messageBusType.type.js.map +0 -1
  48. package/dist/messageContext/index.d.ts.map +0 -1
  49. package/dist/messageContext/index.js.map +0 -1
  50. package/dist/messageContext/messageContextType.type.d.ts.map +0 -1
  51. package/dist/messageContext/messageContextType.type.js.map +0 -1
  52. package/dist/messageHandler/index.d.ts.map +0 -1
  53. package/dist/messageHandler/index.js.map +0 -1
  54. package/dist/messageHandler/messageHandlerType.type.d.ts.map +0 -1
  55. package/dist/messageHandler/messageHandlerType.type.js.map +0 -1
  56. package/dist/messageMiddleware/index.d.ts.map +0 -1
  57. package/dist/messageMiddleware/index.js.map +0 -1
  58. package/dist/messageMiddleware/messageMiddlewarePipeline.d.ts.map +0 -1
  59. package/dist/messageMiddleware/messageMiddlewarePipeline.js.map +0 -1
  60. package/dist/messageMiddleware/messageMiddlewareType.type.d.ts.map +0 -1
  61. package/dist/messageMiddleware/messageMiddlewareType.type.js.map +0 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zudojs Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -15,23 +15,101 @@ import { createMessageBus } from "@zudojs/messaging";
15
15
 
16
16
  const bus = createMessageBus();
17
17
 
18
- bus.subscribe("user.created", (message) => {
18
+ bus.on("user.created", (message) => {
19
19
  console.log("New user:", message.payload);
20
20
  });
21
21
 
22
- await bus.publish({
22
+ await bus.send({
23
23
  type: "user.created",
24
24
  payload: { id: "123" },
25
25
  });
26
+
27
+ bus.dispose();
28
+ ```
29
+
30
+ Use `send` to build and dispatch a message from plain input, or
31
+ `dispatch` when you already hold a `Message`. Both run the middleware
32
+ pipeline before reaching handlers. A disposed bus rejects further use
33
+ with `MessageBusDisposedError`.
34
+
35
+ ## Message identifiers
36
+
37
+ `MessageId`, `MessageCorrelationId` and `MessageCausationId` are branded
38
+ types, so a plain string from a transport frame or a database row is not
39
+ one. `createMessageId()` mints a new identifier; `toMessageId`,
40
+ `toCorrelationId` and `toCausationId` brand an existing string, rejecting
41
+ blank values:
42
+
43
+ ```typescript
44
+ import { createMessage, toMessageId, toCorrelationId } from "@zudojs/messaging";
45
+
46
+ const message = createMessage({
47
+ id: toMessageId(frame.id),
48
+ type: frame.type,
49
+ payload: frame.payload,
50
+ correlationId: toCorrelationId(frame.correlationId),
51
+ });
52
+ ```
53
+
54
+ ## Middleware
55
+
56
+ `use` returns the id `removeMiddleware` takes, so middleware can be taken off
57
+ again. Middleware runs in ascending `priority` order (default 100), with
58
+ registration order breaking ties:
59
+
60
+ ```typescript
61
+ const id = bus.use(logging, { id: "logging", priority: 10 });
62
+ bus.use(validation, { priority: 20 });
63
+
64
+ bus.removeMiddleware(id); // true
26
65
  ```
27
66
 
67
+ `enabled: false` registers middleware without running it.
68
+
69
+ ## Handlers
70
+
71
+ By default a message type may have many handlers and all of them run. Pass
72
+ `allowMultipleHandlers: false` for a command/query bus, where a second handler
73
+ for the same type is a wiring mistake and is rejected at registration:
74
+
75
+ ```typescript
76
+ const bus = createMessageBus({ allowMultipleHandlers: false });
77
+ ```
78
+
79
+ `DispatchResult.handlerResults` records every handler that ran, including the
80
+ one that failed, with its real duration.
81
+
82
+ Handlers receive the dispatch context as their second argument: the
83
+ `headers`, `state` and correlation/causation overrides passed through
84
+ `DispatchOptions.context`, plus anything a middleware stored in
85
+ `context.state`. A dispatch cancelled through its `AbortSignal` between
86
+ handlers fails with `MessageDispatchAbortedError`.
87
+
88
+ ## Timeouts
89
+
90
+ `timeout` is honoured by the dispatcher itself, so it applies whether you hold
91
+ a bus or a dispatcher. On expiry the dispatch context's `AbortSignal` is
92
+ aborted — a handler watching it can wind down — and the result carries a
93
+ `MessageTimeoutError`:
94
+
95
+ ```typescript
96
+ const result = await bus.dispatch(message, { timeout: 5_000 });
97
+ if (!result.success && result.error instanceof MessageTimeoutError) {
98
+ // …
99
+ }
100
+ ```
101
+
102
+ `createMessageBus({ defaultTimeout })` sets the default for every dispatch.
103
+
28
104
  ## Features
29
105
 
30
- - Message bus with pub/sub
31
- - Middleware pipeline for messages
32
- - Message handlers with dependencies
33
- - In-memory transport
34
- - Message serialization
106
+ - Message bus with handler registration and dispatch
107
+ - Middleware pipeline with priorities, removal and per-execution telemetry
108
+ - Named message handlers with priorities
109
+ - Optional single-handler enforcement per message type
110
+ - Per-dispatch timeouts that abort the handler through `AbortSignal`
111
+ - Correlation and causation tracking
112
+ - Branded message identifiers
35
113
 
36
114
  ## Use Cases
37
115
 
@@ -4,7 +4,7 @@
4
4
  * @module dispatcher/dispatcherCore
5
5
  */
6
6
  import type { Message } from "../message/messageType.type.js";
7
- import type { MessageMiddlewareLike } from "../messageMiddleware/messageMiddlewareType.type.js";
7
+ import type { MessageMiddlewareLike, MessageMiddlewareOptions } from "../messageMiddleware/messageMiddlewareType.type.js";
8
8
  import type { Dispatcher, DispatchResult, DispatchOptions } from "./dispatcherType.type.js";
9
9
  import { HandlerRegistryStore } from "../handlerRegistry/handlerRegistryStore.js";
10
10
  /**
@@ -12,16 +12,51 @@ import { HandlerRegistryStore } from "../handlerRegistry/handlerRegistryStore.js
12
12
  */
13
13
  export declare class DefaultDispatcher implements Dispatcher {
14
14
  private readonly registry;
15
+ /**
16
+ * Registered global middleware, in registration order.
17
+ *
18
+ * Held as {@link RegisteredMessageMiddleware} rather than bare functions:
19
+ * without an identity, `removeMiddleware` had nothing to match on and
20
+ * unconditionally returned false, and the `priority` the interface accepts
21
+ * had nowhere to live.
22
+ */
15
23
  private readonly globalMiddleware;
24
+ private middlewareSequence;
16
25
  private disposed;
17
26
  constructor(registry?: HandlerRegistryStore);
18
27
  dispatch<TPayload, TResult>(message: Message<TPayload>, options?: DispatchOptions<TResult>): Promise<DispatchResult<TResult>>;
28
+ /** A promise that rejects with {@link MessageTimeoutError} and aborts. */
29
+ private timeoutRejection;
30
+ /** Global middleware ordered by priority, with disabled entries dropped. */
31
+ private orderedMiddleware;
19
32
  private validateNotDisposed;
33
+ /**
34
+ * Resolves the signal handlers observe.
35
+ *
36
+ * The dispatcher's own controller is chained to the caller's signal so a
37
+ * timeout can abort the work without the caller losing its own cancellation.
38
+ */
20
39
  private resolveSignal;
21
40
  private executeHandlers;
22
41
  private executeHandler;
23
- use<TMessage extends Message = Message, TResult = unknown>(middleware: MessageMiddlewareLike<TMessage, TResult>): void;
24
- removeMiddleware(_middlewareId: string): boolean;
42
+ /**
43
+ * Registers global middleware.
44
+ *
45
+ * @param middleware - The middleware to register.
46
+ * @param options - Identity, priority and enablement.
47
+ * @returns The identifier {@link DefaultDispatcher.removeMiddleware} takes.
48
+ * @throws {MessageMiddlewareError} when the requested id is already taken.
49
+ */
50
+ use<TMessage extends Message = Message, TResult = unknown>(middleware: MessageMiddlewareLike<TMessage, TResult>, options?: MessageMiddlewareOptions): string;
51
+ /**
52
+ * Removes previously registered global middleware.
53
+ *
54
+ * @param middlewareId - The id returned by {@link DefaultDispatcher.use}.
55
+ * @returns Whether a registration was removed.
56
+ */
57
+ removeMiddleware(middlewareId: string): boolean;
58
+ /** The ids of every registered global middleware, in priority order. */
59
+ listMiddleware(): readonly string[];
25
60
  getRegistry(): HandlerRegistryStore;
26
61
  dispose(): void;
27
62
  }
@@ -6,13 +6,24 @@
6
6
  import { createMessageContext } from "../messageContext/messageContextType.type.js";
7
7
  import { HandlerRegistryStore } from "../handlerRegistry/handlerRegistryStore.js";
8
8
  import { runMessagePipeline } from "../messageMiddleware/messageMiddlewarePipeline.js";
9
- import { MessageDispatchAbortedError, MessageHandlerError, MessageDispatchError, } from "@zudojs/errors";
9
+ import { MessageDispatchAbortedError, MessageHandlerError, MessageMiddlewareError, MessageTimeoutError, MessageBusDisposedError, } from "@zudojs/errors";
10
+ /** Default priority for middleware that does not declare one. */
11
+ const DEFAULT_MIDDLEWARE_PRIORITY = 100;
10
12
  /**
11
13
  * Default dispatcher implementation.
12
14
  */
13
15
  export class DefaultDispatcher {
14
16
  registry;
17
+ /**
18
+ * Registered global middleware, in registration order.
19
+ *
20
+ * Held as {@link RegisteredMessageMiddleware} rather than bare functions:
21
+ * without an identity, `removeMiddleware` had nothing to match on and
22
+ * unconditionally returned false, and the `priority` the interface accepts
23
+ * had nowhere to live.
24
+ */
15
25
  globalMiddleware = [];
26
+ middlewareSequence = 0;
16
27
  disposed = false;
17
28
  constructor(registry) {
18
29
  this.registry = registry ?? new HandlerRegistryStore();
@@ -20,23 +31,50 @@ export class DefaultDispatcher {
20
31
  async dispatch(message, options = {}) {
21
32
  const dispatchStart = performance.now();
22
33
  this.validateNotDisposed(message);
23
- const signal = this.resolveSignal(options.signal);
34
+ const timeout = options.timeout ?? 0;
35
+ const controller = new AbortController();
36
+ const { signal, release } = this.resolveSignal(options.signal, controller);
24
37
  const context = createMessageContext(message, {
25
38
  ...options.context,
26
39
  signal,
27
40
  });
41
+ const ordered = this.orderedMiddleware();
42
+ const perDispatch = options.middleware ?? [];
28
43
  const allMiddleware = [
29
- ...this.globalMiddleware,
30
- ...(options.middleware ?? []),
44
+ ...ordered.map((entry) => entry.middleware),
45
+ ...perDispatch,
46
+ ];
47
+ const middlewareIds = [
48
+ ...ordered.map((entry) => entry.id),
49
+ ...perDispatch.map((_mw, index) => `dispatch:${index}`),
31
50
  ];
32
51
  const handlers = this.registry.resolve(message.type);
33
52
  const handlerResults = [];
53
+ let timer;
34
54
  try {
35
- const pipelineResult = await runMessagePipeline(allMiddleware, async (msg, mwCtx) => this.executeHandlers(msg, handlers, handlerResults, mwCtx.signal), message, {
55
+ const run = runMessagePipeline(allMiddleware,
56
+ // Handlers receive the same context the middleware saw: the one
57
+ // built from `DispatchOptions.context` (headers, state, correlation
58
+ // overrides) plus anything a middleware put into `state`. A fresh
59
+ // context per handler used to drop all of that on the floor.
60
+ async (msg, mwCtx) => this.executeHandlers(msg, handlers, handlerResults, mwCtx.context), message, {
36
61
  signal: context.signal,
37
- metadata: options.context?.headers,
38
- state: options.context?.state,
62
+ metadata: context.headers,
63
+ state: context.state,
64
+ middlewareIds,
65
+ context,
39
66
  });
67
+ // `DispatchOptions.timeout` was documented on the dispatcher but only
68
+ // ever honoured by the bus wrapper, so anyone holding a dispatcher
69
+ // directly got no timeout at all.
70
+ const pipelineResult = timeout > 0
71
+ ? await Promise.race([
72
+ run,
73
+ this.timeoutRejection(message, timeout, controller, (t) => {
74
+ timer = t;
75
+ }),
76
+ ])
77
+ : await run;
40
78
  return {
41
79
  success: true,
42
80
  value: pipelineResult.result,
@@ -57,36 +95,101 @@ export class DefaultDispatcher {
57
95
  duration: performance.now() - dispatchStart,
58
96
  };
59
97
  }
98
+ finally {
99
+ if (timer !== undefined)
100
+ clearTimeout(timer);
101
+ // Detach from the caller's signal, otherwise a long-lived signal
102
+ // shared across dispatches accumulates one listener per dispatch.
103
+ release();
104
+ }
105
+ }
106
+ /** A promise that rejects with {@link MessageTimeoutError} and aborts. */
107
+ timeoutRejection(message, timeout, controller, keepTimer) {
108
+ return new Promise((_resolve, reject) => {
109
+ const timer = setTimeout(() => {
110
+ const error = new MessageTimeoutError(timeout, {
111
+ messageType: message.type,
112
+ messageId: message.id,
113
+ });
114
+ // Abort first so a handler watching its signal can wind down rather
115
+ // than running on past the dispatch that gave up on it.
116
+ controller.abort(error);
117
+ reject(error);
118
+ }, timeout);
119
+ if (timer.unref)
120
+ timer.unref();
121
+ keepTimer(timer);
122
+ });
123
+ }
124
+ /** Global middleware ordered by priority, with disabled entries dropped. */
125
+ orderedMiddleware() {
126
+ return this.globalMiddleware
127
+ .filter((entry) => entry.enabled)
128
+ .map((entry, index) => ({ entry, index }))
129
+ .sort((a, b) => a.entry.priority - b.entry.priority || a.index - b.index)
130
+ .map(({ entry }) => entry);
60
131
  }
61
- validateNotDisposed(message) {
132
+ validateNotDisposed(_message) {
62
133
  if (this.disposed)
63
- throw new MessageDispatchError(message.type, "Dispatcher has been disposed.");
134
+ throw new MessageBusDisposedError();
64
135
  }
65
- resolveSignal(signal) {
66
- const resolved = signal ?? new AbortController().signal;
67
- if (resolved.aborted)
136
+ /**
137
+ * Resolves the signal handlers observe.
138
+ *
139
+ * The dispatcher's own controller is chained to the caller's signal so a
140
+ * timeout can abort the work without the caller losing its own cancellation.
141
+ */
142
+ resolveSignal(signal, controller) {
143
+ if (signal?.aborted)
68
144
  throw new MessageDispatchAbortedError();
69
- return resolved;
145
+ if (!signal) {
146
+ return { signal: controller.signal, release: () => { } };
147
+ }
148
+ const onAbort = () => controller.abort(signal.reason);
149
+ signal.addEventListener("abort", onAbort, { once: true });
150
+ return {
151
+ signal: controller.signal,
152
+ release: () => signal.removeEventListener("abort", onAbort),
153
+ };
70
154
  }
71
- async executeHandlers(message, handlers, handlerResults, signal) {
155
+ async executeHandlers(message, handlers, handlerResults, context) {
72
156
  const results = [];
73
157
  for (const handler of handlers) {
74
- const result = await this.executeHandler(handler, message, signal);
75
- results.push(result);
76
- handlerResults.push({
77
- handlerId: handler.id,
78
- success: true,
79
- value: result,
80
- duration: 0,
81
- });
158
+ // A dispatch cancelled between handlers is reported as an abort, not
159
+ // as a failure of the handler that never got to run.
160
+ if (context.signal.aborted) {
161
+ throw new MessageDispatchAbortedError(undefined, {
162
+ messageType: message.type,
163
+ messageId: message.id,
164
+ });
165
+ }
166
+ const start = performance.now();
167
+ try {
168
+ const result = await this.executeHandler(handler, message, context);
169
+ results.push(result);
170
+ handlerResults.push({
171
+ handlerId: handler.id,
172
+ success: true,
173
+ value: result,
174
+ duration: performance.now() - start,
175
+ });
176
+ }
177
+ catch (error) {
178
+ // A failing handler used to leave no trace at all in handlerResults,
179
+ // so a caller inspecting them could not tell which handler broke.
180
+ handlerResults.push({
181
+ handlerId: handler.id,
182
+ success: false,
183
+ error: error instanceof Error ? error : new Error(String(error)),
184
+ duration: performance.now() - start,
185
+ });
186
+ throw error;
187
+ }
82
188
  }
83
189
  return results.length === 1 ? results[0] : results;
84
190
  }
85
- async executeHandler(handler, message, signal) {
191
+ async executeHandler(handler, message, context) {
86
192
  try {
87
- if (signal.aborted)
88
- throw new MessageDispatchAbortedError();
89
- const context = createMessageContext(message, { signal });
90
193
  return await handler.handler(message, context);
91
194
  }
92
195
  catch (error) {
@@ -98,17 +201,57 @@ export class DefaultDispatcher {
98
201
  });
99
202
  }
100
203
  }
101
- use(middleware) {
102
- this.globalMiddleware.push(middleware);
204
+ /**
205
+ * Registers global middleware.
206
+ *
207
+ * @param middleware - The middleware to register.
208
+ * @param options - Identity, priority and enablement.
209
+ * @returns The identifier {@link DefaultDispatcher.removeMiddleware} takes.
210
+ * @throws {MessageMiddlewareError} when the requested id is already taken.
211
+ */
212
+ use(middleware, options = {}) {
213
+ const id = options.id ?? `middleware:${++this.middlewareSequence}`;
214
+ if (this.globalMiddleware.some((entry) => entry.id === id)) {
215
+ throw new MessageMiddlewareError(`Middleware "${id}" is already registered.`, { middlewareId: id });
216
+ }
217
+ const entry = {
218
+ id,
219
+ priority: options.priority ?? DEFAULT_MIDDLEWARE_PRIORITY,
220
+ enabled: options.enabled ?? true,
221
+ middleware: middleware,
222
+ ...(options.description !== undefined
223
+ ? { description: options.description }
224
+ : {}),
225
+ };
226
+ this.globalMiddleware.push(entry);
227
+ return id;
228
+ }
229
+ /**
230
+ * Removes previously registered global middleware.
231
+ *
232
+ * @param middlewareId - The id returned by {@link DefaultDispatcher.use}.
233
+ * @returns Whether a registration was removed.
234
+ */
235
+ removeMiddleware(middlewareId) {
236
+ const index = this.globalMiddleware.findIndex((entry) => entry.id === middlewareId);
237
+ if (index === -1)
238
+ return false;
239
+ this.globalMiddleware.splice(index, 1);
240
+ return true;
103
241
  }
104
- removeMiddleware(_middlewareId) {
105
- return false;
242
+ /** The ids of every registered global middleware, in priority order. */
243
+ listMiddleware() {
244
+ return this.globalMiddleware
245
+ .map((entry, index) => ({ entry, index }))
246
+ .sort((a, b) => a.entry.priority - b.entry.priority || a.index - b.index)
247
+ .map(({ entry }) => entry.id);
106
248
  }
107
249
  getRegistry() {
108
250
  return this.registry;
109
251
  }
110
252
  dispose() {
111
253
  this.disposed = true;
254
+ this.globalMiddleware.length = 0;
112
255
  }
113
256
  }
114
257
  export function createDispatcher(registry) {
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import type { Message } from "../message/messageType.type.js";
10
10
  import type { MessageContext, MessageContextOptions } from "../messageContext/messageContextType.type.js";
11
- import type { MessageMiddlewareLike, MessageMiddlewarePipelineResult } from "../messageMiddleware/messageMiddlewareType.type.js";
11
+ import type { MessageMiddlewareLike, MessageMiddlewareOptions, MessageMiddlewarePipelineResult } from "../messageMiddleware/messageMiddlewareType.type.js";
12
12
  /**
13
13
  * Result of dispatching a message.
14
14
  */
@@ -53,7 +53,12 @@ export interface DispatchOptions<TResult = unknown> {
53
53
  readonly context?: MessageContextOptions;
54
54
  /** Middleware to apply for this dispatch. */
55
55
  readonly middleware?: readonly MessageMiddlewareLike[];
56
- /** Timeout in ms. 0 = no timeout. Default: 0. */
56
+ /**
57
+ * Timeout in ms. 0 = no timeout. Default: 0.
58
+ *
59
+ * On expiry the dispatch is aborted through `context.signal` and the result
60
+ * carries a `MessageTimeoutError`.
61
+ */
57
62
  readonly timeout?: number;
58
63
  /** AbortSignal for cancellation. */
59
64
  readonly signal?: AbortSignal;
@@ -71,12 +76,18 @@ export interface Dispatcher {
71
76
  dispatch<TPayload, TResult>(message: Message<TPayload>, options?: DispatchOptions<TResult>): Promise<DispatchResult<TResult>>;
72
77
  /**
73
78
  * Registers middleware for all dispatches.
79
+ *
80
+ * Middleware runs in ascending `priority` order (default 100), registration
81
+ * order breaking ties.
82
+ *
83
+ * @returns The identifier {@link Dispatcher.removeMiddleware} takes, which
84
+ * is `options.id` when one was supplied.
74
85
  */
75
- use<TMessage extends Message = Message, TResult = unknown>(middleware: MessageMiddlewareLike<TMessage, TResult>, options?: {
76
- priority?: number;
77
- }): void;
86
+ use<TMessage extends Message = Message, TResult = unknown>(middleware: MessageMiddlewareLike<TMessage, TResult>, options?: MessageMiddlewareOptions): string;
78
87
  /**
79
88
  * Removes middleware by ID.
89
+ *
90
+ * @returns Whether a registration was removed.
80
91
  */
81
92
  removeMiddleware(middlewareId: string): boolean;
82
93
  }
@@ -16,6 +16,16 @@ export declare class HandlerRegistryStore {
16
16
  constructor(options?: HandlerRegistryOptions);
17
17
  register<TMessage extends Message, TResult>(handler: NamedMessageHandler<TMessage, TResult>): void;
18
18
  private validateNotDuplicate;
19
+ /**
20
+ * Rejects a second handler for a type when the registry is single-handler.
21
+ *
22
+ * `allowMultipleHandlers` was stored and never read, so a registry created
23
+ * with `allowMultipleHandlers: false` silently fanned a command out to
24
+ * every handler that had claimed its type.
25
+ *
26
+ * @throws {MessageError} when the type already has a handler.
27
+ */
28
+ private validateSingleHandlerPerType;
19
29
  private indexHandlerTypes;
20
30
  unregister(handlerId: string): boolean;
21
31
  private removeHandlerFromTypeIndex;
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * @module handlerRegistry/handlerRegistryStore
5
5
  */
6
- import { DuplicateMessageHandlerError } from "@zudojs/errors";
6
+ import { createMessageError, DuplicateMessageHandlerError, ErrorCode, } from "@zudojs/errors";
7
7
  /**
8
8
  * In-memory store for registered message handlers.
9
9
  */
@@ -15,12 +15,24 @@ export class HandlerRegistryStore {
15
15
  this.options = {
16
16
  allowDuplicateHandlerIds: false,
17
17
  allowMultipleHandlers: true,
18
- requireTypeRegistration: false,
19
18
  ...options,
20
19
  };
21
20
  }
22
21
  register(handler) {
23
22
  this.validateNotDuplicate(handler.id);
23
+ // Replacing a handler id must drop the old entry from the type index
24
+ // first, or the replacement keeps receiving the old handler's types.
25
+ const previous = this.handlers.get(handler.id);
26
+ if (previous !== undefined)
27
+ this.removeHandlerFromTypeIndex(previous);
28
+ try {
29
+ this.validateSingleHandlerPerType(handler);
30
+ }
31
+ catch (error) {
32
+ if (previous !== undefined)
33
+ this.indexHandlerTypes(previous.handler);
34
+ throw error;
35
+ }
24
36
  const entry = {
25
37
  handler,
26
38
  registeredAt: new Date(),
@@ -34,6 +46,30 @@ export class HandlerRegistryStore {
34
46
  throw new DuplicateMessageHandlerError(handlerId);
35
47
  }
36
48
  }
49
+ /**
50
+ * Rejects a second handler for a type when the registry is single-handler.
51
+ *
52
+ * `allowMultipleHandlers` was stored and never read, so a registry created
53
+ * with `allowMultipleHandlers: false` silently fanned a command out to
54
+ * every handler that had claimed its type.
55
+ *
56
+ * @throws {MessageError} when the type already has a handler.
57
+ */
58
+ validateSingleHandlerPerType(handler) {
59
+ if (this.options.allowMultipleHandlers !== false)
60
+ return;
61
+ for (const messageType of handler.messageTypes) {
62
+ const existing = this.handlersByType.get(messageType);
63
+ const taken = existing && existing.size > 0 ? [...existing][0] : undefined;
64
+ if (taken !== undefined) {
65
+ throw createMessageError(`Message type "${messageType}" already has handler "${taken}" and the registry does not allow multiple handlers.`, {
66
+ code: ErrorCode.CONFLICT,
67
+ messageType,
68
+ handlerId: handler.id,
69
+ });
70
+ }
71
+ }
72
+ }
37
73
  indexHandlerTypes(handler) {
38
74
  for (const messageType of handler.messageTypes) {
39
75
  let typeSet = this.handlersByType.get(messageType);
@@ -15,10 +15,13 @@ import type { Message } from "../message/messageType.type.js";
15
15
  export interface HandlerRegistryOptions {
16
16
  /** Allow duplicate handler IDs. Default: false. */
17
17
  readonly allowDuplicateHandlerIds?: boolean;
18
- /** Allow multiple handlers per message type. Default: true. */
18
+ /**
19
+ * Allow multiple handlers per message type. Default: true.
20
+ *
21
+ * Set false for a command/query registry, where a second handler for the
22
+ * same type is a wiring mistake rather than a fan-out.
23
+ */
19
24
  readonly allowMultipleHandlers?: boolean;
20
- /** Require message type registration before handler. Default: false. */
21
- readonly requireTypeRegistration?: boolean;
22
25
  }
23
26
  /**
24
27
  * Registered handler entry.
@@ -4,5 +4,5 @@
4
4
  * Core message types, factory functions, and identity primitives.
5
5
  */
6
6
  export type { MessageId, MessageType, MessageTimestamp, MessageSource, MessageCorrelationId, MessageCausationId, MessagePayload, Message, MessageInput, } from "./messageType.type.js";
7
- export { createMessageId, createMessage, createDerivedMessage, isMessage, getMessageType, getMessagePayload, describeMessage, } from "./messageFactory.js";
7
+ export { createMessageId, toMessageId, toCorrelationId, toCausationId, createMessage, createDerivedMessage, isMessage, getMessageType, getMessagePayload, describeMessage, } from "./messageFactory.js";
8
8
  //# sourceMappingURL=index.d.ts.map
@@ -3,5 +3,5 @@
3
3
  *
4
4
  * Core message types, factory functions, and identity primitives.
5
5
  */
6
- export { createMessageId, createMessage, createDerivedMessage, isMessage, getMessageType, getMessagePayload, describeMessage, } from "./messageFactory.js";
6
+ export { createMessageId, toMessageId, toCorrelationId, toCausationId, createMessage, createDerivedMessage, isMessage, getMessageType, getMessagePayload, describeMessage, } from "./messageFactory.js";
7
7
  //# sourceMappingURL=index.js.map
@@ -3,12 +3,28 @@
3
3
  *
4
4
  * @module message/messageFactory
5
5
  */
6
- import type { Message, MessageId, MessageType, MessagePayload, MessageInput } from "./messageType.type.js";
6
+ import type { Message, MessageId, MessageCorrelationId, MessageCausationId, MessageType, MessagePayload, MessageInput } from "./messageType.type.js";
7
7
  /**
8
8
  * Creates a unique message identifier.
9
9
  * Returns a branded MessageId type from @zudojs/constants.
10
10
  */
11
11
  export declare function createMessageId(): MessageId;
12
+ /**
13
+ * Brands an existing string as a {@link MessageId}.
14
+ *
15
+ * Identifiers arriving from outside the process — a transport frame, a
16
+ * database row, a log record — are plain strings. This is the supported way
17
+ * to restore the branded type without an unchecked cast.
18
+ */
19
+ export declare function toMessageId(value: string): MessageId;
20
+ /**
21
+ * Brands an existing string as a {@link MessageCorrelationId}.
22
+ */
23
+ export declare function toCorrelationId(value: string): MessageCorrelationId;
24
+ /**
25
+ * Brands an existing string as a {@link MessageCausationId}.
26
+ */
27
+ export declare function toCausationId(value: string): MessageCausationId;
12
28
  /**
13
29
  * Creates an immutable message.
14
30
  */