@znight/messaging 0.0.0-stage → 0.1.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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +622 -2
  3. package/dist/async-CFh_bvex.mjs +216 -0
  4. package/dist/async-CFh_bvex.mjs.map +1 -0
  5. package/dist/codec-Dku-NXXU.mjs +83 -0
  6. package/dist/codec-Dku-NXXU.mjs.map +1 -0
  7. package/dist/compiler.d.mts +75 -0
  8. package/dist/compiler.mjs +1023 -0
  9. package/dist/compiler.mjs.map +1 -0
  10. package/dist/driver-ZxLqy_Ap.d.mts +216 -0
  11. package/dist/index.d.mts +459 -0
  12. package/dist/index.mjs +870 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/kafka.d.mts +51 -0
  15. package/dist/kafka.mjs +517 -0
  16. package/dist/kafka.mjs.map +1 -0
  17. package/dist/memory.d.mts +124 -0
  18. package/dist/memory.mjs +429 -0
  19. package/dist/memory.mjs.map +1 -0
  20. package/dist/rabbitmq.d.mts +65 -0
  21. package/dist/rabbitmq.mjs +686 -0
  22. package/dist/rabbitmq.mjs.map +1 -0
  23. package/dist/shared-BJBpLS8V.mjs +52 -0
  24. package/dist/shared-BJBpLS8V.mjs.map +1 -0
  25. package/dist/sqs.d.mts +38 -0
  26. package/dist/sqs.mjs +421 -0
  27. package/dist/sqs.mjs.map +1 -0
  28. package/package.json +123 -4
  29. package/src/broker.ts +156 -0
  30. package/src/codec.ts +98 -0
  31. package/src/compiler/analyze.ts +800 -0
  32. package/src/compiler/constants.ts +73 -0
  33. package/src/compiler/data.ts +75 -0
  34. package/src/compiler/generate.ts +131 -0
  35. package/src/compiler/index.ts +52 -0
  36. package/src/compiler/patterns.ts +54 -0
  37. package/src/compiler/retry.ts +136 -0
  38. package/src/compiler/validate.ts +57 -0
  39. package/src/compiler/values.ts +76 -0
  40. package/src/consumers.ts +260 -0
  41. package/src/decorators.ts +131 -0
  42. package/src/driver.ts +142 -0
  43. package/src/drivers/kafka.ts +765 -0
  44. package/src/drivers/memory.ts +609 -0
  45. package/src/drivers/rabbitmq.ts +944 -0
  46. package/src/drivers/shared.ts +81 -0
  47. package/src/drivers/sqs.ts +583 -0
  48. package/src/errors.ts +95 -0
  49. package/src/headers.ts +70 -0
  50. package/src/hooks.ts +2 -0
  51. package/src/index.ts +89 -0
  52. package/src/internal/async.ts +115 -0
  53. package/src/message.ts +96 -0
  54. package/src/metadata.ts +65 -0
  55. package/src/plugin.ts +236 -0
  56. package/src/retry.ts +129 -0
  57. package/src/runtime.ts +424 -0
  58. package/src/standard-schema.ts +68 -0
  59. package/src/tokens.ts +14 -0
  60. package/src/validation.ts +61 -0
