@nitra/nats 3.2.0 → 4.0.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.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @nitra/nats
2
2
 
3
3
  NATS JetStream helper для Node.js.
4
- Простий API для публікації, обробки та моніторингу повідомлень у черзі з автоматичним створенням consumer для кожного subject або кастомного durable consumer.
4
+ Простий API для публікації, обробки та моніторингу повідомлень у черзі з гнучким управлінням consumer-ами через конфігурацію та CLI інструменти.
5
5
 
6
6
  ---
7
7
 
@@ -44,18 +44,72 @@ projectName:subjectName
44
44
  ```js
45
45
  import { publish } from '@nitra/nats'
46
46
 
47
- // Мінімальний варіант (durable_name = subject)
47
+ // Публікація повідомлення
48
48
  await publish('project:subject', { id: 1, foo: 'bar' })
49
+ await publish('service:notifications', { message: 'Hello!' })
50
+ ```
51
+
52
+ - Повідомлення публікується у subject `dev.project:subject` (або `${NATS_STREAM}.project:subject`)
53
+ - Consumer-и потрібно створювати окремо через `ensureConsumer` або CLI
54
+
55
+ ---
56
+
57
+ ## Управління Consumer-ами
58
+
59
+ ### Програмне створення consumer-а
60
+
61
+ ```js
62
+ import { ensureConsumer } from '@nitra/nats'
63
+
64
+ // Створення простого consumer-а
65
+ await ensureConsumer({
66
+ streamName: 'dev',
67
+ durableName: 'project:subject',
68
+ filterSubjects: ['project:subject'],
69
+ deliverPolicy: 'all',
70
+ ackPolicy: 'explicit'
71
+ })
72
+
73
+ // Створення групового consumer-а для кількох subject-ів
74
+ await ensureConsumer({
75
+ streamName: 'dev',
76
+ durableName: 'worker-group',
77
+ filterSubjects: ['project:orders', 'project:payments'],
78
+ deliverPolicy: 'all',
79
+ ackPolicy: 'explicit'
80
+ })
81
+ ```
49
82
 
50
- // Кастомний durable consumer (наприклад, для групової обробки)
51
- await publish('project:subject', { id: 2 }, [
52
- { durableName: 'worker-group', filterSubjects: ['project:subject', 'project:subject2'] },
53
- { durableName: 'analytics', filterSubjects: ['project:subject'] }
54
- ])
83
+ ### CLI для роботи з YAML конфігураціями
84
+
85
+ Створіть YAML файл з конфігурацією consumer-а:
86
+
87
+ ```yaml
88
+ # consumer.yaml
89
+ apiVersion: jetstream.nats.io/v1beta2
90
+ kind: Consumer
91
+ metadata:
92
+ name: 'nats:test'
93
+ namespace: dev
94
+ spec:
95
+ streamName: dev
96
+ durableName: 'nats:test'
97
+ filterSubjects:
98
+ - 'nats:subject'
99
+ - 'nats:subject2'
100
+ deliverPolicy: all
101
+ ackPolicy: explicit
55
102
  ```
56
103
 
57
- - Consumer буде створений автоматично, якщо не існує.
58
- - Повідомлення публікується у subject `dev.project:subject` (або `${NATS_STREAM}.project:subject`).
104
+ Застосуйте конфігурацію:
105
+
106
+ ```bash
107
+ # З змінними середовища
108
+ NODE_ENV=development NATS_URL=nats://localhost:4222 NATS_STREAM=dev node cli.js consumer.yaml
109
+
110
+ # Або через npx після публікації
111
+ npx @nitra/nats consumer.yaml
112
+ ```
59
113
 
60
114
  ---
61
115
 
@@ -95,65 +149,124 @@ console.log('pending for group:', count2)
95
149
 
96
150
  ## Як це працює
97
151
 
98
- - **publish(subject, data, consumers?):**
99
- - Перевіряє/створює consumer-ів (один раз за процес).
100
- - Публікує повідомлення у subject `${stream}.${subject}`.
101
- - Якщо передати масив consumers створює кастомні durable consumer-и з кастомними іменами та фільтрами.
152
+ - **publish(subject, data):**
153
+
154
+ - Публікує повідомлення у subject `${stream}.${subject}`
155
+ - Перевіряє формат subject (має бути `project:subject`)
156
+
157
+ - **ensureConsumer(spec):**
158
+
159
+ - Створює consumer якщо не існує
160
+ - Оновлює `filter_subjects` якщо вони змінились
161
+ - Перестворює consumer якщо змінились `deliverPolicy` або `ackPolicy`
162
+ - Автоматично створює stream якщо потрібно
102
163
 
