@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
- errors — (no submodules)
- cron-error — (no submodules)
- fields — (no submodules)
- cron-fields — (no submodules)
- macros — (no submodules)
- macros — (no submodules)
- next-run — (no submodules)
- next-run-fields —
next-run-fieldshouses the low-level field-walking helpers used by the forward and backward next-run search loops in… - next-run — (no submodules)
- next-run-fields —
- otel — (no submodules)
- cron-otel — (no submodules)
- parse — (no submodules)
- parse-cron — (no submodules)
- scheduler — (no submodules)
- cron-scheduler — (no submodules)
- timezone — (no submodules)
- timezone — (no submodules)
- next-run — (no submodules)- otel — (no submodules)- cron-otel — (no submodules)- parse — (no submodules)- parse-cron — (no submodules)- scheduler — (no submodules)- cron-scheduler — (no submodules)- timezone — (no submodules)- timezone — (no submodules)- cron-error — (no submodules)- fields — (no submodules)- cron-fields — (no submodules)- macros — (no submodules)- macros — (no submodules)- next-run — (no submodules)- next-run-fields —
next-run-fieldshouses the low-level field-walking helpers used by the forward and backward next-run search loops in… - next-run — (no submodules)- otel — (no submodules)- cron-otel — (no submodules)- parse — (no submodules)- parse-cron — (no submodules)- scheduler — (no submodules)- cron-scheduler — (no submodules)- timezone — (no submodules)- timezone — (no submodules)- cron-error — (no submodules)- fields — (no submodules)- cron-fields — (no submodules)- macros — (no submodules)- macros — (no submodules)- next-run — (no submodules)- next-run-fields —
next-run-fieldshouses the low-level field-walking helpers used by the forward and backward next-run search loops in… - next-run — (no submodules)- otel — (no submodules)- cron-otel — (no submodules)- parse — (no submodules)- parse-cron — (no submodules)- scheduler — (no submodules)- cron-scheduler — (no submodules)- timezone — (no submodules)- timezone — (no submodules)- cron-error — (no submodules)- fields — (no submodules) - cron-fields — (no submodules)- macros — (no submodules) - macros — (no submodules)- next-run — (no submodules) - next-run-fields —
next-run-fieldshouses the low-level field-walking helpers used by the forward and backward next-run search loops in… - next-run — (no submodules)- otel — (no submodules) - cron-otel — (no submodules)- parse — (no submodules) - parse-cron — (no submodules)- scheduler — (no submodules) - cron-scheduler — (no submodules)- timezone — (no submodules) - timezone — (no submodules)
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