@nitra/nats 3.0.4

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 ADDED
@@ -0,0 +1,136 @@
1
+ # @nitra/nats
2
+
3
+ NATS JetStream helper для Node.js.
4
+ Простий API для публікації, обробки та моніторингу повідомлень у черзі з автоматичним створенням stream/consumer для кожного subject.
5
+
6
+ ---
7
+
8
+ ## Встановлення
9
+
10
+ ```sh
11
+ yarn add @nitra/nats
12
+ # або
13
+ npm install @nitra/nats
14
+ ```
15
+
16
+ ---
17
+
18
+ ## Налаштування змінних середовища
19
+
20
+ Пакет використовує такі змінні середовища:
21
+
22
+ - `NATS_SERVER` — адреса сервера NATS (наприклад, `nats://localhost:4222`)
23
+
24
+ ---
25
+
26
+ ## Формат subject
27
+
28
+ Всі функції працюють із subject у форматі:
29
+
30
+ ```
31
+ projectName:subjectName
32
+ ```
33
+
34
+ ---
35
+
36
+ **Приклади:**
37
+
38
+ - `myproject:jobs`
39
+ - `service:notifications`
40
+
41
+ ---
42
+
43
+ ## Публікація повідомлення
44
+
45
+ ```js
46
+ import { publish } from '@nitra/nats'
47
+
48
+ await publish('project:subject', { id: 1, foo: 'bar' })
49
+ ```
50
+
51
+ - Stream буде створено автоматично, якщо не існує.
52
+ - Повідомлення публікується у subject `stream.project:subject`.
53
+
54
+ ---
55
+
56
+ ## Обробка повідомлення (worker)
57
+
58
+ ```js
59
+ import { read, finish } from '@nitra/nats'
60
+
61
+ const data = await read('project:subject')
62
+ // ...обробка data...
63
+ await finish() // підтвердження (ack) повідомлення
64
+ ```
65
+
66
+ - Якщо не викликати `finish()`, повідомлення буде повернуто у чергу (`nak`) при завершенні процесу або помилці.
67
+ - Stream і consumer створюються автоматично при першому запуску воркера для кожного subject.
68
+
69
+ ---
70
+
71
+ ## Кількість непрочитаних повідомлень для subject
72
+
73
+ ```js
74
+ import { getPendingCount } from '@nitra/nats'
75
+
76
+ const count = await getPendingCount('project:subject')
77
+ console.log('pending:', count)
78
+ ```
79
+
80
+ - Якщо consumer для subject ще не існує, він буде створений автоматично.
81
+
82
+ ---
83
+
84
+ ## Як це працює
85
+
86
+ - **publish(subject, data):**
87
+
88
+ - Перевіряє/створює stream (один раз за процес).
89
+ - Публікує повідомлення у subject `stream.${subject}`.
90
+
91
+ - **read(subject):**
92
+
93
+ - Перевіряє/створює stream і consumer для subject (один раз за процес).
94
+ - Читає одне повідомлення з черги для subject.
95
+
96
+ - **finish():**
97
+
98
+ - Підтверджує (ack) повідомлення.
99
+
100
+ - **getPendingCount(subject):**
101
+ - Повертає кількість непрочитаних повідомлень для consumer `durable_${subject}` (створює його, якщо не існує).
102
+
103
+ ---
104
+
105
+ ## Важливо
106
+
107
+ - STREAM у NATS завжди один (`stream`), але subject динамічний.
108
+ - Для кожного subject створюється окремий durable consumer з іменем `durable_${subject}`.
109
+ - Пакет розрахований на single-message workflow (одне повідомлення на читання за раз, публікація - необмежена).
110
+ - Для паралельної обробки або кастомних сценаріїв — дивись вихідний код та розширюй під свої задачі.
111
+
112
+ ---
113
+
114
+ ## Приклад повного workflow
115
+
116
+ ```js
117
+ import { publish, read, finish, getPendingCount } from '@nitra/nats'
118
+
119
+ // Публікація
120
+ await publish('project:subject', { id: 1, prop: 'prop' })
121
+
122
+ // Pending для subject
123
+ const count = await getPendingCount('project:subject')
124
+ console.log('pending:', count)
125
+
126
+ // Читання
127
+ const data = await read('project:subject')
128
+ console.log('read:', data)
129
+ await finish()
130
+ ```
131
+
132
+ ---
133
+
134
+ ## Ліцензія
135
+
136
+ MIT
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@nitra/nats",
3
+ "description": "nats helper",
4
+ "version": "3.0.4",
5
+ "type": "module",
6
+ "files": [
7
+ "src"
8
+ ],
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "keywords": [
13
+ "nitra",
14
+ "nats"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/nitra/nats.git"
19
+ },
20
+ "bugs": "https://github.com/nitra/nats/issues",
21
+ "homepage": "https://github.com/nitra/nats",
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "@nitra/check-env": "^3.2.0",
25
+ "@nitra/isenv": "^2.0.1",
26
+ "@nitra/pino": "^1.5.1",
27
+ "nats": "^2.29.3"
28
+ },
29
+ "engines": {
30
+ "node": ">=22.0.0"
31
+ }
32
+ }
@@ -0,0 +1,21 @@
1
+ import { jsm, AckPolicy } from './nats.js'
2
+
3
+ /**
4
+ * Перевіряє наявність або створює durable consumer для subject.
5
+ * @param {string} subject - subject у форматі project:subject
6
+ * @returns {Promise<void>}
7
+ */
8
+ export async function ensureConsumer(subject) {
9
+ try {
10
+ await jsm.consumers.info('stream', `durable_${subject}`)
11
+ console.debug('✅ Durable consumer already exists')
12
+ } catch {
13
+ await jsm.consumers.add('stream', {
14
+ durable_name: `durable_${subject}`,
15
+ ack_policy: AckPolicy.Explicit, // якщо не підтвердити повідомлення, воно буде повторно надіслано
16
+ filter_subject: `stream.${subject}`,
17
+ deliver_policy: 'all' // 'all' - всі повідомлення, 'last' - останнє повідомлення
18
+ })
19
+ console.debug('✅ Durable consumer created')
20
+ }
21
+ }
package/src/finish.js ADDED
@@ -0,0 +1,13 @@
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
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { publish } from './publish.js'
2
+ export { read } from './worker.js'
3
+ export { finish } from './finish.js'
4
+ export { getPendingCount } from './pending-count.js'
package/src/nats.js ADDED
@@ -0,0 +1,18 @@
1
+ import { connect, JSONCodec } from 'nats'
2
+ import { checkEnv, env } from '@nitra/check-env'
3
+ // AckPolicy
4
+ export { AckPolicy } from 'nats'
5
+
6
+ checkEnv(['NATS_SERVER'])
7
+
8
+ // Connect to NATS
9
+ export const client = await connect({ servers: env.NATS_SERVER })
10
+
11
+ // JSONCodec
12
+ export const jc = JSONCodec()
13
+
14
+ // Створюємо JetStream контекст
15
+ export const js = client.jetstream()
16
+
17
+ // Менеджер JetStream (для створення стрімів, конфігурацій тощо)
18
+ export const jsm = await client.jetstreamManager()
@@ -0,0 +1,24 @@
1
+ import { jsm } from './nats.js'
2
+ import { ensureConsumer } from './consumer.js'
3
+ import { checkSubjectFormat } from './utils.js'
4
+
5
+ /**
6
+ * Повертає кількість непрочитаних (pending) повідомлень для durable consumer, пов’язаного з subject.
7
+ * Consumer створюється автоматично, якщо не існує.
8
+ * @param {string} subject - subject у форматі project:subject
9
+ * @returns {Promise<number>} - кількість непрочитаних повідомлень
10
+ */
11
+ export async function getPendingCount(subject) {
12
+ checkSubjectFormat(subject)
13
+
14
+ let info
15
+ try {
16
+ info = await jsm.consumers.info('stream', `durable_${subject}`)
17
+ } catch {
18
+ console.debug('consumer not found. create new one.')
19
+ await ensureConsumer(subject)
20
+ info = await jsm.consumers.info('stream', `durable_${subject}`)
21
+ }
22
+
23
+ return info?.num_pending || 0
24
+ }
package/src/publish.js ADDED
@@ -0,0 +1,22 @@
1
+ import { client, jc } from './nats.js'
2
+ import { ensureStream } from './stream.js'
3
+
4
+ let streamReady = new Set()
5
+
6
+ /**
7
+ * Публікує повідомлення у JetStream для вказаного subject.
8
+ * Stream створюється автоматично, якщо не існує.
9
+ * @param {string} subject - subject у форматі project:subject
10
+ * @param {object} data - дані для публікації
11
+ * @returns {Promise<void>}
12
+ */
13
+ export async function publish(subject, data) {
14
+ // Ensure stream if not exists
15
+ if (!streamReady.has(subject)) {
16
+ await ensureStream(subject)
17
+ streamReady.add(subject)
18
+ }
19
+ console.debug('publish', data)
20
+ // Publish message
21
+ return client.publish(`stream.${subject}`, jc.encode(data))
22
+ }
package/src/stream.js ADDED
@@ -0,0 +1,25 @@
1
+ import { jsm } from './nats.js'
2
+ import { checkSubjectFormat } from './utils.js'
3
+
4
+ /**
5
+ * Перевіряє наявність або створює JetStream stream.
6
+ * @param {string} subject - subject у форматі project:subject (для валідації)
7
+ * @returns {Promise<void>}
8
+ */
9
+ export async function ensureStream(subject) {
10
+ checkSubjectFormat(subject)
11
+
12
+ try {
13
+ await jsm.streams.info('stream')
14
+ console.debug('✅ Stream already exists')
15
+ } catch {
16
+ await jsm.streams.add({
17
+ name: 'stream',
18
+ subjects: ['stream.>'],
19
+ retention: 'workqueue',
20
+ replicas: 1, // кількість реплік у кластері TODO: проговорити кількість реплік
21
+ storage: 'file' //'memory' TODO: проговорити які параметри потрібні для кластера
22
+ })
23
+ console.debug('✅ Stream created')
24
+ }
25
+ }
package/src/utils.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Перевіряє чи subject має правильний формат: projectName:subjectName
3
+ * @param {string} subject - subject для перевірки
4
+ * @throws {Error} - якщо subject не відповідає формату
5
+ */
6
+ export const checkSubjectFormat = subject => {
7
+ const arr = subject?.split(':')
8
+
9
+ if (arr?.length !== 2 || !arr[0] || !arr[1]) {
10
+ throw new Error('subject must be in the format: projectName:subjectName')
11
+ }
12
+ }
13
+
14
+ // state variables
15
+ export const state = { msg: null, isFinished: false }
package/src/worker.js ADDED
@@ -0,0 +1,34 @@
1
+ import { js, jc } from './nats.js'
2
+ import { ensureStream } from './stream.js'
3
+ import { ensureConsumer } from './consumer.js'
4
+ import { state } from './utils.js'
5
+
6
+ /**
7
+ * Зчитує одне повідомлення з JetStream для вказаного subject.
8
+ * Consumer створюється автоматично, якщо не існує.
9
+ * @param {string} subject - subject у форматі project:subject
10
+ * @returns {Promise<object>} - декодовані дані повідомлення
11
+ */
12
+ export async function read(subject) {
13
+ // Ensure stream and consumer if not exists
14
+ await ensureStream(subject)
15
+ await ensureConsumer(subject)
16
+
17
+ const consumer = await js.consumers.get('stream', `durable_${subject}`)
18
+
19
+ const iter = await consumer.fetch({ max_messages: 1 })
20
+ for await (const msg of iter) {
21
+ state.msg = msg
22
+ console.debug('read msg', jc.decode(msg.data))
23
+
24
+ // Реєструємо хук на завершення процесу. якщо не підтвердили повідомлення або помилка - відправляємо повідомлення назад в чергу
25
+ for (const signal of ['exit', 'SIGINT', 'uncaughtException', 'unhandledRejection']) {
26
+ process.once(signal, () => !state.isFinished && state.msg && state.msg.nak())
27
+ }
28
+
29
+ return jc.decode(msg.data)
30
+ }
31
+
32
+ console.debug('no msg...')
33
+ return {} // якщо не було жодного повідомлення
34
+ }