103
164
  - **read(durableName):**
104
- - Читає одне повідомлення з черги для durable consumer (за замовчуванням durable_name = subject).
165
+
166
+ - Читає одне повідомлення з черги для durable consumer
105
167
 
106
168
  - **finish():**
107
- - Підтверджує (ack) повідомлення.
169
+
170
+ - Підтверджує (ack) повідомлення
108
171
 
109
172
  - **getPendingCount(durableName):**
110
- - Повертає кількість непрочитаних повідомлень для durable consumer.
173
+ - Повертає кількість непрочитаних повідомлень для durable consumer
111
174
 
112
175
  ---
113
176
 
114
177
  ## Важливо
115
178
 
116
- - STREAM у NATS за замовчуванням `dev` (або значення змінної `NATS_STREAM`), але subject і durable consumer-и динамічні.
117
- - Для кожного subject або кастомного durable створюється окремий consumer з іменем `durableName`.
118
- - Пакет розрахований на single-message workflow (одне повідомлення на читання за раз, публікація — необмежена).
119
- - Для паралельної обробки або кастомних сценаріїв дивись вихідний код та розширюй під свої задачі.
179
+ - STREAM у NATS за замовчуванням `dev` (або значення змінної `NATS_STREAM`)
180
+ - Consumer-и потрібно створювати **явно** через `ensureConsumer` або CLI
181
+ - Subject має відповідати формату `project:subject`
182
+ - Пакет розрахований на single-message workflow (одне повідомлення на читання за раз)
183
+ - `ensureConsumer` розумно оновлює конфігурацію без втрати повідомлень
184
+ - CLI підтримує YAML конфігурації для декларативного управління consumer-ами
120
185
 
121
186
  ---
122
187
 
123
188
  ## Приклад повного workflow
124
189
 
125
190
  ```js
126
- import { publish, read, finish, getPendingCount } from '@nitra/nats'
191
+ import { publish, ensureConsumer, read, finish, getPendingCount } from '@nitra/nats'
192
+
193
+ // 1. Створення consumer-ів
194
+ await ensureConsumer({
195
+ streamName: 'dev',
196
+ durableName: 'project:subject',
197
+ filterSubjects: ['project:subject'],
198
+ deliverPolicy: 'all',
199
+ ackPolicy: 'explicit'
200
+ })
201
+
202
+ await ensureConsumer({
203
+ streamName: 'dev',
204
+ durableName: 'worker-group',
205
+ filterSubjects: ['project:subject', 'project:orders'],
206
+ deliverPolicy: 'all',
207
+ ackPolicy: 'explicit'
208
+ })
209
+
210
+ // 2. Публікація повідомлень
211
+ await publish('project:subject', { id: 1, action: 'create' })
212
+ await publish('project:orders', { orderId: 123, amount: 100 })
213
+
214
+ // 3. Перевірка pending повідомлень
215
+ const count1 = await getPendingCount('project:subject')
216
+ const count2 = await getPendingCount('worker-group')
217
+ console.log(`pending: ${count1}, worker-group: ${count2}`)
127
218
 
128
- // Публікація для стандартного durable (durable_name = subject)
129
- await publish('project:subject', { id: 1 })
219
+ // 4. Обробка повідомлень
220
+ const data1 = await read('project:subject')
221
+ console.log('received:', data1)
222
+ await finish()
130
223
 
131
- // Публікація для кількох consumer-ів
132
- await publish('project:subject', { id: 2 }, [
133
- { durableName: 'worker-group', filterSubjects: ['project:subject', 'project:subject2'] },
134
- { durableName: 'analytics', filterSubjects: ['project:subject'] }
135
- ])
224
+ const data2 = await read('worker-group')
225
+ console.log('group received:', data2)
226
+ await finish()
227
+ ```
136
228
 
137
- // Pending для стандартного durable
138
- const count = await getPendingCount('project:subject')
139
- console.log('pending:', count)
229
+ ---
140
230
 
141
- // Pending для кастомного durable
142
- const count2 = await getPendingCount('worker-group')
143
- console.log('pending for group:', count2)
231
+ ## CLI Інструмент
144
232
 
145
- // Читання для стандартного durable
146
- const { id } = await read('project:subject')
147
- console.log(`read. id: ${id}`)
148
- // Підтверджуємо, що повідомлення прочитано (якщо не викликати finish, то повідомлення буде повторно надіслано)
149
- await finish()
233
+ CLI підтримує роботу з YAML конфігураціями consumer-ів у форматі JetStream Consumer API.
150
234
 
