@zudojs/messaging 1.0.2 → 1.2.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.md CHANGED
@@ -88,8 +88,34 @@ one that failed, with its real duration.
88
88
  Handlers receive the dispatch context as their second argument: the
89
89
  `headers`, `state` and correlation/causation overrides passed through
90
90
  `DispatchOptions.context`, plus anything a middleware stored in
91
- `context.state`. A dispatch cancelled through its `AbortSignal` between
92
- handlers fails with `MessageDispatchAbortedError`.
91
+ `context.state`.
92
+
93
+ `send()` also puts a `correlationId` or `causationId` given in
94
+ `options.context` on the message it builds (unless the input carries its
95
+ own), so `createDerivedMessage(message, …)` in a handler stays in the same
96
+ chain:
97
+
98
+ ```typescript
99
+ bus.on("order.placed", (message) => {
100
+ const next = createDerivedMessage(message, { type: "invoice.requested", payload: {} });
101
+ next.correlationId; // "req-42"
102
+ });
103
+
104
+ await bus.send(
105
+ { type: "order.placed", payload: order },
106
+ { context: { correlationId: toCorrelationId("req-42") } },
107
+ );
108
+ ```
109
+
110
+ ## Cancellation
111
+
112
+ A dispatch whose `AbortSignal` fires fails with `MessageDispatchAbortedError`
113
+ (`success: false`), whenever the abort lands: between handlers, or during the
114
+ last or only one — even if that handler ignores the signal and returns
115
+ normally. Like a timeout, it settles promptly rather than waiting for a handler
116
+ that ignores its signal. `handlerResults` still lists every handler that
117
+ finished. Before 1.2.0 an abort during the last handler was reported as
118
+ `success: true`.
93
119
 
94
120
  ## Timeouts
95
121
 
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Turning an aborted dispatch signal into a failed dispatch.
3
+ *
4
+ * A timeout rejected the dispatch as soon as it fired, but a caller's abort
5
+ * only stopped the *next* handler from starting: when it arrived during the
6
+ * last or only handler, and that handler returned normally, the dispatch was
7
+ * reported as `success: true`.
8
+ *
9
+ * @module dispatcher/dispatcher.abort
10
+ */
11
+ import type { Message } from "../message/messageType.type.js";
12
+ /** A rejection armed on a signal, and the means to disarm it. */
13
+ export interface AbortRejection {
14
+ /** Rejects once the signal aborts; never resolves. */
15
+ readonly promise: Promise<never>;
16
+ /** Detaches from the signal and cancels a pending rejection. */
17
+ readonly dispose: () => void;
18
+ }
19
+ /**
20
+ * The error a dispatch fails with once `signal` has aborted.
21
+ *
22
+ * A timeout aborts with its own {@link MessageTimeoutError}, which is kept;
23
+ * any other abort is reported as {@link MessageDispatchAbortedError}.
24
+ */
25
+ export declare function abortErrorFor(signal: AbortSignal, message: Message): Error;
26
+ /** Throws the dispatch's abort error when `signal` has aborted. */
27
+ export declare function assertDispatchNotAborted(signal: AbortSignal, message: Message): void;
28
+ /**
29
+ * A promise that rejects when `signal` aborts, so a dispatch settles
30
+ * promptly — as it does on a timeout — even while a handler that ignores its
31
+ * signal is still running.
32
+ *
33
+ * The rejection waits one macrotask. A handler that aborts and then returns
34
+ * synchronously has its result recorded first, so `handlerResults` still
35
+ * lists every handler that finished.
36
+ */
37
+ export declare function abortRejection(signal: AbortSignal, message: Message): AbortRejection;
38
+ //# sourceMappingURL=dispatcher.abort.d.ts.map
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Turning an aborted dispatch signal into a failed dispatch.
3
+ *
4
+ * A timeout rejected the dispatch as soon as it fired, but a caller's abort
5
+ * only stopped the *next* handler from starting: when it arrived during the
6
+ * last or only handler, and that handler returned normally, the dispatch was
7
+ * reported as `success: true`.
8
+ *
9
+ * @module dispatcher/dispatcher.abort
10
+ */
11
+ import { MessageDispatchAbortedError, MessageTimeoutError, } from "@zudojs/errors";
12
+ /**
13
+ * The error a dispatch fails with once `signal` has aborted.
14
+ *
15
+ * A timeout aborts with its own {@link MessageTimeoutError}, which is kept;
16
+ * any other abort is reported as {@link MessageDispatchAbortedError}.
17
+ */
18
+ export function abortErrorFor(signal, message) {
19
+ if (signal.reason instanceof MessageTimeoutError)
20
+ return signal.reason;
21
+ return new MessageDispatchAbortedError(undefined, {
22
+ messageType: message.type,
23
+ messageId: message.id,
24
+ });
25
+ }
26
+ /** Throws the dispatch's abort error when `signal` has aborted. */
27
+ export function assertDispatchNotAborted(signal, message) {
28
+ if (signal.aborted)
29
+ throw abortErrorFor(signal, message);
30
+ }
31
+ /**
32
+ * A promise that rejects when `signal` aborts, so a dispatch settles
33
+ * promptly — as it does on a timeout — even while a handler that ignores its
34
+ * signal is still running.
35
+ *
36
+ * The rejection waits one macrotask. A handler that aborts and then returns
37
+ * synchronously has its result recorded first, so `handlerResults` still
38
+ * lists every handler that finished.
39
+ */
40
+ export function abortRejection(signal, message) {
41
+ let pending;
42
+ let onAbort;
43
+ const promise = new Promise((_resolve, reject) => {
44
+ onAbort = () => {
45
+ pending = setImmediate(() => reject(abortErrorFor(signal, message)));
46
+ };
47
+ signal.addEventListener("abort", onAbort, { once: true });
48
+ });
49
+ // The race owns this promise's outcome; a rejection that loses the race
50
+ // must not surface as unhandled.
51
+ promise.catch(() => { });
52
+ return {
53
+ promise,
54
+ dispose: () => {
55
+ if (onAbort)
56
+ signal.removeEventListener("abort", onAbort);
57
+ if (pending !== undefined)
58
+ clearImmediate(pending);
59
+ },
60
+ };
61
+ }
62
+ //# sourceMappingURL=dispatcher.abort.js.map
@@ -4,8 +4,10 @@
4
4
  * @module dispatcher/dispatcherCore
