← Complete Projects

阅读中文版本

Build a Cron Expression Parser and Job Runner

A scheduler that parses standard five-field cron expressions, computes the next run time, executes registered jobs on schedule, and prints upcoming runs for any expression.

intermediate~5 hnodejsschedulingcroncli

Project goal

Build a cron engine that parses five-field expressions with ranges, steps, and lists, computes the next matching time, and runs registered jobs on a timer while logging each run.

Final features

  • Parse five-field cron expressions including ranges, steps, lists, and wildcards.
  • Compute the next matching time from a given instant.
  • Validate expressions and reject malformed fields with a clear message.
  • Run registered jobs when their expression next matches.
  • Print the next N run times for an expression without scheduling.

Architecture

A field parser turns each of the five fields into a set of allowed values; a matcher tests a timestamp against those sets; a next-run computation advances minute by minute from a starting point; and a runner schedules registered jobs using the computed times. All time logic takes an explicit starting instant so tests are deterministic.

Prerequisites

  • Node.js 20 LTS or later
  • Understanding of cron field syntax
  • Familiarity with time arithmetic in JavaScript

Dependencies

  • node >=20 — Timers and the CLI entry point
  • vitest ^2.1.0 — Parser and next-run tests over fixed instants

Directory structure

cron-runner/
├── src/
│   ├── parse.js
│   ├── nextRun.js
│   ├── runner.js
│   └── cli.js
├── test/
│   ├── parse.test.js
│   └── nextRun.test.js
└── package.json

Phases

1. Field parser

Parse one cron field into a set of allowed integers, handling wildcards, ranges, steps, and lists.

Files: src/parse.js

function parseField(field, { min, max }) {
  const values = new Set();
  for (const part of field.split(',')) {
    const [range, stepRaw] = part.split('/');
    const step = stepRaw ? Number(stepRaw) : 1;
    if (!Number.isInteger(step) || step < 1) throw new Error('bad step: ' + part);
    let lo; let hi;
    if (range === '*') { lo = min; hi = max; }
    else if (range.includes('-')) { [lo, hi] = range.split('-').map(Number); }
    else { lo = hi = Number(range); }
    if (Number.isNaN(lo) || Number.isNaN(hi) || lo < min || hi > max || lo > hi) throw new Error('bad range: ' + part);
    for (let v = lo; v <= hi; v += step) values.add(v);
  }
  return values;
}

Expected behavior: A wildcard fills the whole range, a step selects every Nth value, a list unions, and an out-of-range value throws.

2. Schedule matcher

Build a matcher from five parsed fields that tests a Date against minute, hour, day, month, and weekday.

Files: src/schedule.js

import { parseField } from './parse.js';

export function parseSchedule(expression) {
  const fields = expression.trim().split(/\s+/);
  if (fields.length !== 5) throw new Error('expected 5 fields, got ' + fields.length);
  const [minute, hour, day, month, weekday] = fields;
  const sets = {
    minute: parseField(minute, { min: 0, max: 59 }),
    hour: parseField(hour, { min: 0, max: 23 }),
    day: parseField(day, { min: 1, max: 31 }),
    month: parseField(month, { min: 1, max: 12 }),
    weekday: parseField(weekday, { min: 0, max: 6 }),
  };
  return {
    expression,
    matches(date) {
      return sets.minute.has(date.getMinutes()) && sets.hour.has(date.getHours()) &&
        sets.day.has(date.getDate()) && sets.month.has(date.getMonth() + 1) &&
        sets.weekday.has(date.getDay());
    },
  };
}

Expected behavior: A valid expression builds a matcher, an expression with the wrong field count throws, and matching respects every field.

3. Next run computation

Advance a candidate time minute by minute until it matches the schedule, with a bounded guard.

Files: src/nextRun.js

export function nextRun(schedule, from = new Date()) {
  const start = new Date(from.getTime());
  start.setSeconds(0, 0);
  start.setTime(start.getTime() + 60_000);
  for (let guard = 0; guard < 366 * 24 * 60; guard++) {
    if (schedule.matches(start)) return new Date(start.getTime());
    start.setTime(start.getTime() + 60_000);
  }
  return null;
}

export function nextRuns(schedule, count, from = new Date()) {
  const runs = [];
  let cursor = from;
  for (let i = 0; i < count; i++) {
    const next = nextRun(schedule, cursor);
    if (!next) break;
    runs.push(next);
    cursor = next;
  }
  return runs;
}

Expected behavior: The next run after a fixed instant matches the expression, and a series of runs is strictly increasing.

4. Job runner

Schedule registered jobs by computing each next run and arming a timer, logging every execution.

