silent-cronx 1.0.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.
package/dist/index.js ADDED
@@ -0,0 +1,1138 @@
1
+ // src/core/eventBus.ts
2
+ import { EventEmitter } from "events";
3
+ var EventBus = class {
4
+ emitter = new EventEmitter();
5
+ on(eventName, handler) {
6
+ this.emitter.on(eventName, handler);
7
+ }
8
+ off(eventName, handler) {
9
+ this.emitter.off(eventName, handler);
10
+ }
11
+ emit(eventName, event) {
12
+ this.emitter.emit(eventName, event);
13
+ }
14
+ };
15
+
16
+ // src/core/retryManager.ts
17
+ function getMaxAttempts(retry) {
18
+ return Math.max(1, retry?.attempts ?? 1);
19
+ }
20
+ function shouldRetry(error, attempt, retry) {
21
+ if (!retry || attempt >= getMaxAttempts(retry)) return false;
22
+ return retry.shouldRetry ? retry.shouldRetry(error) : true;
23
+ }
24
+ function getRetryDelayMs(attempt, retry) {
25
+ if (!retry) return 0;
26
+ const base = retry.delayMs ?? 1e3;
27
+ switch (retry.backoff ?? "fixed") {
28
+ case "linear":
29
+ return base * attempt;
30
+ case "exponential":
31
+ return base * 2 ** Math.max(0, attempt - 1);
32
+ case "fixed":
33
+ default:
34
+ return base;
35
+ }
36
+ }
37
+
38
+ // src/utils/errors.ts
39
+ var SilentCronXError = class extends Error {
40
+ constructor(message) {
41
+ super(message);
42
+ this.name = "SilentCronXError";
43
+ }
44
+ };
45
+ var JobTimeoutError = class extends SilentCronXError {
46
+ constructor(message = "Job timed out") {
47
+ super(message);
48
+ this.name = "JobTimeoutError";
49
+ }
50
+ };
51
+ var JobCancelledError = class extends SilentCronXError {
52
+ constructor(message = "Job cancelled") {
53
+ super(message);
54
+ this.name = "JobCancelledError";
55
+ }
56
+ };
57
+ function serializeError(error) {
58
+ if (error instanceof Error) {
59
+ return {
60
+ name: error.name,
61
+ message: error.message,
62
+ stack: error.stack
63
+ };
64
+ }
65
+ return {
66
+ name: "Error",
67
+ message: typeof error === "string" ? error : JSON.stringify(error)
68
+ };
69
+ }
70
+
71
+ // src/core/timeoutManager.ts
72
+ async function withTimeout(work, timeoutMs, externalSignal) {
73
+ const controller = new AbortController();
74
+ const abort = (reason) => controller.abort(reason);
75
+ if (externalSignal?.aborted) abort(externalSignal.reason);
76
+ externalSignal?.addEventListener("abort", () => abort(externalSignal.reason), { once: true });
77
+ let timer;
78
+ const timeout = new Promise((_, reject) => {
79
+ timer = setTimeout(() => {
80
+ const error = new JobTimeoutError(`Job timed out after ${timeoutMs}ms`);
81
+ abort(error);
82
+ reject(error);
83
+ }, timeoutMs);
84
+ timer.unref?.();
85
+ });
86
+ try {
87
+ return await Promise.race([work(controller.signal), timeout]);
88
+ } finally {
89
+ if (timer) clearTimeout(timer);
90
+ }
91
+ }
92
+
93
+ // src/utils/time.ts
94
+ function nowIso() {
95
+ return (/* @__PURE__ */ new Date()).toISOString();
96
+ }
97
+ function delay(ms, signal) {
98
+ if (ms <= 0) return Promise.resolve();
99
+ return new Promise((resolve, reject) => {
100
+ if (signal?.aborted) {
101
+ reject(signal.reason ?? new Error("Operation aborted"));
102
+ return;
103
+ }
104
+ const timer = setTimeout(resolve, ms);
105
+ timer.unref?.();
106
+ signal?.addEventListener(
107
+ "abort",
108
+ () => {
109
+ clearTimeout(timer);
110
+ reject(signal.reason ?? new Error("Operation aborted"));
111
+ },
112
+ { once: true }
113
+ );
114
+ });
115
+ }
116
+
117
+ // src/core/jobRunner.ts
118
+ var JobRunner = class {
119
+ activeCount = 0;
120
+ getActiveCount() {
121
+ return this.activeCount;
122
+ }
123
+ async run(options) {
124
+ const { record, storage, eventBus } = options;
125
+ const lockKey = options.lockKey;
126
+ let locked = false;
127
+ if (lockKey && options.acquireLock) {
128
+ locked = await options.acquireLock(lockKey, options.lockTtlMs ?? options.timeoutMs);
129
+ if (!locked) {
130
+ const skipped = {
131
+ jobId: record.id,
132
+ name: record.name,
133
+ status: "scheduled",
134
+ attempt: record.attempts,
135
+ durationMs: 0
136
+ };
137
+ return skipped;
138
+ }
139
+ }
140
+ this.activeCount += 1;
141
+ try {
142
+ let attempt = record.attempts;
143
+ while (true) {
144
+ attempt += 1;
145
+ const startedAt = /* @__PURE__ */ new Date();
146
+ await storage.updateJob(record.id, {
147
+ status: "running",
148
+ attempts: attempt,
149
+ startedAt: startedAt.toISOString(),
150
+ lastRunAt: startedAt.toISOString()
151
+ });
152
+ eventBus.emit("job:started", { jobId: record.id, name: record.name, attempt });
153
+ try {
154
+ const result = await withTimeout(
155
+ async (timeoutSignal) => {
156
+ const signal = mergeSignals(options.signal, timeoutSignal);
157
+ if (signal.aborted) throw signal.reason ?? new JobCancelledError();
158
+ return options.task({
159
+ jobId: record.id,
160
+ name: record.name,
161
+ payload: record.payload,
162
+ attempt,
163
+ signal,
164
+ createdAt: new Date(record.createdAt),
165
+ startedAt
166
+ });
167
+ },
168
+ options.timeoutMs,
169
+ options.signal
170
+ );
171
+ const finishedAt = nowIso();
172
+ const durationMs = Date.now() - startedAt.getTime();
173
+ const finalResult = {
174
+ jobId: record.id,
175
+ name: record.name,
176
+ status: "success",
177
+ result,
178
+ attempt,
179
+ durationMs,
180
+ startedAt: startedAt.toISOString(),
181
+ finishedAt
182
+ };
183
+ const latest = await storage.getJob(record.id);
184
+ await storage.updateJob(record.id, {
185
+ status: "success",
186
+ result,
187
+ finishedAt,
188
+ successCount: (latest?.successCount ?? record.successCount) + 1
189
+ });
190
+ eventBus.emit("job:success", finalResult);
191
+ return finalResult;
192
+ } catch (error) {
193
+ const finishedAt = nowIso();
194
+ const durationMs = Date.now() - startedAt.getTime();
195
+ const status = error instanceof JobTimeoutError ? "timeout" : isAbortError(error) ? "cancelled" : "failed";
196
+ const finalResult = {
197
+ jobId: record.id,
198
+ name: record.name,
199
+ status,
200
+ error: serializeError(error),
201
+ attempt,
202
+ durationMs,
203
+ startedAt: startedAt.toISOString(),
204
+ finishedAt
205
+ };
206
+ if (status === "cancelled" || status === "timeout" || !shouldRetry(error, attempt, options.retry)) {
207
+ const latest = await storage.getJob(record.id);
208
+ await storage.updateJob(record.id, {
209
+ status,
210
+ error: finalResult.error,
211
+ finishedAt,
212
+ failureCount: (latest?.failureCount ?? record.failureCount) + 1
213
+ });
214
+ if (status === "timeout") {
215
+ eventBus.emit("job:timeout", finalResult);
216
+ } else if (status === "cancelled") {
217
+ eventBus.emit("job:cancelled", { jobId: record.id, name: record.name });
218
+ } else {
219
+ eventBus.emit("job:failed", finalResult);
220
+ }
221
+ return finalResult;
222
+ }
223
+ const retryDelayMs = getRetryDelayMs(attempt, options.retry);
224
+ eventBus.emit("job:retry", { jobId: record.id, name: record.name, attempt, delayMs: retryDelayMs });
225
+ await delay(retryDelayMs, options.signal);
226
+ }
227
+ }
228
+ } finally {
229
+ this.activeCount -= 1;
230
+ if (locked && lockKey && options.releaseLock) await options.releaseLock(lockKey);
231
+ }
232
+ }
233
+ };
234
+ function mergeSignals(a, b) {
235
+ const controller = new AbortController();
236
+ const abort = (signal) => controller.abort(signal.reason);
237
+ if (a?.aborted) abort(a);
238
+ if (b?.aborted) abort(b);
239
+ a?.addEventListener("abort", () => abort(a), { once: true });
240
+ b?.addEventListener("abort", () => abort(b), { once: true });
241
+ return controller.signal;
242
+ }
243
+ function isAbortError(error) {
244
+ return error instanceof JobCancelledError || error instanceof Error && error.name === "AbortError";
245
+ }
246
+
247
+ // src/core/lockManager.ts
248
+ var LockManager = class {
249
+ constructor(storage, defaultTtlMs) {
250
+ this.storage = storage;
251
+ this.defaultTtlMs = defaultTtlMs;
252
+ }
253
+ storage;
254
+ defaultTtlMs;
255
+ acquire(lockKey, ttlMs = this.defaultTtlMs) {
256
+ return this.storage.acquireLock(lockKey, ttlMs);
257
+ }
258
+ release(lockKey) {
259
+ return this.storage.releaseLock(lockKey);
260
+ }
261
+ };
262
+
263
+ // src/utils/id.ts
264
+ import { randomUUID } from "crypto";
265
+ function createJobId(prefix = "job") {
266
+ return `${prefix}_${randomUUID()}`;
267
+ }
268
+
269
+ // src/utils/safeSerialize.ts
270
+ function assertSerializablePayload(payload, maxBytes) {
271
+ if (payload === void 0) return;
272
+ try {
273
+ const json = JSON.stringify(payload);
274
+ if (maxBytes && Buffer.byteLength(json, "utf8") > maxBytes) {
275
+ throw new SilentCronXError(`Payload exceeds configured maxPayloadBytes (${maxBytes})`);
276
+ }
277
+ } catch (error) {
278
+ if (error instanceof SilentCronXError) throw error;
279
+ throw new SilentCronXError("Payload must be JSON-serializable");
280
+ }
281
+ }
282
+
283
+ // src/utils/validation.ts
284
+ var JOB_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}$/;
285
+ function validateJobName(name) {
286
+ if (!JOB_NAME_RE.test(name)) {
287
+ throw new SilentCronXError(
288
+ "Job name must start with a letter or number and contain only letters, numbers, underscore, dash, dot, or colon"
289
+ );
290
+ }
291
+ }
292
+ function assertPositiveNumber(value, label) {
293
+ if (!Number.isFinite(value) || value <= 0) {
294
+ throw new SilentCronXError(`${label} must be a positive number`);
295
+ }
296
+ }
297
+ function assertTask(task, label = "task") {
298
+ if (typeof task !== "function") {
299
+ throw new SilentCronXError(`${label} must be a function`);
300
+ }
301
+ }
302
+
303
+ // src/core/queueManager.ts
304
+ var QueueManager = class {
305
+ constructor(storage, eventBus, maxPayloadBytes, runJob) {
306
+ this.storage = storage;
307
+ this.eventBus = eventBus;
308
+ this.maxPayloadBytes = maxPayloadBytes;
309
+ this.runJob = runJob;
310
+ }
311
+ storage;
312
+ eventBus;
313
+ maxPayloadBytes;
314
+ runJob;
315
+ queues = /* @__PURE__ */ new Map();
316
+ createQueue(name, options = {}) {
317
+ validateJobName(name);
318
+ const concurrency = options.concurrency ?? 1;
319
+ assertPositiveNumber(concurrency, "queue concurrency");
320
+ const runtime = {
321
+ name,
322
+ options: {
323
+ ...options,
324
+ concurrency,
325
+ maxSize: options.maxSize ?? 1e3
326
+ },
327
+ pending: [],
328
+ running: 0,
329
+ rateWindowStartedAt: Date.now(),
330
+ rateCount: 0
331
+ };
332
+ this.queues.set(name, runtime);
333
+ this.eventBus.emit("queue:created", { queueName: name, concurrency });
334
+ }
335
+ async addJob(queueName, job) {
336
+ const queue = this.getQueue(queueName);
337
+ validateJobName(job.name);
338
+ assertSerializablePayload(job.payload, this.maxPayloadBytes);
339
+ if (!job.task && !queue.options.processor) {
340
+ throw new Error(`Queue "${queueName}" requires either a queue processor or a task on addJob`);
341
+ }
342
+ if (queue.pending.length >= queue.options.maxSize) {
343
+ throw new Error(`Queue "${queueName}" is full`);
344
+ }
345
+ const id = job.id ?? createJobId("queue");
346
+ const createdAt = nowIso();
347
+ const record = {
348
+ id,
349
+ name: job.name,
350
+ kind: "queue",
351
+ status: "pending",
352
+ payload: job.payload,
353
+ priority: job.priority ?? 0,
354
+ attempts: 0,
355
+ maxAttempts: getMaxAttempts(job.retry ?? queue.options.retry),
356
+ createdAt,
357
+ updatedAt: createdAt,
358
+ successCount: 0,
359
+ failureCount: 0,
360
+ queueName
361
+ };
362
+ await this.storage.saveJob(record);
363
+ await this.insertByPriority(queue, id, record.priority);
364
+ void this.drain(queueName, job.task ?? queue.options.processor);
365
+ return id;
366
+ }
367
+ getQueuedCount() {
368
+ return [...this.queues.values()].reduce((total, queue) => total + queue.pending.length, 0);
369
+ }
370
+ async drain(queueName, task) {
371
+ const queue = this.getQueue(queueName);
372
+ while (queue.running < queue.options.concurrency && queue.pending.length > 0 && this.canRunByRateLimit(queue)) {
373
+ const jobId = queue.pending.shift();
374
+ if (!jobId) break;
375
+ queue.running += 1;
376
+ void this.runJob(jobId, task, queueName).finally(() => {
377
+ queue.running = Math.max(0, queue.running - 1);
378
+ if (queue.pending.length === 0 && queue.running === 0) this.eventBus.emit("queue:drain", { queueName });
379
+ void this.drain(queueName, task);
380
+ });
381
+ }
382
+ }
383
+ getQueue(name) {
384
+ const queue = this.queues.get(name);
385
+ if (!queue) throw new Error(`Queue not found: ${name}`);
386
+ return queue;
387
+ }
388
+ async insertByPriority(queue, jobId, priority) {
389
+ let index = queue.pending.length;
390
+ for (let i = 0; i < queue.pending.length; i += 1) {
391
+ const existing = await this.storage.getJob(queue.pending[i]);
392
+ if ((existing?.priority ?? 0) < priority) {
393
+ index = i;
394
+ break;
395
+ }
396
+ }
397
+ queue.pending.splice(index, 0, jobId);
398
+ }
399
+ canRunByRateLimit(queue) {
400
+ const rateLimit = queue.options.rateLimit;
401
+ if (!rateLimit) return true;
402
+ const now = Date.now();
403
+ if (now - queue.rateWindowStartedAt >= rateLimit.intervalMs) {
404
+ queue.rateWindowStartedAt = now;
405
+ queue.rateCount = 0;
406
+ }
407
+ if (queue.rateCount >= rateLimit.limit) {
408
+ const waitMs = rateLimit.intervalMs - (now - queue.rateWindowStartedAt);
409
+ setTimeout(() => void this.drain(queue.name), Math.max(1, waitMs)).unref?.();
410
+ return false;
411
+ }
412
+ queue.rateCount += 1;
413
+ return true;
414
+ }
415
+ };
416
+
417
+ // src/core/cronParser.ts
418
+ var MONTHS = {
419
+ jan: 1,
420
+ feb: 2,
421
+ mar: 3,
422
+ apr: 4,
423
+ may: 5,
424
+ jun: 6,
425
+ jul: 7,
426
+ aug: 8,
427
+ sep: 9,
428
+ oct: 10,
429
+ nov: 11,
430
+ dec: 12
431
+ };
432
+ var DOW = {
433
+ sun: 0,
434
+ mon: 1,
435
+ tue: 2,
436
+ wed: 3,
437
+ thu: 4,
438
+ fri: 5,
439
+ sat: 6
440
+ };
441
+ function parseCronExpression(expression) {
442
+ const parts = expression.trim().replace(/\s+/g, " ").split(" ");
443
+ if (parts.length !== 5 && parts.length !== 6) {
444
+ throw new SilentCronXError("Invalid cron expression. Expected 5 fields, or 6 fields with seconds.");
445
+ }
446
+ const hasSeconds = parts.length === 6;
447
+ const [secondsRaw, minutesRaw, hoursRaw, domRaw, monthsRaw, dowRaw] = hasSeconds ? parts : ["0", ...parts];
448
+ return {
449
+ hasSeconds,
450
+ seconds: parseField(secondsRaw, 0, 59),
451
+ minutes: parseField(minutesRaw, 0, 59),
452
+ hours: parseField(hoursRaw, 0, 23),
453
+ daysOfMonth: parseField(domRaw, 1, 31),
454
+ months: parseField(monthsRaw, 1, 12, MONTHS),
455
+ daysOfWeek: normalizeDow(parseField(dowRaw, 0, 7, DOW)),
456
+ expression
457
+ };
458
+ }
459
+ function getNextCronDate(expression, from = /* @__PURE__ */ new Date(), timezone = "UTC") {
460
+ const parsed = typeof expression === "string" ? parseCronExpression(expression) : expression;
461
+ const stepMs = parsed.hasSeconds ? 1e3 : 6e4;
462
+ let candidate = new Date(Math.ceil((from.getTime() + stepMs) / stepMs) * stepMs);
463
+ const limit = candidate.getTime() + 366 * 24 * 60 * 60 * 1e3;
464
+ while (candidate.getTime() <= limit) {
465
+ const parts = getZonedParts(candidate, timezone);
466
+ if (parsed.seconds.has(parts.second) && parsed.minutes.has(parts.minute) && parsed.hours.has(parts.hour) && parsed.daysOfMonth.has(parts.day) && parsed.months.has(parts.month) && parsed.daysOfWeek.has(parts.weekday)) {
467
+ return candidate;
468
+ }
469
+ candidate = new Date(candidate.getTime() + stepMs);
470
+ }
471
+ throw new SilentCronXError(`Unable to find next run for cron expression: ${parsed.expression}`);
472
+ }
473
+ function parseField(raw, min, max, aliases = {}) {
474
+ if (!raw) throw new SilentCronXError("Cron field is missing");
475
+ const values = /* @__PURE__ */ new Set();
476
+ for (const token of raw.toLowerCase().split(",")) {
477
+ const [rangeRaw, stepRaw] = token.split("/");
478
+ const step = stepRaw ? Number(stepRaw) : 1;
479
+ if (!Number.isInteger(step) || step <= 0) {
480
+ throw new SilentCronXError(`Invalid cron step: ${token}`);
481
+ }
482
+ let start;
483
+ let end;
484
+ if (rangeRaw === "*") {
485
+ start = min;
486
+ end = max;
487
+ } else if (rangeRaw?.includes("-")) {
488
+ const [a, b] = rangeRaw.split("-");
489
+ start = parseValue(a, aliases);
490
+ end = parseValue(b, aliases);
491
+ } else {
492
+ start = parseValue(rangeRaw, aliases);
493
+ end = start;
494
+ }
495
+ if (start < min || end > max || start > end) {
496
+ throw new SilentCronXError(`Cron value out of range: ${token}`);
497
+ }
498
+ for (let value = start; value <= end; value += step) values.add(value);
499
+ }
500
+ return values;
501
+ }
502
+ function parseValue(raw, aliases) {
503
+ if (!raw) throw new SilentCronXError("Invalid empty cron value");
504
+ const aliased = aliases[raw] ?? Number(raw);
505
+ if (!Number.isInteger(aliased)) throw new SilentCronXError(`Invalid cron value: ${raw}`);
506
+ return aliased;
507
+ }
508
+ function normalizeDow(values) {
509
+ const normalized = /* @__PURE__ */ new Set();
510
+ for (const value of values) normalized.add(value === 7 ? 0 : value);
511
+ return normalized;
512
+ }
513
+ function getZonedParts(date, timezone) {
514
+ const formatter = new Intl.DateTimeFormat("en-US", {
515
+ timeZone: timezone,
516
+ second: "numeric",
517
+ minute: "numeric",
518
+ hour: "numeric",
519
+ hourCycle: "h23",
520
+ day: "numeric",
521
+ month: "numeric",
522
+ weekday: "short"
523
+ });
524
+ const map = new Map(formatter.formatToParts(date).map((part) => [part.type, part.value]));
525
+ return {
526
+ second: Number(map.get("second")),
527
+ minute: Number(map.get("minute")),
528
+ hour: Number(map.get("hour")),
529
+ day: Number(map.get("day")),
530
+ month: Number(map.get("month")),
531
+ weekday: DOW[(map.get("weekday") ?? "sun").toLowerCase()] ?? 0
532
+ };
533
+ }
534
+
535
+ // src/storage/MemoryStorageAdapter.ts
536
+ var MemoryStorageAdapter = class {
537
+ jobs = /* @__PURE__ */ new Map();
538
+ locks = /* @__PURE__ */ new Map();
539
+ async saveJob(job) {
540
+ if (this.jobs.has(job.id)) {
541
+ throw new SilentCronXError(`Duplicate job id rejected: ${job.id}`);
542
+ }
543
+ this.jobs.set(job.id, { ...job });
544
+ }
545
+ async updateJob(jobId, patch) {
546
+ const existing = this.jobs.get(jobId);
547
+ if (!existing) throw new SilentCronXError(`Job not found: ${jobId}`);
548
+ this.jobs.set(jobId, { ...existing, ...patch, updatedAt: nowIso() });
549
+ }
550
+ async getJob(jobId) {
551
+ const job = this.jobs.get(jobId);
552
+ return job ? { ...job } : null;
553
+ }
554
+ async listJobs(filter = {}) {
555
+ return [...this.jobs.values()].filter((job) => !filter.status || job.status === filter.status).filter((job) => !filter.name || job.name === filter.name).filter((job) => !filter.kind || job.kind === filter.kind).filter((job) => !filter.queueName || job.queueName === filter.queueName).map((job) => ({ ...job }));
556
+ }
557
+ async deleteJob(jobId) {
558
+ this.jobs.delete(jobId);
559
+ }
560
+ async acquireLock(lockKey, ttlMs) {
561
+ this.cleanupExpiredLocks();
562
+ const existing = this.locks.get(lockKey);
563
+ if (existing && existing.expiresAt > Date.now()) return false;
564
+ this.locks.set(lockKey, { expiresAt: Date.now() + ttlMs });
565
+ return true;
566
+ }
567
+ async releaseLock(lockKey) {
568
+ this.locks.delete(lockKey);
569
+ }
570
+ cleanupExpiredLocks() {
571
+ const now = Date.now();
572
+ for (const [key, lock] of this.locks) {
573
+ if (lock.expiresAt <= now) this.locks.delete(key);
574
+ }
575
+ }
576
+ };
577
+
578
+ // src/workers/workerPool.ts
579
+ import { Worker } from "worker_threads";
580
+
581
+ // src/workers/workerBridge.ts
582
+ function normalizeWorkerReference(worker) {
583
+ return {
584
+ modulePath: worker.path instanceof URL ? worker.path.href : worker.path,
585
+ exportName: worker.exportName ?? "default"
586
+ };
587
+ }
588
+
589
+ // src/workers/workerPool.ts
590
+ var WorkerPool = class {
591
+ constructor(maxWorkers) {
592
+ this.maxWorkers = maxWorkers;
593
+ }
594
+ maxWorkers;
595
+ busy = 0;
596
+ pending = [];
597
+ liveWorkers = /* @__PURE__ */ new Set();
598
+ getStats() {
599
+ return {
600
+ max: this.maxWorkers,
601
+ busy: this.busy,
602
+ idle: Math.max(0, this.maxWorkers - this.busy)
603
+ };
604
+ }
605
+ run(options) {
606
+ return new Promise((resolve, reject) => {
607
+ const execute = () => {
608
+ this.busy += 1;
609
+ const normalized = normalizeWorkerReference(options.worker);
610
+ const worker = new Worker(getWorkerBridgeUrl(), {
611
+ workerData: {
612
+ modulePath: normalized.modulePath,
613
+ exportName: normalized.exportName,
614
+ payload: options.payload,
615
+ jobId: options.jobId,
616
+ name: options.name,
617
+ attempt: options.attempt,
618
+ createdAt: options.createdAt,
619
+ startedAt: options.startedAt
620
+ }
621
+ });
622
+ this.liveWorkers.add(worker);
623
+ let settled = false;
624
+ const timer = setTimeout(() => {
625
+ if (settled) return;
626
+ settled = true;
627
+ void worker.terminate();
628
+ reject(new Error(`Worker timed out after ${options.timeoutMs}ms`));
629
+ this.finish(worker);
630
+ }, options.timeoutMs);
631
+ timer.unref?.();
632
+ worker.once("message", (message) => {
633
+ if (settled) return;
634
+ settled = true;
635
+ clearTimeout(timer);
636
+ if (message.ok) resolve(message.result);
637
+ else reject(serializeError(message.error));
638
+ void worker.terminate();
639
+ this.finish(worker);
640
+ });
641
+ worker.once("error", (error) => {
642
+ if (settled) return;
643
+ settled = true;
644
+ clearTimeout(timer);
645
+ reject(error);
646
+ this.finish(worker);
647
+ });
648
+ worker.once("exit", (code) => {
649
+ if (settled) return;
650
+ settled = true;
651
+ clearTimeout(timer);
652
+ if (code === 0) resolve(void 0);
653
+ else reject(new Error(`Worker exited with code ${code}`));
654
+ this.finish(worker);
655
+ });
656
+ };
657
+ if (this.busy < this.maxWorkers) execute();
658
+ else this.pending.push({ run: execute });
659
+ });
660
+ }
661
+ async shutdown() {
662
+ this.pending.length = 0;
663
+ await Promise.all([...this.liveWorkers].map((worker) => worker.terminate()));
664
+ this.liveWorkers.clear();
665
+ this.busy = 0;
666
+ }
667
+ finish(worker) {
668
+ this.liveWorkers.delete(worker);
669
+ this.busy = Math.max(0, this.busy - 1);
670
+ const next = this.pending.shift();
671
+ if (next) queueMicrotask(next.run);
672
+ }
673
+ };
674
+ var workerBridgeUrl;
675
+ function getWorkerBridgeUrl() {
676
+ workerBridgeUrl ??= new URL(
677
+ `data:text/javascript;charset=utf-8,${encodeURIComponent(`
678
+ import { parentPort, workerData } from "node:worker_threads";
679
+
680
+ async function run() {
681
+ const mod = await import(workerData.modulePath);
682
+ const worker = mod[workerData.exportName];
683
+ if (typeof worker !== "function") {
684
+ throw new Error(\`Worker export "\${workerData.exportName}" was not found or is not a function\`);
685
+ }
686
+ const result = await worker({
687
+ jobId: workerData.jobId,
688
+ name: workerData.name,
689
+ payload: workerData.payload,
690
+ attempt: workerData.attempt,
691
+ signal: new AbortController().signal,
692
+ createdAt: new Date(workerData.createdAt),
693
+ startedAt: new Date(workerData.startedAt)
694
+ });
695
+ parentPort?.postMessage({ ok: true, result });
696
+ }
697
+
698
+ run().catch((error) => {
699
+ parentPort?.postMessage({
700
+ ok: false,
701
+ error: error instanceof Error
702
+ ? { name: error.name, message: error.message, stack: error.stack }
703
+ : error
704
+ });
705
+ });
706
+ `)}`
707
+ );
708
+ return workerBridgeUrl;
709
+ }
710
+
711
+ // src/utils/logger.ts
712
+ var noop = () => void 0;
713
+ function createLogger(config) {
714
+ const base = config.logger ?? {};
715
+ return {
716
+ info: base.info ?? noop,
717
+ warn: base.warn ?? noop,
718
+ error: base.error ?? noop,
719
+ debug: config.debug ? base.debug ?? noop : noop
720
+ };
721
+ }
722
+
723
+ // src/core/SilentCronX.ts
724
+ var SilentCronX = class {
725
+ config;
726
+ storage;
727
+ eventBus = new EventBus();
728
+ runner = new JobRunner();
729
+ workerPool;
730
+ lockManager;
731
+ queueManager;
732
+ logger;
733
+ entries = /* @__PURE__ */ new Map();
734
+ controllers = /* @__PURE__ */ new Map();
735
+ running = false;
736
+ constructor(config = {}) {
737
+ this.config = {
738
+ timezone: config.timezone ?? "UTC",
739
+ maxWorkers: config.maxWorkers ?? 2,
740
+ maxConcurrency: config.maxConcurrency ?? 50,
741
+ defaultTimeout: config.defaultTimeout ?? 3e4,
742
+ preventOverlapping: config.preventOverlapping ?? true,
743
+ lockTimeout: config.lockTimeout ?? config.defaultTimeout ?? 3e4,
744
+ ...config
745
+ };
746
+ this.storage = config.storage && config.storage !== "memory" ? config.storage : new MemoryStorageAdapter();
747
+ this.logger = createLogger(config);
748
+ this.workerPool = new WorkerPool(this.config.maxWorkers);
749
+ this.lockManager = new LockManager(this.storage, this.config.lockTimeout);
750
+ this.queueManager = new QueueManager(
751
+ this.storage,
752
+ this.eventBus,
753
+ this.config.maxPayloadBytes,
754
+ (jobId, task) => this.executeJob(jobId, task)
755
+ );
756
+ }
757
+ async schedule(name, options) {
758
+ validateJobName(name);
759
+ assertTask(options.task);
760
+ assertSerializablePayload(options.payload, this.config.maxPayloadBytes);
761
+ parseCronExpression(options.cron);
762
+ const id = createJobId("cron");
763
+ const nextRunAt = getNextCronDate(options.cron, /* @__PURE__ */ new Date(), options.timezone ?? this.config.timezone).toISOString();
764
+ await this.createRecord(id, name, "cron", options.payload, options.priority, getMaxAttempts(options.retry), {
765
+ nextRunAt,
766
+ metadata: { cron: options.cron, timezone: options.timezone ?? this.config.timezone },
767
+ status: options.enabled === false ? "paused" : "scheduled"
768
+ });
769
+ this.entries.set(id, {
770
+ id,
771
+ name,
772
+ kind: "cron",
773
+ task: options.task,
774
+ cron: options.cron,
775
+ retry: options.retry,
776
+ timeoutMs: options.timeout ?? this.config.defaultTimeout,
777
+ preventOverlap: options.preventOverlap ?? this.config.preventOverlapping
778
+ });
779
+ this.eventBus.emit("job:scheduled", { jobId: id, name, nextRunAt });
780
+ if (this.running && options.enabled !== false) this.armCron(id);
781
+ return id;
782
+ }
783
+ async delay(name, options) {
784
+ validateJobName(name);
785
+ assertPositiveNumber(options.delayMs, "delayMs");
786
+ assertTask(options.task);
787
+ assertSerializablePayload(options.payload, this.config.maxPayloadBytes);
788
+ const id = createJobId("delay");
789
+ const nextRunAt = new Date(Date.now() + options.delayMs).toISOString();
790
+ await this.createRecord(id, name, "delay", options.payload, options.priority, getMaxAttempts(options.retry), {
791
+ nextRunAt,
792
+ status: "scheduled"
793
+ });
794
+ this.entries.set(id, {
795
+ id,
796
+ name,
797
+ kind: "delay",
798
+ task: options.task,
799
+ delayMs: options.delayMs,
800
+ retry: options.retry,
801
+ timeoutMs: options.timeout ?? this.config.defaultTimeout,
802
+ preventOverlap: false
803
+ });
804
+ if (this.running) this.armDelay(id, options.delayMs);
805
+ return id;
806
+ }
807
+ async every(name, options) {
808
+ validateJobName(name);
809
+ assertPositiveNumber(options.intervalMs, "intervalMs");
810
+ assertTask(options.task);
811
+ assertSerializablePayload(options.payload, this.config.maxPayloadBytes);
812
+ const id = createJobId("every");
813
+ const nextRunAt = new Date(Date.now() + options.intervalMs).toISOString();
814
+ await this.createRecord(id, name, "every", options.payload, options.priority, getMaxAttempts(options.retry), {
815
+ nextRunAt,
816
+ status: options.enabled === false ? "paused" : "scheduled"
817
+ });
818
+ this.entries.set(id, {
819
+ id,
820
+ name,
821
+ kind: "every",
822
+ task: options.task,
823
+ intervalMs: options.intervalMs,
824
+ retry: options.retry,
825
+ timeoutMs: options.timeout ?? this.config.defaultTimeout,
826
+ preventOverlap: options.preventOverlap ?? this.config.preventOverlapping
827
+ });
828
+ if (this.running && options.enabled !== false) this.armEvery(id);
829
+ return id;
830
+ }
831
+ async runNow(name, options) {
832
+ validateJobName(name);
833
+ assertTask(options.task);
834
+ assertSerializablePayload(options.payload, this.config.maxPayloadBytes);
835
+ const id = createJobId("now");
836
+ await this.createRecord(id, name, "runNow", options.payload, options.priority, getMaxAttempts(options.retry), {
837
+ status: "pending"
838
+ });
839
+ this.entries.set(id, {
840
+ id,
841
+ name,
842
+ kind: "runNow",
843
+ task: options.task,
844
+ retry: options.retry,
845
+ timeoutMs: options.timeout ?? this.config.defaultTimeout,
846
+ preventOverlap: false
847
+ });
848
+ return this.executeJob(id);
849
+ }
850
+ async runWorker(name, options) {
851
+ validateJobName(name);
852
+ assertSerializablePayload(options.payload, this.config.maxPayloadBytes);
853
+ const id = createJobId("worker");
854
+ await this.createRecord(id, name, "worker", options.payload, options.priority, getMaxAttempts(options.retry), {
855
+ status: "pending"
856
+ });
857
+ const timeoutMs = options.timeout ?? this.config.defaultTimeout;
858
+ const workerTask = typeof options.worker === "function" ? options.worker : async (context) => this.workerPool.run({
859
+ worker: options.worker,
860
+ payload: context.payload,
861
+ jobId: context.jobId,
862
+ name: context.name,
863
+ attempt: context.attempt,
864
+ timeoutMs,
865
+ createdAt: context.createdAt.toISOString(),
866
+ startedAt: context.startedAt.toISOString()
867
+ });
868
+ if (typeof options.worker === "function") {
869
+ this.logger.warn(
870
+ "runWorker received an inline function. For security, SilentCronX does not eval code strings; the function will run through the async runner. Pass { path, exportName } to use worker_threads."
871
+ );
872
+ }
873
+ this.entries.set(id, {
874
+ id,
875
+ name,
876
+ kind: "worker",
877
+ task: workerTask,
878
+ retry: options.retry,
879
+ timeoutMs,
880
+ preventOverlap: false
881
+ });
882
+ return this.executeJob(id);
883
+ }
884
+ queue(name, options = {}) {
885
+ this.queueManager.createQueue(name, options);
886
+ }
887
+ addJob(queueName, job) {
888
+ return this.queueManager.addJob(queueName, job);
889
+ }
890
+ async cancel(jobId) {
891
+ this.controllers.get(jobId)?.abort(new JobCancelledError());
892
+ const job = await this.storage.getJob(jobId);
893
+ if (!job) return;
894
+ this.clearTimer(jobId);
895
+ await this.storage.updateJob(jobId, { status: "cancelled", finishedAt: nowIso() });
896
+ this.eventBus.emit("job:cancelled", { jobId, name: job.name });
897
+ }
898
+ async pause(jobId) {
899
+ const job = await this.storage.getJob(jobId);
900
+ if (!job) return;
901
+ this.clearTimer(jobId);
902
+ await this.storage.updateJob(jobId, { status: "paused" });
903
+ this.eventBus.emit("job:paused", { jobId, name: job.name });
904
+ }
905
+ async resume(jobId) {
906
+ const job = await this.storage.getJob(jobId);
907
+ const entry = this.entries.get(jobId);
908
+ if (!job || !entry) return;
909
+ await this.storage.updateJob(jobId, { status: entry.kind === "runNow" ? "pending" : "scheduled" });
910
+ this.eventBus.emit("job:resumed", { jobId, name: job.name });
911
+ if (this.running) this.armEntry(jobId);
912
+ }
913
+ async getStatus(jobId) {
914
+ const job = await this.storage.getJob(jobId);
915
+ if (!job) return null;
916
+ return {
917
+ jobId,
918
+ name: job.name,
919
+ status: job.status,
920
+ attempt: job.attempts,
921
+ nextRunAt: job.nextRunAt,
922
+ lastRunAt: job.lastRunAt
923
+ };
924
+ }
925
+ getJobs() {
926
+ return this.storage.listJobs();
927
+ }
928
+ start() {
929
+ if (this.running) return;
930
+ this.running = true;
931
+ for (const jobId of this.entries.keys()) this.armEntry(jobId);
932
+ this.eventBus.emit("scheduler:started", { running: true, at: nowIso() });
933
+ }
934
+ stop() {
935
+ if (!this.running) return;
936
+ this.running = false;
937
+ for (const jobId of this.entries.keys()) this.clearTimer(jobId);
938
+ this.eventBus.emit("scheduler:stopped", { running: false, at: nowIso() });
939
+ }
940
+ async shutdown() {
941
+ this.stop();
942
+ for (const controller of this.controllers.values()) controller.abort(new JobCancelledError("Scheduler shutdown"));
943
+ this.controllers.clear();
944
+ await this.workerPool.shutdown();
945
+ this.eventBus.emit("scheduler:shutdown", { at: nowIso() });
946
+ }
947
+ on(eventName, handler) {
948
+ this.eventBus.on(eventName, handler);
949
+ }
950
+ off(eventName, handler) {
951
+ this.eventBus.off(eventName, handler);
952
+ }
953
+ async getHealth() {
954
+ const jobs = await this.storage.listJobs();
955
+ return {
956
+ running: this.running,
957
+ scheduledJobs: jobs.filter((job) => job.status === "scheduled").length,
958
+ runningJobs: jobs.filter((job) => job.status === "running").length,
959
+ queuedJobs: this.queueManager.getQueuedCount(),
960
+ workers: this.workerPool.getStats(),
961
+ memory: {
962
+ storage: this.storage instanceof MemoryStorageAdapter ? "memory" : "custom"
963
+ }
964
+ };
965
+ }
966
+ async executeJob(jobId, taskOverride) {
967
+ while (this.runner.getActiveCount() >= this.config.maxConcurrency) {
968
+ await new Promise((resolve) => setTimeout(resolve, 10));
969
+ }
970
+ const record = await this.storage.getJob(jobId);
971
+ const entry = this.entries.get(jobId);
972
+ const task = taskOverride ?? entry?.task;
973
+ if (!record || !entry || !task) throw new SilentCronXError(`Job cannot be executed: ${jobId}`);
974
+ if (record.status === "cancelled" || record.status === "paused") {
975
+ return { jobId, name: record.name, status: record.status, attempt: record.attempts, durationMs: 0 };
976
+ }
977
+ const controller = new AbortController();
978
+ this.controllers.set(jobId, controller);
979
+ try {
980
+ return await this.runner.run({
981
+ record,
982
+ task,
983
+ retry: entry.retry,
984
+ timeoutMs: entry.timeoutMs,
985
+ signal: controller.signal,
986
+ lockKey: entry.preventOverlap ? `job:${record.name}` : void 0,
987
+ lockTtlMs: this.config.lockTimeout,
988
+ storage: this.storage,
989
+ eventBus: this.eventBus,
990
+ acquireLock: (key, ttl) => this.lockManager.acquire(key, ttl),
991
+ releaseLock: (key) => this.lockManager.release(key)
992
+ });
993
+ } finally {
994
+ this.controllers.delete(jobId);
995
+ }
996
+ }
997
+ armEntry(jobId) {
998
+ const entry = this.entries.get(jobId);
999
+ if (!entry) return;
1000
+ if (entry.kind === "cron") this.armCron(jobId);
1001
+ else if (entry.kind === "delay") this.armDelay(jobId, entry.delayMs ?? 0);
1002
+ else if (entry.kind === "every") this.armEvery(jobId);
1003
+ }
1004
+ async armCron(jobId) {
1005
+ const entry = this.entries.get(jobId);
1006
+ const record = await this.storage.getJob(jobId);
1007
+ if (!entry?.cron || !record || record.status === "paused" || record.status === "cancelled") return;
1008
+ const next = getNextCronDate(entry.cron, /* @__PURE__ */ new Date(), this.config.timezone);
1009
+ await this.storage.updateJob(jobId, { status: "scheduled", nextRunAt: next.toISOString() });
1010
+ this.clearTimer(jobId);
1011
+ const timer = setTimeout(() => {
1012
+ void this.executeJob(jobId).finally(() => {
1013
+ if (this.running) void this.armCron(jobId);
1014
+ });
1015
+ }, Math.max(1, next.getTime() - Date.now()));
1016
+ timer.unref?.();
1017
+ entry.timer = timer;
1018
+ }
1019
+ armDelay(jobId, delayMs) {
1020
+ const entry = this.entries.get(jobId);
1021
+ if (!entry) return;
1022
+ this.clearTimer(jobId);
1023
+ const timer = setTimeout(() => void this.executeJob(jobId), delayMs);
1024
+ timer.unref?.();
1025
+ entry.timer = timer;
1026
+ }
1027
+ async armEvery(jobId) {
1028
+ const entry = this.entries.get(jobId);
1029
+ const record = await this.storage.getJob(jobId);
1030
+ if (!entry?.intervalMs || !record || record.status === "paused" || record.status === "cancelled") return;
1031
+ const next = new Date(Date.now() + entry.intervalMs);
1032
+ await this.storage.updateJob(jobId, { status: "scheduled", nextRunAt: next.toISOString() });
1033
+ this.clearTimer(jobId);
1034
+ const timer = setTimeout(() => {
1035
+ void this.executeJob(jobId).finally(() => {
1036
+ if (this.running) void this.armEvery(jobId);
1037
+ });
1038
+ }, entry.intervalMs);
1039
+ timer.unref?.();
1040
+ entry.timer = timer;
1041
+ }
1042
+ clearTimer(jobId) {
1043
+ const entry = this.entries.get(jobId);
1044
+ if (entry?.timer) clearTimeout(entry.timer);
1045
+ if (entry) entry.timer = void 0;
1046
+ }
1047
+ async createRecord(id, name, kind, payload, priority = 0, maxAttempts = 1, extra = {}) {
1048
+ const createdAt = nowIso();
1049
+ await this.storage.saveJob({
1050
+ id,
1051
+ name,
1052
+ kind,
1053
+ status: extra.status ?? "pending",
1054
+ payload,
1055
+ priority,
1056
+ attempts: 0,
1057
+ maxAttempts,
1058
+ createdAt,
1059
+ updatedAt: createdAt,
1060
+ successCount: 0,
1061
+ failureCount: 0,
1062
+ ...extra
1063
+ });
1064
+ }
1065
+ };
1066
+
1067
+ // src/core/createSilentCronX.ts
1068
+ function createSilentCronX(config = {}) {
1069
+ return new SilentCronX(config);
1070
+ }
1071
+
1072
+ // src/storage/RedisStorageAdapter.placeholder.ts
1073
+ var RedisStorageAdapter = class {
1074
+ constructor() {
1075
+ throw new Error(
1076
+ "RedisStorageAdapter is a design placeholder. Implement StorageAdapter with SET NX PX locks, JSON job records, and atomic updates for multi-instance production use."
1077
+ );
1078
+ }
1079
+ saveJob(_job) {
1080
+ throw new Error("Not implemented");
1081
+ }
1082
+ updateJob(_jobId, _patch) {
1083
+ throw new Error("Not implemented");
1084
+ }
1085
+ getJob(_jobId) {
1086
+ throw new Error("Not implemented");
1087
+ }
1088
+ listJobs(_filter) {
1089
+ throw new Error("Not implemented");
1090
+ }
1091
+ deleteJob(_jobId) {
1092
+ throw new Error("Not implemented");
1093
+ }
1094
+ acquireLock(_lockKey, _ttlMs) {
1095
+ throw new Error("Not implemented");
1096
+ }
1097
+ releaseLock(_lockKey) {
1098
+ throw new Error("Not implemented");
1099
+ }
1100
+ };
1101
+
1102
+ // src/storage/PostgresStorageAdapter.placeholder.ts
1103
+ var PostgresStorageAdapter = class {
1104
+ constructor() {
1105
+ throw new Error(
1106
+ "PostgresStorageAdapter is a design placeholder. Implement StorageAdapter using transactions, row-level locks, indexed status columns, and advisory locks for production use."
1107
+ );
1108
+ }
1109
+ saveJob(_job) {
1110
+ throw new Error("Not implemented");
1111
+ }
1112
+ updateJob(_jobId, _patch) {
1113
+ throw new Error("Not implemented");
1114
+ }
1115
+ getJob(_jobId) {
1116
+ throw new Error("Not implemented");
1117
+ }
1118
+ listJobs(_filter) {
1119
+ throw new Error("Not implemented");
1120
+ }
1121
+ deleteJob(_jobId) {
1122
+ throw new Error("Not implemented");
1123
+ }
1124
+ acquireLock(_lockKey, _ttlMs) {
1125
+ throw new Error("Not implemented");
1126
+ }
1127
+ releaseLock(_lockKey) {
1128
+ throw new Error("Not implemented");
1129
+ }
1130
+ };
1131
+ export {
1132
+ MemoryStorageAdapter,
1133
+ PostgresStorageAdapter,
1134
+ RedisStorageAdapter,
1135
+ SilentCronX,
1136
+ createSilentCronX
1137
+ };
1138
+ //# sourceMappingURL=index.js.map