@zudojs/messaging 1.1.0 → 1.2.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.
package/README.md CHANGED
@@ -88,8 +88,39 @@ 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`.
119
+
120
+ Cancellation uses only `AbortSignal` and `setTimeout`, so it works in
121
+ browsers and other non-Node runtimes. 1.2.0 scheduled the abort with Node's
122
+ `setImmediate`, and aborting a dispatch in a browser threw
123
+ `ReferenceError: setImmediate is not defined`; later releases do not.
93
124
 
94
125
  ## Timeouts
95
126
 
@@ -0,0 +1,43 @@
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
+ * The macrotask is a `setTimeout(…, 0)`, not `setImmediate`: the package is
38
+ * not Node-only, and browsers have no `setImmediate`, so an abort there
39
+ * threw a `ReferenceError`. A microtask would not do — the dispatch records
40
+ * a handler's result several promise hops after the handler returns.
41
+ */
42
+ export declare function abortRejection(signal: AbortSignal, message: Message): AbortRejection;
43
+ //# sourceMappingURL=dispatcher.abort.d.ts.map
@@ -0,0 +1,67 @@
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
+ * The macrotask is a `setTimeout(…, 0)`, not `setImmediate`: the package is
41
+ * not Node-only, and browsers have no `setImmediate`, so an abort there
42
+ * threw a `ReferenceError`. A microtask would not do — the dispatch records
43
+ * a handler's result several promise hops after the handler returns.
44
+ */
45
+ export function abortRejection(signal, message) {
46
+ let pending;
47
+ let onAbort;
48
+ const promise = new Promise((_resolve, reject) => {
49
+ onAbort = () => {
50
+ pending = setTimeout(() => reject(abortErrorFor(signal, message)), 0);
51
+ };
52
+ signal.addEventListener("abort", onAbort, { once: true });
53
+ });
54
+ // The race owns this promise's outcome; a rejection that loses the race
55
+ // must not surface as unhandled.
56
+ promise.catch(() => { });
57
+ return {
58
+ promise,
59
+ dispose: () => {
60
+ if (onAbort)
61
+ signal.removeEventListener("abort", onAbort);
62
+ if (pending !== undefined)
63
+ clearTimeout(pending);
64
+ },
65
+ };
66
+ }
67
+ //# sourceMappingURL=dispatcher.abort.js.map
@@ -7,6 +7,7 @@ import { createMessageContext } from "../messageContext/messageContextType.type.
7
7
  import { resolveMessageHandler } from "../messageHandler/messageHandlerType.type.js";
8
8
  import { HandlerRegistryStore } from "../handlerRegistry/handlerRegistryStore.js";
9
9
  import { runMessagePipeline } from "../messageMiddleware/messageMiddlewarePipeline.js";
10
+ import { abortRejection, assertDispatchNotAborted, } from "./dispatcher.abort.js";
10
11
  import { MessageDispatchAbortedError, MessageHandlerError, MessageMiddlewareError, MessageTimeoutError, MessageBusDisposedError, } from "@zudojs/errors";
11
12
  /** Default priority for middleware that does not declare one. */
12
13
  const DEFAULT_MIDDLEWARE_PRIORITY = 100;
@@ -52,6 +53,7 @@ export class DefaultDispatcher {
52
53
  const handlers = this.registry.resolve(message.type);
53
54
  const handlerResults = [];
54
55
  let timer;
56
+ const aborted = abortRejection(context.signal, message);
55
57
  try {
56
58
  const run = runMessagePipeline(allMiddleware,
57
59
  // Handlers receive the same context the middleware saw: the one
@@ -67,15 +69,22 @@ export class DefaultDispatcher {
67
69
  });
68
70
  // `DispatchOptions.timeout` was documented on the dispatcher but only
69
71
  // ever honoured by the bus wrapper, so anyone holding a dispatcher
70
- // directly got no timeout at all.
71
- const pipelineResult = timeout > 0
72
- ? await Promise.race([
73
- run,
74
- this.timeoutRejection(message, timeout, controller, (t) => {
75
- timer = t;
76
- }),
77
- ])
78
- : 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);
79
88
  return {
80
89
  success: true,
81
90
  value: pipelineResult.result,
@@ -103,6 +112,7 @@ export class DefaultDispatcher {
103
112
  finally {
104
113
  if (timer !== undefined)
105
114
  clearTimeout(timer);
115
+ aborted.dispose();
106
116
  // Detach from the caller's signal, otherwise a long-lived signal
107
117
  // shared across dispatches accumulates one listener per dispatch.
108
118
  release();
@@ -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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zudojs/messaging",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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.1",
23
- "@zudojs/errors": "1.2.0",
24
- "@zudojs/middleware": "1.0.3"
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
  },