@wraugh/cronlib

0.1.3 • Public • Published

Cronlib helps you work with cron-like scheduled events.

const { Crontab } = require('@wraugh/cronlib')

/* Start by scheduling some events */
let crontab = new Crontab()
crontab.add('42 *  * * *', 'coffee')
crontab.add('12 12 * * *', 'lunch!')

/* Now you can ask "which events happen at 11:42?" */
tap.strictSame(crontab.at(new Date('1985-11-05 11:42')), [{
  ev: 'coffee', at: new Date('1985-11-05 11:42')
}])

/* Or, more usefully, "which events happen between 11:42 and 13:42?" */
const from = new Date('1985-11-05 11:42')
const upTo = new Date('1985-11-05 13:42')
tap.strictSame(crontab.between(from, upTo), [
  { ev: 'coffee', at: new Date('1985-11-05 11:42') },
  { ev: 'lunch!', at: new Date('1985-11-05 12:12') },
  { ev: 'coffee', at: new Date('1985-11-05 12:42') },
  { ev: 'coffee', at: new Date('1985-11-05 13:42') }
])

Usage

There are two steps to using cronlib:

  1. Schedule some events by adding them to a crontab.
  2. Choose a time window and ask the crontab which events occur inside it.

These simple steps allow for multiple usage patterns:

You could ask which events are set to occur this very minute by setting the time window to right now. You could build a cron daemon by checking this every minute:

setInterval(
    () => crontab.at(new Date()).map(ev => handle(ev.ev)),
    60000
)

Or you could ask which events occured since I last checked? This is useful in long-lived apps that are in the background most of the time:

let now = new Date()
crontab.between(lastCheck, now).map(ev => handle(ev.ev))
lastCheck = now

Another way to setup a cron daemon is to ask what's scheduled between now and 1000 years into the future? cronlib can answer that with a generator, so you could handle events one at a time with low overhead:

const next = events => {
  const ev = events.next().value
  setTimeout(() => {
    handle(ev.ev)
    next(events)
  }, Math.max(ev.at - new Date(), 0))
}
const farFuture = new Date('9999-12-31 23:59:59')
next(crontab.genBetween(new Date(), farFuture))

Schedules

Schedules are cron-like. They're made up of five fields separated by whitespace:

field allowed values
minute 0-59
hour 0-23
day of month 1-31
month 1-12 (or names, see below)
day of week 0-7 (0 or 7 is Sunday, or use names)

Events are scheduled at the specified minute, hour, day, and month.

Examples
crontab.add('1 2 3 4 5', _)
tap.throws(() => crontab.add('NaN 2 3 4 5', _))
tap.throws(() => crontab.add('1 NaN 3 4 5', _))
tap.throws(() => crontab.add('1 2 NaN 4 5', _))
tap.throws(() => crontab.add('1 2 3 NaN 5', _))
tap.throws(() => crontab.add('1 2 3 4 NaN', _))

tap.throws(() => crontab.add('-1  2  3  4  5', _))
tap.throws(() => crontab.add(' 1 -1  3  4  5', _))
tap.throws(() => crontab.add(' 1  2  0  4  5', _))
tap.throws(() => crontab.add(' 1  2  3  0  5', _))
tap.throws(() => crontab.add(' 1  2  3  4 -1', _))

tap.throws(() => crontab.add('60 2  3  4  5', _))
tap.throws(() => crontab.add('1  24 3  4  5', _))
tap.throws(() => crontab.add('1  2  32 4  5', _))
tap.throws(() => crontab.add('1  2  3  13 5', _))
tap.throws(() => crontab.add('1  2  3  4  8', _))

Asterisks

A field may contain an asterisk (*), which means it's unconstrained. E.g. if you want to schedule something every month, set the month field to *.

Example
crontab = new Crontab()
crontab.add('* * * * *', 'every minute')
tap.match(crontab.at(new Date()), [{ ev: 'every minute' }])

Names