5
5
  */
6
6
  import { createMessageContext } from "../messageContext/messageContextType.type.js";
7
+ import { resolveMessageHandler } from "../messageHandler/messageHandlerType.type.js";
7
8
  import { HandlerRegistryStore } from "../handlerRegistry/handlerRegistryStore.js";
8
9
  import { runMessagePipeline } from "../messageMiddleware/messageMiddlewarePipeline.js";
10
+ import { abortRejection, assertDispatchNotAborted, } from "./dispatcher.abort.js";
9
11
  import { MessageDispatchAbortedError, MessageHandlerError, MessageMiddlewareError, MessageTimeoutError, MessageBusDisposedError, } from "@zudojs/errors";
10
12
  /** Default priority for middleware that does not declare one. */
11
13
  const DEFAULT_MIDDLEWARE_PRIORITY = 100;
@@ -51,6 +53,7 @@ export class DefaultDispatcher {
51
53
  const handlers = this.registry.resolve(message.type);
52
54
  const handlerResults = [];
53
55
  let timer;
56
+ const aborted = abortRejection(context.signal, message);
54
57
  try {
55
58
  const run = runMessagePipeline(allMiddleware,
56
59
  // Handlers receive the same context the middleware saw: the one
@@ -66,21 +69,32 @@ export class DefaultDispatcher {
66
69
  });
67
70
  // `DispatchOptions.timeout` was documented on the dispatcher but only
68
71
  // 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;
72
+ // directly got no timeout at all. An abort settles the dispatch the
73
+ // same way: promptly, and as a failure.
74
+ const pipelineResult = await Promise.race([
75
+ run,
76
+ aborted.promise,
77
+ ...(timeout > 0
78
+ ? [
79
+ this.timeoutRejection(message, timeout, controller, (t) => {
80
+ timer = t;
81
+ }),
82
+ ]
83
+ : []),
84
+ ]);
85
+ // A handler that returned normally after the abort does not make the
86
+ // dispatch a success: it was cancelled.
87
+ assertDispatchNotAborted(context.signal, message);
78
88
  return {
79
89
  success: true,
80
90
  value: pipelineResult.result,
81
91
  message,
82
92
  context,
83
- handlerResults,
93
+ // A copy, not the live array: on a timeout the dispatch settles
94
+ // while a handler is still running, and that handler later pushed
95
+ // a `success: true` record into the result the caller was already
96
+ // holding for a dispatch that had failed.
97
+ handlerResults: [...handlerResults],
84
98
  middlewareResult: pipelineResult,
85
99
  duration: performance.now() - dispatchStart,
86
100
  };
@@ -91,13 +105,14 @@ export class DefaultDispatcher {
91
105
  error: error instanceof Error ? error : new Error(String(error)),
92
106
  message,
93
107
  context,
94
- handlerResults,
108
+ handlerResults: [...handlerResults],
95
109
  duration: performance.now() - dispatchStart,
96
110
  };
97
111
  }
98
112
  finally {
99
113
  if (timer !== undefined)
100
114
  clearTimeout(timer);
115
+ aborted.dispose();
101
116
  // Detach from the caller's signal, otherwise a long-lived signal
102
117
  // shared across dispatches accumulates one listener per dispatch.
103
118
  release();
@@ -190,7 +205,11 @@ export class DefaultDispatcher {
190
205
  }
191
206
  async executeHandler(handler, message, context) {
192
207
  try {
193
- return await handler.handler(message, context);
208
+ // Handlers may be a plain function or an object with a `handle`
209
+ // method. Calling `handler.handler(...)` directly made the object
210
+ // form crash on every dispatch, so it goes through the same
211
+ // normaliser the public type advertises.
212
+ return await resolveMessageHandler(handler.handler)(message, context);
194
213
  }
195
214
  catch (error) {
196
215
  throw new MessageHandlerError(`Handler "${handler.id}" failed: ${error instanceof Error ? error.message : String(error)}`, {
@@ -9,6 +9,7 @@
9
9
  import type { Message } from "../message/messageType.type.js";
10
10
  import type { MessageContext, MessageContextOptions } from "../messageContext/messageContextType.type.js";
11
11
  import type { MessageMiddlewareLike, MessageMiddlewareOptions, MessageMiddlewarePipelineResult } from "../messageMiddleware/messageMiddlewareType.type.js";
12
+ import type { HandlerRegistryStore } from "../handlerRegistry/handlerRegistryStore.js";
12
13
  /**
13
14
  * Result of dispatching a message.
14
15
  */
@@ -90,5 +91,18 @@ export interface Dispatcher {
90
91
  * @returns Whether a registration was removed.
91
92
  */
92
93
  removeMiddleware(middlewareId: string): boolean;
94
+ /**
95
+ * The ids of every registered global middleware, in the order they run.
96
+ */
97
+ listMiddleware(): readonly string[];
98
+ /**
99
+ * The handler registry this dispatcher resolves handlers from.
100
+ */
101
+ getRegistry(): HandlerRegistryStore;
102
+ /**
103
+ * Releases the dispatcher: drops all middleware and rejects any further
104
+ * dispatch with `MessageBusDisposedError`.
105
+ */
106
+ dispose(): void;
93
107
  }
94
108
  //# sourceMappingURL=dispatcherType.type.d.ts.map
@@ -46,7 +46,15 @@ export class InMemoryMessageBus {
46
46
  }
47
47
  }
48
48
  async send(input, options = {}) {
49
- return this.dispatch(createMessage(input), options);
49
+ // Identifiers given through the dispatch context belong on the message
50
+ // too; otherwise `createDerivedMessage` in a handler starts a new chain.
51
+ const context = options.context;
52
+ const message = createMessage({
53
+ ...input,
54
+ correlationId: input.correlationId ?? context?.correlationId,
55
+ causationId: input.causationId ?? context?.causationId,
56
+ });
57
+ return this.dispatch(message, options);
50
58
  }
51
59
  on(messageType, handler, options = {}) {
52
60
  // A clock-derived id collided whenever two handlers for the same type
@@ -40,6 +40,10 @@ export interface MessageBus {
40
40
  dispatch<TPayload, TResult>(message: Message<TPayload>, options?: DispatchOptions<TResult>): Promise<DispatchResult<TResult>>;
41
41
  /**
42
42
  * Convenience method: creates and dispatches a message from input.
43
+ *
44
+ * A `correlationId` or `causationId` given in `options.context` is also
45
+ * put on the message when the input carries none, so a message derived
46
+ * from it in a handler stays in the same chain.
43
47
  */
44
48
  send<TPayload, TResult>(input: MessageInput<TPayload>, options?: DispatchOptions<TResult>): Promise<DispatchResult<TResult>>;
45
49
  /**
@@ -35,8 +35,13 @@ export interface NamedMessageHandler<TMessage extends Message = Message, TResult
35
35
  readonly id: string;
36
36
  /** Human-readable name for debugging. */
37
37
  readonly name: string;
38
- /** The handler function. */
39
- readonly handler: MessageHandler<TMessage, TResult>;
38
+ /**
39
+ * The handler itself: a function, or an object with a `handle` method.
40
+ *
41
+ * The dispatcher normalises both forms through
42
+ * {@link resolveMessageHandler} before invoking them.
43
+ */
44
+ readonly handler: MessageHandlerLike<TMessage, TResult>;
40
45
  /** Message types this handler processes. */
41
46
  readonly messageTypes: readonly string[];
42
47
  /** Execution priority (lower = earlier). Default: 100. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/messaging",
3
- "version": "1.0.2",
3
+ "version": "1.2.0",
4
4
  "description": "In-process message bus infrastructure with handlers, middleware, and publish/subscribe patterns.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,16 +19,16 @@
19
19
  "!dist/.tsbuildinfo"
20
20
  ],
21
21
  "dependencies": {
22
- "@zudojs/constants": "1.1.0",
23
- "@zudojs/errors": "1.1.0",
24
- "@zudojs/middleware": "1.0.2"
22
+ "@zudojs/constants": "1.1.2",
23
+ "@zudojs/errors": "1.3.0",
24
+ "@zudojs/middleware": "1.1.0"
25
25
  },
26
26
  "engines": {
27
27
  "node": ">=24.0.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "typescript": "7.0.2",
31
- "vitest": "^4.1.11"
31
+ "vitest": "^5.0.1"
32
32
  },
33
33
  "license": "MIT",
34
34
  "author": {
@@ -44,7 +44,7 @@
44
44
  "message-bus",
45
45
  "pubsub"
46
46
  ],
47
- "homepage": "https://github.com/oyinlola-tech/zudo#readme",
47
+ "homepage": "https://zudojs.oyinlola.site/docs/packages-messaging",
48
48
  "bugs": {
49
49
  "url": "https://github.com/oyinlola-tech/zudo/issues"
50
50
  },