@blokjs/trigger-worker 0.2.1 → 0.6.1

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.
Files changed (50) hide show
  1. package/__tests__/integration/nats-adapter.real-nats.test.ts +116 -0
  2. package/__tests__/integration/pgboss-adapter.real-pg.test.ts +164 -0
  3. package/__tests__/integration/rabbitmq-adapter.real-rabbitmq.test.ts +179 -0
  4. package/__tests__/integration/sqs-adapter.real-sqs.test.ts +228 -0
  5. package/dist/WorkerTrigger.d.ts +40 -4
  6. package/dist/WorkerTrigger.js +272 -40
  7. package/dist/adapters/BullMQAdapter.d.ts +1 -1
  8. package/dist/adapters/BullMQAdapter.js +5 -42
  9. package/dist/adapters/InMemoryAdapter.d.ts +1 -1
  10. package/dist/adapters/InMemoryAdapter.js +13 -12
  11. package/dist/adapters/KafkaAdapter.d.ts +62 -0
  12. package/dist/adapters/KafkaAdapter.js +236 -0
  13. package/dist/adapters/NATSAdapter.d.ts +110 -0
  14. package/dist/adapters/NATSAdapter.js +394 -0
  15. package/dist/adapters/PgBossAdapter.d.ts +56 -0
  16. package/dist/adapters/PgBossAdapter.js +251 -0
  17. package/dist/adapters/RabbitMQAdapter.d.ts +51 -0
  18. package/dist/adapters/RabbitMQAdapter.js +241 -0
  19. package/dist/adapters/RedisStreamsAdapter.d.ts +64 -0
  20. package/dist/adapters/RedisStreamsAdapter.js +240 -0
  21. package/dist/adapters/SQSAdapter.d.ts +61 -0
  22. package/dist/adapters/SQSAdapter.js +269 -0
  23. package/dist/adapters/factory.d.ts +34 -0
  24. package/dist/adapters/factory.js +103 -0
  25. package/dist/index.d.ts +25 -7
  26. package/dist/index.js +31 -16
  27. package/package.json +27 -5
  28. package/src/WorkerTrigger.test.ts +44 -14
  29. package/src/WorkerTrigger.ts +299 -27
  30. package/src/adapters/InMemoryAdapter.ts +9 -5
  31. package/src/adapters/KafkaAdapter.ts +277 -0
  32. package/src/adapters/NATSAdapter.ts +454 -0
  33. package/src/adapters/PgBossAdapter.ts +293 -0
  34. package/src/adapters/RabbitMQAdapter.ts +285 -0
  35. package/src/adapters/RedisStreamsAdapter.ts +286 -0
  36. package/src/adapters/SQSAdapter.ts +306 -0
  37. package/src/adapters/factory.test.ts +89 -0
  38. package/src/adapters/factory.ts +111 -0
  39. package/src/adapters/new-adapters.test.ts +130 -0
  40. package/src/index.ts +31 -4
  41. package/template/.env.example +13 -0
  42. package/template/package.json +45 -0
  43. package/template/src/Nodes.ts +10 -0
  44. package/template/src/Workflows.ts +8 -0
  45. package/template/src/index.ts +41 -0
  46. package/template/src/runner/WorkerServer.ts +34 -0
  47. package/template/src/runner/types/Workflows.ts +7 -0
  48. package/template/src/workflows/jobs/process-job.ts +47 -0
  49. package/template/tsconfig.json +31 -0
  50. package/template/vitest.config.ts +39 -0
