@nitra/nats 4.0.2 → 4.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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @nitra/nats
2
2
 
3
- NATS JetStream helper для Node.js
3
+ NATS JetStream helper для Node.js.1
4
4
  Простий API для публікації, обробки та моніторингу повідомлень у черзі з гнучким управлінням consumer-ами через конфігурацію та CLI інструменти.
5
5
 
6
6
  ---
@@ -150,23 +150,19 @@ console.log('pending for group:', count2)
150
150
  ## Як це працює
151
151
 
152
152
  - **publish(subject, data):**
153
-
154
153
  - Публікує повідомлення у subject `${stream}.${subject}`
155
154
  - Перевіряє формат subject (має бути `project:subject`)
156
155
 
157
156
  - **ensureConsumer(spec):**
158
-
159
157
  - Створює consumer якщо не існує
160
158
  - Оновлює `filter_subjects` якщо вони змінились
161
159
  - Перестворює consumer якщо змінились `deliverPolicy` або `ackPolicy`
162
160
  - Автоматично створює stream якщо потрібно
163
161
 
164
162
  - **read(durableName):**
165
-
166
163
  - Читає одне повідомлення з черги для durable consumer
167
164
 
168
165
  - **finish():**
169
-
170
166
  - Підтверджує (ack) повідомлення
171
167
 
172
168
  - **getPendingCount(durableName):**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nitra/nats",
3
3
  "description": "nats helper",
4
- "version": "4.0.2",
4
+ "version": "4.1.0",
5
5
  "type": "module",
