@nitra/nats 4.1.0 → 4.2.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
@@ -232,7 +232,7 @@ CLI підтримує роботу з YAML конфігураціями consume
232
232
 
233
233
  ```bash
234
234
  # Застосування конфігурації consumer-а з YAML файлу
235
- NODE_ENV=development NATS_URL=nats://localhost:4222 NATS_STREAM=dev node cli.js consumer.yaml
235
+ NATS_URL=nats://localhost:4222 node cli.js consumer.yaml
236
236
 
237
237
  # Через npx після публікації пакету
238
238
  npx @nitra/nats consumer.yaml
package/package.json CHANGED
@@ -1,34 +1,34 @@
1
1
  {
2
2
  "name": "@nitra/nats",
3
+ "version": "4.2.0",
3
4
  "description": "nats helper",
4
- "version": "4.1.0",
5
- "type": "module",
6
- "files": [
7
- "src"
5
+ "keywords": [
6
+ "nats",
7
+ "nitra"
8
8
  ],
9
+ "homepage": "https://github.com/nitra/nats",
10
+ "bugs": "https://github.com/nitra/nats/issues",
11
+ "license": "MIT",
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "git+https://github.com/nitra/nats.git"
15
+ },
9
16
  "bin": {
10
17
  "nitra-nats": "src/cli.js"
11
18
  },
19
+ "files": [
20
+ "src"
21
+ ],
22
+ "type": "module",
12
23
  "exports": {
13
24
  ".": "./src/index.js"
14
25
  },
15
- "keywords": [
16
- "nitra",
17
- "nats"
18
- ],
19
- "repository": {
20
- "type": "git",
21
- "url": "git+https://github.com/nitra/nats.git"
22
- },
23
- "bugs": "https://github.com/nitra/nats/issues",
24
- "homepage": "https://github.com/nitra/nats",
25
- "license": "MIT",
26
26
  "dependencies": {
27
27
  "@nats-io/jetstream": "^3.3.0",
28
28
  "@nats-io/nats-core": "^3.3.0",
29
29
  "@nats-io/transport-node": "^3.3.0",
30
30
  "@nitra/check-env": "^4.1.0",
31
- "@nitra/pino": "^2.9.1",
31
+ "@nitra/pino": "^2.10.0",
32
32
  "js-yaml": "^4.1.1"
33
33
  },
34
34
  "engines": {
package/src/cli.js CHANGED
@@ -53,7 +53,7 @@ export function validateConsumer(consumer) {
53
53
  if (!Array.isArray(consumer.spec.filterSubjects)) throw new Error('Поле spec.filterSubjects повинно бути масивом')
54
54
 
55
55
  for (const subject of consumer.spec.filterSubjects) {
56
- checkSubjectFormat(subject)
56
+ checkSubjectFormat(consumer.spec.streamName, `${consumer.spec.streamName}.${subject}`)
57
57
  }
58
58
  }
59
59
 
package/src/consumer.js CHANGED
@@ -1,11 +1,10 @@
1
- import { jsm, stream } from './nats.js'
2
- import { ensureStream } from './stream.js'
1
+ import { jsm } from './nats.js'
3
2
  import { log } from '@nitra/pino'
4
3
 
5
4
  /**
6
5
  * Створює або оновлює consumer.
7
6
  * @param {object} spec - об'єкт consumer-а
8
- * @param {string} [spec.streamName] - назва stream
7
+ * @param {string} spec.streamName - назва stream
9
8
  * @param {string} spec.durableName - назва consumer-а
10
9
  * @param {string[]} spec.filterSubjects - масив subject-ів для фільтрації
11
10
  * @param {string} spec.deliverPolicy - політика доставки
@@ -14,12 +13,15 @@ import { log } from '@nitra/pino'
14
13
  */
15
14
  export async function ensureConsumer(spec) {
16
15
  // створюємо stream якщо не існує
17
- const streamName = spec.streamName || stream
18
- await ensureStream(streamName)
16
+ await ensureStream(spec.streamName)
19
17
 
20
- const consumerArr = (await jsm.consumers?.list(streamName)?.next()) || []
18
+ const consumerArr = (await jsm.consumers?.list(spec.streamName)?.next()) || []
21
19
  const consumer = consumerArr.find(ca => ca.config.durable_name === spec.durableName)
22
20
 
21
+ // Додаємо стрім до subject
22
+ // якщо не вказати, то consumer буде слухати всі повідомлення зі stream
23
+ spec.filterSubjects = spec.filterSubjects.map(fs => `${spec.streamName}.${fs}`)
24
+
23
25
  // якщо не існує consumer-а створюємо його. якщо є перевіримо чи відповідає spec(якщо ні, то перестворюємо або оновлюємо)
24
26
  if (consumer) {
25
27
  log.info(`✅ Consumer «${spec.durableName}» already exists`)
@@ -29,56 +31,96 @@ export async function ensureConsumer(spec) {
29
31
  // якщо deliver_policy або ack_policy не відповідають spec, то перестворюємо consumer
30
32
  if (deliver_policy !== spec.deliverPolicy || ack_policy !== spec.ackPolicy) {
31
33
  const tempSpec = {
34
+ streamName: spec.streamName,
32
35
  durableName: `temp_${spec.durableName}`,
33
- filterSubjects: filter_subjects,
36
+ filterSubjects: spec.filterSubjects,
34
37
  deliverPolicy: deliver_policy,
35
38
  ackPolicy: ack_policy
36
39
  }
37
- // create temp consumer with old filter_subjects
38
- await createConsumer(streamName, tempSpec)
40
+ // Цей “тимчасовий consumer потрібен як страховка на час перестворення,
41
+ // щоб не втратити повідомлення у стрімі з retention: 'interest'
42
+ await createConsumer(tempSpec)
39
43
  // delete old consumer
40
- await jsm.consumers.delete(streamName, spec.durableName)
44
+ await jsm.consumers.delete(spec.streamName, spec.durableName)
41
45
  // create new consumer
42
- await createConsumer(streamName, spec)
46
+ await createConsumer(spec)
43
47
  // delete temp consumer
44
- await jsm.consumers.delete(streamName, tempSpec.durableName)
48
+ await jsm.consumers.delete(tempSpec.streamName, tempSpec.durableName)
45
49
 
46
50
  log.info(`🔥 Consumer «${spec.durableName}» - recreated`)
47
51
  }
48
52
  // якщо filter_subjects не відповідають spec, то оновлюємо consumer
49
- else if (
50
- spec.filterSubjects.some(fs => !filter_subjects.includes(`${streamName}.${fs}`)) ||
51
- filter_subjects.some(fs => !spec.filterSubjects.includes(fs.split('.').pop()))
52
- ) {
53
- await jsm.consumers.update(streamName, spec.durableName, {
54
- filter_subjects: spec.filterSubjects.map(fs => `${streamName}.${fs}`)
53
+ else if (compareArrays(spec.filterSubjects, filter_subjects)) {
54
+ log.info(`✅ Consumer «${spec.durableName}» - no changes`)
55
+ } else {
56
+ await jsm.consumers.update(spec.streamName, spec.durableName, {
57
+ filter_subjects: spec.filterSubjects
55
58
  })
56
59
 
57
- log.info(`🔥 Consumer «${spec.durableName}» - updated`)
58
- } else {
59
- log.info(`✅ Consumer «${spec.durableName}» - no changes`)
60
+ log.info(`🔥 Consumer «${spec.durableName}» filter_subjects - updated`)
60
61
  }
61
62
  } else {
62
- await createConsumer(streamName, spec)
63
+ await createConsumer(spec)
63
64
  log.info(`🔥 Consumer «${spec.durableName}» created`)
64
65
  }
65
66
  }
66
67
 
67
68
  /**
68
69
  * Створює consumer для stream
69
- * @param {string} streamName - назва stream
70
70
  * @param {object} spec - специфікація consumer-а
71
+ * @param {string} spec.streamName - назва stream
71
72
  * @param {string} spec.durableName - назва consumer-а
72
73
  * @param {string[]} spec.filterSubjects - масив subject-ів для фільтрації
73
74
  * @param {string} spec.deliverPolicy - політика доставки
74
75
  * @param {string} spec.ackPolicy - політика підтвердження
75
76
  * @returns {Promise<void>}
76
77
  */
77
- async function createConsumer(streamName, spec) {
78
- await jsm.consumers.add(streamName, {
78
+ async function createConsumer(spec) {
79
+ await jsm.consumers.add(spec.streamName, {
79
80
  durable_name: spec.durableName,
80
81
  ack_policy: spec.ackPolicy, // якщо не підтвердити повідомлення, воно буде повторно надіслано
81
- filter_subjects: spec.filterSubjects.map(fs => `${streamName}.${fs}`), // якщо не вказати, то consumer буде слухати всі повідомлення зі stream
82
+ filter_subjects: spec.filterSubjects,
82
83
  deliver_policy: spec.deliverPolicy // 'all' - всі непрочитані повідомлення
83
84
  })
84
85
  }
86
+
87
+ /**
88
+ * Перевіряє наявність або створює JetStream stream.
89
+ * @param {string} streamName - назва stream
90
+ * @returns {Promise<void>}
91
+ */
92
+ async function ensureStream(streamName) {
93
+ try {
94
+ const info = await jsm.streams.info(streamName)
95
+ // Перевіряємо, чи stream підтримує rollup
96
+
97
+ if (!info.config.allow_rollup_hdrs) {
98
+ log.warn(
99
+ `⚠️ Stream «${streamName}» не має увімкненого allow_rollup_hdrs. Для використання Nats-Rollup заголовка потрібно видалити stream і створити його заново з allow_rollup_hdrs: true`
100
+ )
101
+ }
102
+ log.info('✅ Stream already exists')
103
+ } catch {
104
+ await jsm.streams.add({
105
+ name: streamName,
106
+ subjects: [`${streamName}.>`],
107
+ retention: 'interest', // зберігає повідомлення поки є consumers або поки всі повідомлення не будуть прочитані
108
+ num_replicas: 1, // означає, що stream не реплікується — підходить для локальної розробки. Для продакшену в кластері зазвичай використовують 3 або 5.
109
+ storage: 'file', // 'memory'
110
+ allow_rollup_hdrs: true // дозволяє використання заголовка Nats-Rollup для автоматичного видалення старих повідомлень
111
+ })
112
+ log.info('✅ Stream created')
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Порівнює два масиви
118
+ * @param {string[]} a - перший масив
119
+ * @param {string[]} b - другий масив
120
+ * @returns {boolean} - чи масиви рівні, true - рівні, false - не рівні
121
+ */
122
+ const compareArrays = (a, b) => {
123
+ if (a.length !== b.length) return false
124
+ const setB = new Set(b)
125
+ return a.every(v => setB.has(v))
126
+ }
package/src/index.js CHANGED
@@ -1,4 +1,3 @@
1
1
  export { publish } from './publish.js'
2
- export { read } from './worker.js'
3
- export { finish } from './finish.js'
2
+ export { read, finish } from './worker.js'
4
3
  export { getPendingCount } from './pending-count.js'
package/src/nats.js CHANGED
@@ -61,7 +61,7 @@ if (env.NATS_FAKE_DATA) {
61
61
  }
62
62
  }
63
63
  } else {
64
- checkEnv(['NATS_URL', 'NATS_STREAM'])
64
+ checkEnv(['NATS_URL'])
65
65
 
66
66
  // Connect to NATS
67
67
  nc = await connect({ servers: env.NATS_URL })
@@ -72,6 +72,3 @@ if (env.NATS_FAKE_DATA) {
72
72
  // Менеджер JetStream (для створення стрімів, конфігурацій тощо)
73
73
  jsm = await jetstreamManager(nc)
74
74
  }
75
-
76
- // Stream name
77
- export const stream = env.NATS_STREAM
@@ -1,6 +1,7 @@
1
- import { jsm, stream } from './nats.js'
1
+ import { jsm } from './nats.js'
2
2
  import { log } from '@nitra/pino'
3
3
  import { env } from 'node:process'
4
+ import { stream } from './stream.js'
4
5
 
5
6
  /**
6
7
  * Повертає кількість непрочитаних (pending) повідомлень для durable consumer.
package/src/publish.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { log } from '@nitra/pino'
2
2
  import { env } from 'node:process'
3
- import { js, stream } from './nats.js'
3
+ import { js } from './nats.js'
4
4
  import { checkSubjectFormat } from './utils.js'
5
5
  import { headers } from '@nats-io/nats-core'
6
6
  import { createHash } from 'node:crypto'
7
+ import { stream } from './stream.js'
7
8
 
8
9
  /**
9
10
  * Публікує повідомлення у JetStream для вказаного subject.
@@ -19,8 +20,8 @@ export async function publish(subject, data, options = {}) {
19
20
  return
20
21
  }
21
22
 
22
- // перевіряємо чи subject відповідає формату project:subject
23
- checkSubjectFormat(subject)
23
+ // перевіряємо чи subject відповідає формату stream.project:subject
24
+ checkSubjectFormat(stream, `${stream}.${subject}`)
24
25
 
25
26
  log.debug('publish:', data)
26
27
  const payload = JSON.stringify(data)
package/src/stream.js CHANGED
@@ -1,31 +1,8 @@
1
- import { log } from '@nitra/pino'
2
- import { jsm, stream } from './nats.js'
1
+ import { checkEnv } from '@nitra/check-env'
2
+ import { env } from 'node:process'
3
3
 
4
- /**
5
- * Перевіряє наявність або створює JetStream stream.
6
- * @param {string} streamName - назва stream
7
- * @returns {Promise<void>}
8
- */
9
- export async function ensureStream(streamName = stream) {
10
- try {
11
- const info = await jsm.streams.info(streamName)
12
- // Перевіряємо, чи stream підтримує rollup
13
-
14
- if (!info.config.allow_rollup_hdrs) {
15
- log.warn(
16
- `⚠️ Stream «${streamName}» не має увімкненого allow_rollup_hdrs. Для використання Nats-Rollup заголовка потрібно видалити stream і створити його заново з allow_rollup_hdrs: true`
17
- )
18
- }
19
- log.debug('✅ Stream already exists')
20
- } catch {
21
- await jsm.streams.add({
22
- name: streamName,
23
- subjects: [`${streamName}.>`],
24
- retention: 'interest', // зберігає повідомлення поки є consumers або поки всі повідомлення не будуть прочитані
25
- num_replicas: 1, // означає, що stream не реплікується — підходить для локальної розробки. Для продакшену в кластері зазвичай використовують 3 або 5.
26
- storage: 'file', // 'memory'
27
- allow_rollup_hdrs: true // дозволяє використання заголовка Nats-Rollup для автоматичного видалення старих повідомлень
28
- })
29
- log.debug('✅ Stream created')
30
- }
4
+ // якщо не задані демо дані, то перевіряємо чи задана назва stream
5
+ if (!env.NATS_FAKE_DATA) {
6
+ checkEnv(['NATS_STREAM'])
31
7
  }
8
+ export const stream = env.NATS_STREAM
package/src/utils.js CHANGED
@@ -1,15 +1,22 @@
1
1
  /**
2
- * Перевіряє чи subject має правильний формат: projectName:subjectName
2
+ * Перевіряє чи subject має правильний формат: stream.project.subject
3
+ * @param {string} stream - назва stream
3
4
  * @param {string} subject - subject для перевірки
4
5
  * @throws {Error} - якщо subject не відповідає формату
5
6
  */
6
- export const checkSubjectFormat = subject => {
7
- const arr = subject?.split(':')
7
+ export const checkSubjectFormat = (stream, subject) => {
8
+ const arr = subject.split('.')
8
9
 
9
- if (arr?.length !== 2 || !arr[0] || !arr[1]) {
10
- throw new Error('subject must be in the format: projectName:subjectName')
10
+ if (arr?.length < 2 || !arr[0] || !arr[1]) {
11
+ throw new Error('subject must be in the format: stream.project:subject')
12
+ }
13
+
14
+ const theme = arr[1].split(':')
15
+ if (theme?.length < 2) {
16
+ throw new Error('subject must be in the format: stream.project:subject')
11
17
  }
12
- }
13
18
 
14
- // state variables
15
- export const state = { msg: null, isFinished: false }
19
+ if (arr[0] !== stream) {
20
+ throw new Error(`${arr[0]} - stream must be - ${stream}`)
21
+ }
22
+ }
package/src/worker.js CHANGED
@@ -1,35 +1,47 @@
1
1
  import { log } from '@nitra/pino'
2
2
  import { env, exit } from 'node:process'
3
- import { js, stream } from './nats.js'
4
- import { state } from './utils.js'
3
+ import { js } from './nats.js'
4
+ import { stream } from './stream.js'
5
5
 
6
6
  // Кешуємо результат перевірки фейкових даних
7
- const FAKE_DATA = env.NATS_FAKE_DATA ? JSON.parse(env.NATS_FAKE_DATA) : null
7
+ let FAKE_DATA
8
+ if (env.NATS_FAKE_DATA) {
9
+ try {
10
+ FAKE_DATA = JSON.parse(env.NATS_FAKE_DATA)
11
+ } catch (error) {
12
+ log.error('Failed to parse fake data', { error, data: env.NATS_FAKE_DATA })
13
+ throw new Error(`Invalid JSON in fake data: ${error.message}`)
14
+ }
15
+ }
8
16
 
9
17
  // Флаг для відстеження чи вже зареєстровані обробники подій
10
- let handlersRegistered = false
18
+ // let handlersRegistered = false
11
19
 
12
20
  /**
13
21
  * Обробник завершення процесу - відправляє повідомлення назад в чергу якщо воно не було підтверджене
14
22
  */
15
- const handleExit = () => {
16
- if (!state.isFinished && state.msg) {
17
- state.msg.nak()
18
- }
19
- }
23
+ // const handleExit = () => {
24
+ // if (!state.isFinished && state.msg) {
25
+ // state.msg.nak()
26
+ // }
27
+ // }
20
28
 
21
29
  /**
22
30
  * Реєструє обробники подій для автоматичного NAK при завершенні процесу
23
31
  */
24
- function registerExitHandlers() {
25
- if (handlersRegistered) return
26
- handlersRegistered = true
32
+ // function registerExitHandlers() {
33
+ // if (handlersRegistered) return
34
+ // handlersRegistered = true
27
35
 
28
- // Реєструємо хук на завершення процесу. якщо не підтвердили повідомлення або помилка - відправляємо повідомлення назад в чергу
29
- for (const signal of ['exit', 'SIGINT', 'uncaughtException', 'unhandledRejection']) {
30
- process.once(signal, handleExit)
31
- }
32
- }
36
+ // // Реєструємо хук на завершення процесу. якщо не підтвердили повідомлення або помилка - відправляємо повідомлення назад в чергу
37
+ // for (const signal of ['exit', 'SIGINT', 'uncaughtException', 'unhandledRejection']) {
38
+ // process.once(signal, handleExit)
39
+ // }
40
+ // }
41
+
42
+ // state variables
43
+ // const state = { msg: null, isFinished: false }
44
+ let msg = null
33
45
 
34
46
  /**
35
47
  * Зчитує одне повідомлення з JetStream для вказаного consumer-а.
@@ -39,33 +51,53 @@ function registerExitHandlers() {
39
51
  export async function read(consumer) {
40
52
  // якщо задані демо дані, то повертаємо їх
41
53
  if (FAKE_DATA) {
42
- console.log('env.NATS_FAKE_DATA:', env.NATS_FAKE_DATA)
54
+ log.info('env.NATS_FAKE_DATA:', env.NATS_FAKE_DATA)
43
55
  return FAKE_DATA
44
56
  }
45
57
 
46
58
  const consumerObj = await js.consumers.get(stream, consumer)
47
59
 
48
60
  const iter = await consumerObj.fetch({ max_messages: 1, expires: 1000 })
49
- for await (const msg of iter) {
50
- state.msg = msg
61
+ for await (const msgI of iter) {
62
+ msg = msgI
51
63
 
52
64
  let decoded
53
65
  try {
54
66
  decoded = JSON.parse(msg.data)
55
67
  } catch (error) {
56
68
  log.error('Failed to parse message data', { error, data: msg.data })
57
- msg.nak() // Повертаємо повідомлення назад в чергу при помилці парсингу
69
+ msg.ack() // НЕ повертаємо повідомлення назад в чергу при помилці парсингу, бо зациклиться
58
70
  throw new Error(`Invalid JSON in message: ${error.message}`)
59
71
  }
60
72
 
61
73
  log.debug('read msg', decoded)
62
74
 
63
75
  // Реєструємо обробники подій один раз
64
- registerExitHandlers()
76
+ // registerExitHandlers()
65
77
 
78
+ // Оброблюємо тільки перше повідомлення
66
79
  return decoded
67
80
  }
68
81
 
69
82
  log.info(`${consumer} - no msg...`)
70
83
  exit(0) // якщо не було жодного повідомлення
71
84
  }
85
+
86
+ /**
87
+ * Підтверджує (ack) останнє прочитане повідомлення.
88
+ * Якщо не викликати, повідомлення буде повернуто у чергу (nak).
89
+ * @returns {Promise<void>}
90
+ */
91
+ export const finish = async () => {
92
+ // oxlint-disable-next-line require-await
93
+ return msg?.ack()
94
+ }
95
+
96
+ /**
97
+ * Повертає повідомлення назад в чергу (nak).
98
+ * @returns {Promise<void>}
99
+ */
100
+ export const nak = async () => {
101
+ // oxlint-disable-next-line require-await
102
+ return msg?.nak()
103
+ }
package/src/finish.js DELETED
@@ -1,13 +0,0 @@
1
- import { state } from './utils.js'
2
-
3
- /**
4
- * Підтверджує (ack) останнє прочитане повідомлення.
5
- * Якщо не викликати, повідомлення буде повернуто у чергу (nak).
6
- * @returns {Promise<void>}
7
- */
8
- export const finish = async () => {
9
- if (state.msg) {
10
- await state.msg.ack()
11
- state.isFinished = true
12
- }
13
- }