@@ -0,0 +1,277 @@
1
+ /**
2
+ * KafkaAdapter — v0.7 PR 5 — Worker adapter backed by Apache Kafka via
3
+ * `kafkajs`. Consumes from a topic (the `queue` field) with a
4
+ * consumer-group identifier; produces via the same client.
5
+ *
6
+ * Kafka is fundamentally a streaming platform — not a queue — so a
7
+ * few semantics differ from BullMQ/SQS/RabbitMQ:
8
+ *
9
+ * - **Ordering**: per-partition, not per-topic. Set the partition
10
+ * key via the `dedupId` field on `addJob` to keep related
11
+ * messages on the same partition.
12
+ * - **Retries**: Kafka doesn't have a broker-side retry concept.
13
+ * The adapter re-throws on handler failure; offset commit is
14
+ * suppressed so the consumer re-polls the message on the next
15
+ * cycle. For real retry semantics, layer a dead-letter topic.
16
+ * - **Stats**: KafkaJS exposes consumer-group lag via its admin
17
+ * client; the lag count is reported as `waiting`. Other stats
18
+ * are tracked locally per consumer.
19
+ *
20
+ * Requires `kafkajs` as a peer dependency:
21
+ *
22
+ * bun add kafkajs
23
+ *
24
+ * Environment variables (read at adapter construction):
25
+ * - `KAFKA_BROKERS` — comma-separated list (default `localhost:9092`).
26
+ * - `KAFKA_CLIENT_ID` — client.id (default `"blok-worker"`).
27
+ * - `KAFKA_SASL_USERNAME` — SASL/PLAIN username (optional).
28
+ * - `KAFKA_SASL_PASSWORD` — SASL/PLAIN password (optional).
29
+ * - `KAFKA_SSL` — when `"true"`, enable TLS.
30
+ */
31
+
32
+ import type { WorkerTriggerOpts } from "@blokjs/helper";
33
+ import { v4 as uuid } from "uuid";
34
+ import type { WorkerAdapter, WorkerJob, WorkerQueueStats } from "../WorkerTrigger";
35
+
36
+ export interface KafkaConfig {
37
+ brokers: string[];
38
+ clientId: string;
39
+ saslUsername?: string;
40
+ saslPassword?: string;
41
+ ssl: boolean;
42
+ }
43
+
44
+ interface KafkaJsHandle {
45
+ producer?: {
46
+ connect: () => Promise<void>;
47
+ disconnect: () => Promise<void>;
48
+ send: (args: unknown) => Promise<unknown>;
49
+ };
50
+ consumers: Map<
51
+ string,
52
+ {
53
+ disconnect: () => Promise<void>;
54
+ stop: () => Promise<void>;
55
+ run: (opts: unknown) => Promise<void>;
56
+ }
57
+ >;
58
+ admin?: {
59
+ connect: () => Promise<void>;
60
+ disconnect: () => Promise<void>;
61
+ fetchTopicOffsets: (topic: string) => Promise<Array<{ partition: number; offset: string }>>;
62
+ };
63
+ }
64
+
65
+ interface QueueStatsCounters {
66
+ completed: number;
67
+ failed: number;
68
+ active: number;
69
+ }
70
+
71
+ export class KafkaAdapter implements WorkerAdapter {
72
+ readonly provider = "kafka" as const;
73
+ private readonly config: KafkaConfig;
74
+ // biome-ignore lint/suspicious/noExplicitAny: kafkajs's exported `Kafka` constructor is loosely typed.
75
+ private kafka: any = null;
76
+ private handle: KafkaJsHandle = { consumers: new Map() };
77
+ private connected = false;
78
+ private stats: Map<string, QueueStatsCounters> = new Map();
79
+
80
+ constructor(config?: Partial<KafkaConfig>) {
81
+ this.config = {
82
+ brokers: config?.brokers ?? (process.env.KAFKA_BROKERS ?? "localhost:9092").split(",").map((s) => s.trim()),
83
+ clientId: config?.clientId ?? process.env.KAFKA_CLIENT_ID ?? "blok-worker",
84
+ saslUsername: config?.saslUsername ?? process.env.KAFKA_SASL_USERNAME,
85
+ saslPassword: config?.saslPassword ?? process.env.KAFKA_SASL_PASSWORD,
86
+ ssl: config?.ssl ?? process.env.KAFKA_SSL === "true",
87
+ };
88
+ }
89
+
90
+ async connect(): Promise<void> {
91
+ if (this.connected) return;
92
+ try {
93
+ // biome-ignore lint/suspicious/noExplicitAny: kafkajs is a runtime-loaded peer dep.
94
+ const kafkajs: any = await import("kafkajs");
95
+ const sasl =
96
+ this.config.saslUsername && this.config.saslPassword
97
+ ? { mechanism: "plain", username: this.config.saslUsername, password: this.config.saslPassword }
98
+ : undefined;
99
+ this.kafka = new kafkajs.Kafka({
100
+ clientId: this.config.clientId,
101
+ brokers: this.config.brokers,
102
+ ssl: this.config.ssl,
103
+ sasl,
104
+ });
105
+ this.handle.producer = this.kafka.producer();
106
+ await this.handle.producer?.connect();
107
+ this.handle.admin = this.kafka.admin();
108
+ await this.handle.admin?.connect();
109
+ this.connected = true;
110
+ } catch (err) {
111
+ throw new Error(
112
+ `[blok][kafka] connect failed: ${(err as Error).message}. Install kafkajs as a peer dependency: bun add kafkajs`,
113
+ );
114
+ }
115
+ }
116
+
117
+ async disconnect(): Promise<void> {
118
+ if (!this.connected) return;
119
+ for (const [, consumer] of this.handle.consumers) {
120
+ try {
121
+ await consumer.disconnect();
122
+ } catch {
123
+ /* ignore */
124
+ }
125
+ }
126
+ this.handle.consumers.clear();
127
+ try {
128
+ await this.handle.producer?.disconnect();
129
+ } catch {
130
+ /* ignore */
131
+ }
132
+ try {
133
+ await this.handle.admin?.disconnect();
134
+ } catch {
135
+ /* ignore */
136
+ }
137
+ this.connected = false;
138
+ }
139
+
140
+ async process(config: WorkerTriggerOpts, handler: (job: WorkerJob) => Promise<void>): Promise<void> {
141
+ if (!this.connected) throw new Error("[blok][kafka] not connected. Call connect() first.");
142
+ const groupId = config.consumerGroup ?? `${config.queue}-group`;
143
+ const consumer = this.kafka.consumer({ groupId });
144
+ await consumer.connect();
145
+ await consumer.subscribe({ topic: config.queue, fromBeginning: config.fromBeginning === true });
146
+ this.handle.consumers.set(config.queue, consumer);
147
+ this.stats.set(config.queue, { completed: 0, failed: 0, active: 0 });
148
+ const stats = this.stats.get(config.queue) as QueueStatsCounters;
149
+
150
+ await consumer.run({
151
+ autoCommit: config.ack !== false,
152
+ eachMessage: async ({
153
+ message,
154
+ }: {
155
+ message: { key?: Buffer; value?: Buffer; offset: string; timestamp: string; headers?: Record<string, Buffer> };
156
+ }) => {
157
+ const payloadString = message.value?.toString("utf8") ?? "";
158
+ let data: unknown;
159
+ try {
160
+ data = payloadString.length > 0 ? JSON.parse(payloadString) : null;
161
+ } catch {
162
+ data = payloadString;
163
+ }
164
+ const headers: Record<string, string> = {};
165
+ if (message.headers) {
166
+ for (const [k, v] of Object.entries(message.headers)) headers[k] = v?.toString("utf8") ?? "";
167
+ }
168
+ const job: WorkerJob = {
169
+ id: message.key?.toString("utf8") ?? `${config.queue}:${message.offset}`,
170
+ data,
171
+ headers,
172
+ queue: config.queue,
173
+ priority: config.priority ?? 0,
174
+ attempts: 0,
175
+ maxRetries: config.retries ?? 0,
176
+ createdAt: new Date(Number.parseInt(message.timestamp, 10)),
177
+ timeout: config.timeout,
178
+ raw: message,
179
+ complete: async () => {
180
+ stats.completed += 1;
181
+ },
182
+ fail: async (_err: Error) => {
183
+ stats.failed += 1;
184
+ throw _err;
185
+ },
186
+ };
187
+ stats.active += 1;
188
+ try {
189
+ await handler(job);
190
+ stats.completed += 1;
191
+ } catch (err) {
192
+ stats.failed += 1;
193
+ throw err;
194
+ } finally {
195
+ stats.active = Math.max(0, stats.active - 1);
196
+ }
197
+ },
198
+ });
199
+ }
200
+
201
+ async addJob(
202
+ queue: string,
203
+ data: unknown,
204
+ opts?: { priority?: number; delay?: number; retries?: number; timeout?: number; jobId?: string },
205
+ ): Promise<string> {
206
+ if (!this.connected) throw new Error("[blok][kafka] not connected. Call connect() first.");
207
+ if (!this.handle.producer) throw new Error("[blok][kafka] producer not initialized");
208
+ const key = opts?.jobId ?? uuid();
209
+ const payload = typeof data === "string" ? data : JSON.stringify(data);
210
+ await this.handle.producer.send({
211
+ topic: queue,
212
+ messages: [
213
+ {
214
+ key,
215
+ value: payload,
216
+ headers: opts?.delay ? { "x-blok-delay-ms": String(opts.delay) } : undefined,
217
+ },
218
+ ],
219
+ });
220
+ return key;
221
+ }
222
+
223
+ async stopProcessing(queue: string): Promise<void> {
224
+ const consumer = this.handle.consumers.get(queue);
225
+ if (consumer) {
226
+ try {
227
+ await consumer.stop();
228
+ } catch {
229
+ /* ignore */
230
+ }
231
+ try {
232
+ await consumer.disconnect();
233
+ } catch {
234
+ /* ignore */
235
+ }
236
+ this.handle.consumers.delete(queue);
237
+ }
238
+ }
239
+
240
+ isConnected(): boolean {
241
+ return this.connected;
242
+ }
243
+
244
+ async healthCheck(): Promise<boolean> {
245
+ if (!this.connected || !this.handle.admin) return false;
246
+ try {
247
+ await this.handle.admin.fetchTopicOffsets("__consumer_offsets");
248
+ return true;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ async getQueueStats(queue: string): Promise<WorkerQueueStats> {
255
+ const counters = this.stats.get(queue) ?? { completed: 0, failed: 0, active: 0 };
256
+ let waiting = 0;
257
+ if (this.handle.admin) {
258
+ try {
259
+ const offsets = await this.handle.admin.fetchTopicOffsets(queue);
260
+ // Approximate: total committed offsets across partitions. Real lag
261
+ // requires admin.fetchOffsets({ groupId }) — skipped here to keep
262
+ // the call cheap; production deployments should use Kafka's
263
+ // dedicated lag metrics anyway.
264
+ waiting = offsets.reduce((sum, p) => sum + Number.parseInt(p.offset, 10), 0);
265
+ } catch {
266
+ waiting = 0;
267
+ }
268
+ }
269
+ return {
270
+ waiting,
271
+ active: counters.active,
272
+ completed: counters.completed,
273
+ failed: counters.failed,
274
+ delayed: 0,
275
+ };
276
+ }
277
+ }