@nitra/nats 4.0.3 → 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 +0 -4
- package/package.json +6 -4
- package/src/cli.js +0 -1
- package/src/consumer.js +16 -1
- package/src/nats.js +56 -14
- package/src/publish.js +34 -3
- package/src/stream.js +12 -4
- package/src/worker.js +44 -8
package/README.md
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
69
|
+
// Створюємо JetStream контекст
|
|
70
|
+
js = jetstream(nc)
|
|
30
71
|
|
|
31
|
-
// Менеджер JetStream (для створення стрімів, конфігурацій тощо)
|
|
32
|
-
|
|
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 {
|
|
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}`,
|
|
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
|
-
|
|
19
|
-
storage: 'file' //'memory'
|
|
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 {
|
|
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 (
|
|
41
|
+
if (FAKE_DATA) {
|
|
14
42
|
console.log('env.NATS_FAKE_DATA:', env.NATS_FAKE_DATA)
|
|
15
|
-
return
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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...`)
|