@guzelbaspinar/periodic-runner
v0.2.0
Published
A non-overlapping, self-rescheduling periodic task runner with active-hours, weekday, and holiday support (CJS, ESM, and TypeScript compatible).
Maintainers
Readme
periodic-runner
A setTimeout-based, non-overlapping self-rescheduling periodic task runner. Unlike setInterval, the next run is never triggered before the previous one finishes, because the next setTimeout is scheduled only after the task has been awaited.
Fully compatible with CommonJS, ESM, and TypeScript.
Published on npm as @guzelbaspinar/periodic-runner.
Features
- ✅ Overlap protection (a new run never starts before the previous one finishes)
- ✅ Active time window support (
activeHours) — including windows that wrap past midnight (e.g.22:00-06:00) - ✅ Restrict to specific days of the week (
weekDays) - ✅ Skip holiday dates (
holidays), updatable at runtime, accepts an array or aSet - ✅ IANA timezone support
- ✅ Custom error handling (
onError) and custom logger injection - ✅ Optional per-task watchdog (
taskTimeoutMs) — reports hung tasks viaonErrorinstead of locking up forever - ✅ CJS + ESM + TypeScript types in a single package (
dist/index.cjs,dist/index.js,dist/index.d.ts)
Installation
npm install @guzelbaspinar/periodic-runnerUsage
TypeScript / ESM
import { PeriodicRunner } from '@guzelbaspinar/periodic-runner';
const runner = new PeriodicRunner({
name: 'InitialCache',
period: 7000,
task: async () => {
// work to run periodically
},
activeHours: { start: '09:50', end: '18:30' },
weekDays: [1, 2, 3, 4, 5], // weekdays only (0=Sunday ... 6=Saturday)
holidays: new Set(['2026-01-01', '2026-04-23']),
timezone: 'Europe/Istanbul',
onError: (err) => console.error('task failed:', err),
});
await runner.start();
// ... later
runner.stop();CommonJS
const { PeriodicRunner } = require('@guzelbaspinar/periodic-runner');
const runner = new PeriodicRunner({
period: 5000,
task: async () => { /* ... */ },
});
runner.start();See EXAMPLES.md for full CJS, ESM, and TypeScript examples.
Managing holidays dynamically
The holiday list is not static; it can be updated at any time from an external source (e.g. data fetched periodically from a public holiday API). It accepts either an Array or a Set of "YYYY-MM-DD" strings:
runner.setHolidays(['2026-01-01', '2026-05-01', '2026-05-19']); // replaces the whole list, array or Set
runner.addHoliday('2026-08-30'); // adds a single day
runner.removeHoliday('2026-08-30'); // removes a single day
runner.getHolidays(); // -> Set<string>Note: the holiday check is evaluated against the current date (computed according to the
timezoneoption) on every tick, so simply keeping the list up to date is enough — no extra "valid from/to" logic is needed.
Task watchdog (taskTimeoutMs)
By default the runner waits indefinitely for task() to settle; a task that never resolves/rejects (e.g. a hung HTTP request) leaves isRunning true forever and every subsequent tick is silently skipped. Set taskTimeoutMs to bound how long a single run is allowed to take:
const runner = new PeriodicRunner({
period: 5000,
taskTimeoutMs: 3000, // treat the tick as failed if task() hasn't settled within 3s
task: async () => {
await fetch('https://example.com', { signal: AbortSignal.timeout(3000) });
},
onError: (err) => console.error('task timed out or failed:', err),
});Note: the task itself is not cancelled — Promises cannot be aborted from the outside.
taskTimeoutMsonly stops the runner from waiting on it, soisRunningunlocks and the next tick isn't skipped forever;onErrorreceives a timeout error. If your task can hang (e.g. on network I/O), pairtaskTimeoutMswith your own cancellation (likeAbortSignal.timeout) inside the task for full cleanup.
API
new PeriodicRunner(options)
| Field | Type | Required | Description |
|---|---|---|---|
| task | () => Promise<void> \| void | ✅ | Function executed on every period |
| name | string | ❌ | Name shown in logs (default: "PeriodicRunner") |
| period | number | ❌ | Delay between runs in ms (default: 7000) |
| onError | (error: unknown) => void | ❌ | Called when task throws |
| activeHours | { start: string; end: string } | ❌ | Active window in "HH:mm" format |
| weekDays | number[] (0-6) | ❌ | Days of the week the task is allowed to run on (0=Sunday) |
| holidays | string[] \| Set<string> ("YYYY-MM-DD") | ❌ | Dates the task must not run on |
| timezone | string | ❌ | IANA timezone, e.g. "Europe/Istanbul" |
| logger | { debug, error } | ❌ | Custom logger (default: console) |
| taskTimeoutMs | number | ❌ | Watchdog in ms. If task doesn't settle in time, the runner reports it via onError and unlocks (see below) |
Methods
start(): Promise<void>— starts the loopstop(): void— stops the loopsetHolidays(holidays: string[] | Set<string>): voidaddHoliday(date: string): voidremoveHoliday(date: string): voidgetHolidays(): Set<string>getWeekDays(): number[] | null
Getters
isRunning: boolean— whether a task is currently executingisStopped: boolean— whether the runner has been stopped
Development
npm install
npm run typecheck
npm run typecheck:examples
npm test
npm run test:coverage
npm run build # generates cjs + esm + d.ts into dist/Contributing
Changes land on main through pull requests; the Protect main ruleset requires CI (test, coverage) to pass before merge.
License
MIT
