@zintrust/workers 0.1.31 → 0.1.52

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 (56) hide show
  1. package/dist/ClusterLock.js +3 -2
  2. package/dist/DeadLetterQueue.js +3 -2
  3. package/dist/HealthMonitor.js +24 -13
  4. package/dist/Observability.js +8 -0
  5. package/dist/WorkerFactory.d.ts +4 -0
  6. package/dist/WorkerFactory.js +409 -42
  7. package/dist/WorkerInit.js +122 -43
  8. package/dist/WorkerMetrics.js +5 -1
  9. package/dist/WorkerRegistry.js +8 -0
  10. package/dist/WorkerShutdown.d.ts +0 -13
  11. package/dist/WorkerShutdown.js +1 -44
  12. package/dist/build-manifest.json +101 -85
  13. package/dist/config/workerConfig.d.ts +1 -0
  14. package/dist/config/workerConfig.js +7 -1
  15. package/dist/createQueueWorker.js +281 -42
  16. package/dist/dashboard/workers-api.js +8 -1
  17. package/dist/http/WorkerController.js +90 -35
  18. package/dist/http/WorkerMonitoringService.js +29 -2
  19. package/dist/http/middleware/FeaturesValidator.js +5 -4
  20. package/dist/index.d.ts +1 -2
  21. package/dist/index.js +0 -1
  22. package/dist/routes/workers.js +10 -7
  23. package/dist/storage/WorkerStore.d.ts +6 -3
  24. package/dist/storage/WorkerStore.js +16 -0
  25. package/dist/telemetry/api/TelemetryMonitoringService.js +29 -2
  26. package/dist/ui/router/ui.js +58 -29
  27. package/dist/ui/workers/index.html +202 -0
  28. package/dist/ui/workers/main.js +1952 -0
  29. package/dist/ui/workers/styles.css +1350 -0
  30. package/dist/ui/workers/zintrust.svg +30 -0
  31. package/package.json +5 -5
  32. package/src/ClusterLock.ts +13 -7
  33. package/src/ComplianceManager.ts +3 -2
  34. package/src/DeadLetterQueue.ts +6 -4
  35. package/src/HealthMonitor.ts +33 -17
  36. package/src/Observability.ts +11 -0
  37. package/src/WorkerFactory.ts +480 -43
  38. package/src/WorkerInit.ts +167 -48
  39. package/src/WorkerMetrics.ts +14 -8
  40. package/src/WorkerRegistry.ts +11 -0
  41. package/src/WorkerShutdown.ts +1 -69
  42. package/src/config/workerConfig.ts +9 -1
  43. package/src/createQueueWorker.ts +428 -43
  44. package/src/dashboard/workers-api.ts +8 -1
  45. package/src/http/WorkerController.ts +111 -36
  46. package/src/http/WorkerMonitoringService.ts +35 -2
  47. package/src/http/middleware/FeaturesValidator.ts +8 -19
  48. package/src/index.ts +2 -3
  49. package/src/routes/workers.ts +10 -8
  50. package/src/storage/WorkerStore.ts +21 -3
  51. package/src/telemetry/api/TelemetryMonitoringService.ts +35 -2
  52. package/src/types/queue-monitor.d.ts +2 -1
  53. package/src/ui/components/WorkerExpandPanel.js +0 -8
  54. package/src/ui/router/EmbeddedAssets.ts +3 -0
  55. package/src/ui/router/ui.ts +57 -39
  56. package/src/WorkerShutdownDurableObject.ts +0 -64
@@ -1,5 +1,116 @@
1
1
  import type { BullMQPayload, QueueMessage } from '@zintrust/core';