Files: src/runner.js

import { nextRun } from './nextRun.js';

export function createRunner({ now = () => new Date(), setTimeoutImpl = setTimeout, log = console.log }) {
  const jobs = [];
  return {
    register(name, expression, task) { jobs.push({ name, expression, task, schedule: null }); },
    async runDue(at = now()) {
      const executed = [];
      for (const job of jobs) {
        if (job.schedule.matches(at)) { await job.task(); executed.push(job.name); log('ran ' + job.name + ' at ' + at.toISOString()); }
      }
      return executed;
    },
    armAll() {
      for (const job of jobs) {
        const next = nextRun(job.schedule, now());
        const delay = Math.max(0, next.getTime() - now().getTime());
        setTimeoutImpl(() => this.runDue(next), delay);
      }
    },
  };
}

Expected behavior: A job whose schedule matches the given instant runs and is logged, and arming schedules one timer per job for its next run.

5. CLI

Print the next runs for an expression, or start the runner with a job list, and reject bad expressions.

Files: src/cli.js

import { parseSchedule } from './schedule.js';
import { nextRuns } from './nextRun.js';

const [command, expression, countRaw] = process.argv.slice(2);
try {
  if (command === 'next') {
    const schedule = parseSchedule(expression);
    for (const run of nextRuns(schedule, Number(countRaw ?? 5))) console.log(run.toISOString());
  } else {
    console.error('usage: cron next "<expr>" [count]');
    process.exit(1);
  }
} catch (err) {
  console.error('invalid expression: ' + err.message);
  process.exit(1);
}

Expected behavior: Printing next runs for a valid expression lists increasing timestamps, and a malformed expression exits 1 with a clear message.

6. Tests and hardening

Cover field parsing, matching, and next-run sequences, then fix the failures the tests expose.

Files: test/nextRun.test.js

import { parseSchedule } from '../src/schedule.js';
import { nextRun } from '../src/nextRun.js';

test('daily at 09:30 resolves to the next 09:30', () => {
  const schedule = parseSchedule('30 9 * * *');
  const next = nextRun(schedule, new Date('2026-03-01T10:00:00'));
  expect(next.toISOString().slice(0, 16)).toBe('2026-03-02T09:30');
});

test('every 15 minutes crosses an hour boundary', () => {
  const schedule = parseSchedule('*/15 * * * *');
  const next = nextRun(schedule, new Date('2026-03-01T10:52:00'));
  expect(next.toISOString().slice(0, 16)).toBe('2026-03-01T11:00');
});

Expected behavior: The next-run suite passes across day and hour boundaries, and an expression that can never match returns null instead of hanging.

Testing

Run npx vitest. Parser tests cover a plain value, a range, a step, a list, and a wildcard, plus a malformed field. Next-run tests assert the next time after a fixed instant for several expressions, including a month-boundary rollover.

Troubleshooting

The next run for a daily expression jumps a day

Start the search from the next minute and compare in local time consistently; mixing UTC and local getters shifts the boundary by the time-zone offset.

A step expression selects the wrong values

Apply the step relative to the range start, not from the previous selected value; the two differ for ranges that do not start at zero.

The runner spins on an impossible expression

Bound the search window and return null when no match is found; an expression like 31 February never matches and must fail fast.

Deployment

Use the CLI to confirm a schedule before deploying, and embed the runner in a long-lived process. For distributed scheduling, treat the next-run computation as the shared primitive and coordinate leaders separately.

Known limitations

  • The runner is in-process and does not coordinate across replicas, so two instances would run the same job.
  • Time zones are not modelled; matching uses the process local time.

Project overview

Cron looks simple until you meet its syntax: ranges, steps, and lists combine in ways that trip up a first implementation. This project parses the full five-field grammar and computes next run times you can verify.

The runner schedules registered jobs and logs each execution, while a dry-run mode prints the next several run times for any expression so you can confirm a schedule before trusting it.

The project is deliberately sized so that every stage ships something you can run: the first phase produces a working command or endpoint, and each later phase adds one capability without forcing a rewrite of what came before. That property — always runnable, never half-migrated — is what makes the tutorial usable as a weekend build rather than a sketch that only works once the final commit lands.

The finished tool carries no hidden services and no hosted dependencies. Everything it needs lives in the repository and a machine with the stated runtime, so a reader can clone, install, run the tests, and trust that the behaviour they see locally is the behaviour the tutorial promised. Where a real deployment needs an external system, that seam is called out explicitly rather than smuggled in through a default.

