@basaltkit/queue-kafka 1.0.1 → 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/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 { 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'. */
@@ -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,128 +1,152 @@
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
+ 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();
86
58
  await producer.send({
87
- topic: this.retryTopic(queue),
88
- messages: [{ value, headers: { ...headers, [HEADER.attempt]: String(attempt + 1) } }]
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
+ ],
89
70
  });
90
- } else {
91
- await producer.send({ topic: this.deadTopic(queue), messages: [{ value, headers }] });
92
- }
93
71
  }
94
- }
95
- client() {
96
- if (!this.clientPromise) {
97
- this.clientPromise = this.options.client ? Promise.resolve(this.options.client) : defaultClient(this.options);
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
+ })();
98
84
  }
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
- })();
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()));
108
89
  }
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
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;
128
152
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/queue-kafka",
3
- "version": "1.0.1",
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.1.0"
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": "^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
  }