@blokjs/trigger-worker 0.2.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.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * BullMQAdapter - Worker adapter using BullMQ (Redis-backed)
3
+ *
4
+ * Features:
5
+ * - Redis-backed persistent job queues
6
+ * - Configurable concurrency per queue
7
+ * - Job priority support
8
+ * - Delayed job scheduling
9
+ * - Automatic retries with configurable backoff
10
+ * - Queue statistics
11
+ *
12
+ * Requires: bullmq and ioredis as peer dependencies
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const adapter = new BullMQAdapter({
17
+ * host: "localhost",
18
+ * port: 6379,
19
+ * });
20
+ * ```
21
+ */
22
+
23
+ import type { WorkerAdapter, WorkerJob, WorkerQueueStats } from "../WorkerTrigger";
24
+
25
+ import type { WorkerTriggerOpts } from "@blokjs/helper";
26
+
27
+ /**
28
+ * BullMQ adapter configuration
29
+ */
30
+ export interface BullMQConfig {
31
+ /** Redis host (default: from REDIS_HOST env or "localhost") */
32
+ host: string;
33
+ /** Redis port (default: from REDIS_PORT env or 6379) */
34
+ port: number;
35
+ /** Redis password (default: from REDIS_PASSWORD env) */
36
+ password?: string;
37
+ /** Redis database (default: from REDIS_DB env or 0) */
38
+ db?: number;
39
+ /** Key prefix for all BullMQ keys */
40
+ prefix?: string;
41
+ /** Max stalled count before job fails */
42
+ maxStalledCount?: number;
43
+ /** Stalled interval in ms */
44
+ stalledInterval?: number;
45
+ }
46
+
47
+ /**
48
+ * BullMQ Worker Adapter
49
+ *
50
+ * Uses BullMQ for robust, Redis-backed job processing with support for
51
+ * priority queues, delayed jobs, retries, and dead letter handling.
52
+ */
53
+ export class BullMQAdapter implements WorkerAdapter {
54
+ readonly provider = "bullmq" as const;
55
+ private connection: unknown = null;
56
+ private workers: Map<string, unknown> = new Map();
57
+ private queues: Map<string, unknown> = new Map();
58
+ private connected = false;
59
+ private readonly config: BullMQConfig;
60
+
61
+ constructor(config?: Partial<BullMQConfig>) {
62
+ this.config = {
63
+ host: config?.host || process.env.REDIS_HOST || "localhost",
64
+ port: config?.port ?? Number.parseInt(process.env.REDIS_PORT || "6379", 10),
65
+ password: config?.password || process.env.REDIS_PASSWORD,
66
+ db: config?.db ?? Number.parseInt(process.env.REDIS_DB || "0", 10),
67
+ prefix: config?.prefix || "blok-worker",
68
+ maxStalledCount: config?.maxStalledCount ?? 2,
69
+ stalledInterval: config?.stalledInterval ?? 5000,
70
+ };
71
+ }
72
+
73
+ async connect(): Promise<void> {
74
+ if (this.connected) return;
75
+
76
+ try {
77
+ const { default: IORedis } = await import("ioredis");
78
+ this.connection = new IORedis({
79
+ host: this.config.host,
80
+ port: this.config.port,
81
+ password: this.config.password,
82
+ db: this.config.db,
83
+ maxRetriesPerRequest: null, // Required for BullMQ
84
+ });
85
+
86
+ // Verify connection
87
+ await (this.connection as { ping: () => Promise<string> }).ping();
88
+ this.connected = true;
89
+ console.log(`[BullMQAdapter] Connected to Redis at ${this.config.host}:${this.config.port}`);
90
+ } catch (error) {
91
+ throw new Error(
92
+ `Failed to connect to Redis: ${(error as Error).message}. Ensure ioredis and bullmq are installed: npm install ioredis bullmq`,
93
+ );
94
+ }
95
+ }
96
+
97
+ async disconnect(): Promise<void> {
98
+ if (!this.connected) return;
99
+
100
+ try {
101
+ // Close all workers
102
+ for (const [, worker] of this.workers) {
103
+ await (worker as { close: () => Promise<void> }).close();
104
+ }
105
+ this.workers.clear();
106
+
107
+ // Close all queues
108
+ for (const [, queue] of this.queues) {
109
+ await (queue as { close: () => Promise<void> }).close();
110
+ }
111
+ this.queues.clear();
112
+
113
+ // Close Redis connection
114
+ if (this.connection) {
115
+ await (this.connection as { quit: () => Promise<string> }).quit();
116
+ }
117
+
118
+ this.connected = false;
119
+ console.log("[BullMQAdapter] Disconnected from Redis");
120
+ } catch (error) {
121
+ console.error(`[BullMQAdapter] Disconnect error: ${(error as Error).message}`);
122
+ }
123
+ }
124
+
125
+ async process(config: WorkerTriggerOpts, handler: (job: WorkerJob) => Promise<void>): Promise<void> {
126
+ if (!this.connected) {
127
+ throw new Error("Not connected. Call connect() first.");
128
+ }
129
+
130
+ try {
131
+ // Dynamic import to avoid hard dependency on bullmq
132
+ const bullmq = await import("bullmq");
133
+ const BullWorker = bullmq.Worker;
134
+ const BullQueue = bullmq.Queue;
135
+
136
+ // Build worker options
137
+ const workerOpts = {
138
+ connection: this.connection,
139
+ concurrency: config.concurrency ?? 1,
140
+ prefix: this.config.prefix,
141
+ stalledInterval: this.config.stalledInterval ?? 5000,
142
+ maxStalledCount: this.config.maxStalledCount ?? 2,
143
+ };
144
+
145
+ const worker = new BullWorker(
146
+ config.queue,
147
+ // BullMQ processor callback
148
+ ((bullJob: unknown) => {
149
+ const job = bullJob as {
150
+ id?: string;
151
+ data: unknown;
152
+ opts?: { priority?: number; delay?: number };
153
+ attemptsMade: number;
154
+ timestamp: number;
155
+ token?: string;
156
+ moveToFailed: (err: Error, token: string, fetchNext: boolean) => Promise<void>;
157
+ };
158
+ const workerJob: WorkerJob = {
159
+ id: job.id || `job-${Date.now()}`,
160
+ data: job.data,
161
+ headers: ((job.data as Record<string, unknown>)?._headers as Record<string, string>) || {},
162
+ queue: config.queue,
163
+ priority: job.opts?.priority ?? config.priority ?? 0,
164
+ attempts: job.attemptsMade,
165
+ maxRetries: config.retries ?? 3,
166
+ createdAt: new Date(job.timestamp),
167
+ delay: job.opts?.delay,
168
+ timeout: config.timeout,
169
+ raw: job,
170
+ complete: async () => {
171
+ // BullMQ auto-completes when processor resolves
172
+ },
173
+ fail: async (error: Error, requeue?: boolean) => {
174
+ if (!requeue) {
175
+ await job.moveToFailed(error, job.token || "", true);
176
+ } else {
177
+ throw error; // BullMQ will auto-retry
178
+ }
179
+ },
180
+ };
181
+ return handler(workerJob);
182
+ }) as never,
183
+ workerOpts as never,
184
+ );
185
+
186
+ this.workers.set(config.queue, worker);
187
+
188
+ // Ensure queue object exists for job dispatching
189
+ if (!this.queues.has(config.queue)) {
190
+ const queue = new BullQueue(config.queue, {
191
+ connection: this.connection as { host: string; port: number },
192
+ prefix: this.config.prefix,
193
+ } as never);
194
+ this.queues.set(config.queue, queue);
195
+ }
196
+
197
+ console.log(`[BullMQAdapter] Processing queue: ${config.queue} (concurrency=${config.concurrency ?? 1})`);
198
+ } catch (error) {
199
+ throw new Error(`Failed to start processing: ${(error as Error).message}`);
200
+ }
201
+ }
202
+
203
+ async addJob(
204
+ queue: string,
205
+ data: unknown,
206
+ opts?: {
207
+ priority?: number;
208
+ delay?: number;
209
+ retries?: number;
210
+ timeout?: number;
211
+ jobId?: string;
212
+ },
213
+ ): Promise<string> {
214
+ if (!this.connected) {
215
+ throw new Error("Not connected. Call connect() first.");
216
+ }
217
+
218
+ try {
219
+ // Ensure queue exists
220
+ if (!this.queues.has(queue)) {
221
+ const { Queue } = await import("bullmq");
222
+ const q = new Queue(queue, {
223
+ connection: this.connection as { host: string; port: number },
224
+ prefix: this.config.prefix,
225
+ });
226
+ this.queues.set(queue, q);
227
+ }
228
+
229
+ const q = this.queues.get(queue) as {
230
+ add: (name: string, data: unknown, opts: Record<string, unknown>) => Promise<{ id: string }>;
231
+ };
232
+
233
+ const job = await q.add("process", data, {
234
+ priority: opts?.priority,
235
+ delay: opts?.delay,
236
+ attempts: (opts?.retries ?? 3) + 1,
237
+ jobId: opts?.jobId,
238
+ backoff: {
239
+ type: "exponential",
240
+ delay: 1000,
241
+ },
242
+ });
243
+
244
+ return job.id;
245
+ } catch (error) {
246
+ throw new Error(`Failed to add job: ${(error as Error).message}`);
247
+ }
248
+ }
249
+
250
+ async stopProcessing(queue: string): Promise<void> {
251
+ const worker = this.workers.get(queue);
252
+ if (worker) {
253
+ await (worker as { close: () => Promise<void> }).close();
254
+ this.workers.delete(queue);
255
+ console.log(`[BullMQAdapter] Stopped processing queue: ${queue}`);
256
+ }
257
+ }
258
+
259
+ isConnected(): boolean {
260
+ return this.connected;
261
+ }
262
+
263
+ async healthCheck(): Promise<boolean> {
264
+ if (!this.connected || !this.connection) return false;
265
+ try {
266
+ await (this.connection as { ping: () => Promise<string> }).ping();
267
+ return true;
268
+ } catch {
269
+ return false;
270
+ }
271
+ }
272
+
273
+ async getQueueStats(queue: string): Promise<WorkerQueueStats> {
274
+ if (!this.queues.has(queue)) {
275
+ return { waiting: 0, active: 0, completed: 0, failed: 0, delayed: 0 };
276
+ }
277
+
278
+ const q = this.queues.get(queue) as {
279
+ getWaitingCount: () => Promise<number>;
280
+ getActiveCount: () => Promise<number>;
281
+ getCompletedCount: () => Promise<number>;
282
+ getFailedCount: () => Promise<number>;
283
+ getDelayedCount: () => Promise<number>;
284
+ };
285
+
286
+ const [waiting, active, completed, failed, delayed] = await Promise.all([
287
+ q.getWaitingCount(),
288
+ q.getActiveCount(),
289
+ q.getCompletedCount(),
290
+ q.getFailedCount(),
291
+ q.getDelayedCount(),
292
+ ]);
293
+
294
+ return { waiting, active, completed, failed, delayed };
295
+ }
296
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * InMemoryAdapter - Worker adapter using in-process queues
3
+ *
4
+ * Ideal for:
5
+ * - Development and testing
6
+ * - Simple background job processing
7
+ * - Single-instance deployments
8
+ *
9
+ * Limitations:
10
+ * - Jobs are lost on process restart
11
+ * - No distributed processing
12
+ * - No persistence
13
+ *
14
+ * @example
15
+ * ```typescript
16
+ * const adapter = new InMemoryAdapter();
17
+ * ```
18
+ */
19
+
20
+ import type { WorkerTriggerOpts } from "@blokjs/helper";
21
+ import { v4 as uuid } from "uuid";
22
+ import type { WorkerAdapter, WorkerJob, WorkerQueueStats } from "../WorkerTrigger";
23
+
24
+ /**
25
+ * Internal job representation
26
+ */
27
+ interface InternalJob {
28
+ id: string;
29
+ data: unknown;
30
+ queue: string;
31
+ priority: number;
32
+ attempts: number;
33
+ maxRetries: number;
34
+ createdAt: Date;
35
+ delay: number;
36
+ timeout: number;
37
+ status: "waiting" | "active" | "completed" | "failed" | "delayed";
38
+ scheduledAt?: Date;
39
+ error?: Error;
40
+ }
41
+
42
+ /**
43
+ * Queue processor entry
44
+ */
45
+ interface QueueProcessor {
46
+ config: WorkerTriggerOpts;
47
+ handler: (job: WorkerJob) => Promise<void>;
48
+ active: number;
49
+ running: boolean;
50
+ timer?: ReturnType<typeof setInterval>;
51
+ }
52
+
53
+ /**
54
+ * InMemoryAdapter - Simple in-process worker queue
55
+ */
56
+ export class InMemoryAdapter implements WorkerAdapter {
57
+ readonly provider = "in-memory" as const;
58
+ private connected = false;
59
+ private jobs: Map<string, InternalJob[]> = new Map();
60
+ private processors: Map<string, QueueProcessor> = new Map();
61
+ private stats: Map<string, { completed: number; failed: number }> = new Map();
62
+
63
+ async connect(): Promise<void> {
64
+ this.connected = true;
65
+ }
66
+
67
+ async disconnect(): Promise<void> {
68
+ // Stop all processors
69
+ for (const [queue, processor] of this.processors) {
70
+ processor.running = false;
71
+ if (processor.timer) {
72
+ clearInterval(processor.timer);
73
+ }
74
+ }
75
+ this.processors.clear();
76
+ this.jobs.clear();
77
+ this.stats.clear();
78
+ this.connected = false;
79
+ }
80
+
81
+ async process(config: WorkerTriggerOpts, handler: (job: WorkerJob) => Promise<void>): Promise<void> {
82
+ if (!this.connected) {
83
+ throw new Error("Not connected. Call connect() first.");
84
+ }
85
+
86
+ if (!this.jobs.has(config.queue)) {
87
+ this.jobs.set(config.queue, []);
88
+ }
89
+ if (!this.stats.has(config.queue)) {
90
+ this.stats.set(config.queue, { completed: 0, failed: 0 });
91
+ }
92
+
93
+ const processor: QueueProcessor = {
94
+ config,
95
+ handler,
96
+ active: 0,
97
+ running: true,
98
+ };
99
+
100
+ this.processors.set(config.queue, processor);
101
+
102
+ // Start polling for jobs
103
+ processor.timer = setInterval(() => {
104
+ this.processNext(config.queue).catch((err) => {
105
+ console.error(`[InMemoryAdapter] Error processing ${config.queue}: ${(err as Error).message}`);
106
+ });
107
+ }, 50); // Poll every 50ms
108
+ }
109
+
110
+ async addJob(
111
+ queue: string,
112
+ data: unknown,
113
+ opts?: {
114
+ priority?: number;
115
+ delay?: number;
116
+ retries?: number;
117
+ timeout?: number;
118
+ jobId?: string;
119
+ },
120
+ ): Promise<string> {
121
+ if (!this.connected) {
122
+ throw new Error("Not connected. Call connect() first.");
123
+ }
124
+
125
+ if (!this.jobs.has(queue)) {
126
+ this.jobs.set(queue, []);
127
+ }
128
+ if (!this.stats.has(queue)) {
129
+ this.stats.set(queue, { completed: 0, failed: 0 });
130
+ }
131
+
132
+ const job: InternalJob = {
133
+ id: opts?.jobId || uuid(),
134
+ data,
135
+ queue,
136
+ priority: opts?.priority ?? 0,
137
+ attempts: 0,
138
+ maxRetries: opts?.retries ?? 3,
139
+ createdAt: new Date(),
140
+ delay: opts?.delay ?? 0,
141
+ timeout: opts?.timeout ?? 0,
142
+ status: opts?.delay && opts.delay > 0 ? "delayed" : "waiting",
143
+ };
144
+
145
+ if (job.status === "delayed") {
146
+ job.scheduledAt = new Date(Date.now() + job.delay);
147
+ }
148
+
149
+ const jobs = this.jobs.get(queue)!;
150
+
151
+ // Insert sorted by priority (higher first)
152
+ const insertIdx = jobs.findIndex((j) => j.status === "waiting" && j.priority < job.priority);
153
+ if (insertIdx >= 0) {
154
+ jobs.splice(insertIdx, 0, job);
155
+ } else {
156
+ jobs.push(job);
157
+ }
158
+
159
+ return job.id;
160
+ }
161
+
162
+ async stopProcessing(queue: string): Promise<void> {
163
+ const processor = this.processors.get(queue);
164
+ if (processor) {
165
+ processor.running = false;
166
+ if (processor.timer) {
167
+ clearInterval(processor.timer);
168
+ }
169
+ this.processors.delete(queue);
170
+ }
171
+ }
172
+
173
+ isConnected(): boolean {
174
+ return this.connected;
175
+ }
176
+
177
+ async healthCheck(): Promise<boolean> {
178
+ return this.connected;
179
+ }
180
+
181
+ async getQueueStats(queue: string): Promise<WorkerQueueStats> {
182
+ const jobs = this.jobs.get(queue) || [];
183
+ const queueStats = this.stats.get(queue) || { completed: 0, failed: 0 };
184
+
185
+ return {
186
+ waiting: jobs.filter((j) => j.status === "waiting").length,
187
+ active: jobs.filter((j) => j.status === "active").length,
188
+ completed: queueStats.completed,
189
+ failed: queueStats.failed,
190
+ delayed: jobs.filter((j) => j.status === "delayed").length,
191
+ };
192
+ }
193
+
194
+ /**
195
+ * Process the next available job from a queue
196
+ */
197
+ private async processNext(queue: string): Promise<void> {
198
+ const processor = this.processors.get(queue);
199
+ if (!processor || !processor.running) return;
200
+
201
+ const concurrency = processor.config.concurrency ?? 1;
202
+ if (processor.active >= concurrency) return;
203
+
204
+ const jobs = this.jobs.get(queue);
205
+ if (!jobs || jobs.length === 0) return;
206
+
207
+ // Check for delayed jobs that are ready
208
+ const now = Date.now();
209
+ for (const job of jobs) {
210
+ if (job.status === "delayed" && job.scheduledAt && job.scheduledAt.getTime() <= now) {
211
+ job.status = "waiting";
212
+ }
213
+ }
214
+
215
+ // Find next waiting job
216
+ const jobIdx = jobs.findIndex((j) => j.status === "waiting");
217
+ if (jobIdx < 0) return;
218
+
219
+ const internalJob = jobs[jobIdx];
220
+ internalJob.status = "active";
221
+ processor.active++;
222
+
223
+ const workerJob: WorkerJob = {
224
+ id: internalJob.id,
225
+ data: internalJob.data,
226
+ headers: {},
227
+ queue: internalJob.queue,
228
+ priority: internalJob.priority,
229
+ attempts: internalJob.attempts,
230
+ maxRetries: internalJob.maxRetries,
231
+ createdAt: internalJob.createdAt,
232
+ delay: internalJob.delay,
233
+ timeout: internalJob.timeout,
234
+ raw: internalJob,
235
+ complete: async () => {
236
+ internalJob.status = "completed";
237
+ const idx = jobs.indexOf(internalJob);
238
+ if (idx >= 0) jobs.splice(idx, 1);
239
+ const s = this.stats.get(queue);
240
+ if (s) s.completed++;
241
+ },
242
+ fail: async (error: Error, requeue?: boolean) => {
243
+ internalJob.attempts++;
244
+ internalJob.error = error;
245
+
246
+ if (requeue && internalJob.attempts < internalJob.maxRetries) {
247
+ // Requeue with backoff
248
+ const backoff = Math.min(1000 * Math.pow(2, internalJob.attempts), 30000);
249
+ internalJob.status = "delayed";
250
+ internalJob.scheduledAt = new Date(Date.now() + backoff);
251
+ } else {
252
+ internalJob.status = "failed";
253
+ const idx = jobs.indexOf(internalJob);
254
+ if (idx >= 0) jobs.splice(idx, 1);
255
+ const s = this.stats.get(queue);
256
+ if (s) s.failed++;
257
+ }
258
+ },
259
+ };
260
+
261
+ try {
262
+ await processor.handler(workerJob);
263
+ } catch {
264
+ // Handler threw - treat as failure
265
+ if (internalJob.status === "active") {
266
+ internalJob.status = "failed";
267
+ const idx = jobs.indexOf(internalJob);
268
+ if (idx >= 0) jobs.splice(idx, 1);
269
+ const s = this.stats.get(queue);
270
+ if (s) s.failed++;
271
+ }
272
+ } finally {
273
+ processor.active--;
274
+ }
275
+ }
276
+ }
package/src/index.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @blokjs/trigger-worker
3
+ *
4
+ * Worker-based trigger for Blok workflows.
5
+ * Supports background job processing with:
6
+ * - Configurable concurrency per queue
7
+ * - Automatic retries with exponential backoff
8
+ * - Job timeouts
9
+ * - Priority-based job ordering
10
+ * - Delayed job scheduling
11
+ * - Queue statistics and monitoring
12
+ *
13
+ * Adapters:
14
+ * - BullMQ (Redis-backed, production)
15
+ * - InMemory (development/testing)
16
+ *
17
+ * @example BullMQ
18
+ * ```typescript
19
+ * import { WorkerTrigger, BullMQAdapter } from "@blokjs/trigger-worker";
20
+ *
21
+ * class MyWorkerTrigger extends WorkerTrigger {
22
+ * protected adapter = new BullMQAdapter({
23
+ * host: "localhost",
24
+ * port: 6379,
25
+ * });
26
+ *
27
+ * protected nodes = myNodes;
28
+ * protected workflows = myWorkflows;
29
+ * }
30
+ *
31
+ * const trigger = new MyWorkerTrigger();
32
+ * await trigger.listen();
33
+ *
34
+ * // Dispatch a job
35
+ * await trigger.dispatch("background-jobs", { userId: "123" }, {
36
+ * priority: 10,
37
+ * retries: 3,
38
+ * delay: 5000, // delay 5 seconds
39
+ * });
40
+ * ```
41
+ *
42
+ * @example InMemory (development)
43
+ * ```typescript
44
+ * import { WorkerTrigger, InMemoryAdapter } from "@blokjs/trigger-worker";
45
+ *
46
+ * class DevWorkerTrigger extends WorkerTrigger {
47
+ * protected adapter = new InMemoryAdapter();
48
+ * protected nodes = myNodes;
49
+ * protected workflows = myWorkflows;
50
+ * }
51
+ * ```
52
+ */
53
+
54
+ // Core exports
55
+ export {
56
+ WorkerTrigger,
57
+ type WorkerAdapter,
58
+ type WorkerJob,
59
+ type WorkerQueueStats,
60
+ } from "./WorkerTrigger";
61
+
62
+ // Adapters
63
+ export { BullMQAdapter, type BullMQConfig } from "./adapters/BullMQAdapter";
64
+ export { InMemoryAdapter } from "./adapters/InMemoryAdapter";
65
+
66
+ // Re-export types from helper for convenience
67
+ export type { WorkerTriggerOpts } from "@blokjs/helper";
package/tsconfig.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "ts-node": {
3
+ "transpileOnly": true
4
+ },
5
+ "compilerOptions": {
6
+ "target": "ES2022",
7
+ "module": "es2022",
8
+ "lib": ["ES2022"],
9
+ "declaration": true,
10
+ "strict": true,
11
+ "noImplicitAny": true,
12
+ "strictNullChecks": true,
13
+ "noImplicitThis": true,
14
+ "alwaysStrict": true,
15
+ "noUnusedLocals": false,
16
+ "noUnusedParameters": false,
17
+ "noImplicitReturns": true,
18
+ "noFallthroughCasesInSwitch": false,
19
+ "inlineSourceMap": true,
20
+ "inlineSources": true,
21
+ "experimentalDecorators": true,
22
+ "emitDecoratorMetadata": true,
23
+ "skipLibCheck": true,
24
+ "esModuleInterop": true,
25
+ "resolveJsonModule": true,
26
+ "outDir": "./dist",
27
+ "rootDir": "./src",
28
+ "moduleResolution": "bundler"
29
+ },
30
+ "include": ["src/**/*"],
31
+ "exclude": ["node_modules", "dist", "**/*.test.ts"]
32
+ }