It is written for a working developer who wants the whole shape of a real tool in one readable pass rather than a tour of a framework. Each phase states the decision it makes and why, then shows the code that encodes it, so the reader can stop after any phase with something that runs and resume later without relearning the whole design. The prose is deliberately honest about trade-offs: where a simpler approach would break at scale, that ceiling is named instead of hidden behind a benchmark.

Next-run computation advances minute by minute until the timestamp matches every field, which is the clearest way to express the rule:

export function nextRun(schedule, from = new Date()) {
  const candidate = new Date(from.getTime() + 60_000 - (from.getTime() % 60_000));
  for (let guard = 0; guard < 366 * 24 * 60; guard++) {
    if (schedule.matches(candidate)) return candidate;
    candidate.setTime(candidate.getTime() + 60_000);
  }
  return null;
}

The bounded guard prevents an impossible expression from spinning forever, returning null instead of hanging the process.

Setup

Zero runtime dependencies; the parser and scheduler are pure JavaScript with an explicit starting time.

Set up the workspace before writing any logic. The table below pins every dependency to a version so the behaviour you observe matches the tutorial; floating versions are the most common reason a reader’s output diverges from the text. Install exactly what the table lists, then confirm the toolchain reports the expected version before continuing.

Two configuration choices deserve attention up front. First, the project keeps its runtime entry and its test entry as separate scripts so that running tests never starts a server or writes to a data directory. Second, all tunables are read from one place — a small config module or environment variables — so that nothing depends on the working directory a command happens to be launched from.

Dependency Version Why it is here
node >=20 Timers and the CLI entry point
vitest ^2.1.0 Parser and next-run tests over fixed instants

Create the project and install:

mkdir cron-runner && cd cron-runner
npm init -y
npm install -D vitest@^2.1.0

Configure the CLI and tests in package.json:

{
  "name": "cron-runner",
  "type": "module",
  "scripts": {
    "cron": "node src/cli.js",
    "test": "vitest run"
  }
}

All tests pass an explicit from-instant so they never depend on the wall clock.

Phase 1: Field parser

Parse one cron field into a set of allowed integers, handling wildcards, ranges, steps, and lists. The scope is chosen so the project is genuinely finishable, yet every phase has a real decision to make rather than busywork.

The first hard decision is where the boundary between parsing and doing sits. Input arrives as raw text — argv, environment variables, a file on disk — and this phase converts it exactly once into a typed object that the rest of the program trusts. Confining that conversion to a single module means every later layer receives clean, already-validated input and never re-checks a string, which removes a whole class of bugs where two modules quietly disagree about what a flag or a field means. The second decision is failure visibility: invalid input must produce an actionable message and a non-zero exit rather than a stack trace surfacing from three layers down. Getting these two boundaries right early is what lets the remaining phases stay small.

function parseField(field, { min, max }) {
  const values = new Set();
  for (const part of field.split(',')) {
    const [range, stepRaw] = part.split('/');
    const step = stepRaw ? Number(stepRaw) : 1;
    if (!Number.isInteger(step) || step < 1) throw new Error('bad step: ' + part);
    let lo; let hi;
    if (range === '*') { lo = min; hi = max; }
    else if (range.includes('-')) { [lo, hi] = range.split('-').map(Number); }
    else { lo = hi = Number(range); }
    if (Number.isNaN(lo) || Number.isNaN(hi) || lo < min || hi > max || lo > hi) throw new Error('bad range: ' + part);
    for (let v = lo; v <= hi; v += step) values.add(v);
  }
  return values;
}

Acceptance for this phase: A wildcard fills the whole range, a step selects every Nth value, a list unions, and an out-of-range value throws.

Phase 2: Schedule matcher

Build a matcher from five parsed fields that tests a Date against minute, hour, day, month, and weekday. The scope is chosen so the project is genuinely finishable, yet every phase has a real decision to make rather than busywork.

This phase defines the data model that every other module speaks. The choice of shape matters more than it looks: flat records are easy to serialize and diff, nested structures model relationships but complicate equality checks, and identifiers must be stable across edits so that references never silently rebind. The code here stays pure — functions take data and return data, with no clocks, no disk, and no printing — because pure functions are the only kind that can be tested exhaustively without a fixture harness. When the model is right the surrounding code almost writes itself; when it is wrong every later phase pays interest.

import { parseField } from './parse.js';

