Repository: harrisiirak/cron-parser
Pull request #377 (2025-04-25 09:25:46)

Adding support for the H syntax, allowing to add jitter to a cron expression
Hi !

We use this library to schedule jobs on our infrastructure, and we really miss this feature to allow reducing the load on our servers. This PR just adds the hash syntax introduced by Jenkins. Similar to abandoned #109

I only implemented the `H` special character, no alias, to save myself the hassle of handling edge cases with `#` nth-of-day pattern.

Since this feature introduce randomness, without a seed-able source, the resulting expression would be unpredictable when serialized and parsed again. I added a dependency to [seedrandom](https://www.npmjs.com/package/seedrandom) after considering other alternatives and rulling out embedding the PRNG directly in the code using this incredibly usefull [SO](https://stackoverflow.com/a/47593316/6583306) post. Let me know if an alternative would be better.

I'm not the most knowledgeable in cron expression in general, I may have missed an edge case here and there, let me know :wink: 

I implemented some tests, and updated the README,

This library code is delightful (especially the tests), it was a pleasure implementing this !

Thx for your time


Patch:
diff --git a/README.md b/README.md
index a5cd920..5c8399e 100644
--- a/README.md
+++ b/README.md
@@ -35,15 +35,16 @@ npm install cron-parser
 
 ### Special Characters
 
-| Character | Description               | Example                                       |
-| --------- | ------------------------- | --------------------------------------------- |
-| `*`       | Any value                 | `* * * * *` (every minute)                    |
-| `?`       | Any value (alias for `*`) | `? * * * *` (every minute)                    |
-| `,`       | Value list separator      | `1,2,3 * * * *` (1st, 2nd, and 3rd minute)    |
-| `-`       | Range of values           | `1-5 * * * *` (every minute from 1 through 5) |
-| `/`       | Step values               | `*/5 * * * *` (every 5th minute)              |
-| `L`       | Last day of month/week    | `0 0 L * *` (midnight on last day of month)   |
-| `#`       | Nth day of month          | `0 0 * * 1#1` (first Monday of month)         |
+| Character | Description               | Example                                                                |
+| --------- | ------------------------- | ---------------------------------------------------------------------- |
+| `*`       | Any value                 | `* * * * *` (every minute)                                             |
+| `?`       | Any value (alias for `*`) | `? * * * *` (every minute)                                             |
+| `,`       | Value list separator      | `1,2,3 * * * *` (1st, 2nd, and 3rd minute)                             |
+| `-`       | Range of values           | `1-5 * * * *` (every minute from 1 through 5)                          |
+| `/`       | Step values               | `*/5 * * * *` (every 5th minute)                                       |
+| `L`       | Last day of month/week    | `0 0 L * *` (midnight on last day of month)                            |
+| `#`       | Nth day of month          | `0 0 * * 1#1` (first Monday of month)                                  |
+| `H`       | Randomized value          | `H * * * *` (every n minute where n is randomly picked within [0, 59]) |
 
 ### Predefined Expressions
 
@@ -61,24 +62,25 @@ npm install cron-parser
 
 ### Field Values
 
-| Field        | Values | Special Characters          | Aliases                        |
-| ------------ | ------ | --------------------------- | ------------------------------ |
-| second       | 0-59   | `*` `?` `,` `-` `/`         |                                |
-| minute       | 0-59   | `*` `?` `,` `-` `/`         |                                |
-| hour         | 0-23   | `*` `?` `,` `-` `/`         |                                |
-| day of month | 1-31   | `*` `?` `,` `-` `/` `L`     |                                |
-| month        | 1-12   | `*` `?` `,` `-` `/`         | `JAN`-`DEC`                    |
-| day of week  | 0-7    | `*` `?` `,` `-` `/` `L` `#` | `SUN`-`SAT` (0 or 7 is Sunday) |
+| Field        | Values | Special Characters              | Aliases                        |
+| ------------ | ------ | ------------------------------- | ------------------------------ |
+| second       | 0-59   | `*` `?` `,` `-` `/` `H`         |                                |
+| minute       | 0-59   | `*` `?` `,` `-` `/` `H`         |                                |
+| hour         | 0-23   | `*` `?` `,` `-` `/` `H`         |                                |
+| day of month | 1-31   | `*` `?` `,` `-` `/` `H` `L`     |                                |
+| month        | 1-12   | `*` `?` `,` `-` `/` `H`         | `JAN`-`DEC`                    |
+| day of week  | 0-7    | `*` `?` `,` `-` `/` `H` `L` `#` | `SUN`-`SAT` (0 or 7 is Sunday) |
 
 ## Options
 
-| Option      | Type                     | Description                                                    |
-| ----------- | ------------------------ | -------------------------------------------------------------- |
-| currentDate | Date \| string \| number | Current date. Defaults to current local time in UTC            |
-| endDate     | Date \| string \| number | End date of iteration range. Sets iteration range end point    |
-| startDate   | Date \| string \| number | Start date of iteration range. Set iteration range start point |
-| tz          | string                   | Timezone (e.g., 'Europe/London')                               |
-| strict      | boolean                  | Enable strict mode validation                                  |
+| Option      | Type                     | Description                                                     |
+| ----------- | ------------------------ | --------------------------------------------------------------- |
+| currentDate | Date \| string \| number | Current date. Defaults to current local time in UTC             |
+| endDate     | Date \| string \| number | End date of iteration range. Sets iteration range end point     |
+| startDate   | Date \| string \| number | Start date of iteration range. Set iteration range start point  |
+| tz          | string                   | Timezone (e.g., 'Europe/London')                                |
+| hashSeed    | string                   | A seed to be used in conjunction with the `H` special character |
+| strict      | boolean                  | Enable strict mode validation                                   |
 
 When using string dates, the following formats are supported:
 
@@ -290,6 +292,51 @@ console.log(modified2.stringify()); // "30 15 * * 1-5"
 
 The `CronFieldCollection.from` method accepts either CronField instances or raw values that would be valid for creating new CronField instances. This is particularly useful when you need to modify only specific fields while keeping others unchanged.
 
+### Hash support
+
+The library support adding [jitter](https://en.wikipedia.org/wiki/Jitter) to the returned intervals using the `H` special character in a field. When `H` is specified instead of `*`, a random value is used (`H` is replaced by `23`, where 23 is picked randomly, within the valid range of the field).
+
+This jitter allows to spread the load when it comes to job scheduling. This feature is inspired by Jenkins's cron syntax.
+
+```typescript
+import { CronExpressionParser } from 'cron-parser';
+
+// At 23:<randomized> on every day-of-week from Monday through Friday.
+const interval = CronExpressionParser.parse('H 23 * * 1-5');
+
+// At <randomized>:30 everyday.
+const interval = CronExpressionParser.parse('30 H * * *');
+
+// At every minutes of <randomized> hour at <randomized> second everyday.
+const interval = CronExpressionParser.parse('H * H * * *');
+
+// At every 5th minute from <randomized> through 59 everyday.
+const interval = CronExpressionParser.parse('H/5 * * * *');
+
+// At every minute of the third <randomized> day of the month
+const interval = CronExpressionParser.parse('* * * * H#3');
+```
+
+The randomness is seed-able using the `hashSeed` option of `CronExpressionOptions`:
+
+```typescript
+import { CronExpressionParser } from 'cron-parser';
+
+const options = {
+  currentDate: '2023-03-26T01:00:00',
+  hashSeed: 'main-backup', // Generally, hashSeed would be a job name for example
+};
+
+const interval = CronExpressionParser.parse('H * * * H', options);
+
+console.log(interval.stringify()); // "12 * * * 4"
+
+const otherInterval = CronExpressionParser.parse('H * * * H', options);
+
+// Using the same seed will always return the same jitter
+console.log(otherInterval.stringify()); // "12 * * * 4"
+```
+
 ## License
 
 MIT
diff --git a/package-lock.json b/package-lock.json
index 2032060..52c678c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
 {
   "name": "cron-parser",
-  "version": "5.1.0",
+  "version": "5.1.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
diff --git a/package.json b/package.json
index 11575ec..eb5c955 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "cron-parser",
-  "version": "5.1.0",
+  "version": "5.1.1",
   "description": "Node.js library for parsing crontab instructions",
   "main": "dist/index.js",
   "types": "dist/types/index.d.ts",
diff --git a/src/CronExpression.ts b/src/CronExpression.ts
index a422b4c..9bafc34 100644
--- a/src/CronExpression.ts
+++ b/src/CronExpression.ts
@@ -9,6 +9,7 @@ export type CronExpressionOptions = {
   tz?: string;
   nthDayOfWeek?: number;
   expression?: string;
+  hashSeed?: string;
   strict?: boolean;
 };
 
diff --git a/src/CronExpressionParser.ts b/src/CronExpressionParser.ts
index bf45aba..59ed100 100644
--- a/src/CronExpressionParser.ts
+++ b/src/CronExpressionParser.ts
@@ -1,6 +1,7 @@
 import { CronFieldCollection } from './CronFieldCollection';
 import { CronDate } from './CronDate';
 import { CronExpression, CronExpressionOptions } from './CronExpression';
+import { type PRNG, seededRandom } from './utils/random';
 import {
   CronSecond,
   CronMinute,
@@ -85,7 +86,6 @@ export class CronExpressionParser {
    * Parses a cron expression and returns a CronExpression object.
    * @param {string} expression - The cron expression to parse.
    * @param {CronExpressionOptions} [options={}] - The options to use when parsing the expression.
-   * @param {boolean} [options.currentDate=false] - If true, will throw an error if the expression contains both dayOfMonth and dayOfWeek.
    * @param {boolean} [options.strict=false] - If true, will throw an error if the expression contains both dayOfMonth and dayOfWeek.
    * @param {CronDate} [options.currentDate=new CronDate(undefined, 'UTC')] - The date to use when calculating the next/previous occurrence.
    *
@@ -95,6 +95,8 @@ export class CronExpressionParser {
     const { strict = false } = options;
     const currentDate = options.currentDate || new CronDate();
 
+    const rand = seededRandom(options.hashSeed);
+
     expression = PredefinedExpressions[expression as keyof typeof PredefinedExpressions] || expression;
     const rawFields = CronExpressionParser.#getRawFields(expression, strict);
     if (!(rawFields.dayOfMonth === '*' || rawFields.dayOfWeek === '*' || !strict)) {
@@ -105,28 +107,38 @@ export class CronExpressionParser {
       CronUnit.Second,
       rawFields.second,
       CronSecond.constraints,
+      rand,
     ) as SixtyRange[];
     const minute = CronExpressionParser.#parseField(
       CronUnit.Minute,
       rawFields.minute,
       CronMinute.constraints,
+      rand,
     ) as SixtyRange[];
-    const hour = CronExpressionParser.#parseField(CronUnit.Hour, rawFields.hour, CronHour.constraints) as HourRange[];
+    const hour = CronExpressionParser.#parseField(
+      CronUnit.Hour,
+      rawFields.hour,
+      CronHour.constraints,
+      rand,
+    ) as HourRange[];
     const month = CronExpressionParser.#parseField(
       CronUnit.Month,
       rawFields.month,
       CronMonth.constraints,
+      rand,
     ) as MonthRange[];
     const dayOfMonth = CronExpressionParser.#parseField(
       CronUnit.DayOfMonth,
       rawFields.dayOfMonth,
       CronDayOfMonth.constraints,
+      rand,
     ) as DayOfMonthRange[];
     const { dayOfWeek: _dayOfWeek, nthDayOfWeek } = CronExpressionParser.#parseNthDay(rawFields.dayOfWeek);
     const dayOfWeek = CronExpressionParser.#parseField(
       CronUnit.DayOfWeek,
       _dayOfWeek,
       CronDayOfWeek.constraints,
+      rand,
     ) as DayOfWeekRange[];
 
     const fields = new CronFieldCollection({
@@ -175,7 +187,7 @@ export class CronExpressionParser {
    * @private
    * @returns {(number | string)[]} The parsed field.
    */
-  static #parseField(field: CronUnit, value: string, constraints: CronConstraints): (number | string)[] {
+  static #parseField(field: CronUnit, value: string, constraints: CronConstraints, rand: PRNG): (number | string)[] {
     // Replace aliases for month and dayOfWeek
     if (field === CronUnit.Month || field === CronUnit.DayOfWeek) {
       value = value.replace(/[a-z]{3}/gi, (match) => {
@@ -195,6 +207,8 @@ export class CronExpressionParser {
 
     // Replace '*' and '?'
     value = value.replace(/[*?]/g, constraints.min + '-' + constraints.max);
+    // Replace 'H' using the seeded PRNG
+    value = value.replace(/H/g, String(Math.floor(rand() * (constraints.max - constraints.min + 1) + constraints.min)));
     return CronExpressionParser.#parseSequence(field, value, constraints);
   }
 
diff --git a/src/fields/CronDayOfMonth.ts b/src/fields/CronDayOfMonth.ts
index 56b5d22..c827c7b 100644
--- a/src/fields/CronDayOfMonth.ts
+++ b/src/fields/CronDayOfMonth.ts
@@ -23,7 +23,7 @@ export class CronDayOfMonth extends CronField {
     return DAY_CHARS;
   }
   static get validChars(): RegExp {
-    return /^[?,*\dL/-]+$/;
+    return /^[?,*\dLH/-]+$/;
   }
   /**
    * CronDayOfMonth constructor. Initializes the "day of the month" field with the provided values.
diff --git a/src/fields/CronDayOfWeek.ts b/src/fields/CronDayOfWeek.ts
index de28fe4..7117d2f 100644
--- a/src/fields/CronDayOfWeek.ts
+++ b/src/fields/CronDayOfWeek.ts
@@ -24,7 +24,7 @@ export class CronDayOfWeek extends CronField {
   }
 
   static get validChars(): RegExp {
-    return /^[?,*\dL#/-]+$/;
+    return /^[?,*\dLH#/-]+$/;
   }
 
   /**
diff --git a/src/fields/CronField.ts b/src/fields/CronField.ts
index 90fd5a6..d3f167a 100644
--- a/src/fields/CronField.ts
+++ b/src/fields/CronField.ts
@@ -43,7 +43,7 @@ export abstract class CronField {
    * Returns the regular expression used to validate this field.
    */
   static get validChars(): RegExp {
-    return /^[,*\d/-]+$/;
+    return /^[,*\dH/-]+$/;
   }
 
   /**
diff --git a/src/utils/random.ts b/src/utils/random.ts
new file mode 100644
index 0000000..acde34e
--- /dev/null
+++ b/src/utils/random.ts
@@ -0,0 +1,42 @@
+/**
+ * A type representing a Pseudorandom Number Generator, similar to Math.random()
+ */
+export type PRNG = () => number;
+
+/**
+ * Computes a numeric hash from a given string
+ * @param {string} str A value to hash
+ * @returns {number} A numeric hash computed from the given value
+ */
+function xfnv1a(str: string) {
+  let h = 2166136261 >>> 0;
+  for (let i = 0; i < str.length; i++) {
+    h ^= str.charCodeAt(i);
+    h = Math.imul(h, 16777619);
+  }
+  return () => h >>> 0;
+}
+
+/**
+ * Initialize a new PRNG using a given seed
+ * @param {number} seed The seed used to initialize the PRNG
+ * @returns {PRNG} A random number generator
+ */
+function mulberry32(seed: number) {
+  return () => {
+    let t = (seed += 0x6d2b79f5);
+    t = Math.imul(t ^ (t >>> 15), t | 1);
+    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+  };
+}
+
+/**
+ * Generates a PRNG using a given seed. When not provided, the seed is randomly generated
+ * @param {string} str A string to derive the seed from
+ * @returns {PRNG} A random number generator correctly seeded
+ */
+export function seededRandom(str?: string): PRNG {
+  const seed = str ? xfnv1a(str)() : Math.floor(Math.random() * 10_000_000_000);
+  return mulberry32(seed);
+}


Test patch:
diff --git a/tests/CronExpressionParser.test.ts b/tests/CronExpressionParser.test.ts
index c0adf5d..857c9ab 100644
--- a/tests/CronExpressionParser.test.ts
+++ b/tests/CronExpressionParser.test.ts
@@ -1684,4 +1684,84 @@ describe('CronExpressionParser', () => {
       }).toThrow();
     });
   });
+
+  describe('test expressions using the hash extension syntax', () => {
+    // Not having a seed is making tests less useful
+    describe('without a custom seed', () => {
+      test('parses expressions using H on all fields', () => {
+        const options = {
+          currentDate: new Date(2025, 0, 1),
+        };
+
+        const expressions = [
+          'H * * * * *',
+          '* H * * * *',
+          '* * H * * *',
+          '* * * H * *',
+          '* * * * H *',
+          '* * * * * H',
+          'H H * * * *',
+          '* H H * * *',
+          '* * H H * *',
+          '* * * H H *',
+          '* * * * H H',
+          'H H H H H H',
+          'H/5 * * * * *',
+          '* * * * * H#1',
+        ];
+
+        for (const expression of expressions) {
+          const interval = CronExpressionParser.parse(expression, options);
+
+          for (var i = 0; i < 3; i++) {
+            let date = interval.next();
+
+            expect(date.getSeconds()).toBeGreaterThanOrEqual(0);
+            expect(date.getSeconds()).toBeLessThanOrEqual(59);
+            expect(date.getMinutes()).toBeGreaterThanOrEqual(0);
+            expect(date.getMinutes()).toBeLessThanOrEqual(59);
+            expect(date.getHours()).toBeGreaterThanOrEqual(0);
+            expect(date.getHours()).toBeLessThanOrEqual(23);
+            expect(date.getDate()).toBeGreaterThanOrEqual(1);
+            expect(date.getDate()).toBeLessThanOrEqual(31);
+            expect(date.getMonth()).toBeGreaterThanOrEqual(0);
+            expect(date.getMonth()).toBeLessThanOrEqual(11);
+            expect(date.getDay()).toBeGreaterThanOrEqual(0);
+            expect(date.getDay()).toBeLessThanOrEqual(7);
+            expect(date.getFullYear()).toBe(2025);
+          }
+        }
+      });
+    });
+
+    describe('with a custom seed', () => {
+      test('parses expressions using H on all fields', () => {
+        const options = {
+          currentDate: new Date(2025, 0, 1),
+          hashSeed: 'F00D',
+        };
+
+        const expressions = [
+          { expression: 'H * * * * *', expected: '5 * * * * *' },
+          { expression: '* H * * * *', expected: '* 34 * * * *' },
+          { expression: '* * H * * *', expected: '* * 15 * * *' },
+          { expression: '* * * H * *', expected: '* * * 12 * *' },
+          { expression: '* * * * H *', expected: '* * * * 8 *' },
+          { expression: '* * * * * H', expected: '* * * * * 0' },
+          { expression: 'H H * * * *', expected: '5 34 * * * *' },
+          { expression: '* H H * * *', expected: '* 34 15 * * *' },
+          { expression: '* * H H * *', expected: '* * 15 12 * *' },
+          { expression: '* * * H H *', expected: '* * * 12 8 *' },
+          { expression: '* * * * H H', expected: '* * * * 8 0' },
+          { expression: 'H H H H H H', expected: '5 34 15 12 8 0' },
+          { expression: 'H/5 * * * * *', expected: '5/5 * * * * *' },
+          { expression: '* * * * * H#1', expected: '* * * * * 0' },
+        ];
+
+        for (const { expression, expected } of expressions) {
+          expect(CronExpressionParser.parse(expression, options).stringify(true)).toBe(expected);
+        }
+      });
+    });
+  });
 });
diff --git a/tests/random.test.ts b/tests/random.test.ts
new file mode 100644
index 0000000..d687312
--- /dev/null
+++ b/tests/random.test.ts
@@ -0,0 +1,34 @@
+import { seededRandom } from '../src/utils/random';
+
+describe('seededRandom', () => {
+  test('should return a random value each call when no seed is provided', () => {
+    const rand = seededRandom();
+
+    const first = rand();
+    expect(first).toEqual(expect.any(Number));
+
+    const second = rand();
+    expect(second).toEqual(expect.any(Number));
+    expect(second).not.toBe(first);
+
+    const rand2 = seededRandom();
+
+    const third = rand2();
+    expect(third).toEqual(expect.any(Number));
+    expect(third).not.toBe(first);
+
+    const fourth = rand2();
+    expect(fourth).toEqual(expect.any(Number));
+    expect(fourth).not.toBe(third);
+    expect(fourth).not.toBe(second);
+  });
+
+  test('should return the same value each ordered call when a seed is provided', () => {
+    const rand = seededRandom('F00D');
+
+    expect(Math.floor(rand() * 100_000)).toBe(8440);
+    expect(Math.floor(rand() * 100_000)).toBe(57228);
+    expect(Math.floor(rand() * 100_000)).toBe(66401);
+    expect(Math.floor(rand() * 100_000)).toBe(60998);
+  });
+});