@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.
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/plugin.ts ADDED
@@ -0,0 +1,236 @@
1
+ import type { Plugin, PluginContext, PluginOptions } from '@znight/core'
2
+ import type { MessageCodec } from './codec.ts'
3
+ import type { MessagingDriver } from './driver.ts'
4
+ import type { RetryOptions } from './retry.ts'
5
+
6
+ import { resolveOptions } from '@znight/core'
7
+
8
+ import { DriverMessageBroker, type MessageBroker } from './broker.ts'
9
+ import { jsonCodec } from './codec.ts'
10
+ import { type ConsumerDefaults, defaultRetryPolicy, prepareConsumers } from './consumers.ts'
11
+ import { consumerError } from './errors.ts'
12
+ import { isPositiveInteger } from './internal/async.ts'
13
+ import { ConsumerRuntime, type ConsumerState } from './runtime.ts'
14
+ import { MessagingTokens } from './tokens.ts'
15
+
16
+ /** Defaults applied to every consumer (each `@Consumer` / `@Retry` may override them). */
17
+ export interface MessagingDefaults {
18
+ /** Retry policy of handlers without `@Retry`. Default: 3 attempts, exponential from 1 s up to 30 s. `false`: no retry. */
19
+ readonly retry?: RetryOptions | false
20
+ /** Concurrency of consumers without `concurrency` option. Default: 1. */
21
+ readonly concurrency?: number
22
+ /**
23
+ * Dead-letter destination of a queue, or `false` to disable dead-lettering (messages are then
24
+ * rejected natively or discarded). Default: the driver naming (`orders.dlq`, SQS `orders-dlq`).
25
+ */
26
+ readonly deadLetter?: ((queue: string) => string) | false
27
+ /**
28
+ * Pause, in milliseconds, before releasing a message whose settlement failed (dead-letter queue
29
+ * unavailable...), to avoid hot redelivery loops. Default: 1000.
30
+ */
31
+ readonly requeueDelay?: number
32
+ }
33
+
34
+ /** Options of {@link MessagingPlugin.configure}. */
35
+ export interface MessagingPluginOptions {
36
+ /** The broker driver: `rabbitmq()`, `sqs()`, `kafka()` or `memory()` (from the matching subpath). */
37
+ readonly driver: MessagingDriver
38
+ readonly defaults?: MessagingDefaults
39
+ /** Payload encoding. Default: {@link jsonCodec}. */
40
+ readonly codec?: MessageCodec
41
+ /** Subscribe the consumers on start. `false` for publish-only processes. Default: `true`. */
42
+ readonly consume?: boolean
43
+ }
44
+
45
+ /** Runtime status of a consumer. */
46
+ export interface ConsumerStatus {
47
+ /** Display name of the consumer class. */
48
+ readonly consumer: string
49
+ readonly queue: string
50
+ readonly state: ConsumerState
51
+ readonly concurrency: number
52
+ readonly inFlight: number
53
+ readonly deadLetterQueue: string | undefined
54
+ }
55
+
56
+ /** The plugin returned by {@link MessagingPlugin.configure}. */
57
+ export interface MessagingPluginInstance extends Plugin {
58
+ readonly name: 'messaging'
59
+ /** The broker (also injectable as `MessageBroker`). */
60
+ readonly broker: MessageBroker
61
+ /** The driver, once the plugin is bootstrapped. */
62
+ readonly driver: MessagingDriver | undefined
63
+ /** Status of every consumer, once the plugin is bootstrapped. */
64
+ consumers(): ConsumerStatus[]
65
+ }
66
+
67
+ const DEFAULT_REQUEUE_DELAY_MS = 1000
68
+ /** Period of the keep-alive timer (it never does anything: it only keeps the event loop alive). */
69
+ const KEEP_ALIVE_INTERVAL_MS = 2 ** 30
70
+
71
+ /**
72
+ * Message consumers and publishing.
73
+ *
74
+ * ```ts
75
+ * import { MessagingPlugin } from '@znight/messaging'
76
+ * import { rabbitmq } from '@znight/messaging/rabbitmq'
77
+ *
78
+ * const app = await Application.create({
79
+ * metadata: __ZNIGHT_METADATA__,
80
+ * plugins: [MessagingPlugin.configure({ driver: rabbitmq({ url: 'amqp://localhost' }) })],
81
+ * })
82
+ * ```
83
+ *
84
+ * Options may be computed from the container (evaluated in `onBootstrap`):
85
+ * `MessagingPlugin.configure(c => ({ driver: rabbitmq({ url: c.get(AmqpConfig).url }) }))`.
86
+ */
87
+ export const MessagingPlugin: {
88
+ configure(options: PluginOptions<MessagingPluginOptions>): MessagingPluginInstance
89
+ } = Object.freeze({
90
+ configure(options: PluginOptions<MessagingPluginOptions>): MessagingPluginInstance {
91
+ return createPlugin(options)
92
+ },
93
+ })
94
+
95
+ function createPlugin(rawOptions: PluginOptions<MessagingPluginOptions>): MessagingPluginInstance {
96
+ const broker = new DriverMessageBroker()
97
+ let driver: MessagingDriver | undefined
98
+ let connected = false
99
+ let consume = true
100
+ let runtimes: ConsumerRuntime[] = []
101
+ /**
102
+ * Keeps the process alive while consumers run: they are its ingress, and some drivers hold no
103
+ * handle of their own (the memory driver only uses microtasks and short timers), so a process
104
+ * without other handle would exit right after `app.start()`, without graceful shutdown.
105
+ */
106
+ let keepAlive: ReturnType<typeof setInterval> | undefined
107
+ const stopKeepAlive = (): void => {
108
+ if (keepAlive !== undefined) clearInterval(keepAlive)
109
+ keepAlive = undefined
110
+ }
111
+
112
+ return {
113
+ name: 'messaging',
114
+ broker,
115
+ get driver(): MessagingDriver | undefined {
116
+ return driver
117
+ },
118
+ consumers(): ConsumerStatus[] {
119
+ return runtimes.map(runtime => ({
120
+ consumer: runtime.consumer.name,
121
+ queue: runtime.consumer.queue,
122
+ state: runtime.state,
123
+ concurrency: runtime.consumer.concurrency,
124
+ inFlight: runtime.inFlight,
125
+ deadLetterQueue: runtime.consumer.deadLetterQueue,
126
+ }))
127
+ },
128
+
129
+ register(ctx: PluginContext): void {
130
+ ctx.container.registerExternal({
131
+ token: MessagingTokens.InboundMessage,
132
+ scope: 'request',
133
+ providedBy: 'MessagingPlugin',
134
+ })
135
+ ctx.container.registerValue(MessagingTokens.MessageBroker, broker)
136
+ },
137
+
138
+ async onBootstrap(ctx: PluginContext): Promise<void> {
139
+ const options = validateOptions(resolveOptions(rawOptions, ctx.container))
140
+ driver = options.driver
141
+ consume = options.consume ?? true
142
+ const defaults = options.defaults ?? {}
143
+ const consumerDefaults: ConsumerDefaults = {
144
+ retry: defaultRetryPolicy(defaults.retry),
145
+ concurrency: defaults.concurrency ?? 1,
146
+ deadLetter: defaults.deadLetter,
147
+ }
148
+ const prepared = prepareConsumers(ctx.metadata.extensions?.messaging, ctx.container, driver, consumerDefaults)
149
+ const codec = options.codec ?? jsonCodec
150
+ connected = true // close() must run even if connect() failed half-way
151
+ await driver.connect({ logger: ctx.logger })
152
+ broker.bind(driver, codec, ctx.hooks)
153
+ const services = {
154
+ container: ctx.container,
155
+ hooks: ctx.hooks,
156
+ logger: ctx.logger,
157
+ codec,
158
+ driver,
159
+ requeueDelayMs: defaults.requeueDelay ?? DEFAULT_REQUEUE_DELAY_MS,
160
+ }
161
+ runtimes = prepared.map(consumer => new ConsumerRuntime(consumer, services))
162
+ },
163
+
164
+ async onStart(ctx: PluginContext): Promise<void> {
165
+ if (!consume || runtimes.length === 0) return
166
+ const results = await Promise.allSettled(runtimes.map(runtime => runtime.start()))
167
+ const failure = results.find(result => result.status === 'rejected')
168
+ if (failure !== undefined) {
169
+ // the application only stops plugins whose onStart succeeded: undo the subscriptions made
170
+ await Promise.all(runtimes.map(runtime => runtime.stop()))
171
+ throw failure.reason
172
+ }
173
+ keepAlive ??= setInterval(() => undefined, KEEP_ALIVE_INTERVAL_MS)
174
+ ctx.logger.info(`Messaging: ${runtimes.length} consumer(s) subscribed with the ${driver?.name} driver`, {
175
+ queues: runtimes.map(runtime => runtime.consumer.queue),
176
+ })
177
+ },
178
+
179
+ async onStop(): Promise<void> {
180
+ try {
181
+ await Promise.all(runtimes.map(runtime => runtime.stop()))
182
+ } finally {
183
+ stopKeepAlive()
184
+ }
185
+ },
186
+
187
+ async onDispose(): Promise<void> {
188
+ stopKeepAlive()
189
+ broker.unbind()
190
+ if (driver !== undefined && connected) {
191
+ connected = false
192
+ await driver.close()
193
+ }
194
+ },
195
+ }
196
+ }
197
+
198
+ function validateOptions(options: MessagingPluginOptions): MessagingPluginOptions {
199
+ if (options === null || typeof options !== 'object') {
200
+ throw consumerError('MessagingPlugin.configure(): options must be an object')
201
+ }
202
+ const driver = options.driver as Partial<MessagingDriver> | undefined
203
+ if (
204
+ driver === undefined ||
205
+ driver === null ||
206
+ typeof driver.connect !== 'function' ||
207
+ typeof driver.subscribe !== 'function' ||
208
+ typeof driver.publish !== 'function' ||
209
+ typeof driver.close !== 'function' ||
210
+ typeof driver.deadLetterName !== 'function' ||
211
+ typeof driver.capabilities !== 'object'
212
+ ) {
213
+ throw consumerError('MessagingPlugin.configure(): "driver" must be a messaging driver', [
214
+ "Use rabbitmq() from '@znight/messaging/rabbitmq', sqs(), kafka() or memory() from their subpaths",
215
+ ])
216
+ }
217
+ const defaults = options.defaults
218
+ if (defaults?.concurrency !== undefined && !isPositiveInteger(defaults.concurrency)) {
219
+ throw consumerError(
220
+ `MessagingPlugin defaults.concurrency must be a positive integer, got ${String(defaults.concurrency)}`,
221
+ )
222
+ }
223
+ if (defaults?.requeueDelay !== undefined && !(Number.isFinite(defaults.requeueDelay) && defaults.requeueDelay >= 0)) {
224
+ throw consumerError(
225
+ `MessagingPlugin defaults.requeueDelay must be a number of milliseconds >= 0, got ${String(defaults.requeueDelay)}`,
226
+ )
227
+ }
228
+ if (
229
+ defaults?.deadLetter !== undefined &&
230
+ defaults.deadLetter !== false &&
231
+ typeof defaults.deadLetter !== 'function'
232
+ ) {
233
+ throw consumerError('MessagingPlugin defaults.deadLetter must be a function (queue => name) or false')
234
+ }
235
+ return options
236
+ }
package/src/retry.ts ADDED
@@ -0,0 +1,129 @@
1
+ import type { DriverCapabilities } from './driver.ts'
2
+
3
+ import { consumerError } from './errors.ts'
4
+
5
+ /** Backoff strategy between attempts. */
6
+ export type BackoffStrategy = 'fixed' | 'exponential'
7
+
8
+ /** Options of `@Retry()` and of the plugin's `defaults.retry`. */
9
+ export interface RetryOptions {
10
+ /** Total number of attempts, including the first delivery (`1`: no retry). */
11
+ readonly attempts: number
12
+ /** `fixed`: always `delay`; `exponential`: `delay * 2^(attempt - 1)`, capped by `maxDelay`. Default: `exponential`. */
13
+ readonly backoff?: BackoffStrategy
14
+ /** Base delay between attempts, in milliseconds. Default: 1000. */
15
+ readonly delay?: number
16
+ /** Upper bound of exponential delays, in milliseconds. */
17
+ readonly maxDelay?: number
18
+ }
19
+
20
+ /** A normalized retry policy, as stored in the metadata (`MessageHandlerDefinition.retry`). */
21
+ export interface RetryPolicy {
22
+ readonly attempts: number
23
+ readonly backoff: BackoffStrategy
24
+ readonly delay: number
25
+ readonly maxDelay?: number
26
+ }
27
+
28
+ /** Default base delay of a retry policy, in milliseconds. */
29
+ export const DEFAULT_RETRY_DELAY_MS = 1000
30
+
31
+ /** Default backoff strategy. */
32
+ export const DEFAULT_BACKOFF: BackoffStrategy = 'exponential'
33
+
34
+ /** Policy applied to handlers without `@Retry` when the plugin sets no `defaults.retry`. */
35
+ export const DEFAULT_RETRY_POLICY: RetryPolicy = Object.freeze({
36
+ attempts: 3,
37
+ backoff: 'exponential',
38
+ delay: DEFAULT_RETRY_DELAY_MS,
39
+ maxDelay: 30_000,
40
+ })
41
+
42
+ /** The policy of `retry: false` / `{ attempts: 1 }`. */
43
+ export const NO_RETRY_POLICY: RetryPolicy = Object.freeze({ attempts: 1, backoff: 'fixed', delay: 0 })
44
+
45
+ const MAX_ATTEMPTS = 10_000
46
+
47
+ /**
48
+ * Validates retry options and fills defaults.
49
+ *
50
+ * @throws ZnightError ZNIGHT011 for invalid values (`where` names the handler in the message).
51
+ */
52
+ export function normalizeRetry(options: RetryOptions | RetryPolicy, where = '@Retry()'): RetryPolicy {
53
+ if (options === null || typeof options !== 'object') {
54
+ throw consumerError(`${where}: retry options must be an object, got ${String(options)}`)
55
+ }
56
+ const { attempts, backoff = DEFAULT_BACKOFF, delay = DEFAULT_RETRY_DELAY_MS, maxDelay } = options
57
+ if (!Number.isInteger(attempts) || attempts < 1 || attempts > MAX_ATTEMPTS) {
58
+ throw consumerError(
59
+ `${where}: "attempts" must be an integer between 1 and ${MAX_ATTEMPTS}, got ${String(attempts)}`,
60
+ ['attempts counts the first delivery: { attempts: 3 } means 1 delivery + 2 retries'],
61
+ )
62
+ }
63
+ if (backoff !== 'fixed' && backoff !== 'exponential') {
64
+ throw consumerError(`${where}: "backoff" must be 'fixed' or 'exponential', got ${String(backoff)}`)
65
+ }
66
+ if (!isDuration(delay)) {
67
+ throw consumerError(`${where}: "delay" must be a finite number of milliseconds >= 0, got ${String(delay)}`)
68
+ }
69
+ if (maxDelay !== undefined && (!isDuration(maxDelay) || maxDelay < delay)) {
70
+ throw consumerError(
71
+ `${where}: "maxDelay" must be a finite number of milliseconds >= delay (${delay}), got ${String(maxDelay)}`,
72
+ )
73
+ }
74
+ return maxDelay === undefined ? { attempts, backoff, delay } : { attempts, backoff, delay, maxDelay }
75
+ }
76
+
77
+ function isDuration(value: unknown): value is number {
78
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0
79
+ }
80
+
81
+ /**
82
+ * Delay before the next attempt once `attempt` (1-based) failed: `delay` for `fixed`,
83
+ * `delay * 2^(attempt - 1)` capped by `maxDelay` for `exponential`.
84
+ */
85
+ export function computeRetryDelay(policy: RetryPolicy, attempt: number): number {
86
+ if (policy.backoff === 'fixed') return policy.delay
87
+ const exponent = Math.max(0, attempt - 1)
88
+ const raw = policy.delay * 2 ** exponent
89
+ return Math.min(raw, policy.maxDelay ?? Number.MAX_SAFE_INTEGER)
90
+ }
91
+
92
+ /** Every delay the policy may request (one per retry), in attempt order. */
93
+ export function retryDelays(policy: RetryPolicy): number[] {
94
+ const delays: number[] = []
95
+ for (let attempt = 1; attempt < policy.attempts; attempt++) delays.push(computeRetryDelay(policy, attempt))
96
+ return delays
97
+ }
98
+
99
+ /** Whether a delay is waited in-process (`true`) or delegated to the broker (`false`). */
100
+ export function isInProcessDelay(delayMs: number, capabilities: DriverCapabilities): boolean {
101
+ return delayMs <= capabilities.maxInProcessDelayMs
102
+ }
103
+
104
+ /**
105
+ * Checks that the driver can honour every delay of `policy`.
106
+ *
107
+ * @throws ZnightError ZNIGHT011 naming `where` and the offending delay.
108
+ */
109
+ export function assertRetrySupported(
110
+ policy: RetryPolicy,
111
+ capabilities: DriverCapabilities,
112
+ driver: string,
113
+ where: string,
114
+ ): void {
115
+ for (const delay of new Set(retryDelays(policy))) {
116
+ if (isInProcessDelay(delay, capabilities)) continue
117
+ if (capabilities.nativeDelay && delay <= capabilities.maxNativeDelayMs) continue
118
+ const limits = capabilities.nativeDelay
119
+ ? `in-process delays up to ${capabilities.maxInProcessDelayMs} ms and native delays up to ${capabilities.maxNativeDelayMs} ms`
120
+ : `in-process delays up to ${capabilities.maxInProcessDelayMs} ms and no native delay`
121
+ throw consumerError(
122
+ `${where}: the ${driver} driver cannot wait ${delay} ms between attempts (it supports ${limits})`,
123
+ [
124
+ 'Lower "delay"/"maxDelay" of the retry policy',
125
+ 'Or raise the driver budget (e.g. maxInProcessDelayMs) if holding messages that long is acceptable',
126
+ ],
127
+ )
128
+ }
129
+ }