export function parseSchedule(expression) {
  const fields = expression.trim().split(/\s+/);
  if (fields.length !== 5) throw new Error('expected 5 fields, got ' + fields.length);
  const [minute, hour, day, month, weekday] = fields;
  const sets = {
    minute: parseField(minute, { min: 0, max: 59 }),
    hour: parseField(hour, { min: 0, max: 23 }),
    day: parseField(day, { min: 1, max: 31 }),
    month: parseField(month, { min: 1, max: 12 }),
    weekday: parseField(weekday, { min: 0, max: 6 }),
  };
  return {
    expression,
    matches(date) {
      return sets.minute.has(date.getMinutes()) && sets.hour.has(date.getHours()) &&
        sets.day.has(date.getDate()) && sets.month.has(date.getMonth() + 1) &&
        sets.weekday.has(date.getDay());
    },
  };
}

Acceptance for this phase: A valid expression builds a matcher, an expression with the wrong field count throws, and matching respects every field.

Phase 3: Next run computation

Advance a candidate time minute by minute until it matches the schedule, with a bounded guard. The scope is chosen so the project is genuinely finishable, yet every phase has a real decision to make rather than busywork.

The core operation is where the project delivers its value, and the temptation is to cram every option into one function. Resist it. The main path should read as a short, obvious sequence — load, transform, emit — with the branching behaviour factored into small decisions that can be tested on their own. This phase also fixes the error contract: which failures are fatal, which are recoverable, and how a partial result is reported. A tool that can explain what it did and why it stopped is far easier to trust in a pipeline than one that only signals success or silence.

export function nextRun(schedule, from = new Date()) {
  const start = new Date(from.getTime());
  start.setSeconds(0, 0);
  start.setTime(start.getTime() + 60_000);
  for (let guard = 0; guard < 366 * 24 * 60; guard++) {
    if (schedule.matches(start)) return new Date(start.getTime());
    start.setTime(start.getTime() + 60_000);
  }
  return null;
}

export function nextRuns(schedule, count, from = new Date()) {
  const runs = [];
  let cursor = from;
  for (let i = 0; i < count; i++) {
    const next = nextRun(schedule, cursor);
    if (!next) break;
    runs.push(next);
    cursor = next;
  }
  return runs;
}

Acceptance for this phase: The next run after a fixed instant matches the expression, and a series of runs is strictly increasing.

Phase 4: Job runner

Schedule registered jobs by computing each next run and arming a timer, logging every execution. The scope is chosen so the project is genuinely finishable, yet every phase has a real decision to make rather than busywork.

Integration is where the project stops being a toy. Input may arrive in a format an external system produced, output may need to match a contract another system consumes, and the two rarely line up without adjustment. This phase owns the adapters — the small translation functions between the outside world’s shape and the internal model — and it owns the boundary decisions about what to do when the two disagree: coerce, reject, or warn and continue. Keeping adapters thin and explicit means a change to an external format touches one file, and the internal model stays stable as the ecosystem around it moves.

import { nextRun } from './nextRun.js';

export function createRunner({ now = () => new Date(), setTimeoutImpl = setTimeout, log = console.log }) {
  const jobs = [];
  return {
    register(name, expression, task) { jobs.push({ name, expression, task, schedule: null }); },
    async runDue(at = now()) {
      const executed = [];
      for (const job of jobs) {
        if (job.schedule.matches(at)) { await job.task(); executed.push(job.name); log('ran ' + job.name + ' at ' + at.toISOString()); }
      }
      return executed;
    },
    armAll() {
      for (const job of jobs) {
        const next = nextRun(job.schedule, now());
        const delay = Math.max(0, next.getTime() - now().getTime());
        setTimeoutImpl(() => this.runDue(next), delay);
      }
    },
  };
}

Acceptance for this phase: A job whose schedule matches the given instant runs and is logged, and arming schedules one timer per job for its next run.

Phase 5: CLI

Print the next runs for an expression, or start the runner with a job list, and reject bad expressions. The scope is chosen so the project is genuinely finishable, yet every phase has a real decision to make rather than busywork.

The user-facing surface determines whether the project feels finished. A command-line tool owes its caller predictable stdout, meaningful stderr, and honest exit codes; a service owes a stable request and response schema plus consistent status handling. This phase wires the internal modules to that surface and nothing else — it contains no business logic of its own, only translation between the outside contract and the internal calls. That separation is what lets the same core serve a CLI today and an HTTP endpoint tomorrow without duplication, and it is why the tests for this layer assert process-level behaviour rather than poking at internals.

import { parseSchedule } from './schedule.js';
import { nextRuns } from './nextRun.js';

const [command, expression, countRaw] = process.argv.slice(2);
try {
  if (command === 'next') {
    const schedule = parseSchedule(expression);
    for (const run of nextRuns(schedule, Number(countRaw ?? 5))) console.log(run.toISOString());
  } else {
    console.error('usage: cron next "<expr>" [count]');
    process.exit(1);
  }
} catch (err) {
  console.error('invalid expression: ' + err.message);
  process.exit(1);
}

