@basaltkit/queue-kafka 1.0.0 → 1.0.2
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 +1 -1
- package/README.md +6 -0
- package/dist/index.d.ts +7 -10
- package/dist/index.js +144 -119
- package/package.json +9 -10
package/LICENSE
CHANGED
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.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import {
|
|
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'. */
|
|
@@ -63,7 +62,7 @@ interface KafkaDriverOptions {
|
|
|
63
62
|
* Worker concurrency is bounded by the topic's partition count, not the
|
|
64
63
|
* `concurrency` number (passed through as `partitionsConsumedConcurrently`).
|
|
65
64
|
*/
|
|
66
|
-
declare class KafkaQueueDriver implements QueueDriver {
|
|
65
|
+
export declare class KafkaQueueDriver implements QueueDriver {
|
|
67
66
|
private readonly options;
|
|
68
67
|
readonly name = "kafka";
|
|
69
68
|
readonly capabilities: DriverCapabilities;
|
|
@@ -87,5 +86,3 @@ declare class KafkaQueueDriver implements QueueDriver {
|
|
|
87
86
|
private retryTopic;
|
|
88
87
|
private deadTopic;
|
|
89
88
|
}
|
|
90
|
-
|
|
91
|
-
export { type KafkaClient, type KafkaConsumer, type KafkaDriverOptions, type KafkaMessage, type KafkaProducer, KafkaQueueDriver };
|
package/dist/index.js
CHANGED
|
@@ -1,127 +1,152 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 });
|
|
11
13
|
};
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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) {
|
|
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
|
+
constructor(options) {
|
|
45
|
+
this.options = options;
|
|
46
|
+
this.groupId = options.groupId ?? 'basalt-queue';
|
|
47
|
+
this.retrySuffix = options.retrySuffix ?? '.retry';
|
|
48
|
+
this.deadSuffix = options.deadSuffix ?? '.dead';
|
|
49
|
+
}
|
|
50
|
+
setExecutor(executor) {
|
|
51
|
+
this.executor = executor;
|
|
52
|
+
}
|
|
53
|
+
async add(queue, jobName, data, options) {
|
|
54
|
+
// delay/priority are unsupported (see capabilities); the QueueManager has
|
|
55
|
+
// already applied its onUnsupported policy, so they are simply not encoded.
|
|
56
|
+
void options;
|
|
57
|
+
const producer = await this.producer();
|
|
85
58
|
await producer.send({
|
|
86
|
-
|
|
87
|
-
|
|
59
|
+
topic: queue,
|
|
60
|
+
messages: [
|
|
61
|
+
{
|
|
62
|
+
value: JSON.stringify(data),
|
|
63
|
+
headers: {
|
|
64
|
+
[HEADER.job]: jobName,
|
|
65
|
+
[HEADER.attempt]: '1',
|
|
66
|
+
[HEADER.attempts]: String(options.attempts),
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
],
|
|
88
70
|
});
|
|
89
|
-
} else {
|
|
90
|
-
await producer.send({ topic: this.deadTopic(queue), messages: [{ value, headers }] });
|
|
91
|
-
}
|
|
92
71
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
72
|
+
startWorker(queue, options = {}) {
|
|
73
|
+
void (async () => {
|
|
74
|
+
const consumer = (await this.client()).consumer({ groupId: this.groupId });
|
|
75
|
+
this.consumers.push(consumer);
|
|
76
|
+
await consumer.connect();
|
|
77
|
+
await consumer.subscribe({ topic: queue, fromBeginning: false });
|
|
78
|
+
await consumer.subscribe({ topic: this.retryTopic(queue), fromBeginning: false });
|
|
79
|
+
await consumer.run({
|
|
80
|
+
eachMessage: ({ message }) => this.handle(queue, message),
|
|
81
|
+
...(options.concurrency !== undefined ? { partitionsConsumedConcurrently: options.concurrency } : {}),
|
|
82
|
+
});
|
|
83
|
+
})();
|
|
97
84
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
this.producerPromise = (async () => {
|
|
103
|
-
const producer = (await this.client()).producer();
|
|
104
|
-
await producer.connect();
|
|
105
|
-
return producer;
|
|
106
|
-
})();
|
|
85
|
+
async close() {
|
|
86
|
+
const producer = await this.producerPromise?.catch(() => undefined);
|
|
87
|
+
await producer?.disconnect();
|
|
88
|
+
await Promise.all(this.consumers.map((consumer) => consumer.disconnect()));
|
|
107
89
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
90
|
+
// --- internals -----------------------------------------------------------
|
|
91
|
+
async handle(queue, message) {
|
|
92
|
+
if (message.value === null)
|
|
93
|
+
return;
|
|
94
|
+
const headers = decodeHeaders(message.headers);
|
|
95
|
+
const jobName = headers[HEADER.job] ?? '';
|
|
96
|
+
const value = asString(message.value);
|
|
97
|
+
try {
|
|
98
|
+
await this.executor?.(jobName, JSON.parse(value));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
const attempt = Number(headers[HEADER.attempt] ?? 1);
|
|
102
|
+
// Clamp the max-attempts read from the (untrusted) message to a hard
|
|
103
|
+
// ceiling so a crafted `attempts` can't drive a retry-amplification loop.
|
|
104
|
+
const attempts = Math.min(Number(headers[HEADER.attempts] ?? 1) || 1, MAX_ATTEMPTS);
|
|
105
|
+
const producer = await this.producer();
|
|
106
|
+
if (attempt < attempts) {
|
|
107
|
+
await producer.send({
|
|
108
|
+
topic: this.retryTopic(queue),
|
|
109
|
+
messages: [{ value, headers: { ...headers, [HEADER.attempt]: String(attempt + 1) } }],
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
await producer.send({ topic: this.deadTopic(queue), messages: [{ value, headers }] });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Offsets auto-commit after eachMessage resolves — a re-routed failure is
|
|
117
|
+
// considered handled so the same message is not redelivered by Kafka.
|
|
118
|
+
}
|
|
119
|
+
client() {
|
|
120
|
+
if (!this.clientPromise) {
|
|
121
|
+
this.clientPromise = this.options.client
|
|
122
|
+
? Promise.resolve(this.options.client)
|
|
123
|
+
: defaultClient(this.options);
|
|
124
|
+
}
|
|
125
|
+
return this.clientPromise;
|
|
126
|
+
}
|
|
127
|
+
producer() {
|
|
128
|
+
if (!this.producerPromise) {
|
|
129
|
+
this.producerPromise = (async () => {
|
|
130
|
+
const producer = (await this.client()).producer();
|
|
131
|
+
await producer.connect();
|
|
132
|
+
return producer;
|
|
133
|
+
})();
|
|
134
|
+
}
|
|
135
|
+
return this.producerPromise;
|
|
136
|
+
}
|
|
137
|
+
retryTopic(queue) {
|
|
138
|
+
return `${queue}${this.retrySuffix}`;
|
|
139
|
+
}
|
|
140
|
+
deadTopic(queue) {
|
|
141
|
+
return `${queue}${this.deadSuffix}`;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const asString = (value) => typeof value === 'string' ? value : new TextDecoder().decode(value);
|
|
145
|
+
const decodeHeaders = (headers) => {
|
|
146
|
+
const out = {};
|
|
147
|
+
for (const [key, value] of Object.entries(headers ?? {})) {
|
|
148
|
+
if (value !== undefined)
|
|
149
|
+
out[key] = asString(value);
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
127
152
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basaltkit/queue-kafka",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
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.
|
|
17
|
+
"@basaltkit/queue": "^1.2.1"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
20
|
"kafkajs": "^2.0.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
|
-
"@types/node": "^
|
|
24
|
-
"
|
|
25
|
-
"
|
|
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/
|
|
33
|
+
"url": "git+https://github.com/basaltkit/basalt.git",
|
|
35
34
|
"directory": "packages/queue-kafka"
|
|
36
35
|
},
|
|
37
|
-
"homepage": "https://github.com/
|
|
38
|
-
"bugs": "https://github.com/
|
|
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": "
|
|
46
|
+
"build": "tsc -p tsconfig.build.json",
|
|
48
47
|
"test": "vitest run",
|
|
49
48
|
"typecheck": "tsc --noEmit"
|
|
50
49
|
}
|