151
- // Читання для кастомного durable
152
- const data = await read('worker-group')
153
- console.log('read:', data)
154
- await finish()
235
+ ### Використання
236
+
237
+ ```bash
238
+ # Застосування конфігурації consumer-а з YAML файлу
239
+ NODE_ENV=development NATS_URL=nats://localhost:4222 NATS_STREAM=dev node cli.js consumer.yaml
240
+
241
+ # Через npx після публікації пакету
242
+ npx @nitra/nats consumer.yaml
155
243
  ```
156
244
 
245
+ ### Формат YAML конфігурації
246
+
247
+ ```yaml
248
+ apiVersion: jetstream.nats.io/v1beta2
249
+ kind: Consumer
250
+ metadata:
251
+ name: my-consumer
252
+ namespace: dev
253
+ spec:
254
+ streamName: dev
255
+ durableName: my-consumer
256
+ filterSubjects:
257
+ - project:orders
258
+ - project:payments
259
+ deliverPolicy: all
260
+ ackPolicy: explicit
261
+ ```
262
+
263
+ CLI автоматично:
264
+
265
+ - Створить consumer якщо не існує
266
+ - Оновить filter_subjects якщо вони змінились
267
+ - Перестворить consumer якщо змінились deliverPolicy або ackPolicy
268
+ - Створить stream якщо потрібно
269
+
157
270
  ---
158
271
 
159
272
  ## Ліцензія
package/package.json CHANGED
@@ -1,11 +1,14 @@
1
1
  {
2
2
  "name": "@nitra/nats",
3
3
  "description": "nats helper",
4
- "version": "3.2.0",
4
+ "version": "4.0.0",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "src"
8
8
  ],
9
+ "bin": {
10
+ "nitra-nats": "./src/cli.js"
11
+ },
9
12
  "exports": {
10
13
  ".": "./src/index.js"
11
14
  },
@@ -23,6 +26,7 @@
23
26
  "dependencies": {
24
27
  "@nitra/check-env": "^4.1.0",
25
28
  "@nitra/pino": "^2.7.4",
29
+ "js-yaml": "^4.1.1",
26
30
  "nats": "^2.29.3"
27
31
  },
