@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 +21 -0
- package/README.md +97 -0
- package/dist/index.d.ts +91 -0
- package/dist/index.js +127 -0
- package/package.json +51 -0
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).
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|