@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 +136 -0
- package/package.json +32 -0
- package/src/consumer.js +21 -0
- package/src/finish.js +13 -0
- package/src/index.js +4 -0
- package/src/nats.js +18 -0
- package/src/pending-count.js +24 -0
- package/src/publish.js +22 -0
- package/src/stream.js +25 -0
- package/src/utils.js +15 -0
- package/src/worker.js +34 -0
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
|
+
}
|
package/src/consumer.js
ADDED
|
@@ -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
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
|
+
}
|