Files
suaveplan/packages/foundation/timing/cron/README.md
2026-05-27 23:47:53 +01:00

12 KiB

@suaveplan/cron - Universal cron expression parser, next-fire calculator with timezone + DST support, and lightweight in-process scheduler

Summary

Universal cron expression parser, next-fire calculator with timezone + DST awareness, and an opt-in lightweight in-process scheduler. Zero external cron dependencies — written natively against @suaveplan/error, @suaveplan/types, and Intl.DateTimeFormat.

Documentation tree

- [cron-error](./src/errors/cron-error.md) — _(no submodules)_- [fields](./src/fields/cron-fields.md) — _(no submodules)_- [cron-fields](./src/fields/cron-fields.md) — _(no submodules)_- [macros](./src/macros/macros.md) — _(no submodules)_- [macros](./src/macros/macros.md) — _(no submodules)_- [next-run](./src/next-run/next-run.md) — _(no submodules)_- [next-run-fields](./src/next-run/next-run-fields.md) — `next-run-fields` houses the low-level field-walking helpers used by the forward and backward next-run search loops in…

Quick start

import { parseCron, isValidCron } from "@suaveplan/cron";

const expr = parseCron("*/15 * * * *");
console.log(expr.fields.minutes); // [0, 15, 30, 45]

const next = expr.nextRun(Date.UTC(2026, 4, 11, 10, 7, 0), "America/New_York");
console.log(new Date(next as number).toISOString());

console.log(isValidCron("0 9 * * MON-FRI")); // true
console.log(isValidCron("garbage"));          // false

Scheduler example

import { CronScheduler } from "@suaveplan/cron";

const scheduler = new CronScheduler({
    onError: (err, id) => console.error(`job ${id} failed`, err),
});

scheduler.add("0 9 * * MON-FRI", async () => sendDailyDigest(), {
    tz: "America/New_York",
});
scheduler.add("@hourly", () => collectMetrics());

scheduler.start();

process.on("SIGTERM", async () => {
    await scheduler.stop();
    process.exit(0);
});

FakeClock test recipe

import { CronScheduler } from "@suaveplan/cron";
import type { SchedulerTimers } from "@suaveplan/cron";

const now = { value: Date.UTC(2026, 4, 11, 9, 59, 0) };
const pending = new Map<number, { cb: () => void; fireAt: number }>();
let nextId = 1;

const timers: SchedulerTimers = {
    setTimer: (cb, ms) => {
        const id = nextId++;
        pending.set(id, { cb, fireAt: now.value + ms });
        return id;
    },
    clearTimer: (id) => pending.delete(id),
};

const scheduler = new CronScheduler({ clock: () => now.value, timers });
scheduler.add("0 * * * *", () => console.log("fired"));
scheduler.start();

// Fast-forward 1 hour.
now.value += 60 * 60 * 1000;
for (const [id, { cb, fireAt }] of pending) {
    if (fireAt <= now.value) { pending.delete(id); cb(); }
}

await scheduler.stop();

API reference

Surface Purpose
parseCron(expr, options?) Parse a 5/6/7-field expression into a frozen CronExpression.
isValidCron(expr, options?) Non-throwing parse check.
CronExpression.nextRun Next epoch instant strictly greater than after.
CronExpression.nextRuns Strictly monotonic array of count fire times.
CronExpression.previousRun Largest epoch instant strictly less than before.
CronExpression.matches Constant-time wall-clock membership check.
CronScheduler Lightweight in-process driver (no queue/retry/concurrency).
createCronScheduler Factory mirror.
parseField, rangeFor Single-field tokenizer + per-kind range.
expandMacro, macroTable Macro shortcut expansion (@daily, @hourly, …).
toWallClock, fromWallClock Bidirectional epoch ↔ wall-clock conversion in any IANA zone.
assertTimezone Eager IANA-zone validation.
detectDstTransition DST classifier between two adjacent instants.
CronError, CRON_CODES Typed error class + frozen code registry.
recordParse, recordNextRun OpenTelemetry instrumentation helpers.

Why not croner

Croner is excellent reference material but ships as an opaque tarball, imposes its own API conventions, and is not universal in the sense. @suaveplan/cron lives at the same primitive tier as @suaveplan/datetime and @suaveplan/timing: zero external runtime deps beyond the foundation, typed errors via defineCodes, Zod boundary schemas, an OTel surface, and the standard five-files-per-feature layout. Splitting cron parsing out of the job-scheduler keeps the workflow-tier scheduler focused on queue / retry / concurrency concerns while letting validation packages and browser UIs consume parsing without dragging in the runtime.

Coverage

100 % Istanbul coverage on every implementation file is gated by CI:

bunx turbo test:coverage --filter=@suaveplan/cron