145 lines
12 KiB
Markdown
145 lines
12 KiB
Markdown
# @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`.
|
|
|
|
<!-- begin:auto-docs -->
|
|
## Documentation tree
|
|
|
|
- [errors](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [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…
|
|
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
|
|
<!-- end:auto-docs --> - [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(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…
|
|
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(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…
|
|
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(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…
|
|
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_- [cron-error](./src/errors/cron-error.md) — <!-- begin:auto-docs --> _(no submodules)_- [fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cron-fields](./src/fields/cron-fields.md) — <!-- begin:auto-docs --> _(no submodules)_- [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_ - [macros](./src/macros/macros.md) — <!-- begin:auto-docs --> _(no submodules)_- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(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…
|
|
- [next-run](./src/next-run/next-run.md) — <!-- begin:auto-docs --> _(no submodules)_- [otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cron-otel](./src/otel/cron-otel.md) — <!-- begin:auto-docs --> _(no submodules)_- [parse](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_ - [parse-cron](./src/parse/parse-cron.md) — <!-- begin:auto-docs --> _(no submodules)_- [scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_ - [cron-scheduler](./src/scheduler/cron-scheduler.md) — <!-- begin:auto-docs --> _(no submodules)_- [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_ - [timezone](./src/timezone/timezone.md) — <!-- begin:auto-docs --> _(no submodules)_
|
|
## Quick start
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```bash
|
|
bunx turbo test:coverage --filter=@suaveplan/cron
|
|
```
|
|
|