/** * Domain-agnostic poll loop (sleep + deadline + optional backoff). * LiFi / other modules supply classification via `tick`. */ export type PollTickResult = | { kind: 'stop'; value: T } | { kind: 'throw'; error: Error } | { kind: 'continue' } export interface PollLoopBackoff { factor: number maxIntervalMs: number } export interface PollLoopOptions { tick: () => Promise> /** Base delay between polls (ms), after optional {@link initialDelayMs}. */ intervalMs: number /** Wait this long before the first {@link tick}. @default 0 */ initialDelayMs?: number backoff?: PollLoopBackoff timeoutMs: number } export class PollLoopTimeoutError extends Error { override readonly name = 'PollLoopTimeoutError' constructor(message: string) { super(message) } } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } /** * Repeatedly awaits `tick` until it returns `stop` (returns value), `throw` (propagates), * or the deadline passes (throws {@link PollLoopTimeoutError}). */ export async function pollLoop(options: PollLoopOptions): Promise { const { tick, intervalMs: baseIntervalMs, initialDelayMs = 0, backoff, timeoutMs, } = options const deadline = Date.now() + timeoutMs let interval = baseIntervalMs if (initialDelayMs > 0) { await sleep(initialDelayMs) } while (Date.now() < deadline) { const result = await tick() if (result.kind === 'stop') { return result.value } if (result.kind === 'throw') { throw result.error } const remaining = deadline - Date.now() if (remaining <= 0) { break } const sleepMs = Math.min(interval, Math.max(0, remaining)) await sleep(sleepMs) if (backoff) { interval = Math.min(interval * backoff.factor, backoff.maxIntervalMs) } } throw new PollLoopTimeoutError(`pollLoop timed out after ${timeoutMs}ms`) }