UNPKG

timeout-flow

Version:

Fluent, composable, pauseable JavaScript timers and time control flows — plus RAF utilities for frame-based logic.

209 lines (172 loc) 4.84 kB
// ./src/EveryTimer.js import { attachAbort } from './abort.js'; import { TimerBase } from './TimerBase.js'; import { parseDuration } from './parseDuration.js'; /** * Repeatedly runs a function every N ms, with optional max count. */ export class EveryTimer { #interval; #fn; #max; #count = 0; #finished = false; /** @type {TimerBase | null} */ #controller = null; /** @type {AbortSignal | null} */ #signal = null; /** @type {() => void} */ #cleanupAbort = () => {}; /** * @param {string|number} duration - Delay between calls (e.g., '1s', 200) * @param {Function} fn - Function to call each tick * @param {Object} [options] * @param {number} [options.max=Infinity] - Max executions * @param {boolean} [options.runImmediately=false] - Run `fn` once before first delay * @param {AbortSignal} [options.signal] - Optional AbortSignal to auto-cancel */ constructor(duration, fn, { max = Infinity, runImmediately = false, signal } = {}) { this.#interval = parseDuration(duration); this.#fn = fn; this.#max = max; this.#signal = signal ?? null; // If max is already satisfied, finish immediately. if (!(this.#max > 0) && this.#max !== Infinity) { this.#finished = true; this.#cleanupAbort(); return; } // Abort semantics: if already aborted, immediately finish (terminal). if (this.#signal?.aborted) { this.#finished = true; this.#cleanupAbort(); return; } // Attach abort listener for the lifetime of this timer. // (Cleanup happens in #finish().) this.#cleanupAbort = attachAbort(this.#signal, () => this.cancel()); if (runImmediately) { this.#tick(); } else { this.#schedule(); } } /** * Number of times `fn` has been executed. */ get count() { return this.#count; } /** * Whether the timer is actively running (waiting for next tick). * * Note: during the brief moment between a tick firing and the next schedule, * this may be false — which reflects "no countdown currently pending". */ get isRunning() { return !this.#finished && !!this.#controller && this.#controller.isRunning; } /** * Whether the timer is paused (resumable). */ get isPaused() { return !this.#finished && !!this.#controller && this.#controller.isPaused; } /** * Whether the timer has finished (hit max) or been canceled (terminal). */ get isFinished() { return this.#finished; } #finish = () => { this.#controller?.cancel(); this.#controller = null; this.#finished = true; this.#cleanupAbort(); this.#cleanupAbort = () => {}; this.#signal = null; }; #tick = () => { if (this.#finished) return; if (this.#signal?.aborted) { this.#finish(); return; } if (this.#count >= this.#max) { this.#finish(); return; } this.#fn?.(this.#count++); if (this.#count >= this.#max) { this.#finish(); return; } this.#schedule(); }; #schedule() { if (this.#finished) return; if (this.#signal?.aborted) { this.#finish(); return; } // Fresh controller per interval window this.#controller = new TimerBase(this.#interval); this.#controller.resume(this.#tick); } pause() { if (this.#finished) return; this.#controller?.pause(); } resume() { if (this.#finished) return; if (this.#signal?.aborted) { this.#finish(); return; } if (this.#count >= this.#max) { this.#finish(); return; } // Resume existing countdown if paused, otherwise schedule fresh. if (this.#controller) { if (this.#controller.isPaused) { this.#controller.resume(this.#tick); } else if (!this.#controller.isRunning) { // Controller exists but isn't active (edge); schedule next window this.#schedule(); } return; } this.#schedule(); } cancel() { if (this.#finished) return; this.#finish(); } reset(restart = false) { // Reset is not terminal; keep signal semantics: // - If signal exists and is aborted, reset should leave the timer finished. this.#controller?.cancel(); this.#controller = null; this.#count = 0; this.#finished = false; if (this.#signal?.aborted) { this.#finished = true; this.#cleanupAbort(); this.#cleanupAbort = () => {}; this.#signal = null; return; } if (restart) { // If max is zero-ish, immediately finish (consistent with constructor) if (!(this.#max > 0) && this.#max !== Infinity) { this.#finished = true; this.#cleanupAbort(); this.#cleanupAbort = () => {}; this.#signal = null; return; } this.#schedule(); } } }