@zmdb/app 1.0.0-beta.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 +674 -0
- package/README.md +27 -0
- package/dist/application.d.ts +45 -0
- package/dist/application.d.ts.map +1 -0
- package/dist/application.js +186 -0
- package/dist/application.js.map +1 -0
- package/dist/commands/index.d.ts +31 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +338 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/cqrs/index.d.ts +47 -0
- package/dist/cqrs/index.d.ts.map +1 -0
- package/dist/cqrs/index.js +76 -0
- package/dist/cqrs/index.js.map +1 -0
- package/dist/data/index.d.ts +12 -0
- package/dist/data/index.d.ts.map +1 -0
- package/dist/data/index.js +25 -0
- package/dist/data/index.js.map +1 -0
- package/dist/di/index.d.ts +44 -0
- package/dist/di/index.d.ts.map +1 -0
- package/dist/di/index.js +136 -0
- package/dist/di/index.js.map +1 -0
- package/dist/events/index.d.ts +46 -0
- package/dist/events/index.d.ts.map +1 -0
- package/dist/events/index.js +181 -0
- package/dist/events/index.js.map +1 -0
- package/dist/health/index.d.ts +35 -0
- package/dist/health/index.d.ts.map +1 -0
- package/dist/health/index.js +14 -0
- package/dist/health/index.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle.d.ts +21 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +52 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/messaging/index.d.ts +176 -0
- package/dist/messaging/index.d.ts.map +1 -0
- package/dist/messaging/index.js +442 -0
- package/dist/messaging/index.js.map +1 -0
- package/dist/messaging/transport-kit.d.ts +22 -0
- package/dist/messaging/transport-kit.d.ts.map +1 -0
- package/dist/messaging/transport-kit.js +174 -0
- package/dist/messaging/transport-kit.js.map +1 -0
- package/dist/modules/index.d.ts +52 -0
- package/dist/modules/index.d.ts.map +1 -0
- package/dist/modules/index.js +351 -0
- package/dist/modules/index.js.map +1 -0
- package/dist/modules/lifecycle-instances.d.ts +4 -0
- package/dist/modules/lifecycle-instances.d.ts.map +1 -0
- package/dist/modules/lifecycle-instances.js +19 -0
- package/dist/modules/lifecycle-instances.js.map +1 -0
- package/dist/modules/runtime.d.ts +18 -0
- package/dist/modules/runtime.d.ts.map +1 -0
- package/dist/modules/runtime.js +8 -0
- package/dist/modules/runtime.js.map +1 -0
- package/dist/observability/index.d.ts +27 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +145 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/propagation.d.ts +10 -0
- package/dist/observability/propagation.d.ts.map +1 -0
- package/dist/observability/propagation.js +96 -0
- package/dist/observability/propagation.js.map +1 -0
- package/dist/observability/types.d.ts +66 -0
- package/dist/observability/types.d.ts.map +1 -0
- package/dist/observability/types.js +10 -0
- package/dist/observability/types.js.map +1 -0
- package/dist/polyfill.d.ts +2 -0
- package/dist/polyfill.d.ts.map +1 -0
- package/dist/polyfill.js +21 -0
- package/dist/polyfill.js.map +1 -0
- package/dist/state/fixtures.d.ts +10 -0
- package/dist/state/fixtures.d.ts.map +1 -0
- package/dist/state/fixtures.js +14 -0
- package/dist/state/fixtures.js.map +1 -0
- package/dist/state/index.d.ts +26 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +37 -0
- package/dist/state/index.js.map +1 -0
- package/dist/state-transitions.d.ts +57 -0
- package/dist/state-transitions.d.ts.map +1 -0
- package/dist/state-transitions.js +31 -0
- package/dist/state-transitions.js.map +1 -0
- package/package.json +99 -0
- package/src/application.ts +243 -0
- package/src/commands/index.ts +447 -0
- package/src/cqrs/index.ts +124 -0
- package/src/data/index.ts +33 -0
- package/src/di/index.ts +179 -0
- package/src/events/index.ts +248 -0
- package/src/health/index.ts +54 -0
- package/src/index.ts +68 -0
- package/src/lifecycle.ts +62 -0
- package/src/messaging/index.ts +668 -0
- package/src/messaging/transport-kit.ts +234 -0
- package/src/modules/index.ts +476 -0
- package/src/modules/lifecycle-instances.ts +23 -0
- package/src/modules/runtime.ts +26 -0
- package/src/observability/index.ts +204 -0
- package/src/observability/propagation.ts +107 -0
- package/src/observability/types.ts +68 -0
- package/src/polyfill.ts +24 -0
- package/src/state/fixtures.ts +20 -0
- package/src/state/index.ts +57 -0
- package/src/state-transitions.ts +150 -0
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
// @zmdb/app — transport-neutral message dispatch and application-extension
|
|
2
|
+
// contracts. Strategies own broker framing and settlement; the framework owns
|
|
3
|
+
// validation, handler dispatch, request correlation and bounded client waits.
|
|
4
|
+
|
|
5
|
+
import { applicationExtensionControllersOf, type ApplicationExtension } from '../application.js';
|
|
6
|
+
import type { CompiledController } from '../modules/runtime.js';
|
|
7
|
+
import { consumerSpan, toTraceHeaders } from '../observability/index.js';
|
|
8
|
+
import type { Observability, Span, TraceCarrier } from '../observability/index.js';
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
abortError,
|
|
12
|
+
decodeDelivery,
|
|
13
|
+
decodeReply,
|
|
14
|
+
encodeDelivery,
|
|
15
|
+
encodeReply,
|
|
16
|
+
InFlight,
|
|
17
|
+
reportTransportError,
|
|
18
|
+
withinGrace,
|
|
19
|
+
} from './transport-kit.js';
|
|
20
|
+
export type { DeliveryMetadata, TransportErrorSink } from './transport-kit.js';
|
|
21
|
+
|
|
22
|
+
/** A parsed delivery constructed by a transport strategy. */
|
|
23
|
+
export interface RawMessage extends TraceCarrier {
|
|
24
|
+
readonly pattern: string;
|
|
25
|
+
readonly payload: unknown;
|
|
26
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
27
|
+
readonly correlationId: string | undefined;
|
|
28
|
+
readonly replyTo: string | undefined;
|
|
29
|
+
readonly deliveryAttempt: number;
|
|
30
|
+
/** Set when transport framing failed; `payload` then carries inspectable raw input. */
|
|
31
|
+
readonly parseError?: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The broker action applied after dispatch. */
|
|
35
|
+
export type Settlement =
|
|
36
|
+
| { readonly kind: 'ack' }
|
|
37
|
+
| { readonly kind: 'retry'; readonly afterMs: number }
|
|
38
|
+
| { readonly kind: 'dead'; readonly reason: string };
|
|
39
|
+
|
|
40
|
+
/** A correlated reply a strategy publishes to `RawMessage.replyTo`. */
|
|
41
|
+
export type MessageReply =
|
|
42
|
+
| { readonly kind: 'result'; readonly correlationId: string; readonly payload: unknown }
|
|
43
|
+
| { readonly kind: 'error'; readonly correlationId: string; readonly message: string };
|
|
44
|
+
|
|
45
|
+
/** Handler output and broker settlement are separate facts. */
|
|
46
|
+
export interface DispatchOutcome {
|
|
47
|
+
readonly settlement: Settlement;
|
|
48
|
+
readonly reply?: MessageReply;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** What a strategy can truthfully do with a settlement. */
|
|
52
|
+
export interface TransportCapabilities {
|
|
53
|
+
readonly redelivery: boolean;
|
|
54
|
+
readonly deadLetter: boolean;
|
|
55
|
+
readonly requestResponse: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One outbound request, including framework-owned correlation and cancellation. */
|
|
59
|
+
export interface TransportRequest extends TraceCarrier {
|
|
60
|
+
readonly pattern: string;
|
|
61
|
+
readonly payload: unknown;
|
|
62
|
+
readonly correlationId: string;
|
|
63
|
+
readonly timeoutMs: number;
|
|
64
|
+
readonly signal: AbortSignal;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Public boundary implemented by broker adapters and custom transports. */
|
|
68
|
+
export interface TransportStrategy {
|
|
69
|
+
readonly name: string;
|
|
70
|
+
readonly capabilities: TransportCapabilities;
|
|
71
|
+
/** Open intake and hand each decoded delivery to the application dispatcher. */
|
|
72
|
+
listen(dispatch: (message: RawMessage) => Promise<DispatchOutcome>): Promise<void>;
|
|
73
|
+
send(request: TransportRequest): Promise<MessageReply>;
|
|
74
|
+
emit(pattern: string, payload: unknown, carrier?: TraceCarrier): Promise<void>;
|
|
75
|
+
/** Stop intake, drain in-flight dispatches under `graceMs`, then close connections. */
|
|
76
|
+
close(graceMs: number): Promise<void>;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The structural context portion HTTP and message authorisation can share. */
|
|
80
|
+
export type WithHeaders = { readonly headers: Readonly<Record<string, string>> };
|
|
81
|
+
|
|
82
|
+
/** Validated context supplied to one message handler. */
|
|
83
|
+
export interface MessageContext<T> {
|
|
84
|
+
readonly kind: 'message';
|
|
85
|
+
readonly pattern: string;
|
|
86
|
+
readonly payload: T;
|
|
87
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
88
|
+
readonly correlationId: string;
|
|
89
|
+
readonly deliveryAttempt: number;
|
|
90
|
+
readonly transport: string;
|
|
91
|
+
readonly span?: Span;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Public, validator-free description of one decorated handler. */
|
|
95
|
+
export interface ResolvedMessagePattern {
|
|
96
|
+
readonly pattern: string;
|
|
97
|
+
readonly handlerName: string;
|
|
98
|
+
readonly semantics: 'request' | 'event';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Observation and retry policy for one app-owned dispatcher. */
|
|
102
|
+
export interface DispatcherOptions {
|
|
103
|
+
readonly onUnhandled: (message: RawMessage) => void;
|
|
104
|
+
readonly onInvalidPayload: (message: RawMessage, error: unknown) => void;
|
|
105
|
+
readonly onHandlerError: (message: RawMessage, error: unknown) => void;
|
|
106
|
+
readonly onUndeliverable?: (message: RawMessage, settlement: Settlement) => void;
|
|
107
|
+
readonly maxAttempts?: number;
|
|
108
|
+
readonly retryAfterMs?: (attempt: number) => number;
|
|
109
|
+
readonly observability?: Observability;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Startup-built exact-pattern dispatcher. */
|
|
113
|
+
export interface MessageDispatcher {
|
|
114
|
+
dispatch(message: RawMessage, transport: string): Promise<DispatchOutcome>;
|
|
115
|
+
readonly patterns: readonly string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Pattern map for a request/response client. Declare concrete maps as type aliases. */
|
|
119
|
+
export interface ClientPatterns {
|
|
120
|
+
readonly [pattern: string]: { readonly request: unknown; readonly response: unknown };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One callable property per request pattern. */
|
|
124
|
+
export type MessageClient<P extends ClientPatterns> = {
|
|
125
|
+
readonly [K in keyof P]: (payload: P[K]['request'], span?: Span) => Promise<P[K]['response']>;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Required timeout and total response-validation map. */
|
|
129
|
+
export interface MessageClientOptions<P extends ClientPatterns> {
|
|
130
|
+
readonly timeoutMs: number;
|
|
131
|
+
readonly validate: { readonly [K in keyof P]: (raw: unknown) => P[K]['response'] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Pattern map for one-way events. */
|
|
135
|
+
export interface EventPatterns {
|
|
136
|
+
readonly [pattern: string]: unknown;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** One callable property per event pattern. */
|
|
140
|
+
export type EventPublisher<E extends EventPatterns> = {
|
|
141
|
+
readonly [K in keyof E]: (payload: E[K], span?: Span) => Promise<void>;
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** A request was made against a strategy without request/reply support. */
|
|
145
|
+
export class TransportUnsupportedError extends Error {
|
|
146
|
+
readonly transport: string;
|
|
147
|
+
readonly operation: 'send';
|
|
148
|
+
|
|
149
|
+
constructor(transport: string) {
|
|
150
|
+
super(`transport "${transport}" does not support request/response`);
|
|
151
|
+
this.name = 'TransportUnsupportedError';
|
|
152
|
+
this.transport = transport;
|
|
153
|
+
this.operation = 'send';
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A request did not settle before its required deadline. */
|
|
158
|
+
export class MessageTimeoutError extends Error {
|
|
159
|
+
readonly pattern: string;
|
|
160
|
+
readonly timeoutMs: number;
|
|
161
|
+
readonly correlationId: string;
|
|
162
|
+
|
|
163
|
+
constructor(pattern: string, timeoutMs: number, correlationId: string) {
|
|
164
|
+
super(`message request "${pattern}" timed out after ${String(timeoutMs)}ms`);
|
|
165
|
+
this.name = 'MessageTimeoutError';
|
|
166
|
+
this.pattern = pattern;
|
|
167
|
+
this.timeoutMs = timeoutMs;
|
|
168
|
+
this.correlationId = correlationId;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** A remote handler failed without exposing its private error detail. */
|
|
173
|
+
export class MessageRemoteError extends Error {
|
|
174
|
+
readonly pattern: string;
|
|
175
|
+
readonly correlationId: string;
|
|
176
|
+
|
|
177
|
+
constructor(pattern: string, correlationId: string, message: string) {
|
|
178
|
+
super(message);
|
|
179
|
+
this.name = 'MessageRemoteError';
|
|
180
|
+
this.pattern = pattern;
|
|
181
|
+
this.correlationId = correlationId;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** A transport returned a reply for a different outstanding request. */
|
|
186
|
+
export class MessageCorrelationError extends Error {
|
|
187
|
+
readonly pattern: string;
|
|
188
|
+
readonly expected: string;
|
|
189
|
+
readonly received: string;
|
|
190
|
+
|
|
191
|
+
constructor(pattern: string, expected: string, received: string) {
|
|
192
|
+
super(`message request "${pattern}" received correlation "${received}", expected "${expected}"`);
|
|
193
|
+
this.name = 'MessageCorrelationError';
|
|
194
|
+
this.pattern = pattern;
|
|
195
|
+
this.expected = expected;
|
|
196
|
+
this.received = received;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
type MessageSemantics = ResolvedMessagePattern['semantics'];
|
|
201
|
+
type Validator = (raw: unknown) => unknown;
|
|
202
|
+
|
|
203
|
+
interface StoredMessagePattern extends ResolvedMessagePattern {
|
|
204
|
+
readonly validate: Validator;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface MessageMetadata {
|
|
208
|
+
[MESSAGE_PATTERNS]?: StoredMessagePattern[];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
interface BoundMessagePattern extends StoredMessagePattern {
|
|
212
|
+
invoke(ctx: MessageContext<unknown>): Promise<unknown>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const MESSAGE_PATTERNS = Symbol('zmdb.app.messaging.patterns');
|
|
216
|
+
const DEFAULT_MAX_ATTEMPTS = 5;
|
|
217
|
+
const MAX_RETRY_MS = 30_000;
|
|
218
|
+
|
|
219
|
+
// boundary: MessagePattern and EventPattern are the only writers of this
|
|
220
|
+
// private metadata slot, so the typed view is sound.
|
|
221
|
+
function messageMetadata(metadata: DecoratorMetadata): MessageMetadata {
|
|
222
|
+
return metadata;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function pushPattern(metadata: DecoratorMetadata, pattern: StoredMessagePattern): void {
|
|
226
|
+
const view = messageMetadata(metadata);
|
|
227
|
+
const own = Object.hasOwn(metadata, MESSAGE_PATTERNS) ? view[MESSAGE_PATTERNS] : undefined;
|
|
228
|
+
if (own === undefined) {
|
|
229
|
+
view[MESSAGE_PATTERNS] = [pattern];
|
|
230
|
+
} else {
|
|
231
|
+
own.push(pattern);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function ownPatterns(metadata: DecoratorMetadata): readonly StoredMessagePattern[] {
|
|
236
|
+
if (!Object.hasOwn(metadata, MESSAGE_PATTERNS)) {
|
|
237
|
+
return [];
|
|
238
|
+
}
|
|
239
|
+
return messageMetadata(metadata)[MESSAGE_PATTERNS] ?? [];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function storedPatterns(cls: Function): readonly StoredMessagePattern[] {
|
|
243
|
+
const metadata = cls[Symbol.metadata];
|
|
244
|
+
if (metadata === undefined || metadata === null) {
|
|
245
|
+
return [];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const baseFirst: DecoratorMetadata[] = [];
|
|
249
|
+
for (let record: DecoratorMetadata | null = metadata; record !== null; record = Object.getPrototypeOf(record)) {
|
|
250
|
+
baseFirst.unshift(record);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let composed: readonly StoredMessagePattern[] = [];
|
|
254
|
+
for (const record of baseFirst) {
|
|
255
|
+
const own = ownPatterns(record);
|
|
256
|
+
if (own.length === 0) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const overridden = new Set(own.map(pattern => pattern.handlerName));
|
|
260
|
+
composed = [...composed.filter(pattern => !overridden.has(pattern.handlerName)), ...own];
|
|
261
|
+
}
|
|
262
|
+
return composed;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function patternDecorator<T, R>(
|
|
266
|
+
pattern: string,
|
|
267
|
+
validate: (raw: unknown) => T,
|
|
268
|
+
semantics: MessageSemantics,
|
|
269
|
+
): (target: (ctx: MessageContext<T>) => R, context: ClassMethodDecoratorContext) => void {
|
|
270
|
+
return function (_target: (ctx: MessageContext<T>) => R, context: ClassMethodDecoratorContext): void {
|
|
271
|
+
const handlerName = String(context.name);
|
|
272
|
+
pushPattern(context.metadata, { pattern, handlerName, semantics, validate });
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Declare a request/reply handler with its consume-boundary validator. */
|
|
277
|
+
export function MessagePattern<T, R>(
|
|
278
|
+
pattern: string,
|
|
279
|
+
validate: (raw: unknown) => T,
|
|
280
|
+
): (target: (ctx: MessageContext<T>) => R | Promise<R>, context: ClassMethodDecoratorContext) => void {
|
|
281
|
+
return patternDecorator<T, R | Promise<R>>(pattern, validate, 'request');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Declare a one-way event handler. Returning a value is a compile error. */
|
|
285
|
+
export function EventPattern<T>(
|
|
286
|
+
pattern: string,
|
|
287
|
+
validate: (raw: unknown) => T,
|
|
288
|
+
): (target: (ctx: MessageContext<T>) => void | Promise<void>, context: ClassMethodDecoratorContext) => void {
|
|
289
|
+
return patternDecorator<T, void | Promise<void>>(pattern, validate, 'event');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Read declarations from one class without constructing or discovering it. */
|
|
293
|
+
export function getMessagePatterns(cls: abstract new (...args: never[]) => unknown): readonly ResolvedMessagePattern[] {
|
|
294
|
+
return storedPatterns(cls).map(({ pattern, handlerName, semantics }) => ({ pattern, handlerName, semantics }));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function constructorOf(instance: object): Function {
|
|
298
|
+
const ctor = instance.constructor;
|
|
299
|
+
if (typeof ctor !== 'function') {
|
|
300
|
+
throw new Error('@zmdb/app: message consumer has no constructor');
|
|
301
|
+
}
|
|
302
|
+
return ctor;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function boundPatterns(instance: object): readonly BoundMessagePattern[] {
|
|
306
|
+
return storedPatterns(constructorOf(instance)).map(pattern => {
|
|
307
|
+
const value: unknown = Reflect.get(instance, pattern.handlerName);
|
|
308
|
+
if (typeof value !== 'function') {
|
|
309
|
+
throw new Error(`@zmdb/app: message handler "${pattern.handlerName}" is not callable`);
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
...pattern,
|
|
313
|
+
async invoke(ctx): Promise<unknown> {
|
|
314
|
+
return Reflect.apply(value, instance, [ctx]);
|
|
315
|
+
},
|
|
316
|
+
};
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function positiveInteger(value: number, name: string): number {
|
|
321
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
322
|
+
throw new RangeError(`@zmdb/app: ${name} must be a positive integer`);
|
|
323
|
+
}
|
|
324
|
+
return value;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function defaultRetryAfter(attempt: number): number {
|
|
328
|
+
return Math.min(MAX_RETRY_MS, 1_000 * 2 ** Math.max(0, attempt - 1));
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function observe(action: () => void): void {
|
|
332
|
+
try {
|
|
333
|
+
void Promise.resolve(action()).catch(() => undefined);
|
|
334
|
+
} catch {
|
|
335
|
+
// Observation cannot replace dispatch or settlement.
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function withReply(settlement: Settlement, reply: MessageReply | undefined): DispatchOutcome {
|
|
340
|
+
return reply === undefined ? { settlement } : { settlement, reply };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function errorReply(message: RawMessage, detail: string): MessageReply | undefined {
|
|
344
|
+
if (message.replyTo === undefined || message.correlationId === undefined) {
|
|
345
|
+
return undefined;
|
|
346
|
+
}
|
|
347
|
+
return { kind: 'error', correlationId: message.correlationId, message: detail };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function isRequestEnvelope(message: RawMessage): boolean {
|
|
351
|
+
return message.replyTo !== undefined && message.correlationId !== undefined;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Build one exact-pattern map for the supplied application-owned consumers. */
|
|
355
|
+
export function createMessageDispatcher(consumers: readonly object[], options: DispatcherOptions): MessageDispatcher {
|
|
356
|
+
const maxAttempts = positiveInteger(options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, 'maxAttempts');
|
|
357
|
+
const byPattern = new Map<string, BoundMessagePattern>();
|
|
358
|
+
|
|
359
|
+
for (const consumer of consumers) {
|
|
360
|
+
for (const pattern of boundPatterns(consumer)) {
|
|
361
|
+
if (pattern.pattern.length === 0) {
|
|
362
|
+
throw new RangeError('@zmdb/app: a message pattern cannot be empty');
|
|
363
|
+
}
|
|
364
|
+
if (byPattern.has(pattern.pattern)) {
|
|
365
|
+
throw new Error(`@zmdb/app: duplicate message pattern "${pattern.pattern}"`);
|
|
366
|
+
}
|
|
367
|
+
byPattern.set(pattern.pattern, pattern);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const failureSettlement = (message: RawMessage, error: unknown): Settlement => {
|
|
372
|
+
observe(() => options.onHandlerError(message, error));
|
|
373
|
+
if (message.deliveryAttempt >= maxAttempts) {
|
|
374
|
+
return { kind: 'dead', reason: 'attempts-exhausted' };
|
|
375
|
+
}
|
|
376
|
+
const retryAfter = options.retryAfterMs ?? defaultRetryAfter;
|
|
377
|
+
let afterMs: number;
|
|
378
|
+
try {
|
|
379
|
+
afterMs = positiveInteger(retryAfter(message.deliveryAttempt), 'retryAfterMs result');
|
|
380
|
+
} catch (policyError) {
|
|
381
|
+
observe(() => options.onHandlerError(message, policyError));
|
|
382
|
+
return { kind: 'dead', reason: 'invalid-retry-policy' };
|
|
383
|
+
}
|
|
384
|
+
return { kind: 'retry', afterMs };
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
patterns: [...byPattern.keys()],
|
|
389
|
+
|
|
390
|
+
async dispatch(message, transport): Promise<DispatchOutcome> {
|
|
391
|
+
const binding = byPattern.get(message.pattern);
|
|
392
|
+
if (binding === undefined) {
|
|
393
|
+
observe(() => options.onUnhandled(message));
|
|
394
|
+
return withReply({ kind: 'ack' }, errorReply(message, 'message pattern is not handled'));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (message.parseError !== undefined) {
|
|
398
|
+
observe(() => options.onInvalidPayload(message, message.parseError));
|
|
399
|
+
return withReply({ kind: 'dead', reason: 'invalid-payload' }, errorReply(message, 'invalid message payload'));
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let payload: unknown;
|
|
403
|
+
try {
|
|
404
|
+
payload = binding.validate(message.payload);
|
|
405
|
+
} catch (error) {
|
|
406
|
+
observe(() => options.onInvalidPayload(message, error));
|
|
407
|
+
return withReply({ kind: 'dead', reason: 'invalid-payload' }, errorReply(message, 'invalid message payload'));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (binding.semantics === 'request' && !isRequestEnvelope(message)) {
|
|
411
|
+
const error = new Error(`request pattern "${message.pattern}" requires correlationId and replyTo`);
|
|
412
|
+
observe(() => options.onHandlerError(message, error));
|
|
413
|
+
return { settlement: { kind: 'dead', reason: 'invalid-request-envelope' } };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const context: MessageContext<unknown> = {
|
|
417
|
+
kind: 'message',
|
|
418
|
+
pattern: message.pattern,
|
|
419
|
+
payload,
|
|
420
|
+
headers: message.headers,
|
|
421
|
+
correlationId: message.correlationId ?? globalThis.crypto.randomUUID(),
|
|
422
|
+
deliveryAttempt: message.deliveryAttempt,
|
|
423
|
+
transport,
|
|
424
|
+
...(options.observability?.tracer === undefined
|
|
425
|
+
? {}
|
|
426
|
+
: {
|
|
427
|
+
span: consumerSpan(
|
|
428
|
+
options.observability,
|
|
429
|
+
message,
|
|
430
|
+
binding.semantics === 'request' ? 'request-reply' : 'queued',
|
|
431
|
+
),
|
|
432
|
+
}),
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
try {
|
|
436
|
+
const result = await binding.invoke(context);
|
|
437
|
+
if (binding.semantics === 'request' && message.correlationId !== undefined) {
|
|
438
|
+
return {
|
|
439
|
+
settlement: { kind: 'ack' },
|
|
440
|
+
reply: { kind: 'result', correlationId: message.correlationId, payload: result },
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
return { settlement: { kind: 'ack' } };
|
|
444
|
+
} catch (error) {
|
|
445
|
+
context.span?.recordException(error instanceof Error ? error : new Error(String(error)));
|
|
446
|
+
context.span?.setStatus({ error: true });
|
|
447
|
+
if (binding.semantics === 'request') {
|
|
448
|
+
observe(() => options.onHandlerError(message, error));
|
|
449
|
+
return withReply({ kind: 'ack' }, errorReply(message, 'message handler failed'));
|
|
450
|
+
}
|
|
451
|
+
return { settlement: failureSettlement(message, error) };
|
|
452
|
+
} finally {
|
|
453
|
+
context.span?.end();
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Attach transport strategies to the protocol-neutral application lifecycle.
|
|
461
|
+
*
|
|
462
|
+
* The strategy snapshot and exact-pattern dispatcher are application-owned;
|
|
463
|
+
* concrete broker clients remain outside this package.
|
|
464
|
+
*/
|
|
465
|
+
export function transportExtension(options: {
|
|
466
|
+
readonly transports: readonly TransportStrategy[];
|
|
467
|
+
readonly dispatcher: DispatcherOptions;
|
|
468
|
+
}): ApplicationExtension {
|
|
469
|
+
const transports = Object.freeze([...options.transports]);
|
|
470
|
+
const dispatcherOptions = options.dispatcher;
|
|
471
|
+
let entered: TransportStrategy[] = [];
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
name: '@zmdb/app:messaging',
|
|
475
|
+
|
|
476
|
+
async start(context) {
|
|
477
|
+
validateTransportNames(transports);
|
|
478
|
+
validateLazyConsumers(applicationExtensionControllersOf(context));
|
|
479
|
+
for (const transport of transports) {
|
|
480
|
+
validateUndeliverableSink(transport, dispatcherOptions);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const dispatcher = createMessageDispatcher(context.controllers, {
|
|
484
|
+
...dispatcherOptions,
|
|
485
|
+
observability: context.observability,
|
|
486
|
+
});
|
|
487
|
+
for (const transport of transports) {
|
|
488
|
+
entered.push(transport);
|
|
489
|
+
await transport.listen(async message => {
|
|
490
|
+
const outcome = await dispatcher.dispatch(message, transport.name);
|
|
491
|
+
reportUndeliverable(transport, dispatcherOptions, message, outcome.settlement);
|
|
492
|
+
return outcome;
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
|
|
497
|
+
async stop({ graceMs }) {
|
|
498
|
+
const errors: unknown[] = [];
|
|
499
|
+
const deadline = Date.now() + graceMs;
|
|
500
|
+
for (let index = entered.length - 1; index >= 0; index -= 1) {
|
|
501
|
+
const transport = entered[index];
|
|
502
|
+
if (transport === undefined) continue;
|
|
503
|
+
try {
|
|
504
|
+
await transport.close(Math.max(0, deadline - Date.now()));
|
|
505
|
+
} catch (error) {
|
|
506
|
+
errors.push(error);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
entered = [];
|
|
510
|
+
throwTransportErrors(errors);
|
|
511
|
+
},
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function validateTransportNames(transports: readonly TransportStrategy[]): void {
|
|
516
|
+
const names = new Set<string>();
|
|
517
|
+
for (const transport of transports) {
|
|
518
|
+
if (transport.name.length === 0) {
|
|
519
|
+
throw new RangeError('@zmdb/app: a transport name cannot be empty');
|
|
520
|
+
}
|
|
521
|
+
if (names.has(transport.name)) {
|
|
522
|
+
throw new Error(`@zmdb/app: duplicate transport name "${transport.name}"`);
|
|
523
|
+
}
|
|
524
|
+
names.add(transport.name);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function validateLazyConsumers(controllers: readonly CompiledController[]): void {
|
|
529
|
+
for (const binding of controllers) {
|
|
530
|
+
if (binding.kind === 'deferred' && getMessagePatterns(binding.controller).length > 0) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
`@zmdb/app: lazy controller "${binding.controller.name}" declares message patterns; ` +
|
|
533
|
+
'message consumers must be eager',
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function validateUndeliverableSink(transport: TransportStrategy, options: DispatcherOptions): void {
|
|
540
|
+
if (
|
|
541
|
+
(!transport.capabilities.redelivery || !transport.capabilities.deadLetter) &&
|
|
542
|
+
options.onUndeliverable === undefined
|
|
543
|
+
) {
|
|
544
|
+
throw new Error(`@zmdb/app: transport "${transport.name}" requires onUndeliverable`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function reportUndeliverable(
|
|
549
|
+
transport: TransportStrategy,
|
|
550
|
+
options: DispatcherOptions,
|
|
551
|
+
message: RawMessage,
|
|
552
|
+
settlement: Settlement,
|
|
553
|
+
): void {
|
|
554
|
+
const dropped =
|
|
555
|
+
(settlement.kind === 'retry' && !transport.capabilities.redelivery) ||
|
|
556
|
+
(settlement.kind === 'dead' && !transport.capabilities.deadLetter);
|
|
557
|
+
if (!dropped || options.onUndeliverable === undefined) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
observe(() => options.onUndeliverable?.(message, settlement));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function throwTransportErrors(errors: readonly unknown[]): void {
|
|
564
|
+
if (errors.length === 0) return;
|
|
565
|
+
const first = errors[0];
|
|
566
|
+
if (errors.length === 1 && first !== undefined) throw first;
|
|
567
|
+
throw new AggregateError(errors, '@zmdb/app: transport shutdown failed');
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function requestMethod(
|
|
571
|
+
transport: TransportStrategy,
|
|
572
|
+
pattern: string,
|
|
573
|
+
timeoutMs: number,
|
|
574
|
+
validate: Function,
|
|
575
|
+
): (payload: unknown, span?: Span) => Promise<unknown> {
|
|
576
|
+
return async (payload: unknown, span?: Span): Promise<unknown> => {
|
|
577
|
+
if (!transport.capabilities.requestResponse) {
|
|
578
|
+
throw new TransportUnsupportedError(transport.name);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const correlationId = globalThis.crypto.randomUUID();
|
|
582
|
+
const controller = new AbortController();
|
|
583
|
+
const timeoutError = new MessageTimeoutError(pattern, timeoutMs, correlationId);
|
|
584
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
585
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
586
|
+
timer = setTimeout(() => {
|
|
587
|
+
controller.abort(timeoutError);
|
|
588
|
+
reject(timeoutError);
|
|
589
|
+
}, timeoutMs);
|
|
590
|
+
});
|
|
591
|
+
const request: TransportRequest = {
|
|
592
|
+
pattern,
|
|
593
|
+
payload,
|
|
594
|
+
correlationId,
|
|
595
|
+
timeoutMs,
|
|
596
|
+
signal: controller.signal,
|
|
597
|
+
...(span === undefined ? {} : toTraceHeaders(span)),
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
try {
|
|
601
|
+
const reply = await Promise.race([Promise.resolve().then(() => transport.send(request)), timeout]);
|
|
602
|
+
if (reply.correlationId !== correlationId) {
|
|
603
|
+
throw new MessageCorrelationError(pattern, correlationId, reply.correlationId);
|
|
604
|
+
}
|
|
605
|
+
if (reply.kind === 'error') {
|
|
606
|
+
throw new MessageRemoteError(pattern, correlationId, reply.message);
|
|
607
|
+
}
|
|
608
|
+
return Reflect.apply(validate, undefined, [reply.payload]);
|
|
609
|
+
} finally {
|
|
610
|
+
if (timer !== undefined) {
|
|
611
|
+
clearTimeout(timer);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Build a typed request client from a total response-validator map. */
|
|
618
|
+
export function createMessageClient<P extends ClientPatterns>(
|
|
619
|
+
transport: TransportStrategy,
|
|
620
|
+
options: MessageClientOptions<P>,
|
|
621
|
+
): MessageClient<P> {
|
|
622
|
+
const timeoutMs = positiveInteger(options.timeoutMs, 'timeoutMs');
|
|
623
|
+
// boundary: the mapped type and total validator map have the same key set;
|
|
624
|
+
// every own validator key is installed before the client is returned.
|
|
625
|
+
const client: MessageClient<P> = Object.create(null);
|
|
626
|
+
for (const pattern of Object.keys(options.validate)) {
|
|
627
|
+
const validator: unknown = Reflect.get(options.validate, pattern);
|
|
628
|
+
if (typeof validator !== 'function') {
|
|
629
|
+
throw new Error(`@zmdb/app: message pattern "${pattern}" has no response validator`);
|
|
630
|
+
}
|
|
631
|
+
const installed = Reflect.set(client, pattern, requestMethod(transport, pattern, timeoutMs, validator));
|
|
632
|
+
if (!installed) {
|
|
633
|
+
throw new Error(`@zmdb/app: could not install message client pattern "${pattern}"`);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
return client;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Build a typed one-way publisher. Methods are cached on first property access. */
|
|
640
|
+
export function createEventPublisher<E extends EventPatterns>(transport: TransportStrategy): EventPublisher<E> {
|
|
641
|
+
const methods = new Map<string, (payload: unknown, span?: Span) => Promise<void>>();
|
|
642
|
+
// boundary: the mapped return type limits consumer-visible properties to
|
|
643
|
+
// keyof E; the proxy only turns those string properties into emit calls.
|
|
644
|
+
const target: EventPublisher<E> = Object.create(null);
|
|
645
|
+
return new Proxy(target, {
|
|
646
|
+
get(_target, property, receiver): unknown {
|
|
647
|
+
if (typeof property !== 'string') {
|
|
648
|
+
return Reflect.get(target, property, receiver);
|
|
649
|
+
}
|
|
650
|
+
if (property === 'then') {
|
|
651
|
+
// A synthesized `then` makes the publisher a never-settling thenable.
|
|
652
|
+
return undefined;
|
|
653
|
+
}
|
|
654
|
+
const existing = methods.get(property);
|
|
655
|
+
if (existing !== undefined) {
|
|
656
|
+
return existing;
|
|
657
|
+
}
|
|
658
|
+
const method = (payload: unknown, span?: Span): Promise<void> => {
|
|
659
|
+
if (span === undefined) {
|
|
660
|
+
return transport.emit(property, payload);
|
|
661
|
+
}
|
|
662
|
+
return transport.emit(property, payload, toTraceHeaders(span));
|
|
663
|
+
};
|
|
664
|
+
methods.set(property, method);
|
|
665
|
+
return method;
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
}
|