@basaltkit/queue-kafka 1.0.1 → 1.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,3 +1,9 @@
1
+ <p align="center">
2
+ <a href="https://basaltkit-docs.pages.dev">
3
+ <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440">
4
+ </a>
5
+ </p>
6
+
1
7
  # @basaltkit/queue-kafka
2
8
 
3
9
  **Apache Kafka** driver for [`@basaltkit/queue`](https://www.npmjs.com/package/@basaltkit/queue): runs your jobs by producing and consuming messages on Kafka topics, without changing your job code. You need this package when your data platform is already built on Kafka and you want to process background work on the same infrastructure.
@@ -88,6 +94,7 @@ The worker's concurrency is passed as `partitionsConsumedConcurrently` — actua
88
94
  | `retrySuffix` | `string` | `'.retry'` | Suffix for the retry topic. |
89
95
  | `deadSuffix` | `string` | `'.dead'` | Suffix for the dead-letter topic. |
90
96
  | `client` | `KafkaClient` | kafkajs | Injectable client — used in tests without a broker. |
97
+ | `onError` | `(error, { source, queue? }) => void` | contextual `console.error` | Infrastructure-fault hook (same pattern as rabbitmq/sqs): a worker's connect/subscribe/run failing at boot (`source: 'consumer'` — previously an unhandled, process-fatal rejection and an invisible zero-worker app), or a retry/dead-letter re-publish failing (`source: 'producer'` — reported, then rethrown so the offset is not committed and Kafka redelivers; a producer outage cannot silently lose a failing job). |
91
98
 
92
99
  Implements the `QueueDriver` contract from `@basaltkit/queue`.
93
100
 
package/dist/index.d.ts CHANGED
@@ -1,13 +1,12 @@
1
- import { QueueDriver, DriverCapabilities, JobExecutor, AddJobOptions } from '@basaltkit/queue';
2
-
1
+ import type { AddJobOptions, DriverCapabilities, JobExecutor, QueueDriver } from '@basaltkit/queue';
3
2
  /** The subset of a kafkajs client this driver uses. */
4
- interface KafkaClient {
3
+ export interface KafkaClient {
5
4
  producer(): KafkaProducer;
6
5
  consumer(config: {
7
6
  groupId: string;
8
7
  }): KafkaConsumer;
9
8
  }
10
- interface KafkaProducer {
9
+ export interface KafkaProducer {
11
10
  connect(): Promise<void>;
12
11
  send(record: {
13
12
  topic: string;
@@ -18,11 +17,11 @@ interface KafkaProducer {
18
17
  }): Promise<unknown>;
19
18
  disconnect(): Promise<void>;
20
19
  }
21
- interface KafkaMessage {
20
+ export interface KafkaMessage {
22
21
  value: Uint8Array | string | null;
23
22
  headers?: Record<string, Uint8Array | string | undefined>;
24
23
  }
25
- interface KafkaConsumer {
24
+ export interface KafkaConsumer {
26
25
  connect(): Promise<void>;
27
26
  subscribe(subscription: {
28
27
  topic: string;
@@ -37,7 +36,7 @@ interface KafkaConsumer {
37
36
  }): Promise<void>;
38
37
  disconnect(): Promise<void>;
39
38
  }
40
- interface KafkaDriverOptions {
39
+ export interface KafkaDriverOptions {
41
40
  brokers: string[];
42
41
  clientId?: string;
43
42
  /** Consumer group used by workers. Default 'basalt-queue'. */
@@ -48,6 +47,17 @@ interface KafkaDriverOptions {
48
47
  deadSuffix?: string;
49
48
  /** Injectable client — defaults to kafkajs. Tests pass a fake. */
50
49
  client?: KafkaClient;
50
+ /**
51
+ * Called on infrastructure faults the driver cannot recover in line — a
52
+ * worker's connect/subscribe/run failing at boot (otherwise the app reports
53
+ * healthy with ZERO workers and the rejection is process-fatal), or a
54
+ * retry/dead-letter re-publish failing inside the consume callback. Same
55
+ * pattern as the rabbitmq/sqs drivers. Default: console.error with context.
56
+ */
57
+ onError?: (error: unknown, info: {
58
+ source: 'consumer' | 'producer';
59
+ queue?: string;
60
+ }) => void;
51
61
  }
52
62
  /**
53
63
  * Kafka driver for `@basaltkit/queue`. Kafka is a log, not a task queue, so this
@@ -63,7 +73,7 @@ interface KafkaDriverOptions {
63
73
  * Worker concurrency is bounded by the topic's partition count, not the
64
74
  * `concurrency` number (passed through as `partitionsConsumedConcurrently`).
65
75
  */
66
- declare class KafkaQueueDriver implements QueueDriver {
76
+ export declare class KafkaQueueDriver implements QueueDriver {
67
77
  private readonly options;
68
78
  readonly name = "kafka";
69
79
  readonly capabilities: DriverCapabilities;
@@ -74,6 +84,7 @@ declare class KafkaQueueDriver implements QueueDriver {
74
84
  private readonly groupId;
75
85
  private readonly retrySuffix;
76
86
  private readonly deadSuffix;
87
+ private readonly onError;
77
88
  constructor(options: KafkaDriverOptions);
78
89
  setExecutor(executor: JobExecutor): void;
79
90
  add(queue: string, jobName: string, data: unknown, options: AddJobOptions): Promise<void>;
@@ -87,5 +98,3 @@ declare class KafkaQueueDriver implements QueueDriver {
87
98
  private retryTopic;
88
99
  private deadTopic;
89
100
  }
90
-
91
- export { type KafkaClient, type KafkaConsumer, type KafkaDriverOptions, type KafkaMessage, type KafkaProducer, KafkaQueueDriver };
package/dist/index.js CHANGED
@@ -1,128 +1,173 @@
1
- // src/index.ts
2
- var HEADER = {
3
- job: "x-basalt-job",
4
- attempt: "x-basalt-attempt",
5
- attempts: "x-basalt-attempts"
1
+ const HEADER = {
2
+ job: 'x-basalt-job',
3
+ attempt: 'x-basalt-attempt',
4
+ attempts: 'x-basalt-attempts',
6
5
  };
7
- var MAX_ATTEMPTS = 50;
8
- var defaultClient = async (options) => {
9
- const specifier = "kafkajs";
10
- const mod = await import(specifier);
11
- return new mod.Kafka({ clientId: options.clientId ?? "basalt", brokers: options.brokers });
6
+ /** Hard ceiling on retries, so a crafted message can't request unbounded ones. */
7
+ const MAX_ATTEMPTS = 50;
8
+ const defaultClient = async (options) => {
9
+ // Opaque specifier keeps kafkajs an optional peer dependency (runtime-resolved).
10
+ const specifier = 'kafkajs';
11
+ const mod = (await import(specifier));
12
+ return new mod.Kafka({ clientId: options.clientId ?? 'basalt', brokers: options.brokers });
12
13
  };
13
- var KafkaQueueDriver = class {
14
- constructor(options) {
15
- this.options = options;
16
- this.groupId = options.groupId ?? "basalt-queue";
17
- this.retrySuffix = options.retrySuffix ?? ".retry";
18
- this.deadSuffix = options.deadSuffix ?? ".dead";
19
- }
20
- options;
21
- name = "kafka";
22
- capabilities = {
23
- delayed: false,
24
- priority: false,
25
- retries: true,
26
- backoff: false
27
- };
28
- executor;
29
- clientPromise;
30
- producerPromise;
31
- consumers = [];
32
- groupId;
33
- retrySuffix;
34
- deadSuffix;
35
- setExecutor(executor) {
36
- this.executor = executor;
37
- }
38
- async add(queue, jobName, data, options) {
39
- void options;
40
- const producer = await this.producer();
41
- await producer.send({
42
- topic: queue,
43
- messages: [
44
- {
45
- value: JSON.stringify(data),
46
- headers: {
47
- [HEADER.job]: jobName,
48
- [HEADER.attempt]: "1",
49
- [HEADER.attempts]: String(options.attempts)
50
- }
51
- }
52
- ]
53
- });
54
- }
55
- startWorker(queue, options = {}) {
56
- void (async () => {
57
- const consumer = (await this.client()).consumer({ groupId: this.groupId });
58
- this.consumers.push(consumer);
59
- await consumer.connect();
60
- await consumer.subscribe({ topic: queue, fromBeginning: false });
61
- await consumer.subscribe({ topic: this.retryTopic(queue), fromBeginning: false });
62
- await consumer.run({
63
- eachMessage: ({ message }) => this.handle(queue, message),
64
- ...options.concurrency !== void 0 ? { partitionsConsumedConcurrently: options.concurrency } : {}
65
- });
66
- })();
67
- }
68
- async close() {
69
- const producer = await this.producerPromise?.catch(() => void 0);
70
- await producer?.disconnect();
71
- await Promise.all(this.consumers.map((consumer) => consumer.disconnect()));
72
- }
73
- // --- internals -----------------------------------------------------------
74
- async handle(queue, message) {
75
- if (message.value === null) return;
76
- const headers = decodeHeaders(message.headers);
77
- const jobName = headers[HEADER.job] ?? "";
78
- const value = asString(message.value);
79
- try {
80
- await this.executor?.(jobName, JSON.parse(value));
81
- } catch {
82
- const attempt = Number(headers[HEADER.attempt] ?? 1);
83
- const attempts = Math.min(Number(headers[HEADER.attempts] ?? 1) || 1, MAX_ATTEMPTS);
84
- const producer = await this.producer();
85
- if (attempt < attempts) {
14
+ /**
15
+ * Kafka driver for `@basaltkit/queue`. Kafka is a log, not a task queue, so this
16
+ * driver is deliberately honest about what it can't do:
17
+ *
18
+ * - `delayed` and `priority` are NOT supported (Kafka has neither). With the
19
+ * queue's `onUnsupported: 'throw'` policy a delayed/priority dispatch fails
20
+ * loudly; with the default 'warn' it logs and proceeds without them.
21
+ * - `retries` are supported via a retry topic (`<topic>.retry`) the worker also
22
+ * consumes; exhausted jobs go to `<topic>.dead`. There is no backoff delay
23
+ * (Kafka can't defer a message), so `backoff` is not supported either.
24
+ *
25
+ * Worker concurrency is bounded by the topic's partition count, not the
26
+ * `concurrency` number (passed through as `partitionsConsumedConcurrently`).
27
+ */
28
+ export class KafkaQueueDriver {
29
+ options;
30
+ name = 'kafka';
31
+ capabilities = {
32
+ delayed: false,
33
+ priority: false,
34
+ retries: true,
35
+ backoff: false,
36
+ };
37
+ executor;
38
+ clientPromise;
39
+ producerPromise;
40
+ consumers = [];
41
+ groupId;
42
+ retrySuffix;
43
+ deadSuffix;
44
+ onError;
45
+ constructor(options) {
46
+ this.options = options;
47
+ this.groupId = options.groupId ?? 'basalt-queue';
48
+ this.retrySuffix = options.retrySuffix ?? '.retry';
49
+ this.deadSuffix = options.deadSuffix ?? '.dead';
50
+ this.onError =
51
+ options.onError ??
52
+ ((error, info) => console.error(`[basalt:queue] kafka ${info.source} error${info.queue ? ` (queue "${info.queue}")` : ''}:`, error));
53
+ }
54
+ setExecutor(executor) {
55
+ this.executor = executor;
56
+ }
57
+ async add(queue, jobName, data, options) {
58
+ // delay/priority are unsupported (see capabilities); the QueueManager has
59
+ // already applied its onUnsupported policy, so they are simply not encoded.
60
+ void options;
61
+ const producer = await this.producer();
86
62
  await producer.send({
87
- topic: this.retryTopic(queue),
88
- messages: [{ value, headers: { ...headers, [HEADER.attempt]: String(attempt + 1) } }]
63
+ topic: queue,
64
+ messages: [
65
+ {
66
+ value: JSON.stringify(data),
67
+ headers: {
68
+ [HEADER.job]: jobName,
69
+ [HEADER.attempt]: '1',
70
+ [HEADER.attempts]: String(options.attempts),
71
+ },
72
+ },
73
+ ],
89
74
  });
90
- } else {
91
- await producer.send({ topic: this.deadTopic(queue), messages: [{ value, headers }] });
92
- }
93
75
  }
94
- }
95
- client() {
96
- if (!this.clientPromise) {
97
- this.clientPromise = this.options.client ? Promise.resolve(this.options.client) : defaultClient(this.options);
76
+ startWorker(queue, options = {}) {
77
+ ;
78
+ (async () => {
79
+ const consumer = (await this.client()).consumer({ groupId: this.groupId });
80
+ this.consumers.push(consumer);
81
+ await consumer.connect();
82
+ await consumer.subscribe({ topic: queue, fromBeginning: false });
83
+ await consumer.subscribe({ topic: this.retryTopic(queue), fromBeginning: false });
84
+ await consumer.run({
85
+ eachMessage: ({ message }) => this.handle(queue, message),
86
+ ...(options.concurrency !== undefined ? { partitionsConsumedConcurrently: options.concurrency } : {}),
87
+ });
88
+ })().catch((error) => {
89
+ // A broker-connect/subscribe failure at boot must be VISIBLE — otherwise
90
+ // the app reports healthy with zero workers, and the floating rejection
91
+ // would be process-fatal by Node's default.
92
+ this.onError(error, { source: 'consumer', queue });
93
+ });
98
94
  }
99
- return this.clientPromise;
100
- }
101
- producer() {
102
- if (!this.producerPromise) {
103
- this.producerPromise = (async () => {
104
- const producer = (await this.client()).producer();
105
- await producer.connect();
106
- return producer;
107
- })();
95
+ async close() {
96
+ const producer = await this.producerPromise?.catch(() => undefined);
97
+ await producer?.disconnect();
98
+ await Promise.all(this.consumers.map((consumer) => consumer.disconnect()));
108
99
  }
109
- return this.producerPromise;
110
- }
111
- retryTopic(queue) {
112
- return `${queue}${this.retrySuffix}`;
113
- }
114
- deadTopic(queue) {
115
- return `${queue}${this.deadSuffix}`;
116
- }
117
- };
118
- var asString = (value) => typeof value === "string" ? value : new TextDecoder().decode(value);
119
- var decodeHeaders = (headers) => {
120
- const out = {};
121
- for (const [key, value] of Object.entries(headers ?? {})) {
122
- if (value !== void 0) out[key] = asString(value);
123
- }
124
- return out;
125
- };
126
- export {
127
- KafkaQueueDriver
100
+ // --- internals -----------------------------------------------------------
101
+ async handle(queue, message) {
102
+ if (message.value === null)
103
+ return;
104
+ const headers = decodeHeaders(message.headers);
105
+ const jobName = headers[HEADER.job] ?? '';
106
+ const value = asString(message.value);
107
+ try {
108
+ await this.executor?.(jobName, JSON.parse(value));
109
+ }
110
+ catch {
111
+ const attempt = Number(headers[HEADER.attempt] ?? 1);
112
+ // Clamp the max-attempts read from the (untrusted) message to a hard
113
+ // ceiling so a crafted `attempts` can't drive a retry-amplification loop.
114
+ const attempts = Math.min(Number(headers[HEADER.attempts] ?? 1) || 1, MAX_ATTEMPTS);
115
+ try {
116
+ const producer = await this.producer();
117
+ if (attempt < attempts) {
118
+ await producer.send({
119
+ topic: this.retryTopic(queue),
120
+ messages: [{ value, headers: { ...headers, [HEADER.attempt]: String(attempt + 1) } }],
121
+ });
122
+ }
123
+ else {
124
+ await producer.send({ topic: this.deadTopic(queue), messages: [{ value, headers }] });
125
+ }
126
+ }
127
+ catch (publishError) {
128
+ // The failed job could not be re-routed. Surface the fault, then
129
+ // RETHROW so eachMessage rejects and the offset is NOT committed —
130
+ // kafkajs redelivers the message (at-least-once) instead of the job
131
+ // silently vanishing on a producer outage. (Rabbitmq keeps the message
132
+ // unacked for the same reason; Kafka's equivalent is not committing.)
133
+ this.onError(publishError, { source: 'producer', queue });
134
+ throw publishError;
135
+ }
136
+ }
137
+ // Offsets auto-commit after eachMessage resolves — a re-routed failure is
138
+ // considered handled so the same message is not redelivered by Kafka.
139
+ }
140
+ client() {
141
+ if (!this.clientPromise) {
142
+ this.clientPromise = this.options.client
143
+ ? Promise.resolve(this.options.client)
144
+ : defaultClient(this.options);
145
+ }
146
+ return this.clientPromise;
147
+ }
148
+ producer() {
149
+ if (!this.producerPromise) {
150
+ this.producerPromise = (async () => {
151
+ const producer = (await this.client()).producer();
152
+ await producer.connect();
153
+ return producer;
154
+ })();
155
+ }
156
+ return this.producerPromise;
157
+ }
158
+ retryTopic(queue) {
159
+ return `${queue}${this.retrySuffix}`;
160
+ }
161
+ deadTopic(queue) {
162
+ return `${queue}${this.deadSuffix}`;
163
+ }
164
+ }
165
+ const asString = (value) => typeof value === 'string' ? value : new TextDecoder().decode(value);
166
+ const decodeHeaders = (headers) => {
167
+ const out = {};
168
+ for (const [key, value] of Object.entries(headers ?? {})) {
169
+ if (value !== undefined)
170
+ out[key] = asString(value);
171
+ }
172
+ return out;
128
173
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/queue-kafka",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Kafka driver for @basaltkit/queue: produce/consume jobs with retry and dead-letter topics (no delayed delivery or priority — Kafka has neither).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,16 +14,15 @@
14
14
  "dist"
15
15
  ],
16
16
  "dependencies": {
17
- "@basaltkit/queue": "^1.1.0"
17
+ "@basaltkit/queue": "^1.3.1"
18
18
  },
19
19
  "peerDependencies": {
20
20
  "kafkajs": "^2.0.0"
21
21
  },
22
22
  "devDependencies": {
23
- "@types/node": "^22.15.0",
24
- "tsup": "^8.4.0",
25
- "typescript": "^5.8.0",
26
- "vitest": "^3.1.0",
23
+ "@types/node": "^26.3.0",
24
+ "typescript": "^7.0.2",
25
+ "vitest": "^4.1.11",
27
26
  "@basaltkit/tsconfig": "^0.24.0"
28
27
  },
29
28
  "publishConfig": {
@@ -31,11 +30,11 @@
31
30
  },
32
31
  "repository": {
33
32
  "type": "git",
34
- "url": "git+https://github.com/Zebedeu/basalt.git",
33
+ "url": "git+https://github.com/basaltkit/basalt.git",
35
34
  "directory": "packages/queue-kafka"
36
35
  },
37
- "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/queue-kafka#readme",
38
- "bugs": "https://github.com/Zebedeu/basalt/issues",
36
+ "homepage": "https://github.com/basaltkit/basalt/tree/main/packages/queue-kafka#readme",
37
+ "bugs": "https://github.com/basaltkit/basalt/issues",
39
38
  "keywords": [
40
39
  "basalt",
41
40
  "typescript",
@@ -44,7 +43,7 @@
44
43
  "kafkajs"
45
44
  ],
46
45
  "scripts": {
47
- "build": "tsup src/index.ts --format esm --dts --clean",
46
+ "build": "tsc -p tsconfig.build.json",
48
47
  "test": "vitest run",
49
48
  "typecheck": "tsc --noEmit"
50
49
  }