28
32
  "engines": {
package/src/cli.js ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+ import { exit } from 'node:process'
5
+ import yaml from 'js-yaml'
6
+ import { ensureConsumer } from './consumer.js'
7
+ import { checkSubjectFormat } from './utils.js'
8
+
9
+ function parseConsumerYaml() {
10
+ try {
11
+ const filePath = path.resolve(process.cwd(), process.argv[2])
12
+ const fileContents = fs.readFileSync(filePath, 'utf8')
13
+ const data = yaml.load(fileContents)
14
+
15
+ return {
16
+ apiVersion: data.apiVersion,
17
+ kind: data.kind,
18
+ metadata: {
19
+ name: data.metadata?.name,
20
+ namespace: data.metadata?.namespace
21
+ },
22
+ spec: {
23
+ streamName: data.spec?.streamName,
24
+ durableName: data.spec?.durableName,
25
+ filterSubjects: data.spec?.filterSubjects,
26
+ deliverPolicy: data.spec?.deliverPolicy,
27
+ ackPolicy: data.spec?.ackPolicy
28
+ }
29
+ }
30
+ } catch (error) {
31
+ throw new Error(`Помилка парсингу YAML файлу: ${error.message}`)
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Валідує структуру парсеного consumer об'єкта
37
+ * @param {Object} consumer - Об'єкт consumer для валідації
38
+ * @returns {boolean} true якщо валідація пройшла успішно
39
+ * @throws {Error} Якщо валідація не пройшла
40
+ */
41
+ export function validateConsumer(consumer) {
42
+ if (!consumer.apiVersion) throw new Error('Відсутнє поле apiVersion')
43
+ if (!consumer.kind) throw new Error('Відсутнє поле kind')
44
+ if (!consumer.metadata?.name) throw new Error('Відсутнє поле metadata.name')
45
+
46
+ for (const field of ['streamName', 'durableName', 'filterSubjects', 'deliverPolicy', 'ackPolicy']) {
47
+ if (!consumer.spec?.[field]) throw new Error(`Відсутнє поле spec.${field}`)
48
+ }
49
+
50
+ if (!Array.isArray(consumer.spec.filterSubjects)) throw new Error('Поле spec.filterSubjects повинно бути масивом')
51
+
52
+ for (const subject of consumer.spec.filterSubjects) {
53
+ checkSubjectFormat(subject)
54
+ }
55
+ }
56
+
57
+ // Приклад використання
58
+ if (import.meta.url === `file://${process.argv[1]}`) {
59
+ try {
60
+ const consumer = parseConsumerYaml()
61
+
62
+ validateConsumer(consumer)
63
+
64
+ await ensureConsumer(consumer.spec)
65
+ exit(0)
66
+ } catch (error) {
67
+ console.error('Помилка:', error.message)
68
+ exit(1)
69
+ }
70
+ }
package/src/consumer.js CHANGED
@@ -1,49 +1,69 @@
1
- import { jsm, AckPolicy, stream } from './nats.js'
2
- import { checkSubjectFormat } from './utils.js'
1
+ import { jsm, stream } from './nats.js'
2
+ import { ensureStream } from './stream.js'
3
3
  import { log } from '@nitra/pino'
4
- // import { ensureStream } from './stream.js'
5
-
6
- // Set для зберігання існуючих consumer
7
- const consumerReady = new Set()
8
4
 
9
5
  /**
10
- * Перевіряє наявність або створює durable consumer(а) для subject(ів).
11
- * @param {string} subject - subject у форматі project:subject
12
- * @param {object[]} consumers - список consumer-ів. [{durableName, filterSubjects}]
6
+ * Створює або оновлює consumer.
7
+ * @param {object{streamName: string, durableName: string, filterSubjects: string[], deliverPolicy: string, ackPolicy: string}} spec - об'єкт consumer-а.
13
8
  * @returns {Promise<void>}
14
9
  */
15
- export async function ensureConsumer(subject, consumers = [{ durableName: subject, filterSubjects: [subject] }]) {
16
- if (consumers.every(c => consumerReady.has(c.durableName))) {
17
- log.debug(`✅ Consumers: ${consumers.map(c => c.durableName)} already exists`)
18
- return
19
- }
10
+ export async function ensureConsumer(spec) {
11
+ // створюємо stream якщо не існує
12
+ const streamName = spec.streamName || stream
13
+ await ensureStream(streamName)
20
14
 
21
- // перевіряємо чи subject відповідає формату project:subject
22
- checkSubjectFormat(subject)
15
+ const consumerArr = (await jsm.consumers?.list(streamName)?.next()) || []
16
+ const consumer = consumerArr.find(ca => ca.config.durable_name === spec.durableName)
23
17
 
24
- // // створюємо stream якщо не існує
25
- // if (!consumerReady.size) {
26
- // await ensureStream()
27
- // }
18
+ // якщо не існує consumer-а створюємо його. якщо є перевіримо чи відповідає spec(якщо ні, то перестворюємо або оновлюємо)
19
+ if (consumer) {
20
+ log.info(`✅ Consumer «${spec.durableName}» already exists`)
28
21
 
29
- const consumerArr = (await jsm.consumers?.list(stream)?.next()) || []
30
- for (const c of consumers) {
31
- const isExists = consumerArr.some(ca => ca.config.durable_name === c.durableName)
22
+ const { filter_subjects, deliver_policy, ack_policy } = consumer.config
32
23
 
33
- // якщо не існує consumer-а який слухає subject, то створюємо за замовчуванням (durable_name = subject)
34
- if (isExists) {
35
- log.debug(`✅ Consumer «${c.durableName}» already exists`)
36
- } else {
37
- await jsm.consumers.add(stream, {
38
- durable_name: c.durableName,
39
- ack_policy: AckPolicy.Explicit, // якщо не підтвердити повідомлення, воно буде повторно надіслано
40
- filter_subjects: c.filterSubjects.map(fs => `${stream}.${fs}`), // якщо не вказати, то consumer буде слухати всі повідомлення зі stream
41
- deliver_policy: 'all' // 'all' - всі непрочитані повідомлення
42
- })
24
+ // якщо deliver_policy або ack_policy не відповідають spec, то перестворюємо consumer
25
+ if (deliver_policy !== spec.deliverPolicy || ack_policy !== spec.ackPolicy) {
26
+ const tempSpec = {
27
+ durableName: `temp_${spec.durableName}`,
28
+ filterSubjects: filter_subjects,
29
+ deliverPolicy: deliver_policy,
30
+ ackPolicy: ack_policy
31
+ }
32
+ // create temp consumer with old filter_subjects
33
+ await createConsumer(streamName, tempSpec)
34
+ // delete old consumer
35
+ await jsm.consumers.delete(streamName, spec.durableName)
36
+ // create new consumer
37
+ await createConsumer(streamName, spec)
38
+ // delete temp consumer
39
+ await jsm.consumers.delete(streamName, tempSpec.durableName)
43
40
 
44
- log.debug(`🔥 Consumer «${c.durableName}» created`)
41
+ log.info(`🔥 Consumer «${spec.durableName}» - recreated`)
45
42
  }
43
+ // якщо filter_subjects не відповідають spec, то оновлюємо consumer
44
+ else if (
45
+ spec.filterSubjects.some(fs => !filter_subjects.includes(`${streamName}.${fs}`)) ||
46
+ filter_subjects.some(fs => !spec.filterSubjects.includes(fs.split('.').pop()))
47
+ ) {
48
+ await jsm.consumers.update(streamName, spec.durableName, {
49
+ filter_subjects: spec.filterSubjects.map(fs => `${streamName}.${fs}`)
50
+ })
46
51
 
47
- consumerReady.add(c.durableName)
52
+ log.info(`🔥 Consumer «${spec.durableName}» - updated`)
53
+ } else {
54
+ log.info(`✅ Consumer «${spec.durableName}» - no changes`)
55
+ }
56
+ } else {
57
+ await createConsumer(streamName, spec)
58
+ log.info(`🔥 Consumer «${spec.durableName}» created`)
48
59
  }
49
60
  }
61
+
62
+ async function createConsumer(streamName, spec) {
63
+ await jsm.consumers.add(streamName, {
64
+ durable_name: spec.durableName,
65
+ ack_policy: spec.ackPolicy, // якщо не підтвердити повідомлення, воно буде повторно надіслано
66
+ filter_subjects: spec.filterSubjects.map(fs => `${streamName}.${fs}`), // якщо не вказати, то consumer буде слухати всі повідомлення зі stream
67
+ deliver_policy: spec.deliverPolicy // 'all' - всі непрочитані повідомлення
68
+ })
69
+ }
package/src/nats.js CHANGED
@@ -3,7 +3,7 @@ import { connect, JSONCodec } from 'nats'
3
3
  import { env } from 'node:process'
4
4
 
5
5
  // AckPolicy
6
- export { AckPolicy } from 'nats'
6
+ // export { AckPolicy } from 'nats'
7
7
 
8
8
  let nc
9
9
 
package/src/publish.js CHANGED
@@ -1,24 +1,22 @@
1
1
  import { jc, js, stream } from './nats.js'
2
- import { ensureConsumer } from './consumer.js'
2
+ import { checkSubjectFormat } from './utils.js'
3
3
  import { log } from '@nitra/pino'
4
4
  import { env } from 'node:process'
5
5
 
6
6
  /**
7
7
  * Публікує повідомлення у JetStream для вказаного subject.
8
- * Consumer створюється автоматично, якщо не існує.
9
8
  * @param {string} subject - subject у форматі project:subject
10
9
  * @param {object} data - дані для публікації
11
- * @param {object[]} [consumers] - список consumer-ів які потрібно створити. [{durableName, filterSubject}] (необов'язково)
12
10
  * @returns {Promise<void>}
13
11
  */
14
- export async function publish(subject, data, consumers) {
12
+ export async function publish(subject, data) {
15
13
  // якщо задані демо дані, то ігноримо запис
16
14
  if (env.NATS_FAKE_DATA) {
17
15
  return Promise.resolve()
18
16
  }
19
17
 
20
- // Ensure consumer if not exists
21
- await ensureConsumer(subject, consumers)
18
+ // перевіряємо чи subject відповідає формату project:subject
19
+ checkSubjectFormat(subject)
22
20
 
23
21
  log.debug('publish:', data)
24
22
  // Publish message
package/src/stream.js CHANGED
@@ -5,14 +5,14 @@ import { log } from '@nitra/pino'
5
5
  * Перевіряє наявність або створює JetStream stream.
6
6
  * @returns {Promise<void>}
7
7
  */
8
- export async function ensureStream() {
8
+ export async function ensureStream(streamName = stream) {
9
9
  try {
10
- await jsm.streams.info(stream)
10
+ await jsm.streams.info(streamName)
11
11
  log.debug('✅ Stream already exists')
12
12
  } catch {
13
13
  await jsm.streams.add({
14
- name: stream,
15
- subjects: [`${stream}.>`],
14
+ name: streamName,
15
+ subjects: [`${streamName}.>`],
16
16
  retention: 'interest', // зберігає повідомлення поки є consumers або поки всі повідомлення не будуть прочитані
17
17
  replicas: 1, // кількість реплік у кластері TODO: проговорити кількість реплік
18
18
  storage: 'file' //'memory' TODO: проговорити які параметри потрібні для кластера