6
6
  "files": [
7
7
  "src"
@@ -24,10 +24,12 @@
24
24
  "homepage": "https://github.com/nitra/nats",
25
25
  "license": "MIT",
26
26
  "dependencies": {
27
+ "@nats-io/jetstream": "^3.3.0",
28
+ "@nats-io/nats-core": "^3.3.0",
29
+ "@nats-io/transport-node": "^3.3.0",
27
30
  "@nitra/check-env": "^4.1.0",
28
- "@nitra/pino": "^2.8.1",
29
- "js-yaml": "^4.1.1",
30
- "nats": "^2.29.3"
31
+ "@nitra/pino": "^2.9.1",
32
+ "js-yaml": "^4.1.1"
31
33
  },
32
34
  "engines": {
33
35
  "node": ">=22.0.0"
package/src/cli.js CHANGED
@@ -39,7 +39,6 @@ function parseConsumerYaml() {
39
39
  /**
40
40
  * Валідує структуру парсеного consumer об'єкта
41
41
  * @param {object} consumer - Об'єкт consumer для валідації
42
- * @returns {boolean} true якщо валідація пройшла успішно
43
42
  * @throws {Error} Якщо валідація не пройшла
44
43
  */
45
44
  export function validateConsumer(consumer) {
package/src/consumer.js CHANGED
@@ -4,7 +4,12 @@ import { log } from '@nitra/pino'
4
4
 
5
5
  /**
6
6
  * Створює або оновлює consumer.
7
- * @param {object{streamName: string, durableName: string, filterSubjects: string[], deliverPolicy: string, ackPolicy: string}} spec - об'єкт consumer-а.
7
+ * @param {object} spec - об'єкт consumer
8
+ * @param {string} [spec.streamName] - назва stream
9
+ * @param {string} spec.durableName - назва consumer-а
10
+ * @param {string[]} spec.filterSubjects - масив subject-ів для фільтрації
11
+ * @param {string} spec.deliverPolicy - політика доставки
12
+ * @param {string} spec.ackPolicy - політика підтвердження
8
13
  * @returns {Promise<void>}
9
14
  */
10
15
  export async function ensureConsumer(spec) {
@@ -59,6 +64,16 @@ export async function ensureConsumer(spec) {
59
64
  }
60
65
  }
61
66
 
67
+ /**
68
+ * Створює consumer для stream
69
+ * @param {string} streamName - назва stream
70
+ * @param {object} spec - специфікація consumer-а
71
+ * @param {string} spec.durableName - назва consumer-а
72
+ * @param {string[]} spec.filterSubjects - масив subject-ів для фільтрації
73
+ * @param {string} spec.deliverPolicy - політика доставки
74
+ * @param {string} spec.ackPolicy - політика підтвердження
75
+ * @returns {Promise<void>}
76
+ */
62
77
  async function createConsumer(streamName, spec) {
63
78
  await jsm.consumers.add(streamName, {
64
79
  durable_name: spec.durableName,
package/src/nats.js CHANGED
@@ -1,18 +1,63 @@
1
1
  import { checkEnv } from '@nitra/check-env'
2
- import { connect, JSONCodec } from 'nats'
2
+ import { connect } from '@nats-io/transport-node'
3
+ import { jetstream, jetstreamManager } from '@nats-io/jetstream'
3
4
  import { env } from 'node:process'
4
5
 
5
6
  // AckPolicy
6
- // export { AckPolicy } from 'nats'
7
+ // export { AckPolicy } from '@nats-io/jetstream'
7
8
 
8
9
  let nc
9
10
 
11
+ // Експортуємо JetStream контекст
12
+ export let js
13
+ // Експортуємо менеджер JetStream
14
+ export let jsm
15
+
10
16
  // якщо задані демо дані, то використовуємо фейковий NATS
11
17
  if (env.NATS_FAKE_DATA) {
12
- nc = {
13
- jetstream: () => {},
14
- jetstreamManager: async () => {
15
- {}
18
+ // Створюємо фейкові об'єкти для js та jsm
19
+ js = {
20
+ publish: () => Promise.resolve(),
21
+ consumers: {
22
+ get: () => {
23
+ return {
24
+ fetch: () => {
25
+ return {
26
+ [Symbol.asyncIterator]: async function* () {
27
+ yield
28
+ }
29
+ }
30
+ }
31
+ }
32
+ }
33
+ }
34
+ }
35
+ jsm = {
36
+ streams: {
37
+ info: () => {
38
+ return {
39
+ config: {
40
+ allow_rollup_hdrs: true
41
+ }
42
+ }
43
+ },
44
+ add: () => Promise.resolve()
45
+ },
46
+ consumers: {
47
+ info: () => {
48
+ return {
49
+ num_pending: 0,
50
+ num_ack_pending: 0
51
+ }
52
+ },
53
+ list: () => {
54
+ return {
55
+ next: () => []
56
+ }
57
+ },
58
+ delete: () => Promise.resolve(),
59
+ update: () => Promise.resolve(),
60
+ add: () => Promise.resolve()
16
61
  }
17
62
  }
18
63
  } else {
@@ -20,16 +65,13 @@ if (env.NATS_FAKE_DATA) {
20
65
 
21
66
  // Connect to NATS
22
67
  nc = await connect({ servers: env.NATS_URL })
23
- }
24
-
25
- // JSONCodec
26
- export const jc = JSONCodec()
27
68
 
28
- // Створюємо JetStream контекст
29
- export const js = nc.jetstream()
69
+ // Створюємо JetStream контекст
70
+ js = jetstream(nc)
30
71
 
31
- // Менеджер JetStream (для створення стрімів, конфігурацій тощо)
32
- export const jsm = await nc.jetstreamManager()
72
+ // Менеджер JetStream (для створення стрімів, конфігурацій тощо)
73
+ jsm = await jetstreamManager(nc)
74
+ }
33
75
 
34
76
  // Stream name
35
77
  export const stream = env.NATS_STREAM
package/src/publish.js CHANGED
@@ -1,16 +1,19 @@
1
1
  import { log } from '@nitra/pino'
2
2
  import { env } from 'node:process'
3
- import { jc, js, stream } from './nats.js'
3
+ import { js, stream } from './nats.js'
4
4
  import { checkSubjectFormat } from './utils.js'
5
+ import { headers } from '@nats-io/nats-core'
6
+ import { createHash } from 'node:crypto'
5
7
 
6
8
  /**
7
9
  * Публікує повідомлення у JetStream для вказаного subject.
8
10
  * @param {string} subject - subject у форматі project:subject
9
11
  * @param {object} data - дані для публікації
12
+ * @param {object} options - опції публікації
10
13
  * @returns {Promise<void>}
11
14
  */
12
15
  // oxlint-disable-next-line require-await
13
- export async function publish(subject, data) {
16
+ export async function publish(subject, data, options = {}) {
14
17
  // якщо задані демо дані, то ігноримо запис
15
18
  if (env.NATS_FAKE_DATA) {
16
19
  return
@@ -20,6 +23,34 @@ export async function publish(subject, data) {
20
23
  checkSubjectFormat(subject)
21
24
 
22
25
  log.debug('publish:', data)
26
+ const payload = JSON.stringify(data)
27
+ let suffix = ''
28
+
29
+ // якщо не задано опції, то використовуємо default опції
30
+ if (Object.keys(options).length === 0) {
31
+ // headers always have their names turned into a canonical mime header key
32
+ // header names can be any printable ASCII character with the exception of `:`.
33
+ // header values can be any ASCII character except `\r` or `\n`.
34
+ // see https://www.ietf.org/rfc/rfc822.txt
35
+ const h = headers()
36
+ h.set('Nats-Rollup', 'sub')
37
+ options.headers = h
38
+
39
+ suffix = `.${hash(payload)}`
40
+ }
41
+ // log.debug('options:', options)
42
+ // log.debug('suffix:', suffix)
43
+
23
44
  // Publish message
24
- return js.publish(`${stream}.${subject}`, jc.encode(data))
45
+ return js.publish(`${stream}.${subject}${suffix}`, payload, options || {})
46
+ }
47
+
48
+ /**
49
+ * Створює MD5 хеш з тексту та повертає його в форматі base64url.
50
+ * Використовується для дедуплікації повідомлень через суфікс у subject.
51
+ * @param {string} text - текст для хешування
52
+ * @returns {string} - хеш у форматі base64url
53
+ */
54
+ function hash(text) {
55
+ return createHash('md5').update(text, 'utf8').digest('base64url')
25
56
  }
package/src/stream.js CHANGED
@@ -3,20 +3,28 @@ import { jsm, stream } from './nats.js'
3
3
 
4
4
  /**
5
5
  * Перевіряє наявність або створює JetStream stream.
6
- * @param streamName
6
+ * @param {string} streamName - назва stream
7
7
  * @returns {Promise<void>}
8
8
  */
9
9
  export async function ensureStream(streamName = stream) {
10
10
  try {
11
- await jsm.streams.info(streamName)
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
+ }
12
19
  log.debug('✅ Stream already exists')
13
20
  } catch {
14
21
  await jsm.streams.add({
15
22
  name: streamName,
16
23
  subjects: [`${streamName}.>`],
17
24
  retention: 'interest', // зберігає повідомлення поки є consumers або поки всі повідомлення не будуть прочитані
18
- replicas: 1, // кількість реплік у кластері TODO: проговорити кількість реплік
19
- storage: 'file' //'memory' TODO: проговорити які параметри потрібні для кластера
25
+ num_replicas: 1, // означає, що stream не реплікується — підходить для локальної розробки. Для продакшену в кластері зазвичай використовують 3 або 5.
26
+ storage: 'file', // 'memory'
27
+ allow_rollup_hdrs: true // дозволяє використання заголовка Nats-Rollup для автоматичного видалення старих повідомлень
20
28
  })
21
29
  log.debug('✅ Stream created')
22
30
  }
package/src/worker.js CHANGED
@@ -1,8 +1,36 @@
1
1
  import { log } from '@nitra/pino'
2
2
  import { env, exit } from 'node:process'
3
- import { jc, js, stream } from './nats.js'
3
+ import { js, stream } from './nats.js'
4
4
  import { state } from './utils.js'
5
5
 
6
+ // Кешуємо результат перевірки фейкових даних
7
+ const FAKE_DATA = env.NATS_FAKE_DATA ? JSON.parse(env.NATS_FAKE_DATA) : null
8
+
9
+ // Флаг для відстеження чи вже зареєстровані обробники подій
10
+ let handlersRegistered = false
11
+
12
+ /**
13
+ * Обробник завершення процесу - відправляє повідомлення назад в чергу якщо воно не було підтверджене
14
+ */
15
+ const handleExit = () => {
16
+ if (!state.isFinished && state.msg) {
17
+ state.msg.nak()
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Реєструє обробники подій для автоматичного NAK при завершенні процесу
23
+ */
24
+ function registerExitHandlers() {
25
+ if (handlersRegistered) return
26
+ handlersRegistered = true
27
+
28
+ // Реєструємо хук на завершення процесу. якщо не підтвердили повідомлення або помилка - відправляємо повідомлення назад в чергу
29
+ for (const signal of ['exit', 'SIGINT', 'uncaughtException', 'unhandledRejection']) {
30
+ process.once(signal, handleExit)
31
+ }
32
+ }
33
+
6
34
  /**
7
35
  * Зчитує одне повідомлення з JetStream для вказаного consumer-а.
8
36
  * @param {string} consumer - durable_name для consumer(за замовчуванням durable_name = subject)
@@ -10,9 +38,9 @@ import { state } from './utils.js'
10
38
  */
11
39
  export async function read(consumer) {
12
40
  // якщо задані демо дані, то повертаємо їх
13
- if (env.NATS_FAKE_DATA) {
41
+ if (FAKE_DATA) {
14
42
  console.log('env.NATS_FAKE_DATA:', env.NATS_FAKE_DATA)
15
- return JSON.parse(env.NATS_FAKE_DATA)
43
+ return FAKE_DATA
16
44
  }
17
45
 
18
46
  const consumerObj = await js.consumers.get(stream, consumer)
@@ -20,14 +48,22 @@ export async function read(consumer) {
20
48
  const iter = await consumerObj.fetch({ max_messages: 1, expires: 1000 })
21
49
  for await (const msg of iter) {
22
50
  state.msg = msg
23
- log.debug('read msg', jc.decode(msg.data))
24
51
 
25
- // Реєструємо хук на завершення процесу. якщо не підтвердили повідомлення або помилка - відправляємо повідомлення назад в чергу
26
- for (const signal of ['exit', 'SIGINT', 'uncaughtException', 'unhandledRejection']) {
27
- process.once(signal, () => !state.isFinished && state.msg && state.msg.nak())
52
+ let decoded
53
+ try {
54
+ decoded = JSON.parse(msg.data)
55
+ } catch (error) {
56
+ log.error('Failed to parse message data', { error, data: msg.data })
57
+ msg.nak() // Повертаємо повідомлення назад в чергу при помилці парсингу
58
+ throw new Error(`Invalid JSON in message: ${error.message}`)
28
59
  }
29
60
 
30
- return jc.decode(msg.data)
61
+ log.debug('read msg', decoded)
62
+
63
+ // Реєструємо обробники подій один раз
64
+ registerExitHandlers()
65
+
66
+ return decoded
31
67
  }
32
68
 
33
69
  log.info(`${consumer} - no msg...`)