@basaltkit/queue-kafka 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Machize Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # @basaltkit/queue-kafka
2
+
3
+ **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.
4
+
5
+ ## What this module solves
6
+
7
+ `@basaltkit/queue` defines **jobs** in an abstract way and picks the backend via a *driver*. This package provides a driver that talks to **Kafka**: jobs are produced to a topic and consumed by a *consumer group*.
8
+
9
+ `defineJob`, `dispatch`, the workers and context propagation all stay the same — you only swap the driver.
10
+
11
+ ## Installation
12
+
13
+ ```bash
14
+ pnpm add @basaltkit/queue-kafka kafkajs
15
+ ```
16
+
17
+ `kafkajs` is a **peer dependency**. You need a reachable Kafka cluster.
18
+
19
+ ## Getting started in 5 minutes
20
+
21
+ ```ts
22
+ import { createApp } from '@basaltkit/core'
23
+ import { queuePlugin, defineJob } from '@basaltkit/queue'
24
+ import { KafkaQueueDriver } from '@basaltkit/queue-kafka'
25
+
26
+ const IndexDocument = defineJob<{ id: string }>({
27
+ name: 'index-document',
28
+ queue: 'indexing',
29
+ attempts: 3,
30
+ async handle({ id }) {
31
+ // ... index it
32
+ },
33
+ })
34
+
35
+ const app = await createApp({
36
+ plugins: [
37
+ queuePlugin({
38
+ driver: new KafkaQueueDriver({ brokers: ['localhost:9092'], clientId: 'my-app' }),
39
+ jobs: [IndexDocument],
40
+ workers: [{ queue: 'indexing', concurrency: 4 }],
41
+ }),
42
+ ],
43
+ }).boot()
44
+
45
+ await IndexDocument.dispatch({ id: 'doc-1' })
46
+ ```
47
+
48
+ ## Being honest about Kafka
49
+
50
+ Kafka is a **distributed log**, not a *task queue* — and the driver is deliberately honest about that in its `capabilities`:
51
+
52
+ | Capability | Supported | Why |
53
+ |---|:---:|---|
54
+ | `delayed` (delayed delivery) | ❌ | Kafka doesn't delay messages. |
55
+ | `priority` | ❌ | Kafka has no message priority. |
56
+ | `retries` | ✅ | Via a *retry topic* that the worker also consumes. |
57
+ | `backoff` | ❌ | No delay between attempts (Kafka doesn't defer). |
58
+
59
+ Since the driver **declares** this, a job that requests `delay` or `priority` is caught by `@basaltkit/queue`'s `onUnsupported` policy:
60
+
61
+ ```ts
62
+ queuePlugin({ driver: new KafkaQueueDriver({ brokers }), onUnsupported: 'throw' })
63
+ await Job.dispatch(payload, { delay: '5m' }) // → throws UnsupportedJobOptionError
64
+ // with onUnsupported: 'warn' (default) → warns once and runs immediately
65
+ ```
66
+
67
+ > If what you need is *streaming*/pub-sub (rather than jobs with retry/delay), the natural fit in Basalt is usually `@basaltkit/events`, not `@basaltkit/queue`.
68
+
69
+ ## How it works
70
+
71
+ For each queue `t`:
72
+
73
+ - **`t`** — the main topic where jobs are produced.
74
+ - **`t.retry`** — retry topic that the worker also subscribes to; a failed job is re-produced here with the attempt counter incremented.
75
+ - **`t.dead`** — dead-letter topic for jobs that exhausted their attempts.
76
+
77
+ The worker's concurrency is passed as `partitionsConsumedConcurrently` — actual parallelism is limited by the topic's **number of partitions**, not an arbitrary number.
78
+
79
+ ## API reference
80
+
81
+ ### `new KafkaQueueDriver(options)`
82
+
83
+ | Option | Type | Default | Description |
84
+ |---|---|---|---|
85
+ | `brokers` | `string[]` | — (required) | List of brokers, e.g. `['localhost:9092']`. |
86
+ | `clientId` | `string` | `'basalt'` | Kafka client id. |
87
+ | `groupId` | `string` | `'basalt-queue'` | Workers' consumer group. |
88
+ | `retrySuffix` | `string` | `'.retry'` | Suffix for the retry topic. |
89
+ | `deadSuffix` | `string` | `'.dead'` | Suffix for the dead-letter topic. |
90
+ | `client` | `KafkaClient` | kafkajs | Injectable client — used in tests without a broker. |
91
+
92
+ Implements the `QueueDriver` contract from `@basaltkit/queue`.
93
+
94
+ ## How it connects to other modules
95
+
96
+ - **`@basaltkit/queue`** — this is a driver for that package; the jobs API comes from there.
97
+ - Sibling drivers: [`@basaltkit/queue-rabbitmq`](https://www.npmjs.com/package/@basaltkit/queue-rabbitmq) and [`@basaltkit/queue-sqs`](https://www.npmjs.com/package/@basaltkit/queue-sqs).
@@ -0,0 +1,91 @@
1
+ import { QueueDriver, DriverCapabilities, JobExecutor, AddJobOptions } from '@basaltkit/queue';
2
+
3
+ /** The subset of a kafkajs client this driver uses. */
4
+ interface KafkaClient {
5
+ producer(): KafkaProducer;
6
+ consumer(config: {
7
+ groupId: string;
8
+ }): KafkaConsumer;
9
+ }
10
+ interface KafkaProducer {
11
+ connect(): Promise<void>;
12
+ send(record: {
13
+ topic: string;
14
+ messages: {
15
+ value: string;
16
+ headers?: Record<string, string>;
17
+ }[];
18
+ }): Promise<unknown>;
19
+ disconnect(): Promise<void>;
20
+ }
21
+ interface KafkaMessage {
22
+ value: Uint8Array | string | null;
23
+ headers?: Record<string, Uint8Array | string | undefined>;
24
+ }
25
+ interface KafkaConsumer {
26
+ connect(): Promise<void>;
27
+ subscribe(subscription: {
28
+ topic: string;
29
+ fromBeginning?: boolean;
30
+ }): Promise<void>;
31
+ run(config: {
32
+ eachMessage: (payload: {
33
+ topic: string;
34
+ message: KafkaMessage;
35
+ }) => Promise<void>;
36
+ partitionsConsumedConcurrently?: number;
37
+ }): Promise<void>;
38
+ disconnect(): Promise<void>;
39
+ }
40
+ interface KafkaDriverOptions {
41
+ brokers: string[];
42
+ clientId?: string;
43
+ /** Consumer group used by workers. Default 'basalt-queue'. */
44
+ groupId?: string;
45
+ /** Suffix for the retry topic. Default '.retry'. */
46
+ retrySuffix?: string;
47
+ /** Suffix for the dead-letter topic. Default '.dead'. */
48
+ deadSuffix?: string;
49
+ /** Injectable client — defaults to kafkajs. Tests pass a fake. */
50
+ client?: KafkaClient;
51
+ }
52
+ /**
53
+ * Kafka driver for `@basaltkit/queue`. Kafka is a log, not a task queue, so this
54
+ * driver is deliberately honest about what it can't do:
55
+ *
56
+ * - `delayed` and `priority` are NOT supported (Kafka has neither). With the
57
+ * queue's `onUnsupported: 'throw'` policy a delayed/priority dispatch fails
58
+ * loudly; with the default 'warn' it logs and proceeds without them.
59
+ * - `retries` are supported via a retry topic (`<topic>.retry`) the worker also
60
+ * consumes; exhausted jobs go to `<topic>.dead`. There is no backoff delay
61
+ * (Kafka can't defer a message), so `backoff` is not supported either.
62
+ *
63
+ * Worker concurrency is bounded by the topic's partition count, not the
64
+ * `concurrency` number (passed through as `partitionsConsumedConcurrently`).
65
+ */
66
+ declare class KafkaQueueDriver implements QueueDriver {
67
+ private readonly options;
68
+ readonly name = "kafka";
69
+ readonly capabilities: DriverCapabilities;
70
+ private executor;
71
+ private clientPromise;
72
+ private producerPromise;
73
+ private readonly consumers;
74
+ private readonly groupId;
75
+ private readonly retrySuffix;
76
+ private readonly deadSuffix;
77
+ constructor(options: KafkaDriverOptions);
78
+ setExecutor(executor: JobExecutor): void;
79
+ add(queue: string, jobName: string, data: unknown, options: AddJobOptions): Promise<void>;
80
+ startWorker(queue: string, options?: {
81
+ concurrency?: number;
82
+ }): void;
83
+ close(): Promise<void>;
84
+ private handle;
85
+ private client;
86
+ private producer;
87
+ private retryTopic;
88
+ private deadTopic;
89
+ }
90
+
91
+ export { type KafkaClient, type KafkaConsumer, type KafkaDriverOptions, type KafkaMessage, type KafkaProducer, KafkaQueueDriver };
package/dist/index.js ADDED
@@ -0,0 +1,127 @@
1
+ // src/index.ts
2
+ var HEADER = {
3
+ job: "x-basalt-job",
4
+ attempt: "x-basalt-attempt",
5
+ attempts: "x-basalt-attempts"
6
+ };
7
+ var defaultClient = async (options) => {
8
+ const specifier = "kafkajs";
9
+ const mod = await import(specifier);
10
+ return new mod.Kafka({ clientId: options.clientId ?? "basalt", brokers: options.brokers });
11
+ };
12
+ var KafkaQueueDriver = class {
13
+ constructor(options) {
14
+ this.options = options;
15
+ this.groupId = options.groupId ?? "basalt-queue";
16
+ this.retrySuffix = options.retrySuffix ?? ".retry";
17
+ this.deadSuffix = options.deadSuffix ?? ".dead";
18
+ }
19
+ options;
20
+ name = "kafka";
21
+ capabilities = {
22
+ delayed: false,
23
+ priority: false,
24
+ retries: true,
25
+ backoff: false
26
+ };
27
+ executor;
28
+ clientPromise;
29
+ producerPromise;
30
+ consumers = [];
31
+ groupId;
32
+ retrySuffix;
33
+ deadSuffix;
34
+ setExecutor(executor) {
35
+ this.executor = executor;
36
+ }
37
+ async add(queue, jobName, data, options) {
38
+ void options;
39
+ const producer = await this.producer();
40
+ await producer.send({
41
+ topic: queue,
42
+ messages: [
43
+ {
44
+ value: JSON.stringify(data),
45
+ headers: {
46
+ [HEADER.job]: jobName,
47
+ [HEADER.attempt]: "1",
48
+ [HEADER.attempts]: String(options.attempts)
49
+ }
50
+ }
51
+ ]
52
+ });
53
+ }
54
+ startWorker(queue, options = {}) {
55
+ void (async () => {
56
+ const consumer = (await this.client()).consumer({ groupId: this.groupId });
57
+ this.consumers.push(consumer);
58
+ await consumer.connect();
59
+ await consumer.subscribe({ topic: queue, fromBeginning: false });
60
+ await consumer.subscribe({ topic: this.retryTopic(queue), fromBeginning: false });
61
+ await consumer.run({
62
+ eachMessage: ({ message }) => this.handle(queue, message),
63
+ ...options.concurrency !== void 0 ? { partitionsConsumedConcurrently: options.concurrency } : {}
64
+ });
65
+ })();
66
+ }
67
+ async close() {
68
+ const producer = await this.producerPromise?.catch(() => void 0);
69
+ await producer?.disconnect();
70
+ await Promise.all(this.consumers.map((consumer) => consumer.disconnect()));
71
+ }
72
+ // --- internals -----------------------------------------------------------
73
+ async handle(queue, message) {
74
+ if (message.value === null) return;
75
+ const headers = decodeHeaders(message.headers);
76
+ const jobName = headers[HEADER.job] ?? "";
77
+ const value = asString(message.value);
78
+ try {
79
+ await this.executor?.(jobName, JSON.parse(value));
80
+ } catch {
81
+ const attempt = Number(headers[HEADER.attempt] ?? 1);
82
+ const attempts = Number(headers[HEADER.attempts] ?? 1);
83
+ const producer = await this.producer();
84
+ if (attempt < attempts) {
85
+ await producer.send({
86
+ topic: this.retryTopic(queue),
87
+ messages: [{ value, headers: { ...headers, [HEADER.attempt]: String(attempt + 1) } }]
88
+ });
89
+ } else {
90
+ await producer.send({ topic: this.deadTopic(queue), messages: [{ value, headers }] });
91
+ }
92
+ }
93
+ }
94
+ client() {
95
+ if (!this.clientPromise) {
96
+ this.clientPromise = this.options.client ? Promise.resolve(this.options.client) : defaultClient(this.options);
97
+ }
98
+ return this.clientPromise;
99
+ }
100
+ producer() {
101
+ if (!this.producerPromise) {
102
+ this.producerPromise = (async () => {
103
+ const producer = (await this.client()).producer();
104
+ await producer.connect();
105
+ return producer;
106
+ })();
107
+ }
108
+ return this.producerPromise;
109
+ }
110
+ retryTopic(queue) {
111
+ return `${queue}${this.retrySuffix}`;
112
+ }
113
+ deadTopic(queue) {
114
+ return `${queue}${this.deadSuffix}`;
115
+ }
116
+ };
117
+ var asString = (value) => typeof value === "string" ? value : new TextDecoder().decode(value);
118
+ var decodeHeaders = (headers) => {
119
+ const out = {};
120
+ for (const [key, value] of Object.entries(headers ?? {})) {
121
+ if (value !== void 0) out[key] = asString(value);
122
+ }
123
+ return out;
124
+ };
125
+ export {
126
+ KafkaQueueDriver
127
+ };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@basaltkit/queue-kafka",
3
+ "version": "1.0.0",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "dependencies": {
17
+ "@basaltkit/queue": "^1.0.0"
18
+ },
19
+ "peerDependencies": {
20
+ "kafkajs": "^2.0.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/node": "^22.15.0",
24
+ "tsup": "^8.4.0",
25
+ "typescript": "^5.8.0",
26
+ "vitest": "^3.1.0",
27
+ "@basaltkit/tsconfig": "^0.24.0"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/Zebedeu/basalt.git",
35
+ "directory": "packages/queue-kafka"
36
+ },
37
+ "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/queue-kafka#readme",
38
+ "bugs": "https://github.com/Zebedeu/basalt/issues",
39
+ "keywords": [
40
+ "basalt",
41
+ "typescript",
42
+ "queue",
43
+ "kafka",
44
+ "kafkajs"
45
+ ],
46
+ "scripts": {
47
+ "build": "tsup src/index.ts --format esm --dts --clean",
48
+ "test": "vitest run",
49
+ "typecheck": "tsc --noEmit"
50
+ }
51
+ }