Acceptance for this phase: Printing next runs for a valid expression lists increasing timestamps, and a malformed expression exits 1 with a clear message.

Phase 6: Tests and hardening

Cover field parsing, matching, and next-run sequences, then fix the failures the tests expose. The scope is chosen so the project is genuinely finishable, yet every phase has a real decision to make rather than busywork.

The final phase turns a working program into a dependable one. Tests are the visible part, but the discipline behind them is deeper: every claim the earlier phases made is pinned by an executable check, and every failure mode the tutorial mentioned has a test that reproduces it before the fix. Edge cases — empty input, malformed records, concurrent invocations, interrupted writes — are enumerated and asserted rather than assumed away. The payoff is a suite that fails loudly and precisely when someone changes a decision, which is exactly the signal a maintainer wants the moment they touch the code.

import { parseSchedule } from '../src/schedule.js';
import { nextRun } from '../src/nextRun.js';

test('daily at 09:30 resolves to the next 09:30', () => {
  const schedule = parseSchedule('30 9 * * *');
  const next = nextRun(schedule, new Date('2026-03-01T10:00:00'));
  expect(next.toISOString().slice(0, 16)).toBe('2026-03-02T09:30');
});

test('every 15 minutes crosses an hour boundary', () => {
  const schedule = parseSchedule('*/15 * * * *');
  const next = nextRun(schedule, new Date('2026-03-01T10:52:00'));
  expect(next.toISOString().slice(0, 16)).toBe('2026-03-01T11:00');
});

Acceptance for this phase: The next-run suite passes across day and hour boundaries, and an expression that can never match returns null instead of hanging.

Design trade-offs

Minimal dependencies over framework convenience. A framework would hand over plumbing for free, but it would also own the interesting decisions this project exists to teach. Keeping the dependency surface small means the lifecycle is visible end to end, the install is instant, and the tool runs anywhere the stated runtime does. The cost is owning the edge cases yourself — which is precisely why each of them gets a test.

Explicit contracts over clever conventions. Where a convention would infer behaviour from a filename or a folder, the project asks for a named export or a declared field instead. It reads as slightly more verbose and is far easier to review: a reader sees the intent on the line that owns it rather than reconstructing it from directory layout. Debugging also gets cheaper, because the failure points at the declaration instead of the convention that silently produced it.

Deterministic behaviour over convenience defaults. Anything that could vary between machines — time, ordering, encoding, locale — is injected or pinned rather than read from the ambient environment. That choice costs a little ceremony and buys reproducible output, which is the property that makes the output diffable in review and the tests trustworthy across the team’s machines.

Testing strategy

Run npx vitest. Parser tests cover a plain value, a range, a step, a list, and a wildcard, plus a malformed field. Next-run tests assert the next time after a fixed instant for several expressions, including a month-boundary rollover.

Tests are organised by layer so a failure points at the decision that broke. Pure unit tests cover the transformation logic without touching disk or network and run in milliseconds; integration tests exercise the real entry point against a temporary working directory and assert the observable contract; a small end-to-end tier proves the whole path produces the documented output. Keeping the fast tiers large and the slow tier thin is what lets the suite run on every save while still catching integration mistakes.

Every test asserts a specific promise from the phases above rather than merely covering lines. A passing suite therefore means the tool kept its word: filters compose, identifiers stay stable, failures exit non-zero with a readable message, and the output matches the format a downstream consumer expects. When someone changes a contract the matching test fails with an exact diff, turning a silent regression into a line in the pull request.

Running the suite in CI is the last line of defence, but the same command runs locally before every commit, which is what keeps the feedback loop short. A green run means the documented behaviour still holds; a red run names the exact promise that broke. Treat the suite as executable documentation and it stays current, because a stale assertion fails the moment the code and the text disagree.

The month-rollover test is where off-by-one bugs surface: advancing minute by minute across a month boundary is exactly where naive date arithmetic breaks.

Extending the project

Seconds and years fields, time zones, and a persistent job registry are additions around the parser and runner. Because matching is a pure predicate over a date, new fields extend the parser and the matcher together.

The structure leaves obvious seams for growth. Each capability that could be added — a new output format, an extra filter, an interactive mode — lands in one layer because the boundaries between parsing, logic, and presentation are already drawn. Extensions that would break the current design are equally visible: the moment a feature needs cross-process coordination or a database, the storage interface is where the change belongs, and its tests tell you immediately whether the surrounding code survives the swap.