2
- import { Logger, Queue } from '@zintrust/core';
2
+ import * as Core from '@zintrust/core';
3
+ import { Env, Logger, Queue } from '@zintrust/core';
4
+
5
+ const RETRY_BASE_DELAY_MS = 1000;
6
+ const RETRY_MAX_DELAY_MS = 30000;
7
+
8
+ const getJobStateTracker = (): unknown => {
9
+ try {
10
+ return (Core as Record<string, unknown>)['JobStateTracker'];
11
+ } catch {
12
+ return undefined;
13
+ }
14
+ };
15
+
16
+ const getJobHeartbeatStore = (): unknown => {
17
+ try {
18
+ return (Core as Record<string, unknown>)['JobHeartbeatStore'];
19
+ } catch {
20
+ return undefined;
21
+ }
22
+ };
23
+
24
+ const getTimeoutManager = (): unknown => {
25
+ try {
26
+ return (Core as Record<string, unknown>)['TimeoutManager'];
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ };
31
+
32
+ const getEnvInt = (key: string, fallback: number): number => {
33
+ const getter = (Env as { getInt?: (name: string, defaultValue: number) => number }).getInt;
34
+ if (typeof getter === 'function') {
35
+ return getter(key, fallback);
36
+ }
37
+
38
+ const raw = (Env as Record<string, unknown>)[key];
39
+ if (typeof raw === 'number' && Number.isFinite(raw)) return Math.floor(raw);
40
+ if (typeof raw === 'string' && raw.trim() !== '') {
41
+ const parsed = Number(raw);
42
+ if (Number.isFinite(parsed)) return Math.floor(parsed);
43
+ }
44
+ return fallback;
45
+ };
46
+
47
+ const resolveQueueJobTimeoutMs = (): number => {
48
+ const timeoutManager = getTimeoutManager();
49
+ const tm = (timeoutManager ?? {}) as { getQueueJobTimeoutMs?: () => number };
50
+ if (typeof tm.getQueueJobTimeoutMs === 'function') {
51
+ return tm.getQueueJobTimeoutMs();
52
+ }
53
+ return Math.max(1000, getEnvInt('QUEUE_JOB_TIMEOUT', 60) * 1000);
54
+ };
55
+
56
+ const runWithTimeout = async <T>(
57
+ operation: () => Promise<T>,
58
+ timeoutMs: number,
59
+ operationName: string
60
+ ): Promise<T> => {
61
+ const timeoutManager = getTimeoutManager();
62
+ const tm = (timeoutManager ?? {}) as {
63
+ withTimeout?: <R>(
64
+ op: () => Promise<R>,
65
+ t: number,
66
+ name: string,
67
+ timeoutHandler?: () => Promise<R>
68
+ ) => Promise<R>;
69
+ };
70
+ if (typeof tm.withTimeout === 'function') {
71
+ return tm.withTimeout(operation, timeoutMs, operationName);
72
+ }
73
+ return operation();
74
+ };
75
+
76
+ const isTimeoutError = (error: unknown): boolean => {
77
+ const timeoutManager = getTimeoutManager();
78
+ const tm = (timeoutManager ?? {}) as { isTimeoutError?: (value: unknown) => boolean };
79
+ if (typeof tm.isTimeoutError === 'function') {
80
+ return tm.isTimeoutError(error);
81
+ }
82
+ if (error instanceof Error) {
83
+ return error.message.toLowerCase().includes('timed out');
84
+ }
85
+ return false;
86
+ };
87
+
88
+ const normalizeAttempts = (value: unknown): number => {
89
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0) {
90
+ return Math.floor(value);
91
+ }
92
+ return 0;
93
+ };
94
+
95
+ const getAttemptsFromMessage = <TPayload>(message: QueueMessage<TPayload>): number => {
96
+ const payloadAttempts =
97
+ typeof message.payload === 'object' && message.payload !== null
98
+ ? normalizeAttempts((message.payload as Record<string, unknown>)['attempts'])
99
+ : 0;
100
+ const messageAttempts = normalizeAttempts(
101
+ (message as QueueMessage<TPayload> & { attempts?: number }).attempts
102
+ );
103
+ return Math.max(payloadAttempts, messageAttempts);
104
+ };
105
+
106
+ const getRetryDelayMs = (nextAttempts: number): number => {
107
+ const exponentialDelay = Math.min(
108
+ RETRY_BASE_DELAY_MS * 2 ** Math.max(0, nextAttempts - 1),
109
+ RETRY_MAX_DELAY_MS
110
+ );
111
+ const jitterMs = Math.floor(Math.random() * 250); // NOSONAR
112
+ return exponentialDelay + jitterMs;
113
+ };
3
114
 