Names can be used for the 'month' and 'day of week' fields.You can use the full name or the first three letters of the particular day or month (case doesn't matter).

Example
crontab = new Crontab()
crontab.add('0 0 * Jan Mon', 'These')
crontab.add('1 0 * JAN MON', 'have')
crontab.add('2 0 * January Monday', 'equivalent')
crontab.add('3 0 * january monday', 'months')
crontab.add('4 0 * 1 1', { and: 'days' })
tap.match(crontab.between(new Date('2000-01-01'), new Date('2000-01-07')), [
  { ev: 'These' },
  { ev: 'have' },
  { ev: 'equivalent' },
  { ev: 'months' },
  { ev: { and: 'days' } }
])

Day constraints are ORed

Days can be given two ways: as a day of month, or as a weekday. As a special case, if both are constrained, then the event is scheduled at times that match either constraint.

Example
crontab = new Crontab()
crontab.add('0 0 *  * Friday', 'Fridays                ')
crontab.add('1 0 13 * *     ', 'The 13th of the month  ')
crontab.add('2 0 13 * Fri   ', 'Fridays and/or the 13th')
tap.strictSame(crontab.between(new Date(1980, 5, 1), new Date(1980, 5, 14)), [
  { ev: 'Fridays                ', at: new Date(1980, 5, 6, 0, 0) },
  { ev: 'Fridays and/or the 13th', at: new Date(1980, 5, 6, 0, 2) },
  { ev: 'Fridays                ', at: new Date(1980, 5, 13, 0, 0) },
  { ev: 'The 13th of the month  ', at: new Date(1980, 5, 13, 0, 1) },
  { ev: 'Fridays and/or the 13th', at: new Date(1980, 5, 13, 0, 2) }
])

Ranges

Ranges are allowed. Ranges are two numbers or names separated with a hyphen. The specified range is inclusive. For example, "8-11" for an hours entry means hours 8, 9, 10, and 11. If the first element is greater than the second one, the range "loops around" the last value; e.g. "Fri-Mon" means Friday, Saturday, Sunday, and Monday.

Example
crontab = new Crontab()
crontab.add('0 8  * Sep-May Mon-Fri', 'Open')
crontab.add('0 10 * Sep-May Sat-Sun', 'Open')
crontab.add('0 18 * Sep-May Mon-Wed', 'Close')
crontab.add('0 21 * Sep-May Thu-Sun', 'Close')
tap.strictSame(crontab.between(new Date(1985, 10, 5), new Date(1985, 10, 6)), [
  /* Nov 5, 1985 was a Tuesday */
  { ev: 'Open', at: new Date(1985, 10, 5, 8) },
  { ev: 'Close', at: new Date(1985, 10, 5, 18) }
])
tap.strictSame(crontab.between(new Date(1985, 10, 7), new Date(1985, 10, 8)), [
  /* Nov 7, 1985 was a Thursday */
  { ev: 'Open', at: new Date(1985, 10, 7, 8) },
  { ev: 'Close', at: new Date(1985, 10, 7, 21) }
])
tap.strictSame(crontab.between(new Date(1985, 10, 9), new Date(1985, 10, 10)), [
  /* Nov 9, 1985 was a Saturday */
  { ev: 'Open', at: new Date(1985, 10, 9, 10) },
  { ev: 'Close', at: new Date(1985, 10, 9, 21) }
])

a range that starts and ends on the same value is the same as giving just the value on its own. Saying "April to April" means just "April", it doesn't mean all months of the year.

crontab.add('0-0 1-1 5-5 Nov-November *', {
  issue: 'stutter',
  effect: 'benign'
})
tap.strictSame(crontab.at(new Date('1985-11-05 01:00')), [{
  ev: { issue: 'stutter', effect: 'benign' },
  at: new Date('1985-11-05 01:00')
}])

Steps

Step values can be used in conjunction with ranges. Following a range with "/n" specifies skips by n through the range. For example, "0-23/2" can be used in the 'hours' field to specify every other hour ("0,2,4,6,8,10,12,14,16,18,20,22"). Step values are also permitted after an asterisk, so you could also write this as "*/2".

Example
crontab = new Crontab()
crontab.add('0 */5 * * *', 'every five hours, from 0:00')
crontab.add('0 3-23/5 * * *', 'every five hours, from 3:00')
tap.strictSame(crontab.between(new Date(1970, 0, 1), new Date(1970, 0, 1, 23, 59)), [
  { ev: 'every five hours, from 0:00', at: new Date(1970, 0, 1, 0) },
  { ev: 'every five hours, from 3:00', at: new Date(1970, 0, 1, 3) },
  { ev: 'every five hours, from 0:00', at: new Date(1970, 0, 1, 5) },
  { ev: 'every five hours, from 3:00', at: new Date(1970, 0, 1, 8) },
  { ev: 'every five hours, from 0:00', at: new Date(1970, 0, 1, 10) },
  { ev: 'every five hours, from 3:00', at: new Date(1970, 0, 1, 13) },
  { ev: 'every five hours, from 0:00', at: new Date(1970, 0, 1, 15) },
  { ev: 'every five hours, from 3:00', at: new Date(1970, 0, 1, 18) },
  { ev: 'every five hours, from 0:00', at: new Date(1970, 0, 1, 20) },
  { ev: 'every five hours, from 3:00', at: new Date(1970, 0, 1, 23) }
])

The step must be an integer in these ranges:

field allowed step values
minute 1-59
hour 1-23
day of month 1-30
month 1-11
day of week 1-6

Otherwise add throws an Error:

crontab.add('                 */1  */1  */1  */1  */1', _)
crontab.add('                 */59 */23 */30 */11 */6', _)
tap.throws(() => crontab.add('*/60 *    *    *    *  ', _))
tap.throws(() => crontab.add('*    */24 *    *    *  ', _))
tap.throws(() => crontab.add('*    *    */31 *    *  ', _))
tap.throws(() => crontab.add('*    *    *    */12 *  ', _))
tap.throws(() => crontab.add('*    *    *    *    */7', _))

tap.throws(() => crontab.add('*/-1 *    *    *    *   ', _))
tap.throws(() => crontab.add('*    */-1 *    *    *   ', _))
tap.throws(() => crontab.add('*    *    */-1 *    *   ', _))
tap.throws(() => crontab.add('*    *    *    */-1 *   ', _))
tap.throws(() => crontab.add('*    *    *    *    */-1', _))

tap.throws(() => crontab.add('*/0 *   *   *   *  ', _))
tap.throws(() => crontab.add('*   */0 *   *   *  ', _))
tap.throws(() => crontab.add('*   *   */0 *   *  ', _))
tap.throws(() => crontab.add('*   *   *   */0 *  ', _))
tap.throws(() => crontab.add('*   *   *   *   */0', _))

tap.throws(() => crontab.add('*/0.1 *     *     *     *    ', _))
tap.throws(() => crontab.add('*     */2.3 *     *     *    ', _))
tap.throws(() => crontab.add('*     *     */4.5 *     *    ', _))
tap.throws(() => crontab.add('*     *     *     */6.7 *    ', _))
tap.throws(() => crontab.add('*     *     *     *     */8.9', _))

Lists

Lists are allowed. A list is a set of numbers, names, or ranges separated by commas. Examples: "1,2,5,9", "0-4,8-12".

Example
crontab = new Crontab()
crontab.add('0 1,3-5,7 1 Jan-March/2,5-7,9 *', _)
crontab.add('0 1,2,3,1-3 1,1,1-1 Dec *', 'redundant')
tap.strictSame(crontab.between(new Date('2039-01-01'), new Date('2039-12-01')), [
  { ev: _, at: new Date('2039-01-01 01:00') },
  { ev: _, at: new Date('2039-01-01 03:00') },
  { ev: _, at: new Date('2039-01-01 04:00') },
  { ev: _, at: new Date('2039-01-01 05:00') },
  { ev: _, at: new Date('2039-01-01 07:00') },

  { ev: _, at: new Date('2039-03-01 01:00') },
  { ev: _, at: new Date('2039-03-01 03:00') },
  { ev: _, at: new Date('2039-03-01 04:00') },
  { ev: _, at: new Date('2039-03-01 05:00') },
  { ev: _, at: new Date('2039-03-01 07:00') },

  { ev: _, at: new Date('2039-05-01 01:00') },
  { ev: _, at: new Date('2039-05-01 03:00') },
  { ev: _, at: new Date('2039-05-01 04:00') },
  { ev: _, at: new Date('2039-05-01 05:00') },
  { ev: _, at: new Date('2039-05-01 07:00') },

  { ev: _, at: new Date('2039-06-01 01:00') },
  { ev: _, at: new Date('2039-06-01 03:00') },
  { ev: _, at: new Date('2039-06-01 04:00') },
  { ev: _, at: new Date('2039-06-01 05:00') },
  { ev: _, at: new Date('2039-06-01 07:00') },

  { ev: _, at: new Date('2039-07-01 01:00') },
  { ev: _, at: new Date('2039-07-01 03:00') },
  { ev: _, at: new Date('2039-07-01 04:00') },
  { ev: _, at: new Date('2039-07-01 05:00') },
  { ev: _, at: new Date('2039-07-01 07:00') },

  { ev: _, at: new Date('2039-09-01 01:00') },
  { ev: _, at: new Date('2039-09-01 03:00') },
  { ev: _, at: new Date('2039-09-01 04:00') },
  { ev: _, at: new Date('2039-09-01 05:00') },
  { ev: _, at: new Date('2039-09-01 07:00') }
])
tap.strictSame(crontab.between(new Date('2039-12-01'), new Date('2039-12-31')), [
  { at: new Date('2039-12-01 01:00'), ev: 'redundant' },
  { at: new Date('2039-12-01 02:00'), ev: 'redundant' },
  { at: new Date('2039-12-01 03:00'), ev: 'redundant' }
])

Daylight Saving Time (DST) considerations

By default, cronlib behaves like Vixie Cron around DST:

non-existent times, such as the "missing hours" during the daylight savings time conversion, will never match, causing events scheduled during the "missing times" not to occur. Similarly, times that occur more than once (again, during the daylight savings time conversion) will cause matching events to occur twice.

But we can do better. cronlib has the following options for controlling how schedules work around DST:

option value effect
dstRunSkipped "always" Events that would be skipped over by Spring Forward are instead scheduled one hour later.
dstRunSkipped "auto" Events that would be skipped over by Spring Forward are instead scheduled one hour later, unless they're already scheduled within that hour.
dstRunSkipped anything else Events that would be skipped over by Spring Forward are indeed skipped.
dstNoRepeat "first" Events that would happen twice because of Fall Back are instead scheduled only the first time around.
dstNoRepeat "second" Events that would happen twice because of Fall Back are instead scheduled only the second time around.
dstNoRepeat "auto" Like "first", but doesn't apply if the event is scheduled to occur every hour of the day.
dstNoRepeat anything else Events that would happen twice because of Fall Back do indeed happen twice.
Examples

DST start and end times vary by time zone. JavaScript Dates don't support time zones, so cronlib uses luxon internally to represent dates. Wherever one of its methods takes a Date, we can pass a luxon DateTime instead. This allows us to specify the time zone in the following examples.

So: in New York, in 2018, DST started on March 11. When clocks would have reached 2:00, an hour was skipped and they landed instead on 3:00.

crontab = new Crontab()
crontab.add('30 2    11 Mar *', 'default Spring Forward')
crontab.add('30 *    11 Mar *', 'default Spring Forward (every hour)')
crontab.add('30 2    11 Mar *', 'always', { dstRunSkipped: 'always' })
crontab.add('30 *    11 Mar *', 'always (every hour)', { dstRunSkipped: 'always' })
crontab.add('30 2    11 Mar *', 'auto', { dstRunSkipped: 'auto' })
crontab.add('30 *    11 Mar *', 'auto (every hour)', { dstRunSkipped: 'auto' })
crontab.add('30 0-23 11 Mar *', 'auto (every hour alt)', { dstRunSkipped: 'auto' })

const nyTz = { zone: 'America/New_York' }
const nyTargetDate = DateTime.fromISO('2018-03-11T03:30:00', nyTz)

tap.strictSame(new Set(crontab.between(
  DateTime.fromISO('2018-03-11T01:50:00', nyTz),
  DateTime.fromISO('2018-03-11T03:50:00', nyTz)
)), new Set([
  { at: nyTargetDate, ev: 'default Spring Forward (every hour)' },

  /* dstRunSkipped "always" causes skipped events to happen one hour "later",
   * even if that would cause them to occur twice at the same time */
  { at: nyTargetDate, ev: 'always' },
  { at: nyTargetDate, ev: 'always (every hour)' },
  { at: nyTargetDate, ev: 'always (every hour)' },

  /* dstRunSkipped "auto" causes skipped events to happen one hour "later",
   * unless they're already set to happen then. This prevents the same event
   * from being scheduled twice at one time */
  { at: nyTargetDate, ev: 'auto' },
  { at: nyTargetDate, ev: 'auto (every hour)' },
  { at: nyTargetDate, ev: 'auto (every hour alt)' }
]))

DST in New York lasted until November 4 2018 at 2:00, when clocks were turned back to 1:00, thus repeating the hour between 1:00 and 2:00.

crontab.add('30 1    4 Nov *', 'default Fall Back')
crontab.add('30 *    4 Nov *', 'default Fall Back (every hour)')
crontab.add('30 1    4 Nov *', 'first', { dstNoRepeat: 'first' })
crontab.add('30 *    4 Nov *', 'first (every hour)', { dstNoRepeat: 'first' })
crontab.add('30 1    4 Nov *', 'second', { dstNoRepeat: 'second' })
crontab.add('30 *    4 Nov *', 'second (every hour)', { dstNoRepeat: 'second' })
crontab.add('30 1    4 Nov *', 'auto', { dstNoRepeat: 'auto' })
crontab.add('30 *    4 Nov *', 'auto (every hour)', { dstNoRepeat: 'auto' })
crontab.add('30 0-23 4 Nov *', 'auto (every hour alt)', { dstNoRepeat: 'auto' })

const ny1stTime = DateTime.fromISO('2018-11-04T01:30:00-0400', nyTz)
const ny2ndTime = DateTime.fromISO('2018-11-04T01:30:00-0500', nyTz)

tap.strictSame(new Set(crontab.between(
  DateTime.fromISO('2018-11-04T00:50:00', nyTz),
  DateTime.fromISO('2018-11-04T02:10:00', nyTz)
)), new Set([
  /* Events scheduled during the Fall Back hour occur
   * twice by default */
  { at: ny1stTime, ev: 'default Fall Back' },
  { at: ny2ndTime, ev: 'default Fall Back' },
  { at: ny1stTime, ev: 'default Fall Back (every hour)' },
  { at: ny2ndTime, ev: 'default Fall Back (every hour)' },

  /* with dstNoRepeat set to "first", these events will
   * only occur the first time around */
  { at: ny1stTime, ev: 'first' },
  { at: ny1stTime, ev: 'first (every hour)' },

  /* with dstNoRepeat set to "second", these events will
   * only occur the second time around */
  { at: ny2ndTime, ev: 'second' },
  { at: ny2ndTime, ev: 'second (every hour)' },

  /* with dstNoRepeat set to "auto", these events will
   * only occur the first time around, unless they would
   * be scheduled every hour anyway */
  { at: ny1stTime, ev: 'auto' },
  { at: ny1stTime, ev: 'auto (every hour)' },
  { at: ny2ndTime, ev: 'auto (every hour)' },
  { at: ny1stTime, ev: 'auto (every hour alt)' },
  { at: ny2ndTime, ev: 'auto (every hour alt)' }
]))

API

new Crontab([options])

  • options Object
    • dstRunSkipped string What to do with events skipped by the start of DST. See table above for valid values.
    • dstNoRepeat string What to do with events repeated by the end of DST. See table above for valid values.

Creates a new, empty crontab. The options given here serve as the default options for all entries added to the crontab.

crontab.add(schedule, event[, options])

  • schedule string when this event occurs.
  • event Object the "payload" for this schedule. Can be anything!
  • options Object
    • dstRunSkipped string What to do with events skipped by the start of DST. See table above for valid values.
    • dstNoRepeat string What to do with events repeated by the end of DST. See table above for valid values.

Adds the given entry to the crontab. If no options are given, the ones setup when crontab was created are used instead.

Schedules get parsed before they're added. An Error is thrown if that fails:

tap.throws(() => crontab.add('61 * * * *', _), 'invalid minutes')

crontab.at(t)

  • t Date or DateTime Point in time at which to look for events.
  • returns Array Events that are scheduled to occur at time t. These objects have two properties: ev, the event passed to crontab.add, and at, the time at which the event occurs (a copy of t).

cronlib accepts either plain JavaScript Dates or luxon DateTimes. The at property of returned events will be of the same type as the argument you use.

crontab = new Crontab()
crontab.add('0 * * * *', 'Flip hourglass')
crontab.add('12 12 * * *', 'Lunch')
crontab.add('0 17 * * Fri', 'Happy Hour')
crontab.add('0 2 10 March *', 'DST start')
crontab.add('0 2 3 November *', 'DST end')

const d = new Date('2038-02-05 17:00:00')
tap.strictSame(new Set(crontab.at(d)), new Set([
  { at: d, ev: 'Flip hourglass' },
  { at: d, ev: 'Happy Hour' }
]))

crontab.between(from, to)

  • from Date or DateTime Start of time window (inclusive)
  • to Date or DateTime End of time window (inclusive)
  • returns Array Events that are scheduled to occur between from and to, ordered chronologically. Events have two properties: ev, the event passed to crontab.add, and at, the time at which the event occurs.
tap.strictSame(crontab.between(new Date('2019-12-01 11:30'), new Date('2019-12-01 13:00')), [
  { at: new Date('2019-12-01 12:00'), ev: 'Flip hourglass' },
  { at: new Date('2019-12-01 12:12'), ev: 'Lunch' },
  { at: new Date('2019-12-01 13:00'), ev: 'Flip hourglass' }
])

Both arguments passed to crontab.between must be of the same type. You can't mix JavaScript Dates and luxon DateTimes in the same function call. If you use luxon DateTimes, they must both be in the same time zone.

tap.throws(() => crontab.between(new Date(), DateTime.local()), 'different types')

const ekoDate = DateTime.fromISO('1960-10-01', { zone: 'Africa/Lagos' })
const hkDate = DateTime.fromISO('1997-07-01', { zone: 'Asia/Hong_Kong' })
tap.throws(() => crontab.between(ekoDate, hkDate), 'different time zones')

Note also that if from is a later date than to, you'll always get empty results:

tap.strictSame(crontab.between(new Date(1985), new Date(1955)), [])

crontab.genBetween(from, to)

Like crontab.between, but returns a generator instead of an array.

const it = crontab.genBetween(new Date('2019-12-01 11:30'), new Date('2019-12-01 13:00'))
tap.strictSame(it.next().value, { ev: 'Flip hourglass', at: new Date('2019-12-01 12:00') })
tap.strictSame(it.next().value, { ev: 'Lunch', at: new Date('2019-12-01 12:12') })
tap.strictSame(it.next().value, { ev: 'Flip hourglass', at: new Date('2019-12-01 13:00') })
tap.strictSame(it.next().value, undefined)

Differences from vixie cron

cronlib behaves very much like your usual unix crontab. It's backwards-compatible with vixie cron: you can import entries from your unix crontab into cronlib, and they will behave exactly the same. But cronlib has a few extensions:

In cronlib, you can use lists and ranges of names, e.g. "Apr-Oct". In cron that's not supported; you'd have to write "4-10".

In cronlib, ranges that go from large-to-small are allowed, e.g. "11-3". In cron, that's an error.

In cronlib, you get some say about how DST is handled. cron doesn't attempt to do anything about it.

Contributing

You're welcome to contribute to this project. If you make a Pull Request that

  • explains and solves a problem,
  • follows standard style,
  • maintains 100% test coverage, and
  • keeps the documentation in sync with actual behaviour,

it will be merged: this project follows the C4 process.

To make sure your commits follow the style guide and pass all tests, you can add

./.pre-commit

to your git pre-commit hook.

Package Sidebar

Install

npm i @wraugh/cronlib

Weekly Downloads

0

Version

0.1.3

License

MPL-2.0

Unpacked Size

75.8 kB

Total Files

6

Last publish

Collaborators

  • wraugh