package/src/runtime.ts ADDED
@@ -0,0 +1,424 @@
1
+ /**
2
+ * The delivery state machine (docs/internals/architecture.md §9.2), one {@link ConsumerRuntime} per
3
+ * `@Consumer` class.
4
+ *
5
+ * For each delivery: concurrency slot → request scope seeded with the `InboundMessage` →
6
+ * `hooks.run('messaging:message')` → decode → select the handler by type → invoke →
7
+ * ack | retry (in-process wait or native redelivery) | `@OnError` → dead-letter (publish then ack;
8
+ * on failure the message is released for a later redelivery). Nothing is ever dropped: every path
9
+ * ends with the broker owning the message again.
10
+ */
11
+ import type { Container, CoreLogger, Hooks, MessageDeliveryEvent } from '@znight/core'
12
+ import type { MessageCodec } from './codec.ts'
13
+ import type { PreparedConsumer, PreparedHandler } from './consumers.ts'
14
+ import type { DriverDelivery, MessagingDriver, Subscription } from './driver.ts'
15
+ import type { DeadLetterReason, InboundMessage } from './message.ts'
16
+
17
+ import {
18
+ AttemptsExhaustedError,
19
+ isNonRetryable,
20
+ MessageDecodeError,
21
+ MessageValidationError,
22
+ NoHandlerError,
23
+ } from './errors.ts'
24
+ import { errorMessage, Semaphore, sleep } from './internal/async.ts'
25
+ import { computeRetryDelay, isInProcessDelay } from './retry.ts'
26
+ import { MessagingTokens } from './tokens.ts'
27
+
28
+ /** Services shared by every consumer runtime. */
29
+ export interface RuntimeServices {
30
+ readonly container: Container
31
+ readonly hooks: Hooks
32
+ readonly logger: CoreLogger
33
+ readonly codec: MessageCodec
34
+ readonly driver: MessagingDriver
35
+ /** Pause before releasing a message whose settlement failed (avoids hot redelivery loops). */
36
+ readonly requeueDelayMs: number
37
+ }
38
+
39
+ /** State of a {@link ConsumerRuntime}. */
40
+ export type ConsumerState = 'created' | 'starting' | 'running' | 'stopping' | 'stopped'
41
+
42
+ /** What remains to do once the scope of a delivery closed. */
43
+ type Followup =
44
+ | { readonly kind: 'retry'; readonly delayMs: number; readonly error: unknown }
45
+ | { readonly kind: 'requeue' }
46
+ | undefined
47
+
48
+ /** Per-delivery working state. */
49
+ interface DeliveryContext {
50
+ readonly delivery: DriverDelivery
51
+ readonly message: InboundMessage
52
+ readonly handler: PreparedHandler | undefined
53
+ readonly event: MessageDeliveryEvent
54
+ readonly decodeError: unknown
55
+ executed: boolean
56
+ followup: Followup
57
+ }
58
+
59
+ /** Runs the deliveries of one consumer. */
60
+ export class ConsumerRuntime {
61
+ readonly consumer: PreparedConsumer
62
+ readonly #services: RuntimeServices
63
+ readonly #slots: Semaphore
64
+ readonly #abort = new AbortController()
65
+ readonly #inflight = new Set<Promise<void>>()
66
+ #subscription: Subscription | undefined
67
+ #state: ConsumerState = 'created'
68
+
69
+ constructor(consumer: PreparedConsumer, services: RuntimeServices) {
70
+ this.consumer = consumer
71
+ this.#services = services
72
+ this.#slots = new Semaphore(consumer.concurrency)
73
+ }
74
+
75
+ get state(): ConsumerState {
76
+ return this.#state
77
+ }
78
+
79
+ /** Deliveries currently handled (including those waiting for a slot or an in-process retry delay). */
80
+ get inFlight(): number {
81
+ return this.#inflight.size
82
+ }
83
+
84
+ /** Subscribes to the queue. */
85
+ async start(): Promise<void> {
86
+ if (this.#state !== 'created') throw new Error(`[znight] Consumer ${this.consumer.name} was already started`)
87
+ this.#state = 'starting'
88
+ const { consumer } = this
89
+ try {
90
+ this.#subscription = await this.#services.driver.subscribe(consumer.queue, delivery => this.handle(delivery), {
91
+ consumer: consumer.name,
92
+ concurrency: consumer.concurrency,
93
+ deadLetterQueue: consumer.deadLetterQueue,
94
+ maxAttempts: consumer.maxAttempts,
95
+ retryDelays: consumer.retryDelays,
96
+ options: consumer.driverOptions,
97
+ })
98
+ } catch (error) {
99
+ this.#state = 'stopped'
100
+ throw error
101
+ }
102
+ this.#state = 'running'
103
+ }
104
+
105
+ /**
106
+ * Graceful stop: stops receiving, aborts in-process retry delays (those messages are released to
107
+ * the broker), awaits the in-flight handlers, then closes the subscription. Never throws.
108
+ */
109
+ async stop(): Promise<void> {
110
+ if (this.#state === 'stopping' || this.#state === 'stopped') return
111
+ this.#state = 'stopping'
112
+ this.#abort.abort()
113
+ const subscription = this.#subscription
114
+ if (subscription !== undefined) {
115
+ try {
116
+ await subscription.cancel()
117
+ } catch (error) {
118
+ this.#services.logger.error(`Consumer ${this.consumer.name}: failed to cancel the subscription`, { err: error })
119
+ }
120
+ }
121
+ while (this.#inflight.size > 0) await Promise.allSettled([...this.#inflight])
122
+ if (subscription !== undefined) {
123
+ try {
124
+ await subscription.close()
125
+ } catch (error) {
126
+ this.#services.logger.error(`Consumer ${this.consumer.name}: failed to close the subscription`, { err: error })
127
+ }
128
+ }
129
+ this.#state = 'stopped'
130
+ }
131
+
132
+ /** Handles one delivery (the driver's `DeliveryHandler`). Never rejects. */
133
+ handle(delivery: DriverDelivery): Promise<void> {
134
+ const promise = this.#handle(delivery)
135
+ this.#inflight.add(promise)
136
+ const done = (): void => {
137
+ this.#inflight.delete(promise)
138
+ }
139
+ promise.then(done, done)
140
+ return promise
141
+ }
142
+
143
+ async #handle(delivery: DriverDelivery): Promise<void> {
144
+ const signal = this.#abort.signal
145
+ if (signal.aborted) return this.#release(delivery)
146
+ try {
147
+ await this.#slots.acquire(signal)
148
+ } catch {
149
+ return this.#release(delivery)
150
+ }
151
+ try {
152
+ await this.#process(delivery)
153
+ } catch (error) {
154
+ // defensive: #process handles its failures; never leave a message unsettled
155
+ this.#services.logger.error(`Consumer ${this.consumer.name}: unexpected delivery failure`, { err: error })
156
+ await this.#release(delivery)
157
+ } finally {
158
+ this.#slots.release()
159
+ }
160
+ }
161
+
162
+ async #process(delivery: DriverDelivery): Promise<void> {
163
+ const { container, hooks, codec, driver } = this.#services
164
+ const { consumer } = this
165
+ const handler = consumer.select(delivery.type)
166
+ let payload: unknown
167
+ let decodeError: unknown
168
+ try {
169
+ payload = codec.decode(delivery.body, delivery.contentType)
170
+ } catch (error) {
171
+ decodeError = isNonRetryable(error)
172
+ ? error
173
+ : new MessageDecodeError(`Cannot decode the message body: ${errorMessage(error)}`, { cause: error })
174
+ }
175
+ const message: InboundMessage = {
176
+ id: delivery.id,
177
+ queue: consumer.queue,
178
+ type: delivery.type,
179
+ payload,
180
+ headers: delivery.headers,
181
+ key: delivery.key,
182
+ attempt: delivery.attempt,
183
+ maxAttempts: handler?.policy.attempts ?? 1,
184
+ redelivered: delivery.redelivered,
185
+ timestamp: delivery.timestamp,
186
+ receivedAt: Date.now(),
187
+ contentType: delivery.contentType,
188
+ body: delivery.body,
189
+ driver: driver.name,
190
+ raw: delivery.raw,
191
+ signal: this.#abort.signal,
192
+ }
193
+ const event: MessageDeliveryEvent = {
194
+ queue: consumer.queue,
195
+ type: delivery.type,
196
+ messageId: delivery.id,
197
+ attempt: delivery.attempt,
198
+ headers: delivery.headers,
199
+ consumer: consumer.name,
200
+ handler: handler?.method,
201
+ message,
202
+ startTime: performance.now(),
203
+ }
204
+ const context: DeliveryContext = {
205
+ delivery,
206
+ message,
207
+ handler,
208
+ event,
209
+ decodeError,
210
+ executed: false,
211
+ followup: undefined,
212
+ }
213
+
214
+ let failure: unknown = new Error('A messaging:message hook wrapper did not call next()')
215
+ try {
216
+ await container.runInScope(
217
+ () => hooks.run('messaging:message', event, () => this.#execute(context)),
218
+ [[MessagingTokens.InboundMessage, message]],
219
+ )
220
+ } catch (error) {
221
+ failure = error
222
+ if (context.executed) {
223
+ // the delivery was settled: a wrapper failed afterwards, or a request-scoped instance failed to dispose
224
+ this.#services.logger.error(`Consumer ${consumer.name}: error after handling message ${delivery.id}`, {
225
+ err: error,
226
+ queue: consumer.queue,
227
+ })
228
+ }
229
+ }
230
+ // the handler never ran (a hooks wrapper threw or did not call next()): count it as a failed attempt
231
+ if (!context.executed) await this.#failure(context, failure, false)
232
+ if (hooks.hasListeners('messaging:message')) hooks.emit('messaging:message', event)
233
+ await this.#followup(context)
234
+ }
235
+
236
+ /** Runs inside the request scope and the hooks: handler, then settlement. Never throws. */
237
+ async #execute(context: DeliveryContext): Promise<void> {
238
+ if (context.executed) return // a wrapper called next() twice
239
+ context.executed = true
240
+ const { handler, message } = context
241
+ if (context.decodeError !== undefined) return this.#failure(context, context.decodeError, true)
242
+ if (handler === undefined)
243
+ return this.#terminal(context, new NoHandlerError(this.consumer.name, message.type), 'no-handler', true)
244
+ if (message.attempt > message.maxAttempts) {
245
+ return this.#terminal(
246
+ context,
247
+ new AttemptsExhaustedError(message.attempt, message.maxAttempts),
248
+ 'max-attempts',
249
+ false,
250
+ )
251
+ }
252
+ try {
253
+ const instance = this.#services.container.get(this.consumer.token)
254
+ await handler.definition.invoke(instance, message)
255
+ } catch (error) {
256
+ return this.#failure(context, error, true)
257
+ }
258
+ return this.#ack(context)
259
+ }
260
+
261
+ /** A failed attempt: retry while attempts remain, dead-letter otherwise. */
262
+ #failure(context: DeliveryContext, error: unknown, withErrorHandler: boolean): Promise<void> {
263
+ if (isNonRetryable(error)) return this.#terminal(context, error, reasonOf(error), withErrorHandler)
264
+ if (context.message.attempt < context.message.maxAttempts) return this.#retry(context, error)
265
+ return this.#terminal(context, error, 'max-attempts', withErrorHandler)
266
+ }
267
+
268
+ async #retry(context: DeliveryContext, error: unknown): Promise<void> {
269
+ const { message, delivery, event } = context
270
+ const policy = context.handler?.policy
271
+ const delayMs = policy === undefined ? 0 : computeRetryDelay(policy, message.attempt)
272
+ event.outcome = 'retry'
273
+ event.error = error
274
+ this.#services.logger.warn(
275
+ `Consumer ${this.consumer.name}: attempt ${message.attempt}/${message.maxAttempts} of message ${message.id} failed, retrying in ${delayMs} ms`,
276
+ { queue: message.queue, messageId: message.id, type: message.type, ...this.#errorContext(error) },
277
+ )
278
+ if (isInProcessDelay(delayMs, this.#services.driver.capabilities)) {
279
+ context.followup = { kind: 'retry', delayMs, error }
280
+ return
281
+ }
282
+ try {
283
+ await delivery.retry({ attempt: message.attempt + 1, delayMs, error })
284
+ } catch (retryError) {
285
+ this.#services.logger.error(
286
+ `Consumer ${this.consumer.name}: failed to schedule the retry of message ${message.id}`,
287
+ {
288
+ err: retryError,
289
+ queue: message.queue,
290
+ },
291
+ )
292
+ context.followup = { kind: 'requeue' }
293
+ }
294
+ }
295
+
296
+ /** No more attempts: `@OnError` may acknowledge, otherwise the message is dead-lettered. */
297
+ async #terminal(
298
+ context: DeliveryContext,
299
+ error: unknown,
300
+ reason: DeadLetterReason,
301
+ withErrorHandler: boolean,
302
+ ): Promise<void> {
303
+ context.event.error = error
304
+ const method = this.consumer.errorHandler
305
+ if (withErrorHandler && method !== undefined) {
306
+ try {
307
+ const instance = this.#services.container.get(this.consumer.token) as Record<
308
+ string,
309
+ (...args: unknown[]) => unknown
310
+ >
311
+ const result = await instance[method]?.(error, context.message)
312
+ if (result === 'ack') return this.#ack(context)
313
+ } catch (handlerError) {
314
+ this.#services.logger.error(`Consumer ${this.consumer.name}: @OnError ${method}() failed`, {
315
+ err: handlerError,
316
+ queue: context.message.queue,
317
+ messageId: context.message.id,
318
+ })
319
+ }
320
+ }
321
+ return this.#deadLetter(context, error, reason)
322
+ }
323
+
324
+ async #deadLetter(context: DeliveryContext, error: unknown, reason: DeadLetterReason): Promise<void> {
325
+ const { message, delivery, event } = context
326
+ const queue = this.consumer.deadLetterQueue
327
+ event.outcome = 'dead-letter'
328
+ try {
329
+ await delivery.deadLetter({ queue, reason, error, attempt: message.attempt })
330
+ } catch (deadLetterError) {
331
+ event.outcome = 'retry'
332
+ event.error = deadLetterError
333
+ this.#services.logger.error(
334
+ `Consumer ${this.consumer.name}: failed to dead-letter message ${message.id}, it will be redelivered`,
335
+ { err: deadLetterError, cause: error, queue: message.queue, deadLetterQueue: queue },
336
+ )
337
+ context.followup = { kind: 'requeue' }
338
+ return
339
+ }
340
+ this.#services.logger.error(
341
+ queue === undefined
342
+ ? `Consumer ${this.consumer.name}: message ${message.id} rejected (${reason})`
343
+ : `Consumer ${this.consumer.name}: message ${message.id} dead-lettered to ${queue} (${reason})`,
344
+ {
345
+ queue: message.queue,
346
+ messageId: message.id,
347
+ type: message.type,
348
+ attempt: message.attempt,
349
+ reason,
350
+ ...this.#errorContext(error),
351
+ },
352
+ )
353
+ }
354
+
355
+ /**
356
+ * The error of a failed delivery in the retry / dead-letter log entries: the error itself (with its
357
+ * stack), or only its message when a `messaging:message` wrapper is installed. Such a wrapper (the
358
+ * ObservabilityPlugin delivery log) writes its own entry for the delivery with the error: the stack
359
+ * is logged once. Settlement failures always log their error.
360
+ */
361
+ #errorContext(error: unknown): { readonly err: unknown } | { readonly error: string } {
362
+ return this.#services.hooks.hasWrappers('messaging:message') ? { error: errorMessage(error) } : { err: error }
363
+ }
364
+
365
+ async #ack(context: DeliveryContext): Promise<void> {
366
+ context.event.outcome = 'ack'
367
+ try {
368
+ await context.delivery.ack()
369
+ } catch (error) {
370
+ this.#services.logger.warn(
371
+ `Consumer ${this.consumer.name}: failed to acknowledge message ${context.message.id}, the broker will redeliver it`,
372
+ { err: error, queue: context.message.queue },
373
+ )
374
+ }
375
+ }
376
+
377
+ /** Work done after the scope closed: in-process retry delays and requeues. */
378
+ async #followup(context: DeliveryContext): Promise<void> {
379
+ const followup = context.followup
380
+ if (followup === undefined) return
381
+ const { delivery, message } = context
382
+ if (followup.kind === 'retry') {
383
+ try {
384
+ await sleep(followup.delayMs, this.#abort.signal)
385
+ } catch {
386
+ return this.#release(delivery) // stopping: the broker redelivers it later
387
+ }
388
+ try {
389
+ await delivery.retry({ attempt: message.attempt + 1, delayMs: 0, error: followup.error })
390
+ return
391
+ } catch (error) {
392
+ this.#services.logger.error(`Consumer ${this.consumer.name}: failed to redeliver message ${message.id}`, {
393
+ err: error,
394
+ queue: message.queue,
395
+ })
396
+ }
397
+ }
398
+ try {
399
+ await sleep(this.#services.requeueDelayMs, this.#abort.signal)
400
+ } catch {
401
+ // stopping: release right away
402
+ }
403
+ return this.#release(delivery)
404
+ }
405
+
406
+ async #release(delivery: DriverDelivery): Promise<void> {
407
+ try {
408
+ await delivery.release()
409
+ } catch (error) {
410
+ this.#services.logger.error(
411
+ `Consumer ${this.consumer.name}: failed to release message ${delivery.id}, the broker will redeliver it`,
412
+ { err: error, queue: this.consumer.queue },
413
+ )
414
+ }
415
+ }
416
+ }
417
+
418
+ function reasonOf(error: unknown): DeadLetterReason {
419
+ if (error instanceof MessageValidationError) return 'validation'
420
+ if (error instanceof MessageDecodeError) return 'decode'
421
+ if (error instanceof NoHandlerError) return 'no-handler'
422
+ if (error instanceof AttemptsExhaustedError) return 'max-attempts'
423
+ return 'non-retryable'
424
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Copy of the [Standard Schema v1](https://standardschema.dev) interface (MIT). Zod, Valibot, ArkType,
3
+ * Effect Schema... implement it: `@Message(schema)` accepts any of them without depending on one.
4
+ *
5
+ * The upstream declaration uses a namespace; the types are flattened here (`erasableSyntaxOnly`) but
6
+ * remain structurally identical, so any Standard Schema implementation is assignable.
7
+ */
8
+
9
+ /** A schema implementing the Standard Schema v1 interface. */
10
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
11
+ readonly '~standard': StandardSchemaV1Props<Input, Output>
12
+ }
13
+
14
+ /** The `~standard` property of a {@link StandardSchemaV1}. */
15
+ export interface StandardSchemaV1Props<Input = unknown, Output = Input> {
16
+ /** Version of the Standard Schema specification. */
17
+ readonly version: 1
18
+ /** Library implementing the schema (`zod`, `valibot`...). */
19
+ readonly vendor: string
20
+ /** Validates an unknown value, synchronously or asynchronously. */
21
+ readonly validate: (value: unknown) => StandardSchemaV1Result<Output> | Promise<StandardSchemaV1Result<Output>>
22
+ /** Inferred types (type-level only). */
23
+ readonly types?: StandardSchemaV1Types<Input, Output> | undefined
24
+ }
25
+
26
+ /** Result of {@link StandardSchemaV1Props.validate}. */
27
+ export type StandardSchemaV1Result<Output> = StandardSchemaV1SuccessResult<Output> | StandardSchemaV1FailureResult
28
+
29
+ /** Successful validation: `value` is the schema output (defaults, transforms applied). */
30
+ export interface StandardSchemaV1SuccessResult<Output> {
31
+ readonly value: Output
32
+ readonly issues?: undefined
33
+ }
34
+
35
+ /** Failed validation. */
36
+ export interface StandardSchemaV1FailureResult {
37
+ readonly issues: readonly StandardSchemaV1Issue[]
38
+ }
39
+
40
+ /** One validation issue. */
41
+ export interface StandardSchemaV1Issue {
42
+ readonly message: string
43
+ readonly path?: readonly (PropertyKey | StandardSchemaV1PathSegment)[] | undefined
44
+ }
45
+
46
+ /** A segment of an issue path. */
47
+ export interface StandardSchemaV1PathSegment {
48
+ readonly key: PropertyKey
49
+ }
50
+
51
+ /** Input and output types of a schema (type-level only). */
52
+ export interface StandardSchemaV1Types<Input = unknown, Output = Input> {
53
+ readonly input: Input
54
+ readonly output: Output
55
+ }
56
+
57
+ /** Input type of a Standard Schema. */
58
+ export type InferSchemaInput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['input']
59
+
60
+ /** Output type of a Standard Schema (what handlers receive). */
61
+ export type InferSchemaOutput<Schema extends StandardSchemaV1> = NonNullable<Schema['~standard']['types']>['output']
62
+
63
+ /** Whether `value` looks like a Standard Schema v1 (has a `~standard.validate` function). */
64
+ export function isStandardSchema(value: unknown): value is StandardSchemaV1 {
65
+ if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return false
66
+ const props = (value as { '~standard'?: unknown })['~standard']
67
+ return props !== null && typeof props === 'object' && typeof (props as { validate?: unknown }).validate === 'function'
68
+ }
package/src/tokens.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { TypedToken } from '@znight/core'
2
+ import type { MessageBroker } from './broker.ts'
3
+ import type { InboundMessage } from './message.ts'
4
+
5
+ /** Tokens of the externals provided by `MessagingPlugin` (declared in the package manifest). */
6
+ export const MessagingTokens: {
7
+ /** The {@link MessageBroker} singleton. */
8
+ readonly MessageBroker: TypedToken<MessageBroker>
9
+ /** The {@link InboundMessage} of the current delivery (request-scoped). */
10
+ readonly InboundMessage: TypedToken<InboundMessage>
11
+ } = Object.freeze({
12
+ MessageBroker: '@znight/messaging#MessageBroker',
13
+ InboundMessage: '@znight/messaging#InboundMessage',
14
+ })
@@ -0,0 +1,61 @@
1
+ import type {
2
+ InferSchemaOutput,
3
+ StandardSchemaV1,
4
+ StandardSchemaV1Issue,
5
+ StandardSchemaV1PathSegment,
6
+ StandardSchemaV1Result,
7
+ } from './standard-schema.ts'
8
+
9
+ import { MessageValidationError, type MessageValidationIssue } from './errors.ts'
10
+ import { isStandardSchema } from './standard-schema.ts'
11
+
12
+ /**
13
+ * Validates `value` with a Standard Schema (Zod, Valibot, ArkType...) and returns the schema
14
+ * **output** (defaults and transforms applied). On failure it throws a {@link MessageValidationError},
15
+ * a `NonRetryableError`: the message is dead-lettered without further attempts.
16
+ *
17
+ * Returns synchronously when the schema validates synchronously, a promise otherwise. Called by the
18
+ * code generated for `@Message(schema)` parameters:
19
+ *
20
+ * ```ts
21
+ * invoke: async (c, m) => c.handle(await validateMessage(OrderCreated, m.payload))
22
+ * ```
23
+ *
24
+ * @param label Describes the value in the error message. Default: `'message payload'`.
25
+ */
26
+ export function validateMessage<S extends StandardSchemaV1>(
27
+ schema: S,
28
+ value: unknown,
29
+ label = 'message payload',
30
+ ): InferSchemaOutput<S> | Promise<InferSchemaOutput<S>> {
31
+ if (!isStandardSchema(schema)) {
32
+ throw new TypeError(`[znight] validateMessage(): the schema of the ${label} is not a Standard Schema v1`)
33
+ }
34
+ const result = schema['~standard'].validate(value) as
35
+ | StandardSchemaV1Result<InferSchemaOutput<S>>
36
+ | Promise<StandardSchemaV1Result<InferSchemaOutput<S>>>
37
+ if (isPromiseLike(result)) return Promise.resolve(result).then(settled => unwrap(settled, label))
38
+ return unwrap(result, label)
39
+ }
40
+
41
+ function unwrap<T>(result: StandardSchemaV1Result<T>, label: string): T {
42
+ if (result.issues === undefined) return result.value
43
+ throw new MessageValidationError(label, result.issues.map(toIssue))
44
+ }
45
+
46
+ function toIssue(issue: StandardSchemaV1Issue): MessageValidationIssue {
47
+ return { path: formatPath(issue.path), message: String(issue.message) }
48
+ }
49
+
50
+ function formatPath(path: StandardSchemaV1Issue['path']): string {
51
+ if (path === undefined || path.length === 0) return ''
52
+ return path.map(segment => String(isSegment(segment) ? segment.key : segment)).join('.')
53
+ }
54
+
55
+ function isPromiseLike<T>(value: T | PromiseLike<T>): value is PromiseLike<T> {
56
+ return typeof (value as { then?: unknown } | null)?.then === 'function'
57
+ }
58
+
59
+ function isSegment(segment: PropertyKey | StandardSchemaV1PathSegment): segment is StandardSchemaV1PathSegment {
60
+ return typeof segment === 'object' && segment !== null && 'key' in segment
61
+ }