sentinel-kafka-manager 1.0.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 ADDED
@@ -0,0 +1,391 @@
1
+ # sentinel-kafka-manager
2
+
3
+ Reusable Kafka manager for Node.js microservices built on top of `kafkajs`.
4
+
5
+ This package helps services:
6
+
7
+ - connect to Kafka with a small shared API
8
+ - create and reuse producers, consumers, and admin clients safely
9
+ - publish JSON messages
10
+ - provision topics if they do not already exist
11
+ - bootstrap from environment variables for both Docker-internal and host-external Kafka access
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ npm install sentinel-kafka-manager
17
+ ```
18
+
19
+ For local development in this package:
20
+
21
+ ```bash
22
+ npm install
23
+ npm run build
24
+ ```
25
+
26
+ ## Features
27
+
28
+ - built on `kafkajs`
29
+ - supports direct broker lists or env-driven broker discovery
30
+ - supports internal Docker-network broker routing
31
+ - supports external localhost or host-based broker routing
32
+ - supports optional SSL, SASL, retry, and timeout settings
33
+ - avoids duplicate producer, consumer, and admin connections during concurrent startup
34
+
35
+ ## Exports
36
+
37
+ ```ts
38
+ import { KafkaManager } from 'sentinel-kafka-manager'
39
+ ```
40
+
41
+ ## Quick Start
42
+
43
+ ### 1. Create a manager with explicit brokers
44
+
45
+ ```ts
46
+ import { KafkaManager } from 'sentinel-kafka-manager'
47
+
48
+ const kafkaManager = new KafkaManager({
49
+ clientId: 'claims-service',
50
+ brokers: ['localhost:29092', 'localhost:39092', 'localhost:49092'],
51
+ })
52
+ ```
53
+
54
+ ### 2. Publish a message
55
+
56
+ ```ts
57
+ await kafkaManager.publish({
58
+ topic: 'claims.created',
59
+ key: 'claim-1001',
60
+ payload: {
61
+ claimId: 'claim-1001',
62
+ status: 'created',
63
+ },
64
+ })
65
+ ```
66
+
67
+ ### 3. Provision topics
68
+
69
+ ```ts
70
+ await kafkaManager.provisionTopics([
71
+ {
72
+ topic: 'claims.created',
73
+ numPartitions: 6,
74
+ replicationFactor: 3,
75
+ },
76
+ ])
77
+ ```
78
+
79
+ ### 4. Reuse a connected consumer
80
+
81
+ ```ts
82
+ const consumer = await kafkaManager.getConsumer('claims-service-group')
83
+
84
+ await consumer.subscribe({
85
+ topic: 'claims.created',
86
+ fromBeginning: false,
87
+ })
88
+ ```
89
+
90
+ ### 5. Disconnect on shutdown
91
+
92
+ ```ts
93
+ await kafkaManager.disconnect()
94
+ ```
95
+
96
+ ## Environment-Based Setup
97
+
98
+ `KafkaManager.fromEnv()` supports two styles:
99
+
100
+ 1. explicit broker list with `KAFKA_BROKERS`
101
+ 2. derived broker list from your Kafka runtime env naming
102
+
103
+ ### Option A: Explicit broker list
104
+
105
+ ```env
106
+ KAFKA_BROKERS=localhost:29092,localhost:39092,localhost:49092
107
+ ```
108
+
109
+ ```ts
110
+ import { KafkaManager } from 'sentinel-kafka-manager'
111
+
112
+ const kafkaManager = KafkaManager.fromEnv({
113
+ clientId: 'claims-service',
114
+ })
115
+ ```
116
+
117
+ ### Option B: Derived from your current Kafka cluster env
118
+
119
+ This package supports the env structure you shared for your KRaft cluster.
120
+
121
+ #### External mode
122
+
123
+ Use this when your app runs on the host machine or outside the Kafka Docker network.
124
+
125
+ ```env
126
+ KAFKA_EXTERNAL_HOST=localhost
127
+ KAFKA_BROKER_1_EXTERNAL_PORT=29092
128
+ KAFKA_BROKER_2_EXTERNAL_PORT=39092
129
+ KAFKA_BROKER_3_EXTERNAL_PORT=49092
130
+ KAFKA_BROKER_INTERNAL_PORT=9092
131
+ ```
132
+
133
+ ```ts
134
+ const kafkaManager = KafkaManager.fromEnv({
135
+ clientId: 'claims-service',
136
+ mode: 'external',
137
+ })
138
+ ```
139
+
140
+ This resolves to:
141
+
142
+ ```text
143
+ localhost:29092
144
+ localhost:39092
145
+ localhost:49092
146
+ ```
147
+
148
+ #### Internal mode
149
+
150
+ Use this when your app runs inside Docker on the same Kafka network.
151
+
152
+ ```env
153
+ KAFKA_BROKER_INTERNAL_PORT=9092
154
+ ```
155
+
156
+ ```ts
157
+ const kafkaManager = KafkaManager.fromEnv({
158
+ clientId: 'claims-service',
159
+ mode: 'internal',
160
+ })
161
+ ```
162
+
163
+ This resolves to:
164
+
165
+ ```text
166
+ broker-1:9092
167
+ broker-2:9092
168
+ broker-3:9092
169
+ ```
170
+
171
+ ## Supported Environment Variables
172
+
173
+ ### Minimal variables
174
+
175
+ - `KAFKA_BROKERS`
176
+ - `KAFKA_EXTERNAL_HOST`
177
+ - `KAFKA_BROKER_1_EXTERNAL_PORT`
178
+ - `KAFKA_BROKER_2_EXTERNAL_PORT`
179
+ - `KAFKA_BROKER_3_EXTERNAL_PORT`
180
+ - `KAFKA_BROKER_INTERNAL_PORT`
181
+
182
+ ### `fromEnv()` options
183
+
184
+ You can customize the env lookup behavior:
185
+
186
+ ```ts
187
+ const kafkaManager = KafkaManager.fromEnv({
188
+ clientId: 'claims-service',
189
+ mode: 'external',
190
+ brokerCount: 3,
191
+ envKey: 'KAFKA_BROKERS',
192
+ externalHostEnvKey: 'KAFKA_EXTERNAL_HOST',
193
+ internalPortEnvKey: 'KAFKA_BROKER_INTERNAL_PORT',
194
+ externalPortEnvKeyPrefix: 'KAFKA_BROKER_',
195
+ externalPortEnvKeySuffix: '_EXTERNAL_PORT',
196
+ internalBrokerHostPrefix: 'broker-',
197
+ })
198
+ ```
199
+
200
+ ## Advanced Connection Options
201
+
202
+ ### SSL
203
+
204
+ ```ts
205
+ const kafkaManager = new KafkaManager({
206
+ clientId: 'claims-service',
207
+ brokers: ['localhost:29092'],
208
+ ssl: {
209
+ rejectUnauthorized: true,
210
+ },
211
+ })
212
+ ```
213
+
214
+ ### SASL
215
+
216
+ ```ts
217
+ const kafkaManager = new KafkaManager({
218
+ clientId: 'claims-service',
219
+ brokers: ['localhost:29092'],
220
+ sasl: {
221
+ mechanism: 'plain',
222
+ username: 'kafka-user',
223
+ password: 'secret',
224
+ },
225
+ })
226
+ ```
227
+
228
+ ### Retry and timeout tuning
229
+
230
+ ```ts
231
+ const kafkaManager = new KafkaManager({
232
+ clientId: 'claims-service',
233
+ brokers: ['localhost:29092'],
234
+ connectionTimeout: 5000,
235
+ requestTimeout: 30000,
236
+ retry: {
237
+ initialRetryTime: 300,
238
+ retries: 10,
239
+ },
240
+ })
241
+ ```
242
+
243
+ ## API
244
+
245
+ ### `new KafkaManager(options)`
246
+
247
+ Creates a manager from an explicit broker list.
248
+
249
+ Options:
250
+
251
+ - `clientId: string`
252
+ - `brokers: string[]`
253
+ - `ssl?: boolean | { rejectUnauthorized?: boolean }`
254
+ - `sasl?: { mechanism, username, password }`
255
+ - `connectionTimeout?: number`
256
+ - `requestTimeout?: number`
257
+ - `retry?: { initialRetryTime?: number; retries?: number }`
258
+
259
+ ### `KafkaManager.fromEnv(options)`
260
+
261
+ Creates a manager by reading broker information from environment variables.
262
+
263
+ Options:
264
+
265
+ - `clientId: string`
266
+ - `envKey?: string`
267
+ - `mode?: 'internal' | 'external'`
268
+ - `brokerCount?: number`
269
+ - `externalHostEnvKey?: string`
270
+ - `internalPortEnvKey?: string`
271
+ - `externalPortEnvKeyPrefix?: string`
272
+ - `externalPortEnvKeySuffix?: string`
273
+ - `internalBrokerHostPrefix?: string`
274
+ - `ssl?: boolean | { rejectUnauthorized?: boolean }`
275
+ - `sasl?: { mechanism, username, password }`
276
+ - `connectionTimeout?: number`
277
+ - `requestTimeout?: number`
278
+ - `retry?: { initialRetryTime?: number; retries?: number }`
279
+
280
+ ### `getProducer()`
281
+
282
+ Returns a shared connected Kafka producer.
283
+
284
+ ### `getConsumer(groupId)`
285
+
286
+ Returns a shared connected Kafka consumer for the provided group id.
287
+
288
+ ### `provisionTopics(topics)`
289
+
290
+ Creates missing topics using the Kafka admin client.
291
+
292
+ Topic shape:
293
+
294
+ ```ts
295
+ {
296
+ topic: string
297
+ numPartitions?: number
298
+ replicationFactor?: number
299
+ }
300
+ ```
301
+
302
+ ### `publish(options)`
303
+
304
+ Publishes one JSON-encoded message.
305
+
306
+ Message shape:
307
+
308
+ ```ts
309
+ {
310
+ topic: string
311
+ payload: T
312
+ key?: string
313
+ headers?: IHeaders
314
+ }
315
+ ```
316
+
317
+ ### `disconnect()`
318
+
319
+ Disconnects the managed producer, admin client, and any connected consumers.
320
+
321
+ ## Example With Your Kafka Cluster
322
+
323
+ ### Host-based app
324
+
325
+ ```ts
326
+ import { KafkaManager } from 'sentinel-kafka-manager'
327
+
328
+ const kafkaManager = KafkaManager.fromEnv({
329
+ clientId: 'claims-api',
330
+ mode: 'external',
331
+ })
332
+
333
+ await kafkaManager.publish({
334
+ topic: 'claims.created',
335
+ key: 'claim-1001',
336
+ payload: {
337
+ claimId: 'claim-1001',
338
+ source: 'claims-api',
339
+ },
340
+ })
341
+ ```
342
+
343
+ ### Dockerized app on the same network
344
+
345
+ ```ts
346
+ import { KafkaManager } from 'sentinel-kafka-manager'
347
+
348
+ const kafkaManager = KafkaManager.fromEnv({
349
+ clientId: 'claims-worker',
350
+ mode: 'internal',
351
+ })
352
+ ```
353
+
354
+ ## Publishing
355
+
356
+ Build before publishing:
357
+
358
+ ```bash
359
+ npm run build
360
+ ```
361
+
362
+ Publish the package:
363
+
364
+ ```bash
365
+ npm publish
366
+ ```
367
+
368
+ This package already sets `"publishConfig": { "access": "public" }`, so you do not need to pass `--access public` every time.
369
+
370
+ ### Publish Troubleshooting
371
+
372
+ #### `403` with 2FA or token message
373
+
374
+ If npm says two-factor authentication or a granular token with bypass 2FA is required, authenticate with an account that can publish or use a publish-capable npm token.
375
+
376
+ Useful checks:
377
+
378
+ ```bash
379
+ npm whoami
380
+ ```
381
+
382
+ #### `404` or package name unavailable
383
+
384
+ If npm says the package could not be published, confirm that the package name is available to your npm account and not already owned by someone else.
385
+
386
+ ## Notes
387
+
388
+ - connect clients only to brokers, not KRaft controller nodes
389
+ - for your current cluster, PLAINTEXT is enough and SSL/SASL is optional
390
+ - if your services use `localhost`, use `mode: 'external'`
391
+ - if your services run in Docker on the Kafka network, use `mode: 'internal'`
@@ -0,0 +1,77 @@
1
+ import { SASLOptions, Consumer, IHeaders, Producer } from 'kafkajs';
2
+
3
+ type KafkaConnectionMode = 'internal' | 'external';
4
+ interface KafkaRetryOptions {
5
+ initialRetryTime?: number;
6
+ retries?: number;
7
+ }
8
+ interface KafkaSslOptions {
9
+ rejectUnauthorized?: boolean;
10
+ }
11
+ type KafkaSaslOptions = SASLOptions;
12
+ interface KafkaTopicConfig {
13
+ topic: string;
14
+ numPartitions?: number;
15
+ replicationFactor?: number;
16
+ }
17
+ interface PublishMessageOptions<T> {
18
+ topic: string;
19
+ payload: T;
20
+ key?: string;
21
+ headers?: IHeaders;
22
+ }
23
+ interface KafkaManagerOptions {
24
+ clientId: string;
25
+ brokers: string[];
26
+ ssl?: boolean | KafkaSslOptions;
27
+ sasl?: KafkaSaslOptions;
28
+ connectionTimeout?: number;
29
+ requestTimeout?: number;
30
+ retry?: KafkaRetryOptions;
31
+ }
32
+ interface KafkaConnectionOptions {
33
+ clientId: string;
34
+ envKey?: string;
35
+ mode?: KafkaConnectionMode;
36
+ brokerCount?: number;
37
+ externalHostEnvKey?: string;
38
+ internalPortEnvKey?: string;
39
+ externalPortEnvKeyPrefix?: string;
40
+ externalPortEnvKeySuffix?: string;
41
+ internalBrokerHostPrefix?: string;
42
+ ssl?: boolean | KafkaSslOptions;
43
+ sasl?: KafkaSaslOptions;
44
+ connectionTimeout?: number;
45
+ requestTimeout?: number;
46
+ retry?: KafkaRetryOptions;
47
+ }
48
+ interface KafkaConsumers {
49
+ [groupId: string]: Consumer;
50
+ }
51
+ type KafkaProducer = Producer;
52
+ type KafkaConsumer = Consumer;
53
+
54
+ declare class KafkaManager {
55
+ private kafka;
56
+ private producer;
57
+ private producerPromise;
58
+ private admin;
59
+ private adminPromise;
60
+ private consumers;
61
+ private consumerPromises;
62
+ constructor(options: KafkaManagerOptions);
63
+ static fromEnv(options: KafkaConnectionOptions): KafkaManager;
64
+ getProducer(): Promise<Producer>;
65
+ getConsumer(groupId: string): Promise<Consumer>;
66
+ provisionTopics(topics: KafkaTopicConfig[]): Promise<void>;
67
+ publish<T>(options: PublishMessageOptions<T>): Promise<void>;
68
+ disconnect(): Promise<void>;
69
+ private static parseBrokerList;
70
+ private static buildBrokersFromRuntime;
71
+ private connectProducer;
72
+ private connectConsumer;
73
+ private getAdmin;
74
+ private connectAdmin;
75
+ }
76
+
77
+ export { type KafkaConnectionMode, type KafkaConnectionOptions, type KafkaConsumer, type KafkaConsumers, KafkaManager, type KafkaManagerOptions, type KafkaProducer, type KafkaRetryOptions, type KafkaSaslOptions, type KafkaSslOptions, type KafkaTopicConfig, type PublishMessageOptions };
@@ -0,0 +1,77 @@
1
+ import { SASLOptions, Consumer, IHeaders, Producer } from 'kafkajs';
2
+
3
+ type KafkaConnectionMode = 'internal' | 'external';
4
+ interface KafkaRetryOptions {
5
+ initialRetryTime?: number;
6
+ retries?: number;
7
+ }
8
+ interface KafkaSslOptions {
9
+ rejectUnauthorized?: boolean;
10
+ }
11
+ type KafkaSaslOptions = SASLOptions;
12
+ interface KafkaTopicConfig {
13
+ topic: string;
14
+ numPartitions?: number;
15
+ replicationFactor?: number;
16
+ }
17
+ interface PublishMessageOptions<T> {
18
+ topic: string;
19
+ payload: T;
20
+ key?: string;
21
+ headers?: IHeaders;
22
+ }
23
+ interface KafkaManagerOptions {
24
+ clientId: string;
25
+ brokers: string[];
26
+ ssl?: boolean | KafkaSslOptions;
27
+ sasl?: KafkaSaslOptions;
28
+ connectionTimeout?: number;
29
+ requestTimeout?: number;
30
+ retry?: KafkaRetryOptions;
31
+ }
32
+ interface KafkaConnectionOptions {
33
+ clientId: string;
34
+ envKey?: string;
35
+ mode?: KafkaConnectionMode;
36
+ brokerCount?: number;
37
+ externalHostEnvKey?: string;
38
+ internalPortEnvKey?: string;
39
+ externalPortEnvKeyPrefix?: string;
40
+ externalPortEnvKeySuffix?: string;
41
+ internalBrokerHostPrefix?: string;
42
+ ssl?: boolean | KafkaSslOptions;
43
+ sasl?: KafkaSaslOptions;
44
+ connectionTimeout?: number;
45
+ requestTimeout?: number;
46
+ retry?: KafkaRetryOptions;
47
+ }
48
+ interface KafkaConsumers {
49
+ [groupId: string]: Consumer;
50
+ }
51
+ type KafkaProducer = Producer;
52
+ type KafkaConsumer = Consumer;
53
+
54
+ declare class KafkaManager {
55
+ private kafka;
56
+ private producer;
57
+ private producerPromise;
58
+ private admin;
59
+ private adminPromise;
60
+ private consumers;
61
+ private consumerPromises;
62
+ constructor(options: KafkaManagerOptions);
63
+ static fromEnv(options: KafkaConnectionOptions): KafkaManager;
64
+ getProducer(): Promise<Producer>;
65
+ getConsumer(groupId: string): Promise<Consumer>;
66
+ provisionTopics(topics: KafkaTopicConfig[]): Promise<void>;
67
+ publish<T>(options: PublishMessageOptions<T>): Promise<void>;
68
+ disconnect(): Promise<void>;
69
+ private static parseBrokerList;
70
+ private static buildBrokersFromRuntime;
71
+ private connectProducer;
72
+ private connectConsumer;
73
+ private getAdmin;
74
+ private connectAdmin;
75
+ }
76
+
77
+ export { type KafkaConnectionMode, type KafkaConnectionOptions, type KafkaConsumer, type KafkaConsumers, KafkaManager, type KafkaManagerOptions, type KafkaProducer, type KafkaRetryOptions, type KafkaSaslOptions, type KafkaSslOptions, type KafkaTopicConfig, type PublishMessageOptions };
package/dist/index.js ADDED
@@ -0,0 +1,219 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ KafkaManager: () => KafkaManager
24
+ });
25
+ module.exports = __toCommonJS(index_exports);
26
+
27
+ // src/kafka-manager.ts
28
+ var import_kafkajs = require("kafkajs");
29
+ var KafkaManager = class _KafkaManager {
30
+ constructor(options) {
31
+ this.producer = null;
32
+ // Share the same in-flight connection across concurrent callers.
33
+ this.producerPromise = null;
34
+ this.admin = null;
35
+ // Admin access is reused so topic provisioning does not race on startup.
36
+ this.adminPromise = null;
37
+ this.consumers = /* @__PURE__ */ new Map();
38
+ // Consumers are cached per group id to avoid duplicate group members.
39
+ this.consumerPromises = /* @__PURE__ */ new Map();
40
+ if (!options.brokers.length) {
41
+ throw new Error("Kafka brokers are required.");
42
+ }
43
+ this.kafka = new import_kafkajs.Kafka({
44
+ clientId: options.clientId,
45
+ brokers: options.brokers,
46
+ ssl: options.ssl,
47
+ sasl: options.sasl,
48
+ connectionTimeout: options.connectionTimeout,
49
+ requestTimeout: options.requestTimeout,
50
+ retry: options.retry ?? {
51
+ initialRetryTime: 300,
52
+ retries: 10
53
+ }
54
+ });
55
+ }
56
+ static fromEnv(options) {
57
+ const brokerString = options.envKey ? process.env[options.envKey] : process.env.KAFKA_BROKERS;
58
+ const brokers = brokerString ? _KafkaManager.parseBrokerList(brokerString) : _KafkaManager.buildBrokersFromRuntime(options);
59
+ if (!brokers.length) {
60
+ throw new Error("Kafka brokers are not configured.");
61
+ }
62
+ return new _KafkaManager({
63
+ clientId: options.clientId,
64
+ brokers,
65
+ ssl: options.ssl,
66
+ sasl: options.sasl,
67
+ connectionTimeout: options.connectionTimeout,
68
+ requestTimeout: options.requestTimeout,
69
+ retry: options.retry
70
+ });
71
+ }
72
+ async getProducer() {
73
+ if (this.producer) {
74
+ return this.producer;
75
+ }
76
+ if (!this.producerPromise) {
77
+ this.producerPromise = this.connectProducer();
78
+ }
79
+ return this.producerPromise;
80
+ }
81
+ async getConsumer(groupId) {
82
+ const existingConsumer = this.consumers.get(groupId);
83
+ if (existingConsumer) {
84
+ return existingConsumer;
85
+ }
86
+ const existingPromise = this.consumerPromises.get(groupId);
87
+ if (existingPromise) {
88
+ return existingPromise;
89
+ }
90
+ const consumerPromise = this.connectConsumer(groupId);
91
+ this.consumerPromises.set(groupId, consumerPromise);
92
+ return consumerPromise;
93
+ }
94
+ async provisionTopics(topics) {
95
+ const admin = await this.getAdmin();
96
+ const existingTopics = await admin.listTopics();
97
+ const topicsToCreate = topics.filter((topic) => !existingTopics.includes(topic.topic)).map((topic) => ({
98
+ topic: topic.topic,
99
+ numPartitions: topic.numPartitions ?? 3,
100
+ replicationFactor: topic.replicationFactor ?? 1
101
+ }));
102
+ if (!topicsToCreate.length) {
103
+ return;
104
+ }
105
+ await admin.createTopics({
106
+ topics: topicsToCreate
107
+ });
108
+ }
109
+ async publish(options) {
110
+ const producer = await this.getProducer();
111
+ await producer.send({
112
+ topic: options.topic,
113
+ messages: [
114
+ {
115
+ key: options.key,
116
+ value: JSON.stringify(options.payload),
117
+ headers: options.headers
118
+ }
119
+ ]
120
+ });
121
+ }
122
+ async disconnect() {
123
+ this.producerPromise = null;
124
+ if (this.producer) {
125
+ await this.producer.disconnect();
126
+ this.producer = null;
127
+ }
128
+ this.consumerPromises.clear();
129
+ for (const consumer of this.consumers.values()) {
130
+ await consumer.disconnect();
131
+ }
132
+ this.consumers.clear();
133
+ this.adminPromise = null;
134
+ if (this.admin) {
135
+ await this.admin.disconnect().catch(() => void 0);
136
+ this.admin = null;
137
+ }
138
+ }
139
+ static parseBrokerList(brokerString) {
140
+ return brokerString.split(",").map((broker) => broker.trim()).filter(Boolean);
141
+ }
142
+ static buildBrokersFromRuntime(options) {
143
+ const mode = options.mode ?? "external";
144
+ const brokerCount = options.brokerCount ?? 3;
145
+ if (mode === "internal") {
146
+ const internalPortEnvKey = options.internalPortEnvKey ?? "KAFKA_BROKER_INTERNAL_PORT";
147
+ const internalPort = process.env[internalPortEnvKey];
148
+ const hostPrefix = options.internalBrokerHostPrefix ?? "broker-";
149
+ if (!internalPort) {
150
+ throw new Error(`${internalPortEnvKey} is not configured.`);
151
+ }
152
+ return Array.from({ length: brokerCount }, (_, index) => `${hostPrefix}${index + 1}:${internalPort}`);
153
+ }
154
+ const externalHostEnvKey = options.externalHostEnvKey ?? "KAFKA_EXTERNAL_HOST";
155
+ const externalHost = process.env[externalHostEnvKey];
156
+ const externalPortEnvKeyPrefix = options.externalPortEnvKeyPrefix ?? "KAFKA_BROKER_";
157
+ const externalPortEnvKeySuffix = options.externalPortEnvKeySuffix ?? "_EXTERNAL_PORT";
158
+ if (!externalHost) {
159
+ throw new Error(`${externalHostEnvKey} is not configured.`);
160
+ }
161
+ return Array.from({ length: brokerCount }, (_, index) => {
162
+ const brokerNumber = index + 1;
163
+ const portEnvKey = `${externalPortEnvKeyPrefix}${brokerNumber}${externalPortEnvKeySuffix}`;
164
+ const port = process.env[portEnvKey];
165
+ if (!port) {
166
+ throw new Error(`${portEnvKey} is not configured.`);
167
+ }
168
+ return `${externalHost}:${port}`;
169
+ });
170
+ }
171
+ async connectProducer() {
172
+ const producer = this.kafka.producer();
173
+ try {
174
+ await producer.connect();
175
+ this.producer = producer;
176
+ return producer;
177
+ } catch (error) {
178
+ this.producerPromise = null;
179
+ throw error;
180
+ }
181
+ }
182
+ async connectConsumer(groupId) {
183
+ const consumer = this.kafka.consumer({
184
+ groupId
185
+ });
186
+ try {
187
+ await consumer.connect();
188
+ this.consumers.set(groupId, consumer);
189
+ return consumer;
190
+ } catch (error) {
191
+ this.consumerPromises.delete(groupId);
192
+ throw error;
193
+ }
194
+ }
195
+ async getAdmin() {
196
+ if (this.admin) {
197
+ return this.admin;
198
+ }
199
+ if (!this.adminPromise) {
200
+ this.adminPromise = this.connectAdmin();
201
+ }
202
+ return this.adminPromise;
203
+ }
204
+ async connectAdmin() {
205
+ const admin = this.kafka.admin();
206
+ try {
207
+ await admin.connect();
208
+ this.admin = admin;
209
+ return admin;
210
+ } catch (error) {
211
+ this.adminPromise = null;
212
+ throw error;
213
+ }
214
+ }
215
+ };
216
+ // Annotate the CommonJS export names for ESM import in node:
217
+ 0 && (module.exports = {
218
+ KafkaManager
219
+ });
package/dist/index.mjs ADDED
@@ -0,0 +1,194 @@
1
+ // src/kafka-manager.ts
2
+ import {
3
+ Kafka
4
+ } from "kafkajs";
5
+ var KafkaManager = class _KafkaManager {
6
+ constructor(options) {
7
+ this.producer = null;
8
+ // Share the same in-flight connection across concurrent callers.
9
+ this.producerPromise = null;
10
+ this.admin = null;
11
+ // Admin access is reused so topic provisioning does not race on startup.
12
+ this.adminPromise = null;
13
+ this.consumers = /* @__PURE__ */ new Map();
14
+ // Consumers are cached per group id to avoid duplicate group members.
15
+ this.consumerPromises = /* @__PURE__ */ new Map();
16
+ if (!options.brokers.length) {
17
+ throw new Error("Kafka brokers are required.");
18
+ }
19
+ this.kafka = new Kafka({
20
+ clientId: options.clientId,
21
+ brokers: options.brokers,
22
+ ssl: options.ssl,
23
+ sasl: options.sasl,
24
+ connectionTimeout: options.connectionTimeout,
25
+ requestTimeout: options.requestTimeout,
26
+ retry: options.retry ?? {
27
+ initialRetryTime: 300,
28
+ retries: 10
29
+ }
30
+ });
31
+ }
32
+ static fromEnv(options) {
33
+ const brokerString = options.envKey ? process.env[options.envKey] : process.env.KAFKA_BROKERS;
34
+ const brokers = brokerString ? _KafkaManager.parseBrokerList(brokerString) : _KafkaManager.buildBrokersFromRuntime(options);
35
+ if (!brokers.length) {
36
+ throw new Error("Kafka brokers are not configured.");
37
+ }
38
+ return new _KafkaManager({
39
+ clientId: options.clientId,
40
+ brokers,
41
+ ssl: options.ssl,
42
+ sasl: options.sasl,
43
+ connectionTimeout: options.connectionTimeout,
44
+ requestTimeout: options.requestTimeout,
45
+ retry: options.retry
46
+ });
47
+ }
48
+ async getProducer() {
49
+ if (this.producer) {
50
+ return this.producer;
51
+ }
52
+ if (!this.producerPromise) {
53
+ this.producerPromise = this.connectProducer();
54
+ }
55
+ return this.producerPromise;
56
+ }
57
+ async getConsumer(groupId) {
58
+ const existingConsumer = this.consumers.get(groupId);
59
+ if (existingConsumer) {
60
+ return existingConsumer;
61
+ }
62
+ const existingPromise = this.consumerPromises.get(groupId);
63
+ if (existingPromise) {
64
+ return existingPromise;
65
+ }
66
+ const consumerPromise = this.connectConsumer(groupId);
67
+ this.consumerPromises.set(groupId, consumerPromise);
68
+ return consumerPromise;
69
+ }
70
+ async provisionTopics(topics) {
71
+ const admin = await this.getAdmin();
72
+ const existingTopics = await admin.listTopics();
73
+ const topicsToCreate = topics.filter((topic) => !existingTopics.includes(topic.topic)).map((topic) => ({
74
+ topic: topic.topic,
75
+ numPartitions: topic.numPartitions ?? 3,
76
+ replicationFactor: topic.replicationFactor ?? 1
77
+ }));
78
+ if (!topicsToCreate.length) {
79
+ return;
80
+ }
81
+ await admin.createTopics({
82
+ topics: topicsToCreate
83
+ });
84
+ }
85
+ async publish(options) {
86
+ const producer = await this.getProducer();
87
+ await producer.send({
88
+ topic: options.topic,
89
+ messages: [
90
+ {
91
+ key: options.key,
92
+ value: JSON.stringify(options.payload),
93
+ headers: options.headers
94
+ }
95
+ ]
96
+ });
97
+ }
98
+ async disconnect() {
99
+ this.producerPromise = null;
100
+ if (this.producer) {
101
+ await this.producer.disconnect();
102
+ this.producer = null;
103
+ }
104
+ this.consumerPromises.clear();
105
+ for (const consumer of this.consumers.values()) {
106
+ await consumer.disconnect();
107
+ }
108
+ this.consumers.clear();
109
+ this.adminPromise = null;
110
+ if (this.admin) {
111
+ await this.admin.disconnect().catch(() => void 0);
112
+ this.admin = null;
113
+ }
114
+ }
115
+ static parseBrokerList(brokerString) {
116
+ return brokerString.split(",").map((broker) => broker.trim()).filter(Boolean);
117
+ }
118
+ static buildBrokersFromRuntime(options) {
119
+ const mode = options.mode ?? "external";
120
+ const brokerCount = options.brokerCount ?? 3;
121
+ if (mode === "internal") {
122
+ const internalPortEnvKey = options.internalPortEnvKey ?? "KAFKA_BROKER_INTERNAL_PORT";
123
+ const internalPort = process.env[internalPortEnvKey];
124
+ const hostPrefix = options.internalBrokerHostPrefix ?? "broker-";
125
+ if (!internalPort) {
126
+ throw new Error(`${internalPortEnvKey} is not configured.`);
127
+ }
128
+ return Array.from({ length: brokerCount }, (_, index) => `${hostPrefix}${index + 1}:${internalPort}`);
129
+ }
130
+ const externalHostEnvKey = options.externalHostEnvKey ?? "KAFKA_EXTERNAL_HOST";
131
+ const externalHost = process.env[externalHostEnvKey];
132
+ const externalPortEnvKeyPrefix = options.externalPortEnvKeyPrefix ?? "KAFKA_BROKER_";
133
+ const externalPortEnvKeySuffix = options.externalPortEnvKeySuffix ?? "_EXTERNAL_PORT";
134
+ if (!externalHost) {
135
+ throw new Error(`${externalHostEnvKey} is not configured.`);
136
+ }
137
+ return Array.from({ length: brokerCount }, (_, index) => {
138
+ const brokerNumber = index + 1;
139
+ const portEnvKey = `${externalPortEnvKeyPrefix}${brokerNumber}${externalPortEnvKeySuffix}`;
140
+ const port = process.env[portEnvKey];
141
+ if (!port) {
142
+ throw new Error(`${portEnvKey} is not configured.`);
143
+ }
144
+ return `${externalHost}:${port}`;
145
+ });
146
+ }
147
+ async connectProducer() {
148
+ const producer = this.kafka.producer();
149
+ try {
150
+ await producer.connect();
151
+ this.producer = producer;
152
+ return producer;
153
+ } catch (error) {
154
+ this.producerPromise = null;
155
+ throw error;
156
+ }
157
+ }
158
+ async connectConsumer(groupId) {
159
+ const consumer = this.kafka.consumer({
160
+ groupId
161
+ });
162
+ try {
163
+ await consumer.connect();
164
+ this.consumers.set(groupId, consumer);
165
+ return consumer;
166
+ } catch (error) {
167
+ this.consumerPromises.delete(groupId);
168
+ throw error;
169
+ }
170
+ }
171
+ async getAdmin() {
172
+ if (this.admin) {
173
+ return this.admin;
174
+ }
175
+ if (!this.adminPromise) {
176
+ this.adminPromise = this.connectAdmin();
177
+ }
178
+ return this.adminPromise;
179
+ }
180
+ async connectAdmin() {
181
+ const admin = this.kafka.admin();
182
+ try {
183
+ await admin.connect();
184
+ this.admin = admin;
185
+ return admin;
186
+ } catch (error) {
187
+ this.adminPromise = null;
188
+ throw error;
189
+ }
190
+ }
191
+ };
192
+ export {
193
+ KafkaManager
194
+ };
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "sentinel-kafka-manager",
3
+ "version": "1.0.0",
4
+ "description": "Reusable Kafka manager for Node.js microservices",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.mjs",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "require": "./dist/index.js",
12
+ "import": "./dist/index.mjs"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist"
17
+ ],
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "scripts": {
22
+ "build": "tsup src/index.ts --format cjs,esm --dts",
23
+ "prepublishOnly": "npm run build"
24
+ },
25
+ "keywords": [
26
+ "kafka",
27
+ "kafkajs",
28
+ "nodejs",
29
+ "microservice"
30
+ ],
31
+ "author": "Ngen Claims",
32
+ "license": "MIT",
33
+ "type": "commonjs",
34
+ "dependencies": {
35
+ "kafkajs": "^2.2.4"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^24.0.0",
39
+ "tsup": "^8.0.0",
40
+ "typescript": "^5.0.0"
41
+ }
42
+ }