timeout-flow
Version:
Fluent, composable, pauseable JavaScript timers and time control flows — plus RAF utilities for frame-based logic.
401 lines (326 loc) • 10.7 kB
JavaScript
// ./src/TimeoutFlow.js
import { after } from './after.js';
import { every } from './every.js';
import { pendingAbort } from './abort.js';
/**
* Creates a fluent scheduler.
* Returns a chainable timeline.
*
* @param {Object} [options]
* @param {AbortSignal} [options.signal] - Optional AbortSignal to auto-cancel the flow
*/
export function flow({ signal } = {}) {
const steps = [];
const labelMap = new Map();
let currentIndex = 0;
let started = false;
let isPaused = false;
let isCancelled = false;
let isFinished = false;
let loopEnabled = false;
let loopLimit = Infinity;
let loopCount = 0;
/** @type {Function | null} */
let onFinishCallback = null;
/** @type {Function | null} */
let onErrorCallback = null;
let jumpTarget = null;
/** @type {Array<() => boolean>} */
let conditionStack = [];
let skipMode = false;
let whileCondition = null;
let doWhileCondition = null;
/** @type {any} */
let runner;
// Abort wiring is per-run (so we don't retain references forever)
const abort = pendingAbort(signal, () => runner.cancel());
const clearController = (step) => {
if (!step) return;
if (step.controller) step.controller = null;
};
const cleanupCurrentController = () => {
const step = steps[currentIndex];
step?.controller?.cancel?.();
if (step) step.controller = null;
};
const finishFlow = () => {
if (isFinished || isCancelled) return;
started = false;
isPaused = false;
isFinished = true;
cleanupCurrentController();
abort.remove();
onFinishCallback?.();
};
const cancelFlow = () => {
if (isCancelled) return;
started = false;
isPaused = false;
isFinished = false;
isCancelled = true;
cleanupCurrentController();
abort.remove();
};
const handleError = (err, step) => {
// best hygiene: cancel current controller so nothing leaks
cancelFlow();
if (onErrorCallback) {
try {
onErrorCallback(err, {
step,
index: currentIndex,
});
} catch (_) {
// If the error handler itself throws, fall through to rethrow original.
}
return;
}
// No handler: preserve existing behavior (throw)
throw err;
};
const safeCall = (fn, stepForContext) => {
try {
return fn();
} catch (err) {
handleError(err, stepForContext);
return undefined;
}
};
const incrementStep = (step) => {
clearController(step);
currentIndex += 1;
queueMicrotask(executeNext);
};
function executeNext() {
if (isCancelled || isPaused || isFinished) return;
if (signal?.aborted) {
cancelFlow();
return;
}
// Jump handling
if (jumpTarget) {
const targetIndex = labelMap.get(jumpTarget);
if (typeof targetIndex === 'number') {
currentIndex = targetIndex;
} else {
// treat as a flow error
safeCall(() => {
throw new Error(`Label "${jumpTarget}" not found`);
}, steps[currentIndex]);
return;
}
jumpTarget = null;
}
// Handle if/unless conditions (applies to the next step only)
if (conditionStack.length > 0) {
const predicate = conditionStack.shift();
const shouldRun = safeCall(predicate, steps[currentIndex]);
if (!shouldRun) skipMode = true;
}
// Skip mode: keep skipping until we hit a label (or end)
if (skipMode) {
const next = steps[currentIndex];
if (!next || next.type === 'label') {
skipMode = false;
} else {
return incrementStep(next);
}
}
const step = steps[currentIndex];
// End of timeline
if (!step || currentIndex >= steps.length) {
if (loopEnabled && ++loopCount < loopLimit) {
currentIndex = 0;
executeNext();
} else {
finishFlow(); // ✅ only natural completion calls onFinish
}
return;
}
if (step.type === 'label') {
return incrementStep(step);
}
if (step.type === 'after') {
step.controller = after(
() => safeCall(step.fn, step),
step.duration,
{ signal, onFinish: () => incrementStep(step) }
);
return;
}
if (step.type === 'every') {
// No ticks will ever fire => advance immediately.
if (!(step.times > 0) && step.times !== Infinity) {
return incrementStep(step);
}
let count = 0;
const ctrl = every(
() => {
if (isPaused || isCancelled || isFinished) return;
if (signal?.aborted) {
cancelFlow();
return;
}
// Pre-run while guard
if (step.whileCondition && !safeCall(step.whileCondition, step)) {
ctrl.cancel();
return incrementStep(step);
}
safeCall(() => step.fn(count++), step);
// Post-run doWhile guard
if (step.doWhileCondition && !safeCall(step.doWhileCondition, step)) {
ctrl.cancel();
return incrementStep(step);
}
if (count >= step.times) {
ctrl.cancel();
return incrementStep(step);
}
},
step.duration,
{ max: step.times, signal }
);
step.controller = ctrl;
}
}
runner = {
after(duration, fn) {
steps.push({ type: 'after', duration, fn, controller: null });
return runner;
},
every(duration, fn, timesOrOptions = Infinity) {
const raw =
typeof timesOrOptions === 'number'
? timesOrOptions
: timesOrOptions?.max ?? Infinity;
const times =
raw === Infinity ? Infinity : Math.max(0, raw | 0); // int >= 0
steps.push({
type: 'every',
duration,
fn,
times,
whileCondition,
doWhileCondition,
controller: null,
});
whileCondition = null;
doWhileCondition = null;
return runner;
},
loop(n = true) {
loopEnabled = true;
loopLimit = n === true ? Infinity : n;
return runner;
},
onFinish(cb) {
onFinishCallback = cb;
return runner;
},
onError(cb) {
onErrorCallback = cb;
return runner;
},
start() {
// restart hygiene: cancel any active controller from a previous run
cleanupCurrentController();
isCancelled = false;
isFinished = false;
isPaused = false;
started = true;
currentIndex = 0;
loopCount = 0;
// Abort wiring is per-run
abort.reset();
if (!signal?.aborted) abort.add();
if (signal?.aborted) {
cancelFlow(); // no-op start if already aborted
return runner;
}
executeNext();
return runner;
},
pause() {
if (isPaused || isCancelled || isFinished) return;
isPaused = true;
steps[currentIndex]?.controller?.pause?.();
},
resume() {
if (!isPaused || isCancelled || isFinished) return;
if (signal?.aborted) {
cancelFlow();
return;
}
isPaused = false;
steps[currentIndex]?.controller?.resume?.();
},
cancel() {
cancelFlow(); // ✅ never calls onFinish
},
reset() {
cancelFlow();
steps.length = 0;
labelMap.clear();
currentIndex = 0;
loopCount = 0;
onFinishCallback = null;
onErrorCallback = null;
jumpTarget = null;
conditionStack = [];
skipMode = false;
whileCondition = null;
doWhileCondition = null;
// allow reuse after reset (fresh state)
started = false;
isPaused = false;
isCancelled = false;
isFinished = false;
return runner;
},
label(name) {
labelMap.set(name, steps.length); // point to next step index
steps.push({ type: 'label', name, controller: null });
return runner;
},
jumpTo(name) {
jumpTarget = name;
return runner;
},
if(predicate) {
conditionStack.push(() =>
typeof predicate === 'function' ? !!predicate() : !!predicate
);
return runner;
},
unless(predicate) {
conditionStack.push(() =>
typeof predicate === 'function' ? !predicate() : !predicate
);
return runner;
},
while(predicate) {
whileCondition = () =>
typeof predicate === 'function' ? !!predicate() : !!predicate;
return runner;
},
doWhile(predicate) {
doWhileCondition = () =>
typeof predicate === 'function' ? !!predicate() : !!predicate;
return runner;
},
// unified lifecycle surface
get isRunning() {
return started && !isPaused && !isCancelled && !isFinished;
},
get isPaused() {
return isPaused;
},
get isCancelled() {
return isCancelled;
},
get isFinished() {
return isFinished;
},
};
return runner;
}