@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.
- package/LICENSE +21 -0
- package/README.md +85 -7
- package/dist/dispatcher/dispatcherCore.d.ts +38 -3
- package/dist/dispatcher/dispatcherCore.js +173 -30
- package/dist/dispatcher/dispatcherType.type.d.ts +16 -5
- package/dist/handlerRegistry/handlerRegistryStore.d.ts +10 -0
- package/dist/handlerRegistry/handlerRegistryStore.js +38 -2
- package/dist/handlerRegistry/handlerRegistryType.type.d.ts +6 -3
- package/dist/message/index.d.ts +1 -1
- package/dist/message/index.js +1 -1
- package/dist/message/messageFactory.d.ts +17 -1
- package/dist/message/messageFactory.js +33 -0
- package/dist/messageBus/messageBusCore.d.ts +4 -3
- package/dist/messageBus/messageBusCore.js +20 -25
- package/dist/messageBus/messageBusType.type.d.ts +12 -4
- package/dist/messageMiddleware/messageMiddlewarePipeline.js +18 -7
- package/dist/messageMiddleware/messageMiddlewareType.type.d.ts +11 -0
- package/package.json +25 -13
- package/dist/.tsbuildinfo +0 -1
- package/dist/dispatcher/dispatcherCore.d.ts.map +0 -1
- package/dist/dispatcher/dispatcherCore.js.map +0 -1
- package/dist/dispatcher/dispatcherType.type.d.ts.map +0 -1
- package/dist/dispatcher/dispatcherType.type.js.map +0 -1
- package/dist/dispatcher/index.d.ts.map +0 -1
- package/dist/dispatcher/index.js.map +0 -1
- package/dist/errors/index.d.ts.map +0 -1
- package/dist/errors/index.js.map +0 -1
- package/dist/handlerRegistry/handlerRegistryStore.d.ts.map +0 -1
- package/dist/handlerRegistry/handlerRegistryStore.js.map +0 -1
- package/dist/handlerRegistry/handlerRegistryType.type.d.ts.map +0 -1
- package/dist/handlerRegistry/handlerRegistryType.type.js.map +0 -1
- package/dist/handlerRegistry/index.d.ts.map +0 -1
- package/dist/handlerRegistry/index.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/message/index.d.ts.map +0 -1
- package/dist/message/index.js.map +0 -1
- package/dist/message/messageFactory.d.ts.map +0 -1
- package/dist/message/messageFactory.js.map +0 -1
- package/dist/message/messageType.type.d.ts.map +0 -1
- package/dist/message/messageType.type.js.map +0 -1
- package/dist/messageBus/index.d.ts.map +0 -1
- package/dist/messageBus/index.js.map +0 -1
- package/dist/messageBus/messageBusCore.d.ts.map +0 -1
- package/dist/messageBus/messageBusCore.js.map +0 -1
- package/dist/messageBus/messageBusType.type.d.ts.map +0 -1
- package/dist/messageBus/messageBusType.type.js.map +0 -1
- package/dist/messageContext/index.d.ts.map +0 -1
- package/dist/messageContext/index.js.map +0 -1
- package/dist/messageContext/messageContextType.type.d.ts.map +0 -1
- package/dist/messageContext/messageContextType.type.js.map +0 -1
- package/dist/messageHandler/index.d.ts.map +0 -1
- package/dist/messageHandler/index.js.map +0 -1
- package/dist/messageHandler/messageHandlerType.type.d.ts.map +0 -1
- package/dist/messageHandler/messageHandlerType.type.js.map +0 -1
- package/dist/messageMiddleware/index.d.ts.map +0 -1
- package/dist/messageMiddleware/index.js.map +0 -1
- package/dist/messageMiddleware/messageMiddlewarePipeline.d.ts.map +0 -1
- package/dist/messageMiddleware/messageMiddlewarePipeline.js.map +0 -1
- package/dist/messageMiddleware/messageMiddlewareType.type.d.ts.map +0 -1
- 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.
|
|
18
|
+
bus.on("user.created", (message) => {
|
|
19
19
|
console.log("New user:", message.payload);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
|
-
await bus.
|
|
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
|
|
31
|
-
- Middleware pipeline
|
|
32
|
-
-
|
|
33
|
-
-
|
|
34
|
-
-
|
|
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
|
-
|
|
24
|
-
|
|
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,
|
|
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
|
|
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
|
-
...
|
|
30
|
-
...
|
|
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
|
|
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:
|
|
38
|
-
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(
|
|
132
|
+
validateNotDisposed(_message) {
|
|
62
133
|
if (this.disposed)
|
|
63
|
-
throw new
|
|
134
|
+
throw new MessageBusDisposedError();
|
|
64
135
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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,
|
|
155
|
+
async executeHandlers(message, handlers, handlerResults, context) {
|
|
72
156
|
const results = [];
|
|
73
157
|
for (const handler of handlers) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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,
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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.
|
package/dist/message/index.d.ts
CHANGED
|
@@ -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
|
package/dist/message/index.js
CHANGED
|
@@ -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
|
*/
|