4
115
  type QueueWorker = {
5
116
  processOne: (queueName?: string, driverName?: string) => Promise<boolean>;
@@ -36,58 +147,332 @@ const buildBaseLogFields = <TPayload>(
36
147
  };
37
148
  };
38
149
 
39
- const createProcessOne = <TPayload>(
40
- options: CreateQueueWorkerOptions<TPayload>
41
- ): ((queueName?: string, driverName?: string) => Promise<boolean>) => {
42
- return async (queueName = options.defaultQueueName, driverName?: string): Promise<boolean> => {
43
- const message = await Queue.dequeue<TPayload>(queueName, driverName);
44
- if (!message) return false;
150
+ type TrackerApi = {
151
+ started?: (input: {
152
+ queueName: string;
153
+ jobId: string;
154
+ attempts: number;
155
+ timeoutMs?: number;
156
+ workerName?: string;
157
+ workerInstanceId?: string;
158
+ }) => Promise<void>;
159
+ heartbeat?: (input: {
160
+ queueName: string;
161
+ jobId: string;
162
+ workerInstanceId?: string;
163
+ }) => Promise<void>;
164
+ completed?: (input: {
165
+ queueName: string;
166
+ jobId: string;
167
+ processingTimeMs?: number;
168
+ }) => Promise<void>;
169
+ timedOut?: (input: {
170
+ queueName: string;
171
+ jobId: string;
172
+ reason?: string;
173
+ error?: unknown;
174
+ }) => Promise<void>;
175
+ failed?: (input: {
176
+ queueName: string;
177
+ jobId: string;
178
+ attempts?: number;
179
+ isFinal?: boolean;
180
+ retryAt?: string;
181
+ error?: unknown;
182
+ }) => Promise<void>;
183
+ };
184
+
185
+ type HeartbeatStoreApi = {
186
+ heartbeat?: (input: {
187
+ queueName: string;
188
+ jobId: string;
189
+ workerInstanceId?: string;
190
+ intervalMs?: number;
191
+ }) => Promise<void>;
192
+ remove?: (queueName: string, jobId: string) => Promise<void>;
193
+ };
45
194
 
46
- const baseLogFields = buildBaseLogFields(message, options.getLogFields);
195
+ const getWorkerInstanceId = (): string | undefined => {
196
+ return typeof (Env as Record<string, unknown>)['WORKER_INSTANCE_ID'] === 'string'
197
+ ? String((Env as Record<string, unknown>)['WORKER_INSTANCE_ID'])
198
+ : undefined;
199
+ };
200
+
201
+ const getTrackerApi = (): TrackerApi => {
202
+ return ((getJobStateTracker() ?? {}) as TrackerApi) ?? {};
203
+ };
204
+
205
+ const getHeartbeatStoreApi = (): HeartbeatStoreApi => {
206
+ return ((getJobHeartbeatStore() ?? {}) as HeartbeatStoreApi) ?? {};
207
+ };
47
208
 
48
- // Check for delayed execution
49
- const payload = message.payload as Record<string, unknown> & { timestamp?: number };
50
- const rawTimestamp = 'timestamp' in payload ? payload['timestamp'] : 0;
51
- const timestamp = typeof rawTimestamp === 'number' ? rawTimestamp : 0;
209
+ const removeHeartbeatIfSupported = async (queueName: string, jobId: string): Promise<void> => {
210
+ const heartbeatStore = getHeartbeatStoreApi();
211
+ if (typeof heartbeatStore.remove === 'function') {
212
+ await heartbeatStore.remove(queueName, jobId);
213
+ }
214
+ };
52
215
 
53
- if (timestamp > Date.now()) {
54
- Logger.info(`${options.kindLabel} not due yet, re-queueing`, {
55
- ...baseLogFields,
56
- dueAt: new Date(timestamp).toISOString(),
216
+ const scheduleHeartbeatLoop = (
217
+ trackerApi: TrackerApi,
218
+ queueName: string,
219
+ jobId: string,
220
+ workerInstanceId: string | undefined,
221
+ heartbeatIntervalMs: number
222
+ ): ReturnType<typeof setInterval> => {
223
+ return setInterval(() => {
224
+ if (typeof trackerApi.heartbeat === 'function') {
225
+ void trackerApi.heartbeat({
226
+ queueName,
227
+ jobId,
228
+ workerInstanceId,
57
229
  });
58
- // Re-queue original payload
59
- await Queue.enqueue(queueName, message.payload as BullMQPayload, driverName);
60
- await Queue.ack(queueName, message.id, driverName);
61
- return false;
62
230
  }
63
231
 
64
- try {
65
- Logger.info(`Processing queued ${options.kindLabel}`, baseLogFields);
66
- await options.handle(message.payload);
67
- await Queue.ack(queueName, message.id, driverName);
68
- Logger.info(`${options.kindLabel} processed successfully`, baseLogFields);
69
- return true;
70
- } catch (error) {
71
- const attempts = (message as QueueMessage<TPayload> & { attempts?: number }).attempts ?? 0;
72
-
73
- Logger.error(`Failed to process ${options.kindLabel}`, {
74
- ...baseLogFields,
75
- error,
76
- attempts,
232
+ const heartbeatStore = getHeartbeatStoreApi();
233
+ if (typeof heartbeatStore.heartbeat === 'function') {
234
+ void heartbeatStore.heartbeat({
235
+ queueName,
236
+ jobId,
237
+ workerInstanceId,
238
+ intervalMs: heartbeatIntervalMs,
77
239
  });
240
+ }
241
+ }, heartbeatIntervalMs);
242
+ };
78
243
 
79
- if (attempts < options.maxAttempts) {
80
- await Queue.enqueue(queueName, message.payload as BullMQPayload, driverName);
81
- Logger.info(`${options.kindLabel} re-queued for retry`, {
82
- ...baseLogFields,
83
- attempts: attempts + 1,
84
- });
85
- }
244
+ const checkAndRequeueIfNotDue = async <TPayload>(
245
+ options: CreateQueueWorkerOptions<TPayload>,
246
+ queueName: string,
247
+ driverName: string | undefined,
248
+ message: QueueMessage<TPayload>,
249
+ baseLogFields: Record<string, unknown>
250
+ ): Promise<boolean> => {
251
+ const payload = message.payload as Record<string, unknown> & { timestamp?: number };
252
+ const rawTimestamp = 'timestamp' in payload ? payload['timestamp'] : 0;
253
+ const timestamp = typeof rawTimestamp === 'number' ? rawTimestamp : 0;
86
254
 
87
- await Queue.ack(queueName, message.id, driverName);
88
- // We processed the message (even if it failed), so return true to continue processing
89
- return true;
255
+ if (timestamp <= Date.now()) return false;
256
+
257
+ Logger.info(`${options.kindLabel} not due yet, re-queueing`, {
258
+ ...baseLogFields,
259
+ dueAt: new Date(timestamp).toISOString(),
260
+ });
261
+ await Queue.enqueue(queueName, message.payload as BullMQPayload, driverName);
262
+ await Queue.ack(queueName, message.id, driverName);
263
+ return true;
264
+ };
265
+
266
+ const onProcessSuccess = async <TPayload>(input: {
267
+ options: CreateQueueWorkerOptions<TPayload>;
268
+ trackerApi: TrackerApi;
269
+ queueName: string;
270
+ driverName?: string;
271
+ message: QueueMessage<TPayload>;
272
+ startedAtMs: number;
273
+ baseLogFields: Record<string, unknown>;
274
+ }): Promise<boolean> => {
275
+ await Queue.ack(input.queueName, input.message.id, input.driverName);
276
+
277
+ if (typeof input.trackerApi.completed === 'function') {
278
+ await input.trackerApi.completed({
279
+ queueName: input.queueName,
280
+ jobId: input.message.id,
281
+ processingTimeMs: Date.now() - input.startedAtMs,
282
+ });
283
+ }
284
+
285
+ await removeHeartbeatIfSupported(input.queueName, input.message.id);
286
+ Logger.info(`${input.options.kindLabel} processed successfully`, input.baseLogFields);
287
+ return true;
288
+ };
289
+
290
+ const onProcessFailure = async <TPayload>(input: {
291
+ options: CreateQueueWorkerOptions<TPayload>;
292
+ trackerApi: TrackerApi;
293
+ queueName: string;
294
+ driverName?: string;
295
+ message: QueueMessage<TPayload>;
296
+ baseLogFields: Record<string, unknown>;
297
+ error: unknown;
298
+ }): Promise<boolean> => {
299
+ const attempts = getAttemptsFromMessage(input.message);
300
+ const nextAttempts = attempts + 1;
301
+ const isFinal = nextAttempts >= input.options.maxAttempts;
302
+ let retryAt: string | undefined;
303
+
304
+ Logger.error(`Failed to process ${input.options.kindLabel}`, {
305
+ ...input.baseLogFields,
306
+ error: input.error,
307
+ attempts: nextAttempts,
308
+ });
309
+
310
+ if (isTimeoutError(input.error) && typeof input.trackerApi.timedOut === 'function') {
311
+ await input.trackerApi.timedOut({
312
+ queueName: input.queueName,
313
+ jobId: input.message.id,
314
+ reason: `Worker processing exceeded timeout for ${input.options.kindLabel}`,
315
+ error: input.error,
316
+ });
317
+ }
318
+
319
+ if (nextAttempts < input.options.maxAttempts) {
320
+ const retryDelayMs = getRetryDelayMs(nextAttempts);
321
+ retryAt = new Date(Date.now() + retryDelayMs).toISOString();
322
+ const currentPayload =
323
+ typeof input.message.payload === 'object' && input.message.payload !== null
324
+ ? (input.message.payload as Record<string, unknown>)
325
+ : ({ payload: input.message.payload } as Record<string, unknown>);
326
+
327
+ const payloadForRetry: BullMQPayload = {
328
+ ...currentPayload,
329
+ attempts: nextAttempts,
330
+ timestamp: Date.now() + retryDelayMs,
331
+ };
332
+
333
+ await Queue.enqueue(input.queueName, payloadForRetry, input.driverName);
334
+ Logger.info(`${input.options.kindLabel} re-queued for retry`, {
335
+ ...input.baseLogFields,
336
+ attempts: nextAttempts,
337
+ retryDelayMs,
338
+ });
339
+ }
340
+
341
+ await Queue.ack(input.queueName, input.message.id, input.driverName);
342
+ await removeHeartbeatIfSupported(input.queueName, input.message.id);
343
+
344
+ if (typeof input.trackerApi.failed === 'function') {
345
+ await input.trackerApi.failed({
346
+ queueName: input.queueName,
347
+ jobId: input.message.id,
348
+ attempts: nextAttempts,
349
+ isFinal,
350
+ retryAt,
351
+ error: input.error,
352
+ });
353
+ }
354
+
355
+ return true;
356
+ };
357
+
358
+ const startTrackingAndHeartbeat = async <TPayload>(input: {
359
+ options: CreateQueueWorkerOptions<TPayload>;
360
+ trackerApi: TrackerApi;
361
+ queueName: string;
362
+ message: QueueMessage<TPayload>;
363
+ }): Promise<{ startedAtMs: number; heartbeatTimer?: ReturnType<typeof setInterval> }> => {
364
+ const startedAtMs = Date.now();
365
+ const timeoutMs = resolveQueueJobTimeoutMs();
366
+ const heartbeatIntervalMs = Math.max(1000, getEnvInt('JOB_HEARTBEAT_INTERVAL_MS', 10000));
367
+ const attempts = getAttemptsFromMessage(input.message);
368
+ const workerInstanceId = getWorkerInstanceId();
369
+
370
+ if (typeof input.trackerApi.started === 'function') {
371
+ await input.trackerApi.started({
372
+ queueName: input.queueName,
373
+ jobId: input.message.id,
374
+ attempts: attempts + 1,
375
+ timeoutMs,
376
+ workerName: input.options.kindLabel,
377
+ workerInstanceId,
378
+ });
379
+ }
380
+
381
+ const heartbeatStore = getHeartbeatStoreApi();
382
+ if (typeof heartbeatStore.heartbeat === 'function') {
383
+ await heartbeatStore.heartbeat({
384
+ queueName: input.queueName,
385
+ jobId: input.message.id,
386
+ workerInstanceId,
387
+ intervalMs: heartbeatIntervalMs,
388
+ });
389
+ }
390
+
391
+ const heartbeatTimer = scheduleHeartbeatLoop(
392
+ input.trackerApi,
393
+ input.queueName,
394
+ input.message.id,
395
+ workerInstanceId,
396
+ heartbeatIntervalMs
397
+ );
398
+
399
+ return { startedAtMs, heartbeatTimer };
400
+ };
401
+
402
+ const processQueueMessage = async <TPayload>(
403
+ options: CreateQueueWorkerOptions<TPayload>,
404
+ queueName: string,
405
+ driverName?: string
406
+ ): Promise<boolean> => {
407
+ const message = await Queue.dequeue<TPayload>(queueName, driverName);
408
+ if (!message) return false;
409
+
410
+ const baseLogFields = buildBaseLogFields(message, options.getLogFields);
411
+
412
+ const isRequeued = await checkAndRequeueIfNotDue(
413
+ options,
414
+ queueName,
415
+ driverName,
416
+ message,
417
+ baseLogFields
418
+ );
419
+ if (isRequeued) return false;
420
+
421
+ let heartbeatTimer: ReturnType<typeof setInterval> | undefined;
422
+ const trackerApi = getTrackerApi();
423
+ const timeoutMs = resolveQueueJobTimeoutMs();
424
+ let startedAtMs: number;
425
+
426
+ try {
427
+ const tracking = await startTrackingAndHeartbeat({
428
+ options,
429
+ trackerApi,
430
+ queueName,
431
+ message,
432
+ });
433
+ startedAtMs = tracking.startedAtMs;
434
+ heartbeatTimer = tracking.heartbeatTimer;
435
+
436
+ Logger.info(`Processing queued ${options.kindLabel}`, baseLogFields);
437
+ await runWithTimeout(
438
+ async () => {
439
+ await options.handle(message.payload);
440
+ },
441
+ timeoutMs,
442
+ `${options.kindLabel}:${queueName}:${message.id}`
443
+ );
444
+
445
+ return onProcessSuccess({
446
+ options,
447
+ trackerApi,
448
+ queueName,
449
+ driverName,
450
+ message,
451
+ startedAtMs,
452
+ baseLogFields,
453
+ });
454
+ } catch (error) {
455
+ return onProcessFailure({
456
+ options,
457
+ trackerApi,
458
+ queueName,
459
+ driverName,
460
+ message,
461
+ baseLogFields,
462
+ error,
463
+ });
464
+ } finally {
465
+ if (heartbeatTimer !== undefined) {
466
+ clearInterval(heartbeatTimer);
90
467
  }
468
+ }
469
+ };
470
+
471
+ const createProcessOne = <TPayload>(
472
+ options: CreateQueueWorkerOptions<TPayload>
473
+ ): ((queueName?: string, driverName?: string) => Promise<boolean>) => {
474
+ return async (queueName = options.defaultQueueName, driverName?: string): Promise<boolean> => {
475
+ return processQueueMessage(options, queueName, driverName);
91
476
  };
92
477
  };
93
478
 
@@ -552,7 +552,14 @@ async function getRedisQueueData(): Promise<QueueData> {
552
552
  throw ErrorFactory.createConfigError('Redis driver not configured');
553
553
  }
554
554
 
555
- const monitor = QueueMonitor.create({ redis: redisConfig });
555
+ const monitor = QueueMonitor.create({
556
+ redis: {
557
+ host: redisConfig.host || 'localhost',
558
+ port: redisConfig.port || 6379,
559
+ db: redisConfig.database || 1,
560
+ password: redisConfig.password,
561
+ },
562
+ });
556
563
  const snapshot = await monitor.getSnapshot();
557
564
 
558
565
  let totalJobs = 0;
@@ -4,7 +4,14 @@
4
4
  * HTTP handlers for worker management API
5
5
  */
6
6
 
7
- import { Env, Logger, getValidatedBody, type IRequest, type IResponse } from '@zintrust/core';
7
+ import {
8
+ Cloudflare,
9
+ Env,
10
+ Logger,
11
+ getValidatedBody,
12
+ type IRequest,
13
+ type IResponse,
14
+ } from '@zintrust/core';
8
15
  import type { Job } from 'bullmq';
9
16
  import { CanaryController } from '../CanaryController';
10
17
  import { HealthMonitor } from '../HealthMonitor';
@@ -34,42 +41,52 @@ const getBody = (req: IRequest): Record<string, unknown> => {
34
41
 
35
42
  // ==================== Core Worker Operations ====================
36
43
 
37
- /**
38
- * Create a new worker instance
39
- * @param req.body.name - Worker name (required)
40
- * @param req.body.queueName - Queue name (required)
41
- * @param req.body.processor - Job processor function (required; internal only)
42
- * @param req.body.version - Worker version (optional)
43
- * @param req.body.options - BullMQ worker options (optional)
44
- * @param req.body.infrastructure - Infrastructure config (optional)
45
- * @param req.body.features - Feature flags (optional)
46
- * @param req.body.datacenter - Datacenter placement config (optional)
47
- * @returns Success response with worker name
48
- */
49
- async function create(req: IRequest, res: IResponse): Promise<void> {
50
- Logger.info('WorkerController.create called');
51
- try {
52
- const body = req.data() as unknown as WorkerFactoryConfig;
44
+ const isCloudflareEnv = (): boolean => Cloudflare.getWorkersEnv() !== null;
53
45
 
54
- // Validate required fields
55
- if (!body.name || !body.queueName || !body.processor || !body.version) {
56
- return res.setStatus(400).json({
57
- error: 'Missing required fields',
58
- message: 'name, queueName, processor, and version are required',
59
- code: 'MISSING_REQUIRED_FIELDS',
60
- });
61
- }
46
+ const validateCreatePayload = (body: WorkerFactoryConfig, res: IResponse): boolean => {
47
+ if (!body.name || !body.queueName || !body.processor || !body.version) {
48
+ res.setStatus(400).json({
49
+ error: 'Missing required fields',
50
+ message: 'name, queueName, processor, and version are required',
51
+ code: 'MISSING_REQUIRED_FIELDS',
52
+ });
53
+ return false;
54
+ }
55
+ return true;
56
+ };
57
+
58
+ const respondIfWorkerExists = async (
59
+ name: string,
60
+ persistenceOverride: ReturnType<typeof resolvePersistenceOverride>,
61
+ res: IResponse
62
+ ): Promise<boolean> => {
63
+ const existing = await WorkerFactory.getPersisted(name, persistenceOverride);
64
+ if (!existing) return false;
65
+
66
+ res.status(409).json({
67
+ ok: false,
68
+ error: `Worker ${name} already exists`,
69
+ code: 'WORKER_EXISTS',
70
+ worker: existing,
71
+ });
72
+ return true;
73
+ };
62
74
 
63
- const rawProcessor = body.processor;
64
- let processor: (job: Job) => Promise<unknown>;
65
- let processorSpec: string | undefined;
75
+ const resolveCreateProcessor = async (
76
+ body: WorkerFactoryConfig,
77
+ res: IResponse
78
+ ): Promise<{ processor: (job: Job) => Promise<unknown>; processorSpec?: string } | null> => {
79
+ const rawProcessor = body.processor;
80
+ let processor = rawProcessor as (job: Job) => Promise<unknown>;
81
+ let processorSpec: string | undefined;
66
82
 
83
+ if (!isCloudflareEnv()) {
67
84
  if (typeof rawProcessor === 'string') {
68
85
  processorSpec = rawProcessor;
69
86
  const resolved = await WorkerFactory.resolveProcessorSpec(rawProcessor);
70
87
  if (!resolved) {
71
88
  res.setStatus(400).json({ error: 'Processor spec could not be resolved' });
72
- return;
89
+ return null;
73
90
  }
74
91
  processor = resolved;
75
92
  } else {
@@ -78,23 +95,81 @@ async function create(req: IRequest, res: IResponse): Promise<void> {
78
95
 
79
96
  if (typeof processor !== 'function') {
80
97
  res.setStatus(400).json({ error: 'Processor must be a function or resolvable path' });
81
- return;
98
+ return null;
82
99
  }
83
100
 
84
- const config = {
85
- ...(body as WorkerFactoryConfig),
86
- processor,
87
- processorSpec,
88
- };
101
+ return { processor, processorSpec };
102
+ }
89
103
 
90
- await WorkerFactory.create(config);
104
+ // Cloudflare environment: treat string as spec, otherwise accept as-is
105
+ if (typeof rawProcessor === 'string') {
106
+ processorSpec = rawProcessor;
107
+ processor = async () => {};
108
+ }
109
+
110
+ return { processor, processorSpec };
111
+ };
91
112
 
113
+ const finalizeWorkerCreate = async (config: WorkerFactoryConfig, res: IResponse): Promise<void> => {
114
+ const globalAutoStart = Env.getBool('WORKER_AUTO_START', false);
115
+ const workerAutoStart = config.autoStart ?? globalAutoStart;
116
+ const isCloudflare = isCloudflareEnv();
117
+
118
+ if (!isCloudflare && globalAutoStart && workerAutoStart) {
119
+ await WorkerFactory.create(config);
92
120
  res.json({
93
121
  ok: true,
94
122
  workerName: config.name,
95
123
  status: 'creating',
96
124
  message: 'Worker creation started. Check status endpoint for progress.',
97
125
  });
126
+ return;
127
+ }
128
+
129
+ await WorkerFactory.register(config);
130
+ res.json({
131
+ ok: true,
132
+ workerName: config.name,
133
+ status: 'registered',
134
+ message: isCloudflare
135
+ ? 'Worker registered. Cloudflare environment detected; worker will be picked up by external processor.'
136
+ : 'Worker registered successfully.',
137
+ });
138
+ };
139
+
140
+ /**
141
+ * Create a new worker instance
142
+ * @param req.body.name - Worker name (required)
143
+ * @param req.body.queueName - Queue name (required)
144
+ * @param req.body.processor - Job processor function (required; internal only)
145
+ * @param req.body.version - Worker version (optional)
146
+ * @param req.body.options - BullMQ worker options (optional)
147
+ * @param req.body.infrastructure - Infrastructure config (optional)
148
+ * @param req.body.features - Feature flags (optional)
149
+ * @param req.body.datacenter - Datacenter placement config (optional)
150
+ * @returns Success response with worker name
151
+ */
152
+ async function create(req: IRequest, res: IResponse): Promise<void> {
153
+ Logger.info('WorkerController.create called');
154
+ try {
155
+ const body = req.data() as unknown as WorkerFactoryConfig;
156
+ if (!validateCreatePayload(body, res)) return;
157
+
158
+ const name = body.name;
159
+ const persistenceOverride = resolvePersistenceOverride(req);
160
+ const exists = await respondIfWorkerExists(name, persistenceOverride, res);
161
+ if (exists) return;
162
+
163
+ const resolvedProcessor = await resolveCreateProcessor(body, res);
164
+ if (!resolvedProcessor) return;
165
+
166
+ const config = {
167
+ ...(body as WorkerFactoryConfig),
168
+ processor: resolvedProcessor.processor,
169
+ processorSpec: resolvedProcessor.processorSpec,
170
+ };
171
+
172
+ await finalizeWorkerCreate(config, res);
98
173
  } catch (error) {
99
174
  Logger.error('WorkerController.create failed', error);
100
175
  res.setStatus(500).json({ error: (error as Error).message });