@znight/messaging 0.0.0-stage → 0.1.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 +622 -2
- package/dist/async-CFh_bvex.mjs +216 -0
- package/dist/async-CFh_bvex.mjs.map +1 -0
- package/dist/codec-Dku-NXXU.mjs +83 -0
- package/dist/codec-Dku-NXXU.mjs.map +1 -0
- package/dist/compiler.d.mts +75 -0
- package/dist/compiler.mjs +1023 -0
- package/dist/compiler.mjs.map +1 -0
- package/dist/driver-ZxLqy_Ap.d.mts +216 -0
- package/dist/index.d.mts +459 -0
- package/dist/index.mjs +870 -0
- package/dist/index.mjs.map +1 -0
- package/dist/kafka.d.mts +51 -0
- package/dist/kafka.mjs +517 -0
- package/dist/kafka.mjs.map +1 -0
- package/dist/memory.d.mts +124 -0
- package/dist/memory.mjs +429 -0
- package/dist/memory.mjs.map +1 -0
- package/dist/rabbitmq.d.mts +65 -0
- package/dist/rabbitmq.mjs +686 -0
- package/dist/rabbitmq.mjs.map +1 -0
- package/dist/shared-BJBpLS8V.mjs +52 -0
- package/dist/shared-BJBpLS8V.mjs.map +1 -0
- package/dist/sqs.d.mts +38 -0
- package/dist/sqs.mjs +421 -0
- package/dist/sqs.mjs.map +1 -0
- package/package.json +123 -4
- package/src/broker.ts +156 -0
- package/src/codec.ts +98 -0
- package/src/compiler/analyze.ts +800 -0
- package/src/compiler/constants.ts +73 -0
- package/src/compiler/data.ts +75 -0
- package/src/compiler/generate.ts +131 -0
- package/src/compiler/index.ts +52 -0
- package/src/compiler/patterns.ts +54 -0
- package/src/compiler/retry.ts +136 -0
- package/src/compiler/validate.ts +57 -0
- package/src/compiler/values.ts +76 -0
- package/src/consumers.ts +260 -0
- package/src/decorators.ts +131 -0
- package/src/driver.ts +142 -0
- package/src/drivers/kafka.ts +765 -0
- package/src/drivers/memory.ts +609 -0
- package/src/drivers/rabbitmq.ts +944 -0
- package/src/drivers/shared.ts +81 -0
- package/src/drivers/sqs.ts +583 -0
- package/src/errors.ts +95 -0
- package/src/headers.ts +70 -0
- package/src/hooks.ts +2 -0
- package/src/index.ts +89 -0
- package/src/internal/async.ts +115 -0
- package/src/message.ts +96 -0
- package/src/metadata.ts +65 -0
- package/src/plugin.ts +236 -0
- package/src/retry.ts +129 -0
- package/src/runtime.ts +424 -0
- package/src/standard-schema.ts +68 -0
- package/src/tokens.ts +14 -0
- package/src/validation.ts +61 -0
package/src/errors.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { ErrorCode, ZnightError } from '@znight/core'
|
|
2
|
+
|
|
3
|
+
const NON_RETRYABLE = Symbol.for('znight.messaging.nonRetryable')
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Thrown by a handler to dead-letter the message immediately, without further attempts (invalid
|
|
7
|
+
* business data, permanent failure...). Subclass it for domain errors.
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* if (order.total < 0) throw new NonRetryableError('Negative order total')
|
|
11
|
+
* ```
|
|
12
|
+
*/
|
|
13
|
+
export class NonRetryableError extends Error {
|
|
14
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
15
|
+
super(message, options)
|
|
16
|
+
this.name = 'NonRetryableError'
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
Object.defineProperty(NonRetryableError.prototype, NON_RETRYABLE, { value: true })
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Whether retrying `error` is pointless: a {@link NonRetryableError} (also recognised across copies of
|
|
23
|
+
* this package, through a global symbol brand).
|
|
24
|
+
*/
|
|
25
|
+
export function isNonRetryable(error: unknown): boolean {
|
|
26
|
+
return (
|
|
27
|
+
error instanceof NonRetryableError ||
|
|
28
|
+
(error !== null && typeof error === 'object' && (error as Record<symbol, unknown>)[NON_RETRYABLE] === true)
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One failed check of a `@Message(schema)` validation. */
|
|
33
|
+
export interface MessageValidationIssue {
|
|
34
|
+
/** Dotted path of the invalid value (`items.0.quantity`), empty for the root. */
|
|
35
|
+
readonly path: string
|
|
36
|
+
readonly message: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The payload does not match the `@Message(schema)` of the handler. Never retried. */
|
|
40
|
+
export class MessageValidationError extends NonRetryableError {
|
|
41
|
+
readonly issues: readonly MessageValidationIssue[]
|
|
42
|
+
|
|
43
|
+
constructor(label: string, issues: readonly MessageValidationIssue[]) {
|
|
44
|
+
super(`Invalid ${label}: ${formatIssues(issues)}`)
|
|
45
|
+
this.name = 'MessageValidationError'
|
|
46
|
+
this.issues = issues
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The body cannot be decoded (malformed JSON...). Never retried. */
|
|
51
|
+
export class MessageDecodeError extends NonRetryableError {
|
|
52
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
53
|
+
super(message, options)
|
|
54
|
+
this.name = 'MessageDecodeError'
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** No `@OnMessage` handler of the consumer matches the message type. Never retried. */
|
|
59
|
+
export class NoHandlerError extends NonRetryableError {
|
|
60
|
+
readonly messageType: string | undefined
|
|
61
|
+
|
|
62
|
+
constructor(consumer: string, type: string | undefined) {
|
|
63
|
+
super(
|
|
64
|
+
type === undefined
|
|
65
|
+
? `${consumer} has no default @OnMessage() handler for a message without type`
|
|
66
|
+
: `${consumer} has no @OnMessage handler for message type "${type}"`,
|
|
67
|
+
)
|
|
68
|
+
this.name = 'NoHandlerError'
|
|
69
|
+
this.messageType = type
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A delivery arrived after its last allowed attempt (e.g. redelivered after a dead-letter failure). */
|
|
74
|
+
export class AttemptsExhaustedError extends NonRetryableError {
|
|
75
|
+
constructor(attempt: number, maxAttempts: number) {
|
|
76
|
+
super(`Delivery attempt ${attempt} exceeds the ${maxAttempts} attempt(s) allowed by the retry policy`)
|
|
77
|
+
this.name = 'AttemptsExhaustedError'
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatIssues(issues: readonly MessageValidationIssue[]): string {
|
|
82
|
+
const shown = issues.slice(0, 5).map(issue => (issue.path === '' ? issue.message : `${issue.path}: ${issue.message}`))
|
|
83
|
+
const more = issues.length > 5 ? ` (+${issues.length - 5} more)` : ''
|
|
84
|
+
return `${shown.join('; ')}${more}`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A messaging configuration error (ZNIGHT011): invalid consumer, retry policy, driver options... */
|
|
88
|
+
export function consumerError(message: string, hints?: readonly string[]): ZnightError {
|
|
89
|
+
return new ZnightError(ErrorCode.InvalidConsumer, message, hints === undefined ? {} : { hints })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** An operation on the broker or a driver that is not available (not connected, closed...). */
|
|
93
|
+
export function brokerUnavailableError(message: string, cause?: unknown): ZnightError {
|
|
94
|
+
return new ZnightError(ErrorCode.LifecycleFailed, message, cause === undefined ? {} : { cause })
|
|
95
|
+
}
|
package/src/headers.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Header names used by Znight on the wire. They are plain message headers (AMQP headers, SQS message
|
|
3
|
+
* attributes, Kafka record headers), so non-Znight producers and consumers can read and write them.
|
|
4
|
+
*/
|
|
5
|
+
export const MessagingHeaders: {
|
|
6
|
+
/** Znight message id (Kafka and SQS; AMQP uses the `message-id` property). */
|
|
7
|
+
readonly Id: 'x-znight-id'
|
|
8
|
+
/** Message type used to select the handler (Kafka and SQS; AMQP uses the `type` property). */
|
|
9
|
+
readonly Type: 'x-znight-type'
|
|
10
|
+
/** Content type of the body (Kafka and SQS; AMQP uses the `content-type` property). */
|
|
11
|
+
readonly ContentType: 'content-type'
|
|
12
|
+
/** 1-based attempt number carried by republished retries (RabbitMQ, Kafka). */
|
|
13
|
+
readonly Attempt: 'x-znight-attempt'
|
|
14
|
+
/** Epoch milliseconds before which a Kafka retry record must not be processed. */
|
|
15
|
+
readonly NotBefore: 'x-znight-not-before'
|
|
16
|
+
/** Body encoding when the transport only carries text (SQS: `base64` for binary payloads). */
|
|
17
|
+
readonly Encoding: 'x-znight-encoding'
|
|
18
|
+
/** Why the message was dead-lettered (`DeadLetterReason`). */
|
|
19
|
+
readonly DeadLetterReason: 'x-znight-dead-letter-reason'
|
|
20
|
+
/** Message of the error that dead-lettered the message (truncated). */
|
|
21
|
+
readonly Error: 'x-znight-error'
|
|
22
|
+
/** Queue the dead-lettered message was consumed from. */
|
|
23
|
+
readonly OriginalQueue: 'x-znight-original-queue'
|
|
24
|
+
/** Number of attempts made before the message was dead-lettered. */
|
|
25
|
+
readonly Attempts: 'x-znight-attempts'
|
|
26
|
+
/** Kafka consumer group that dead-lettered the record. */
|
|
27
|
+
readonly ConsumerGroup: 'x-znight-consumer-group'
|
|
28
|
+
} = Object.freeze({
|
|
29
|
+
Id: 'x-znight-id',
|
|
30
|
+
Type: 'x-znight-type',
|
|
31
|
+
ContentType: 'content-type',
|
|
32
|
+
Attempt: 'x-znight-attempt',
|
|
33
|
+
NotBefore: 'x-znight-not-before',
|
|
34
|
+
Encoding: 'x-znight-encoding',
|
|
35
|
+
DeadLetterReason: 'x-znight-dead-letter-reason',
|
|
36
|
+
Error: 'x-znight-error',
|
|
37
|
+
OriginalQueue: 'x-znight-original-queue',
|
|
38
|
+
Attempts: 'x-znight-attempts',
|
|
39
|
+
ConsumerGroup: 'x-znight-consumer-group',
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/** A record safe to fill with untrusted keys (`__proto__` is stored as a regular key). */
|
|
43
|
+
export function headerRecord(): Record<string, string> {
|
|
44
|
+
return Object.create(null) as Record<string, string>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reads a header: exact name first, then a case-insensitive match. Used by the code generated for
|
|
49
|
+
* `@Headers('name')` parameters.
|
|
50
|
+
*/
|
|
51
|
+
export function messageHeader(
|
|
52
|
+
message: { readonly headers: Readonly<Record<string, string>> },
|
|
53
|
+
name: string,
|
|
54
|
+
): string | undefined {
|
|
55
|
+
const headers = message.headers
|
|
56
|
+
if (Object.hasOwn(headers, name)) return headers[name]
|
|
57
|
+
const lower = name.toLowerCase()
|
|
58
|
+
for (const key of Object.keys(headers)) {
|
|
59
|
+
if (key.toLowerCase() === lower) return headers[key]
|
|
60
|
+
}
|
|
61
|
+
return undefined
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Parses the {@link MessagingHeaders.Attempt} header: a positive integer, `1` when absent or invalid. */
|
|
65
|
+
export function attemptFromHeaders(headers: Readonly<Record<string, string>>): number {
|
|
66
|
+
const value = Object.hasOwn(headers, MessagingHeaders.Attempt) ? headers[MessagingHeaders.Attempt] : undefined
|
|
67
|
+
if (value === undefined) return 1
|
|
68
|
+
const attempt = Number(value)
|
|
69
|
+
return Number.isSafeInteger(attempt) && attempt >= 1 ? attempt : 1
|
|
70
|
+
}
|
package/src/hooks.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
DeadLetterRequest,
|
|
3
|
+
DeliveryHandler,
|
|
4
|
+
DriverCapabilities,
|
|
5
|
+
DriverContext,
|
|
6
|
+
DriverDelivery,
|
|
7
|
+
MessagingDriver,
|
|
8
|
+
RetryRequest,
|
|
9
|
+
SubscribeOptions,
|
|
10
|
+
Subscription,
|
|
11
|
+
} from './driver.ts'
|
|
12
|
+
export type * from './hooks.ts'
|
|
13
|
+
export type {
|
|
14
|
+
DeadLetterReason,
|
|
15
|
+
InboundMessage,
|
|
16
|
+
OutboundMessage,
|
|
17
|
+
PublishOptions,
|
|
18
|
+
PublishResult,
|
|
19
|
+
} from './message.ts'
|
|
20
|
+
export type * from './metadata.ts'
|
|
21
|
+
export type { ConsumerState } from './runtime.ts'
|
|
22
|
+
|
|
23
|
+
export { MessageBroker } from './broker.ts'
|
|
24
|
+
export {
|
|
25
|
+
BINARY_CONTENT_TYPE,
|
|
26
|
+
bodyToBytes,
|
|
27
|
+
bodyToText,
|
|
28
|
+
type EncodedBody,
|
|
29
|
+
JSON_CONTENT_TYPE,
|
|
30
|
+
jsonCodec,
|
|
31
|
+
type MessageCodec,
|
|
32
|
+
mediaType,
|
|
33
|
+
TEXT_CONTENT_TYPE,
|
|
34
|
+
} from './codec.ts'
|
|
35
|
+
export {
|
|
36
|
+
Consumer,
|
|
37
|
+
type ConsumerOptions,
|
|
38
|
+
Ctx,
|
|
39
|
+
type ErrorHandlerMarker,
|
|
40
|
+
type ErrorHandlerMethod,
|
|
41
|
+
type ErrorHandlerResult,
|
|
42
|
+
Headers,
|
|
43
|
+
Message,
|
|
44
|
+
OnError,
|
|
45
|
+
OnMessage,
|
|
46
|
+
Retry,
|
|
47
|
+
} from './decorators.ts'
|
|
48
|
+
export {
|
|
49
|
+
AttemptsExhaustedError,
|
|
50
|
+
isNonRetryable,
|
|
51
|
+
MessageDecodeError,
|
|
52
|
+
MessageValidationError,
|
|
53
|
+
type MessageValidationIssue,
|
|
54
|
+
NoHandlerError,
|
|
55
|
+
NonRetryableError,
|
|
56
|
+
} from './errors.ts'
|
|
57
|
+
export { attemptFromHeaders, MessagingHeaders, messageHeader } from './headers.ts'
|
|
58
|
+
export {
|
|
59
|
+
type ConsumerStatus,
|
|
60
|
+
type MessagingDefaults,
|
|
61
|
+
MessagingPlugin,
|
|
62
|
+
type MessagingPluginInstance,
|
|
63
|
+
type MessagingPluginOptions,
|
|
64
|
+
} from './plugin.ts'
|
|
65
|
+
export {
|
|
66
|
+
type BackoffStrategy,
|
|
67
|
+
computeRetryDelay,
|
|
68
|
+
DEFAULT_BACKOFF,
|
|
69
|
+
DEFAULT_RETRY_DELAY_MS,
|
|
70
|
+
DEFAULT_RETRY_POLICY,
|
|
71
|
+
normalizeRetry,
|
|
72
|
+
type RetryOptions,
|
|
73
|
+
type RetryPolicy,
|
|
74
|
+
} from './retry.ts'
|
|
75
|
+
export {
|
|
76
|
+
type InferSchemaInput,
|
|
77
|
+
type InferSchemaOutput,
|
|
78
|
+
isStandardSchema,
|
|
79
|
+
type StandardSchemaV1,
|
|
80
|
+
type StandardSchemaV1FailureResult,
|
|
81
|
+
type StandardSchemaV1Issue,
|
|
82
|
+
type StandardSchemaV1PathSegment,
|
|
83
|
+
type StandardSchemaV1Props,
|
|
84
|
+
type StandardSchemaV1Result,
|
|
85
|
+
type StandardSchemaV1SuccessResult,
|
|
86
|
+
type StandardSchemaV1Types,
|
|
87
|
+
} from './standard-schema.ts'
|
|
88
|
+
export { MessagingTokens } from './tokens.ts'
|
|
89
|
+
export { validateMessage } from './validation.ts'
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** Internal async helpers (not part of the public API). */
|
|
2
|
+
|
|
3
|
+
/** Rejection reason of operations interrupted by an `AbortSignal`. */
|
|
4
|
+
export class AbortedError extends Error {
|
|
5
|
+
constructor(message = 'The operation was aborted') {
|
|
6
|
+
super(message)
|
|
7
|
+
this.name = 'AbortError'
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Longest delay of one timer (2^31 - 1 ms, about 24.8 days): longer `setTimeout` delays overflow and
|
|
13
|
+
* fire after 1 ms. Longer waits chain several timers.
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolves after `ms` milliseconds (any duration: long waits chain timers), or rejects with
|
|
19
|
+
* {@link AbortedError} as soon as `signal` aborts.
|
|
20
|
+
*/
|
|
21
|
+
export function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
22
|
+
if (signal?.aborted === true) return Promise.reject(new AbortedError())
|
|
23
|
+
if (ms <= 0 && signal === undefined) return Promise.resolve()
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
26
|
+
const onAbort = (): void => {
|
|
27
|
+
clearTimeout(timer)
|
|
28
|
+
reject(new AbortedError())
|
|
29
|
+
}
|
|
30
|
+
const arm = (remaining: number): void => {
|
|
31
|
+
const wait = Math.min(remaining, MAX_TIMER_DELAY_MS)
|
|
32
|
+
timer = setTimeout(() => {
|
|
33
|
+
if (remaining > wait) return arm(remaining - wait)
|
|
34
|
+
signal?.removeEventListener('abort', onAbort)
|
|
35
|
+
resolve()
|
|
36
|
+
}, wait)
|
|
37
|
+
}
|
|
38
|
+
arm(ms)
|
|
39
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Counting semaphore. `acquire()` resolves when a permit is available, in FIFO order, or rejects with
|
|
45
|
+
* {@link AbortedError} when `signal` aborts while waiting.
|
|
46
|
+
*/
|
|
47
|
+
export class Semaphore {
|
|
48
|
+
#available: number
|
|
49
|
+
readonly #waiters: { resolve: () => void; reject: (error: unknown) => void }[] = []
|
|
50
|
+
|
|
51
|
+
constructor(permits: number) {
|
|
52
|
+
this.#available = permits
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Permits currently available. */
|
|
56
|
+
get available(): number {
|
|
57
|
+
return this.#available
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Number of callers waiting for a permit. */
|
|
61
|
+
get waiting(): number {
|
|
62
|
+
return this.#waiters.length
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
acquire(signal?: AbortSignal): Promise<void> {
|
|
66
|
+
if (signal?.aborted === true) return Promise.reject(new AbortedError())
|
|
67
|
+
if (this.#available > 0) {
|
|
68
|
+
this.#available--
|
|
69
|
+
return Promise.resolve()
|
|
70
|
+
}
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const waiter = {
|
|
73
|
+
resolve: (): void => {
|
|
74
|
+
signal?.removeEventListener('abort', onAbort)
|
|
75
|
+
resolve()
|
|
76
|
+
},
|
|
77
|
+
reject,
|
|
78
|
+
}
|
|
79
|
+
const onAbort = (): void => {
|
|
80
|
+
const index = this.#waiters.indexOf(waiter)
|
|
81
|
+
if (index !== -1) this.#waiters.splice(index, 1)
|
|
82
|
+
reject(new AbortedError())
|
|
83
|
+
}
|
|
84
|
+
this.#waiters.push(waiter)
|
|
85
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
release(): void {
|
|
90
|
+
const next = this.#waiters.shift()
|
|
91
|
+
if (next === undefined) this.#available++
|
|
92
|
+
else next.resolve()
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Message of any thrown value. */
|
|
97
|
+
export function errorMessage(error: unknown): string {
|
|
98
|
+
if (error instanceof Error) return error.message
|
|
99
|
+
if (typeof error === 'string') return error
|
|
100
|
+
try {
|
|
101
|
+
return String(error)
|
|
102
|
+
} catch {
|
|
103
|
+
return Object.prototype.toString.call(error)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Truncates `text` to `max` characters (with an ellipsis). */
|
|
108
|
+
export function truncate(text: string, max: number): string {
|
|
109
|
+
return text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Whether `value` is a positive integer. */
|
|
113
|
+
export function isPositiveInteger(value: unknown): value is number {
|
|
114
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0
|
|
115
|
+
}
|
package/src/message.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A message being handled by a consumer. It is the value of the request-scoped external
|
|
3
|
+
* `@znight/messaging#InboundMessage` during a delivery (inject it into request-scoped providers, or
|
|
4
|
+
* receive it with `@Ctx()`).
|
|
5
|
+
*
|
|
6
|
+
* Delivery is **at-least-once**: the same message may be handled more than once (redelivery after a
|
|
7
|
+
* crash, a lost ack, a retry...). Handlers must be idempotent; {@link id} is stable across
|
|
8
|
+
* redeliveries when the producer set it (Znight producers always do).
|
|
9
|
+
*/
|
|
10
|
+
export interface InboundMessage<T = unknown> {
|
|
11
|
+
/** Message id: set by the producer (`MessageBroker.publish` generates a UUID), stable across redeliveries. */
|
|
12
|
+
readonly id: string
|
|
13
|
+
/** Queue (RabbitMQ, SQS) or topic (Kafka) the consumer subscribed to. */
|
|
14
|
+
readonly queue: string
|
|
15
|
+
/** Message type used to select the `@OnMessage(type)` handler, `undefined` when the producer set none. */
|
|
16
|
+
readonly type: string | undefined
|
|
17
|
+
/** Decoded payload (the schema output when the handler declares `@Message(schema)`). */
|
|
18
|
+
readonly payload: T
|
|
19
|
+
/** Message headers. Names are case-sensitive; non-string values are converted to strings. */
|
|
20
|
+
readonly headers: Readonly<Record<string, string>>
|
|
21
|
+
/** Partition / ordering key (Kafka record key, SQS FIFO message group), when any. */
|
|
22
|
+
readonly key: string | undefined
|
|
23
|
+
/** 1-based delivery attempt (broker delivery count or `x-znight-attempt` header). */
|
|
24
|
+
readonly attempt: number
|
|
25
|
+
/** Attempts allowed by the retry policy of the selected handler (`1` without retries). */
|
|
26
|
+
readonly maxAttempts: number
|
|
27
|
+
/** Whether the broker flagged the delivery as a redelivery. */
|
|
28
|
+
readonly redelivered: boolean
|
|
29
|
+
/** When the message was produced (epoch milliseconds), when the broker reports it. */
|
|
30
|
+
readonly timestamp: number | undefined
|
|
31
|
+
/** When this delivery was received (epoch milliseconds). */
|
|
32
|
+
readonly receivedAt: number
|
|
33
|
+
/** Content type of the body. */
|
|
34
|
+
readonly contentType: string | undefined
|
|
35
|
+
/** Encoded body, as received. */
|
|
36
|
+
readonly body: Uint8Array | string
|
|
37
|
+
/** Name of the driver that received the message (`rabbitmq`, `sqs`, `kafka`, `memory`). */
|
|
38
|
+
readonly driver: string
|
|
39
|
+
/** Driver-native message (amqplib `ConsumeMessage`, SQS `Message`, Kafka `Message`...). */
|
|
40
|
+
readonly raw: unknown
|
|
41
|
+
/** Aborted when the consumer stops: long-running handlers can use it to stop early. */
|
|
42
|
+
readonly signal: AbortSignal
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Options of `MessageBroker.publish()`. */
|
|
46
|
+
export interface PublishOptions {
|
|
47
|
+
/** Message type, used by consumers to select the `@OnMessage(type)` handler. */
|
|
48
|
+
readonly type?: string
|
|
49
|
+
/** Additional headers (string values). */
|
|
50
|
+
readonly headers?: Readonly<Record<string, string>>
|
|
51
|
+
/** Partition key (Kafka) or message group id (SQS FIFO queues). */
|
|
52
|
+
readonly key?: string
|
|
53
|
+
/** Delays the delivery by this many milliseconds (support and maximum depend on the driver). */
|
|
54
|
+
readonly delay?: number
|
|
55
|
+
/** Message id. Default: a random UUID. Set it to make publishing idempotent end-to-end. */
|
|
56
|
+
readonly id?: string
|
|
57
|
+
/** Content type, when the payload is already encoded (`Uint8Array` or `string`). */
|
|
58
|
+
readonly contentType?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Result of `MessageBroker.publish()`. */
|
|
62
|
+
export interface PublishResult {
|
|
63
|
+
/** Id of the published message. */
|
|
64
|
+
readonly id: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A message handed to a driver for publication: the payload is already encoded by the codec. The
|
|
69
|
+
* driver resolves `publish()` only once the broker confirmed the message (publisher confirms, SQS
|
|
70
|
+
* response, Kafka acks).
|
|
71
|
+
*/
|
|
72
|
+
export interface OutboundMessage {
|
|
73
|
+
readonly id: string
|
|
74
|
+
readonly type: string | undefined
|
|
75
|
+
readonly key: string | undefined
|
|
76
|
+
readonly headers: Readonly<Record<string, string>>
|
|
77
|
+
readonly body: Uint8Array | string
|
|
78
|
+
readonly contentType: string
|
|
79
|
+
/** Production time, epoch milliseconds. */
|
|
80
|
+
readonly timestamp: number
|
|
81
|
+
/** Requested delivery delay in milliseconds (`0`: immediate). */
|
|
82
|
+
readonly delayMs: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Why a message was moved to the dead-letter queue (`x-znight-dead-letter-reason` header). */
|
|
86
|
+
export type DeadLetterReason =
|
|
87
|
+
/** No `@OnMessage` handler matches the message type. */
|
|
88
|
+
| 'no-handler'
|
|
89
|
+
/** The body could not be decoded (malformed JSON...). */
|
|
90
|
+
| 'decode'
|
|
91
|
+
/** The `@Message(schema)` validation failed. */
|
|
92
|
+
| 'validation'
|
|
93
|
+
/** The handler threw a `NonRetryableError`. */
|
|
94
|
+
| 'non-retryable'
|
|
95
|
+
/** The last attempt allowed by the retry policy failed. */
|
|
96
|
+
| 'max-attempts'
|
package/src/metadata.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Messaging section of the generated metadata (`__ZNIGHT_METADATA__.extensions.messaging`), produced
|
|
3
|
+
* by the `@znight/messaging/compiler` extension and consumed by `MessagingPlugin`.
|
|
4
|
+
* See docs/internals/architecture.md §9.2.
|
|
5
|
+
*/
|
|
6
|
+
import type { Token } from '@znight/core'
|
|
7
|
+
import type { InboundMessage } from './message.ts'
|
|
8
|
+
import type { RetryPolicy } from './retry.ts'
|
|
9
|
+
|
|
10
|
+
/** Messaging metadata of the application. */
|
|
11
|
+
export interface MessagingMetadata {
|
|
12
|
+
readonly consumers: readonly ConsumerDefinition[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Options of a consumer: `concurrency` and `deadLetter` are read by the runtime, every other key is a
|
|
17
|
+
* driver option (e.g. Kafka `groupId`) forwarded to `MessagingDriver.subscribe`.
|
|
18
|
+
*/
|
|
19
|
+
export interface ConsumerRuntimeOptions {
|
|
20
|
+
/** Maximum number of messages handled concurrently by this consumer. Default: plugin `defaults.concurrency`. */
|
|
21
|
+
readonly concurrency?: number
|
|
22
|
+
/** Dead-letter destination, or `false` to disable dead-lettering. Default: the plugin/driver naming. */
|
|
23
|
+
readonly deadLetter?: string | false
|
|
24
|
+
readonly [driverOption: string]: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One `@Consumer` class. */
|
|
28
|
+
export interface ConsumerDefinition {
|
|
29
|
+
/** Provider token of the consumer class. */
|
|
30
|
+
readonly token: Token
|
|
31
|
+
/** Queue (RabbitMQ, SQS) or topic (Kafka) consumed. */
|
|
32
|
+
readonly queue: string
|
|
33
|
+
/** `@Consumer({ queue, ...options })` without `queue` and `scope`. */
|
|
34
|
+
readonly options?: ConsumerRuntimeOptions
|
|
35
|
+
/** `@OnMessage` methods, in declaration order. */
|
|
36
|
+
readonly handlers: readonly MessageHandlerDefinition[]
|
|
37
|
+
/** Name of the `@OnError` method, if any: `(error, message) => void | 'ack'`. */
|
|
38
|
+
readonly errorHandler?: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One `@OnMessage` method. */
|
|
42
|
+
export interface MessageHandlerDefinition {
|
|
43
|
+
/** Method name (logs, hook events). */
|
|
44
|
+
readonly method: string
|
|
45
|
+
/**
|
|
46
|
+
* `@OnMessage(type)`: the message type handled (exact match, or a pattern where `*` matches any
|
|
47
|
+
* sequence of characters). Without type, the handler is the consumer's default handler.
|
|
48
|
+
*/
|
|
49
|
+
readonly type?: string
|
|
50
|
+
/** `@Retry` policy, normalized. Default: the plugin's `defaults.retry`. */
|
|
51
|
+
readonly retry?: RetryPolicy
|
|
52
|
+
/**
|
|
53
|
+
* Calls the handler: extracts and validates the parameters from the message
|
|
54
|
+
* (`@Message(schema)` → `validateMessage(schema, m.payload)`, `@Headers(name)` →
|
|
55
|
+
* `messageHeader(m, name)`, `@Ctx()` → `m`).
|
|
56
|
+
*/
|
|
57
|
+
// biome-ignore lint/suspicious/noExplicitAny: generated invokers receive the concrete consumer class
|
|
58
|
+
readonly invoke: (consumer: any, message: InboundMessage) => unknown
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
declare module '@znight/core' {
|
|
62
|
+
interface ZnightExtensionsMetadata {
|
|
63
|
+
readonly messaging?: MessagingMetadata
|
|
64
|
+
}
|
|
65
|
+
}
|