From 8dd1c8bb6401edbd330597e42cf1531fb841df7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 21:26:14 +0000 Subject: [PATCH 1/6] Initial plan From 0fc6ee477253d4a616b82b245a1631ac87f7216b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 21:28:50 +0000 Subject: [PATCH 2/6] Initial plan for promise-based setRecursive implementation Co-authored-by: parzhitsky <59528510+parzhitsky@users.noreply.github.com> --- package-lock.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 21991f8..20f9d05 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1217,7 +1217,6 @@ "integrity": "sha512-anNG/V/Efn/YZY4pRzbACnKxNKoBng2VTFydVu8RRs5hQjikP8CQfaeAV59VFSCzKNp90mXiVXW2QzV56rwMrg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2506,7 +2505,6 @@ "integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -2911,7 +2909,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -2960,7 +2957,6 @@ "integrity": "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -3086,7 +3082,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", From 554d7a3a3b094e80670f7108f385058f5f53d1c0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 21:33:31 +0000 Subject: [PATCH 3/6] Implement promise-based setRecursive API with AsyncIterator support Co-authored-by: parzhitsky <59528510+parzhitsky@users.noreply.github.com> --- .../clear-recursive-timeout.spec.ts | 39 +++++ src/with-promises/clear-recursive-timeout.ts | 12 +- .../create-recursive-timeout.spec.ts | 156 +++++++++++++++++ src/with-promises/create-recursive-timeout.ts | 47 ++++- src/with-promises/examples.ts | 161 ++++++++++++++++++ src/with-promises/recursive-timeout.spec.ts | 62 +++++++ src/with-promises/recursive-timeout.ts | 117 +++++++++++-- 7 files changed, 569 insertions(+), 25 deletions(-) create mode 100644 src/with-promises/clear-recursive-timeout.spec.ts create mode 100644 src/with-promises/create-recursive-timeout.spec.ts create mode 100644 src/with-promises/examples.ts create mode 100644 src/with-promises/recursive-timeout.spec.ts diff --git a/src/with-promises/clear-recursive-timeout.spec.ts b/src/with-promises/clear-recursive-timeout.spec.ts new file mode 100644 index 0000000..2634928 --- /dev/null +++ b/src/with-promises/clear-recursive-timeout.spec.ts @@ -0,0 +1,39 @@ +import { createRecursiveTimeout } from './create-recursive-timeout' +import { clearRecursiveTimeout } from './clear-recursive-timeout' + +describe(clearRecursiveTimeout, () => { + it('should stop the recursive timeout', async () => { + const recursive = createRecursiveTimeout(50) + + let count = 0 + const promise = (async () => { + try { + for await (const _ of recursive) { + count++ + } + } catch (err) { + // Expected when clear() is called + } + })() + + // Wait a bit and then clear + await new Promise(resolve => setTimeout(resolve, 125)) + clearRecursiveTimeout(recursive) + + // Wait for the loop to finish + await promise + + // Should have completed ~2 iterations before being cleared + expect(count).toBeGreaterThanOrEqual(2) + expect(count).toBeLessThanOrEqual(3) + }) + + it('should be idempotent', () => { + const recursive = createRecursiveTimeout(50) + + clearRecursiveTimeout(recursive) + clearRecursiveTimeout(recursive) // Should not throw + + expect(true).toBe(true) // If we get here, no error was thrown + }) +}) diff --git a/src/with-promises/clear-recursive-timeout.ts b/src/with-promises/clear-recursive-timeout.ts index 6e20da8..6480421 100644 --- a/src/with-promises/clear-recursive-timeout.ts +++ b/src/with-promises/clear-recursive-timeout.ts @@ -1,5 +1,13 @@ -import type { RecursiveTimeout, ArgsShape } from './recursive-timeout' +import type { RecursiveTimeout } from './recursive-timeout' -export function clearRecursiveTimeout(recursive: RecursiveTimeout): void { +/** + * Cancels a recursive timeout created by `setRecursive`. + * + * Note: When using the promise-based API, you can also use AbortController + * to cancel the timeout, or simply break out of the `for await...of` loop. + * + * @param recursive - The RecursiveTimeout instance to clear + */ +export function clearRecursiveTimeout(recursive: RecursiveTimeout): void { recursive.clear() } diff --git a/src/with-promises/create-recursive-timeout.spec.ts b/src/with-promises/create-recursive-timeout.spec.ts new file mode 100644 index 0000000..1520ef6 --- /dev/null +++ b/src/with-promises/create-recursive-timeout.spec.ts @@ -0,0 +1,156 @@ +import { createRecursiveTimeout } from './create-recursive-timeout' +import { RecursiveTimeout } from './recursive-timeout' + +describe(createRecursiveTimeout, () => { + it('should create an instance of RecursiveTimeout class', () => { + const recursive = createRecursiveTimeout(100) + + expect(recursive).toBeInstanceOf(RecursiveTimeout) + + recursive.clear() + }) + + it('should yield values at regular intervals', async () => { + const recursive = createRecursiveTimeout(50) + const startTime = Date.now() + const iterations: number[] = [] + + let count = 0 + for await (const _ of recursive) { + iterations.push(Date.now() - startTime) + count++ + if (count >= 3) { + break + } + } + + expect(count).toBe(3) + // Each iteration should be approximately 50ms apart + expect(iterations[0]).toBeGreaterThanOrEqual(45) + expect(iterations[0]).toBeLessThan(100) + expect(iterations[1] - iterations[0]).toBeGreaterThanOrEqual(45) + expect(iterations[1] - iterations[0]).toBeLessThan(100) + }) + + it('should wait for async work to complete before scheduling next iteration', async () => { + const recursive = createRecursiveTimeout(50) + const timestamps: { start: number; end: number }[] = [] + + let count = 0 + for await (const _ of recursive) { + const start = Date.now() + // Simulate async work that takes 100ms + await new Promise(resolve => setTimeout(resolve, 100)) + const end = Date.now() + timestamps.push({ start, end }) + count++ + if (count >= 2) { + break + } + } + + expect(count).toBe(2) + + // The second iteration should start AFTER the first one completes (not during) + // First iteration: starts at ~50ms, ends at ~150ms + // Second iteration: should start at ~200ms (150ms + 50ms delay), not at ~100ms + const firstEnd = timestamps[0].end + const secondStart = timestamps[1].start + + expect(secondStart).toBeGreaterThanOrEqual(firstEnd + 40) // Allow some timing variance + }) + + it('should support yielding custom values', async () => { + const testValue = 'custom-value' + const recursive = createRecursiveTimeout(50, testValue) + + let count = 0 + for await (const value of recursive) { + expect(value).toBe(testValue) + count++ + if (count >= 2) { + break + } + } + + expect(count).toBe(2) + }) + + it('should support cancellation via AbortController', async () => { + const ac = new AbortController() + const recursive = createRecursiveTimeout(50, undefined, { signal: ac.signal }) + + setTimeout(() => ac.abort(), 125) // Abort after ~2.5 iterations + + let count = 0 + let errorThrown = false + try { + for await (const _ of recursive) { + count++ + } + } catch (err: any) { + errorThrown = true + } + + // Should have completed 2 iterations before abort + expect(count).toBe(2) + expect(errorThrown).toBe(true) + }) + + it('should support ref option', () => { + const recursive1 = createRecursiveTimeout(100, undefined, { ref: true }) + const recursive2 = createRecursiveTimeout(100, undefined, { ref: false }) + + recursive1.clear() + recursive2.clear() + }) + + it('should handle already-aborted signal', async () => { + const ac = new AbortController() + ac.abort() + + const recursive = createRecursiveTimeout(50, undefined, { signal: ac.signal }) + + let count = 0 + let errorThrown = false + try { + for await (const _ of recursive) { + count++ + } + } catch (err: any) { + errorThrown = true + } + + expect(count).toBe(0) + expect(errorThrown).toBe(true) + }) + + it('should be cancellable via clear() method', async () => { + const recursive = createRecursiveTimeout(50) + + let count = 0 + for await (const _ of recursive) { + count++ + if (count >= 2) { + recursive.clear() + break + } + } + + expect(count).toBe(2) + }) + + it('should clean up resources when breaking from loop', async () => { + const recursive = createRecursiveTimeout(50) + + let count = 0 + for await (const _ of recursive) { + count++ + if (count >= 3) { + break // This should trigger cleanup + } + } + + expect(count).toBe(3) + }) +}) diff --git a/src/with-promises/create-recursive-timeout.ts b/src/with-promises/create-recursive-timeout.ts index 1354da0..51e412c 100644 --- a/src/with-promises/create-recursive-timeout.ts +++ b/src/with-promises/create-recursive-timeout.ts @@ -1,9 +1,42 @@ -import { RecursiveTimeout, type ArgsShape, type Callback } from './recursive-timeout' +import { RecursiveTimeout, type RecursiveTimeoutOptions } from './recursive-timeout' -export function createRecursiveTimeout( - callback: Callback, - delay?: number, - ...args: Args -): RecursiveTimeout { - return new RecursiveTimeout(callback, delay, args) +/** + * Creates a promise-based recursive timeout that yields values at regular intervals. + * + * Unlike Node.js's `setInterval` from `timers/promises`, this implementation waits + * for any async work in the iteration to complete before scheduling the next iteration. + * + * @param delay - The number of milliseconds to wait between iterations + * @param value - Optional value to yield on each iteration + * @param options - Optional configuration including AbortSignal for cancellation + * @returns An AsyncIterator that can be used with `for await...of` + * + * @example + * ```ts + * // Basic usage + * for await (const _ of setRecursive(1000)) { + * console.log('tick') + * } + * + * // With abort signal + * const ac = new AbortController() + * setTimeout(() => ac.abort(), 5000) + * + * try { + * for await (const _ of setRecursive(1000, undefined, { signal: ac.signal })) { + * await doAsyncWork() // Next iteration waits for this to complete + * } + * } catch (err) { + * if (err.name === 'AbortError') { + * console.log('Cancelled') + * } + * } + * ``` + */ +export function createRecursiveTimeout( + delay: number, + value?: T, + options?: RecursiveTimeoutOptions, +): RecursiveTimeout { + return new RecursiveTimeout(delay, value, options) } diff --git a/src/with-promises/examples.ts b/src/with-promises/examples.ts new file mode 100644 index 0000000..84f7a60 --- /dev/null +++ b/src/with-promises/examples.ts @@ -0,0 +1,161 @@ +/** + * Examples demonstrating the promise-based API for recursive-timeout + * + * This file shows various usage patterns of the setRecursive function + * from the promises module, analogous to Node.js timers/promises API. + */ + +import { setRecursive, clearRecursive } from './promises' + +// Example 1: Basic usage with for await...of +async function example1() { + console.log('\n=== Example 1: Basic usage ===') + + let count = 0 + for await (const _ of setRecursive(1000)) { + console.log(`Tick ${++count}`) + if (count >= 3) break + } + + console.log('Done!') +} + +// Example 2: With async work in the loop +async function example2() { + console.log('\n=== Example 2: Async work waits before next iteration ===') + + let count = 0 + for await (const _ of setRecursive(500)) { + const start = Date.now() + console.log(`Iteration ${++count} starts at ${start}`) + + // Simulate async work that takes 200ms + await new Promise(resolve => setTimeout(resolve, 200)) + + console.log(`Iteration ${count} ends at ${Date.now()} (took ${Date.now() - start}ms)`) + console.log(`Next iteration will start in 500ms...`) + + if (count >= 2) break + } + + console.log('Done!') +} + +// Example 3: With AbortController for cancellation +async function example3() { + console.log('\n=== Example 3: Cancellation with AbortController ===') + + const ac = new AbortController() + + // Abort after 2.5 seconds + setTimeout(() => { + console.log('Aborting...') + ac.abort() + }, 2500) + + try { + let count = 0 + for await (const _ of setRecursive(1000, undefined, { signal: ac.signal })) { + console.log(`Tick ${++count}`) + } + } catch (err) { + console.log('Caught error:', (err as Error).message) + } + + console.log('Done!') +} + +// Example 4: Yielding custom values +async function example4() { + console.log('\n=== Example 4: Yielding custom values ===') + + let count = 0 + for await (const message of setRecursive(500, 'Hello from recursive timeout!')) { + console.log(`[${++count}] ${message}`) + if (count >= 3) break + } + + console.log('Done!') +} + +// Example 5: Manual clear using clearRecursive +async function example5() { + console.log('\n=== Example 5: Manual cancellation ===') + + const recursive = setRecursive(500) + + let count = 0 + try { + for await (const _ of recursive) { + console.log(`Tick ${++count}`) + + if (count >= 3) { + console.log('Clearing manually...') + clearRecursive(recursive) + } + } + } catch (err) { + console.log('Loop exited after clear') + } + + console.log('Done!') +} + +// Example 6: Comparison with regular setInterval from timers/promises +async function example6() { + console.log('\n=== Example 6: Comparison with Node.js setInterval ===') + + const { setInterval } = await import('node:timers/promises') + + console.log('\nNode.js setInterval (does NOT wait for async work):') + { + const ac = new AbortController() + setTimeout(() => ac.abort(), 350) + + let count = 0 + let lastTime = Date.now() + try { + for await (const _ of setInterval(100, undefined, { signal: ac.signal })) { + const now = Date.now() + console.log(`[${++count}] ${now - lastTime}ms since last | Starting 50ms work`) + lastTime = now + await new Promise(resolve => setTimeout(resolve, 50)) + console.log(` Work done`) + } + } catch {} + } + + console.log('\nrecursive-timeout setRecursive (WAITS for async work):') + { + const ac = new AbortController() + setTimeout(() => ac.abort(), 350) + + let count = 0 + let lastTime = Date.now() + try { + for await (const _ of setRecursive(100, undefined, { signal: ac.signal })) { + const now = Date.now() + console.log(`[${++count}] ${now - lastTime}ms since last | Starting 50ms work`) + lastTime = now + await new Promise(resolve => setTimeout(resolve, 50)) + console.log(` Work done`) + } + } catch {} + } + + console.log('\nNotice how setRecursive waits for the 50ms work before scheduling the next iteration!') + console.log('Done!') +} + +// Run all examples +async function runAllExamples() { + await example1() + await example2() + await example3() + await example4() + await example5() + await example6() +} + +// Uncomment to run: +// runAllExamples().catch(console.error) diff --git a/src/with-promises/recursive-timeout.spec.ts b/src/with-promises/recursive-timeout.spec.ts new file mode 100644 index 0000000..39ff1b5 --- /dev/null +++ b/src/with-promises/recursive-timeout.spec.ts @@ -0,0 +1,62 @@ +import { RecursiveTimeout } from './recursive-timeout' +import { clearRecursiveTimeout } from './clear-recursive-timeout' + +describe(RecursiveTimeout, () => { + it('should implement AsyncIterator interface', () => { + const recursive = new RecursiveTimeout(100) + + expect(typeof recursive.next).toBe('function') + expect(typeof recursive.return).toBe('function') + expect(typeof recursive[Symbol.asyncIterator]).toBe('function') + + recursive.clear() + }) + + it('should return itself when Symbol.asyncIterator is called', () => { + const recursive = new RecursiveTimeout(100) + + expect(recursive[Symbol.asyncIterator]()).toBe(recursive) + + recursive.clear() + }) + + it('should return done: false when iterating', async () => { + const recursive = new RecursiveTimeout(50) + + const result = await recursive.next() + + expect(result.done).toBe(false) + expect(result.value).toBeUndefined() + + recursive.clear() + }) + + it('should return done: true after being cleared', async () => { + const recursive = new RecursiveTimeout(50) + + recursive.clear() + + const result = await recursive.next() + + expect(result.done).toBe(true) + }) + + it('should handle return() method', async () => { + const recursive = new RecursiveTimeout(50) + + const result = await recursive.return() + + expect(result.done).toBe(true) + }) +}) + +describe(clearRecursiveTimeout, () => { + it('should clear a recursive timeout', async () => { + const recursive = new RecursiveTimeout(50) + + clearRecursiveTimeout(recursive) + + const result = await recursive.next() + expect(result.done).toBe(true) + }) +}) diff --git a/src/with-promises/recursive-timeout.ts b/src/with-promises/recursive-timeout.ts index 02fc882..cadc6e2 100644 --- a/src/with-promises/recursive-timeout.ts +++ b/src/with-promises/recursive-timeout.ts @@ -2,31 +2,116 @@ export type ArgsShape = readonly unknown[] export type Callback = (...args: Args) => void | Promise -/** @deprecated TODO: Remove when everything planned is supported */ -class NotSupportedError extends Error { - constructor() { - super(`Promise-based recursive timeout is not supported yet`) - } +export interface RecursiveTimeoutOptions { + /** + * An optional AbortSignal to cancel the recursive timeout + */ + signal?: AbortSignal + + /** + * Set to `false` to indicate that the scheduled timeout should not + * require the Node.js event loop to remain active. + * @default true + */ + ref?: boolean } -export class RecursiveTimeout implements AsyncIterator { +export class RecursiveTimeout implements AsyncIterator { + private timer?: NodeJS.Timeout + private aborted = false + private cleared = false + private readonly signal?: AbortSignal + private readonly ref: boolean + private onAbort?: () => void + private pendingReject?: (reason?: any) => void + constructor( - protected readonly callback: Callback, - protected readonly delay: number | undefined, - protected readonly args: Args, + private readonly delay: number, + private readonly value?: T, + options?: RecursiveTimeoutOptions, ) { - this.start() - } + this.signal = options?.signal + this.ref = options?.ref !== false - protected start(): void { - throw new NotSupportedError() + if (this.signal?.aborted) { + this.aborted = true + } else if (this.signal) { + this.onAbort = () => { + this.aborted = true + this.clear() + } + this.signal.addEventListener('abort', this.onAbort, { once: true }) + } } clear(): void { - throw new NotSupportedError() + this.cleared = true + if (this.timer) { + clearTimeout(this.timer) + this.timer = undefined + } + if (this.pendingReject) { + this.pendingReject(new Error('Cleared')) + this.pendingReject = undefined + } + if (this.onAbort && this.signal) { + this.signal.removeEventListener('abort', this.onAbort) + this.onAbort = undefined + } + } + + async next(): Promise> { + // If already cleared, return done immediately + if (this.cleared) { + return { done: true, value: undefined } + } + + // If signal was already aborted before first iteration, throw + if (this.aborted) { + this.clear() + throw this.signal?.reason ?? new Error('AbortError') + } + + // Wait for the delay + try { + await new Promise((resolve, reject) => { + this.pendingReject = reject + + this.timer = setTimeout(() => { + this.timer = undefined + this.pendingReject = undefined + + if (this.aborted || this.cleared) { + reject(this.signal?.reason ?? new Error('AbortError')) + } else { + resolve() + } + }, this.delay) + + if (!this.ref && this.timer) { + this.timer.unref() + } + }) + } catch (error) { + this.clear() + throw error + } + + // After waiting, check if we were cleared or aborted + if (this.cleared || this.aborted) { + this.clear() + throw this.signal?.reason ?? new Error('AbortError') + } + + return { done: false, value: this.value as T } + } + + return(): Promise> { + this.clear() + return Promise.resolve({ done: true, value: undefined }) } - next(): Promise> { - throw new NotSupportedError() + [Symbol.asyncIterator](): AsyncIterator { + return this } } From 19b2d352b78114c39070018eed944a694b00000c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 21:37:30 +0000 Subject: [PATCH 4/6] Update README with comprehensive promise-based API documentation and examples Co-authored-by: parzhitsky <59528510+parzhitsky@users.noreply.github.com> --- README.md | 103 +++++++++++++++++++++++++++++++--- src/with-promises/examples.ts | 6 +- 2 files changed, 99 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index a70ebe0..2d8323d 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,10 @@ setRecursive(runsForOneSecond, 500) - **Dual Module Support:** Works seamlessly with both ECMAScript Modules (`import`) and CommonJS (`require`). - **Familiar API:** Designed as a drop-in replacement for `setInterval`. -- **Promise-based API:** ⚠️ _(coming soon)_ ⚠️ A promise-based interface for use with asynchronous callbacks. +- **Promise-based API:** A promise-based interface using `for await...of` loops, inspired by Node.js `timers/promises`. + - Returns an AsyncIterator for use with `for await...of` + - Supports AbortController for cancellation + - **Waits for async work to complete** before scheduling the next iteration (unlike Node.js `setInterval`) ## Installation @@ -99,17 +102,59 @@ setRecursive(sum, 100, 42, 17) // ✅ OK (logs 59 every ~100 milliseconds) ``` -#### ECMAScript (promise-based) – _coming soon_ ⚠️ +#### ECMAScript (promise-based) + +The promise-based API uses `for await...of` loops and returns an AsyncIterator, similar to Node.js `timers/promises`: + +```js +import { setRecursive } from 'recursive-timeout/promises' + +// Basic usage - yields every 1000ms +for await (const _ of setRecursive(1000)) { + console.log('tick') + // break when done +} +``` + +**Key difference from Node.js `setInterval`:** The next iteration is scheduled **after** any async work in the loop body completes: + +```js +import { setRecursive } from 'recursive-timeout/promises' + +for await (const _ of setRecursive(1000)) { + console.log('start') + await doAsyncWork() // Takes 500ms + console.log('end') + // Next iteration starts 1000ms AFTER doAsyncWork() completes + // (not 1000ms after the previous iteration started) +} +``` + +**Cancellation with AbortController:** ```js -import { setRecursive, clearRecursive } from 'recursive-timeout/promises' +const controller = new AbortController() + +// Cancel after 5 seconds +setTimeout(() => controller.abort(), 5000) + +try { + for await (const _ of setRecursive(1000, undefined, { signal: controller.signal })) { + console.log('tick') + } +} catch (err) { + console.log('Cancelled') +} ``` +**Alternative import style:** + ```js import { promises } from 'recursive-timeout' -promises.setRecursive(…) -promises.clearRecursive(…) +for await (const _ of promises.setRecursive(1000)) { + console.log('tick') +} ``` #### CommonJS @@ -118,18 +163,58 @@ promises.clearRecursive(…) const { setRecursive, clearRecursive } = require('recursive-timeout') ``` -#### CommonJS (promise-based) – _coming soon_ ⚠️ +#### CommonJS (promise-based) ```js -const { setRecursive, clearRecursive } = require('recursive-timeout/promises') +const { setRecursive } = require('recursive-timeout/promises') + +;(async () => { + for await (const _ of setRecursive(1000)) { + console.log('tick') + } +})() ``` +**Alternative import style:** + ```js const { promises } = require('recursive-timeout') -promises.setRecursive(…) -promises.clearRecursive(…) +;(async () => { + for await (const _ of promises.setRecursive(1000)) { + console.log('tick') + } +})() +``` + +## Promise-based API: Comparison with Node.js `timers/promises` + +The promise-based API is inspired by Node.js `timers/promises` but with a crucial difference: + +### Node.js `setInterval` (from `timers/promises`) +```js +import { setInterval } from 'node:timers/promises' + +for await (const _ of setInterval(100)) { + await asyncWork() // Takes 50ms + // Next iteration starts 100ms after the PREVIOUS one started + // (not after asyncWork completes) +} +``` +Schedule: 0ms → 100ms → 200ms → 300ms (regardless of async work) + +### `recursive-timeout` `setRecursive` (promise-based) +```js +import { setRecursive } from 'recursive-timeout/promises' + +for await (const _ of setRecursive(100)) { + await asyncWork() // Takes 50ms + // Next iteration starts 100ms AFTER asyncWork completes +} ``` +Schedule: 0ms → 150ms → 300ms → 450ms (waits for async work) + +This is the same "recursive timeout" behavior as the callback-based API, but with the convenience of async iterators. ## License diff --git a/src/with-promises/examples.ts b/src/with-promises/examples.ts index 84f7a60..498f526 100644 --- a/src/with-promises/examples.ts +++ b/src/with-promises/examples.ts @@ -3,9 +3,13 @@ * * This file shows various usage patterns of the setRecursive function * from the promises module, analogous to Node.js timers/promises API. + * + * Note: This file is for documentation purposes and uses direct imports. + * In actual usage, import from 'recursive-timeout/promises' */ -import { setRecursive, clearRecursive } from './promises' +import { createRecursiveTimeout as setRecursive } from './create-recursive-timeout' +import { clearRecursiveTimeout as clearRecursive } from './clear-recursive-timeout' // Example 1: Basic usage with for await...of async function example1() { From 6425c85c37249076a568bd4ba9e578c8890e4eeb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 7 Jan 2026 21:40:25 +0000 Subject: [PATCH 5/6] Address code review feedback: use proper DOMException for AbortError and remove unused types Co-authored-by: parzhitsky <59528510+parzhitsky@users.noreply.github.com> --- src/with-promises/create-recursive-timeout.spec.ts | 6 ++++++ src/with-promises/recursive-timeout.ts | 14 ++++++-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/with-promises/create-recursive-timeout.spec.ts b/src/with-promises/create-recursive-timeout.spec.ts index 1520ef6..20dafd6 100644 --- a/src/with-promises/create-recursive-timeout.spec.ts +++ b/src/with-promises/create-recursive-timeout.spec.ts @@ -84,17 +84,20 @@ describe(createRecursiveTimeout, () => { let count = 0 let errorThrown = false + let errorName = '' try { for await (const _ of recursive) { count++ } } catch (err: any) { errorThrown = true + errorName = err.name } // Should have completed 2 iterations before abort expect(count).toBe(2) expect(errorThrown).toBe(true) + expect(errorName).toBe('AbortError') }) it('should support ref option', () => { @@ -113,16 +116,19 @@ describe(createRecursiveTimeout, () => { let count = 0 let errorThrown = false + let errorName = '' try { for await (const _ of recursive) { count++ } } catch (err: any) { errorThrown = true + errorName = err.name } expect(count).toBe(0) expect(errorThrown).toBe(true) + expect(errorName).toBe('AbortError') }) it('should be cancellable via clear() method', async () => { diff --git a/src/with-promises/recursive-timeout.ts b/src/with-promises/recursive-timeout.ts index cadc6e2..61117f0 100644 --- a/src/with-promises/recursive-timeout.ts +++ b/src/with-promises/recursive-timeout.ts @@ -1,7 +1,3 @@ -export type ArgsShape = readonly unknown[] - -export type Callback = (...args: Args) => void | Promise - export interface RecursiveTimeoutOptions { /** * An optional AbortSignal to cancel the recursive timeout @@ -51,7 +47,9 @@ export class RecursiveTimeout implements AsyncIterator implements AsyncIterator implements AsyncIterator implements AsyncIterator Date: Wed, 7 Jan 2026 21:58:33 +0000 Subject: [PATCH 6/6] Fix promise-based API to match callback behavior: yield immediately, delay after work Co-authored-by: parzhitsky <59528510+parzhitsky@users.noreply.github.com> --- README.md | 40 ++++++++++++++----- .../create-recursive-timeout.spec.ts | 26 +++++++----- src/with-promises/create-recursive-timeout.ts | 25 +++++++++--- src/with-promises/recursive-timeout.ts | 11 ++++- 4 files changed, 75 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 2d8323d..6a27614 100644 --- a/README.md +++ b/README.md @@ -109,24 +109,35 @@ The promise-based API uses `for await...of` loops and returns an AsyncIterator, ```js import { setRecursive } from 'recursive-timeout/promises' -// Basic usage - yields every 1000ms +// Basic usage - first iteration happens immediately, then waits 1000ms between iterations for await (const _ of setRecursive(1000)) { console.log('tick') // break when done } ``` -**Key difference from Node.js `setInterval`:** The next iteration is scheduled **after** any async work in the loop body completes: +**Key differences from Node.js `setInterval`:** + +1. **First iteration is immediate** (no initial delay) +2. **Delay happens AFTER the loop body completes** (not before) ```js import { setRecursive } from 'recursive-timeout/promises' +// Node.js setInterval behavior: +// Wait 1000ms → yield → work (500ms) → Wait 1000ms → yield → work +// Iterations at: 1000ms, 2000ms, 3000ms... + +// setRecursive behavior: +// Yield immediately → work (500ms) → Wait 1000ms → yield → work (500ms) → Wait 1000ms +// Iterations at: 0ms, 1500ms, 3000ms... + for await (const _ of setRecursive(1000)) { console.log('start') await doAsyncWork() // Takes 500ms console.log('end') // Next iteration starts 1000ms AFTER doAsyncWork() completes - // (not 1000ms after the previous iteration started) + // Total time between iterations: 500ms (work) + 1000ms (delay) = 1500ms } ``` @@ -189,32 +200,41 @@ const { promises } = require('recursive-timeout') ## Promise-based API: Comparison with Node.js `timers/promises` -The promise-based API is inspired by Node.js `timers/promises` but with a crucial difference: +The promise-based API is inspired by Node.js `timers/promises` but with crucial differences: ### Node.js `setInterval` (from `timers/promises`) +- Waits for delay **before** yielding +- Schedules next iteration at fixed intervals regardless of work duration +- First iteration waits for the delay + ```js import { setInterval } from 'node:timers/promises' for await (const _ of setInterval(100)) { await asyncWork() // Takes 50ms - // Next iteration starts 100ms after the PREVIOUS one started - // (not after asyncWork completes) + // Iterations happen at fixed 100ms intervals } ``` -Schedule: 0ms → 100ms → 200ms → 300ms (regardless of async work) +Timeline: **Wait 100ms** → yield → work (50ms) → **Wait 100ms** → yield → work (50ms) +Schedule: Yields at 100ms, 200ms, 300ms... ### `recursive-timeout` `setRecursive` (promise-based) +- Yields **immediately** on first iteration +- Waits for delay **after** work completes +- Schedules next iteration after work finishes + ```js import { setRecursive } from 'recursive-timeout/promises' for await (const _ of setRecursive(100)) { await asyncWork() // Takes 50ms - // Next iteration starts 100ms AFTER asyncWork completes + // Next iteration waits for work to complete, then delays } ``` -Schedule: 0ms → 150ms → 300ms → 450ms (waits for async work) +Timeline: Yield immediately → work (50ms) → **Wait 100ms** → yield → work (50ms) → **Wait 100ms** +Schedule: Yields at 0ms, 150ms, 300ms... -This is the same "recursive timeout" behavior as the callback-based API, but with the convenience of async iterators. +This matches the "recursive timeout" behavior of the callback-based API, ensuring the delay happens between iterations, not before them. ## License diff --git a/src/with-promises/create-recursive-timeout.spec.ts b/src/with-promises/create-recursive-timeout.spec.ts index 20dafd6..b36c533 100644 --- a/src/with-promises/create-recursive-timeout.spec.ts +++ b/src/with-promises/create-recursive-timeout.spec.ts @@ -25,11 +25,14 @@ describe(createRecursiveTimeout, () => { } expect(count).toBe(3) - // Each iteration should be approximately 50ms apart - expect(iterations[0]).toBeGreaterThanOrEqual(45) - expect(iterations[0]).toBeLessThan(100) - expect(iterations[1] - iterations[0]).toBeGreaterThanOrEqual(45) - expect(iterations[1] - iterations[0]).toBeLessThan(100) + // First iteration should happen immediately (within a few ms) + expect(iterations[0]).toBeLessThan(10) + // Second iteration should happen after ~50ms delay from first + expect(iterations[1]).toBeGreaterThanOrEqual(45) + expect(iterations[1]).toBeLessThan(100) + // Third iteration should happen after ~50ms delay from second + expect(iterations[2] - iterations[1]).toBeGreaterThanOrEqual(45) + expect(iterations[2] - iterations[1]).toBeLessThan(100) }) it('should wait for async work to complete before scheduling next iteration', async () => { @@ -51,9 +54,10 @@ describe(createRecursiveTimeout, () => { expect(count).toBe(2) - // The second iteration should start AFTER the first one completes (not during) - // First iteration: starts at ~50ms, ends at ~150ms - // Second iteration: should start at ~200ms (150ms + 50ms delay), not at ~100ms + // The second iteration should start AFTER the first one completes AND the delay + // First iteration: starts at ~0ms, ends at ~100ms + // Then 50ms delay + // Second iteration: should start at ~150ms (100ms work + 50ms delay) const firstEnd = timestamps[0].end const secondStart = timestamps[1].start @@ -80,7 +84,7 @@ describe(createRecursiveTimeout, () => { const ac = new AbortController() const recursive = createRecursiveTimeout(50, undefined, { signal: ac.signal }) - setTimeout(() => ac.abort(), 125) // Abort after ~2.5 iterations + setTimeout(() => ac.abort(), 125) // Abort after first iteration completes and during second delay let count = 0 let errorThrown = false @@ -94,8 +98,8 @@ describe(createRecursiveTimeout, () => { errorName = err.name } - // Should have completed 2 iterations before abort - expect(count).toBe(2) + // Should have completed at least 2 iterations before abort (first is immediate, second after 50ms) + expect(count).toBeGreaterThanOrEqual(2) expect(errorThrown).toBe(true) expect(errorName).toBe('AbortError') }) diff --git a/src/with-promises/create-recursive-timeout.ts b/src/with-promises/create-recursive-timeout.ts index 51e412c..c1dbbe0 100644 --- a/src/with-promises/create-recursive-timeout.ts +++ b/src/with-promises/create-recursive-timeout.ts @@ -3,19 +3,34 @@ import { RecursiveTimeout, type RecursiveTimeoutOptions } from './recursive-time /** * Creates a promise-based recursive timeout that yields values at regular intervals. * - * Unlike Node.js's `setInterval` from `timers/promises`, this implementation waits - * for any async work in the iteration to complete before scheduling the next iteration. + * Unlike Node.js's `setInterval` from `timers/promises`, this implementation: + * 1. Yields immediately on the first iteration (no initial delay) + * 2. Waits for the loop body to complete before starting the delay timer + * 3. Only then schedules the next iteration * - * @param delay - The number of milliseconds to wait between iterations + * This ensures that the delay happens AFTER the work completes, not BEFORE it starts, + * matching the behavior of the callback-based `setRecursive`. + * + * @param delay - The number of milliseconds to wait between iterations (after work completes) * @param value - Optional value to yield on each iteration * @param options - Optional configuration including AbortSignal for cancellation * @returns An AsyncIterator that can be used with `for await...of` * * @example * ```ts - * // Basic usage + * // Basic usage - first iteration happens immediately * for await (const _ of setRecursive(1000)) { * console.log('tick') + * // After this completes, waits 1000ms, then yields again + * } + * + * // Demonstrating the key difference from Node.js setInterval: + * // setInterval: delay -> yield -> work -> delay -> yield -> work + * // setRecursive: yield -> work -> delay -> yield -> work -> delay + * for await (const _ of setRecursive(1000)) { + * await doWork() // Takes 500ms + * // Next iteration happens 1000ms AFTER doWork() completes + * // Total time between iterations: 500ms (work) + 1000ms (delay) = 1500ms * } * * // With abort signal @@ -24,7 +39,7 @@ import { RecursiveTimeout, type RecursiveTimeoutOptions } from './recursive-time * * try { * for await (const _ of setRecursive(1000, undefined, { signal: ac.signal })) { - * await doAsyncWork() // Next iteration waits for this to complete + * await doAsyncWork() * } * } catch (err) { * if (err.name === 'AbortError') { diff --git a/src/with-promises/recursive-timeout.ts b/src/with-promises/recursive-timeout.ts index 61117f0..cc78aef 100644 --- a/src/with-promises/recursive-timeout.ts +++ b/src/with-promises/recursive-timeout.ts @@ -20,6 +20,7 @@ export class RecursiveTimeout implements AsyncIterator void private pendingReject?: (reason?: any) => void + private isFirstIteration = true constructor( private readonly delay: number, @@ -70,7 +71,15 @@ export class RecursiveTimeout implements AsyncIterator((resolve, reject) => { this.pendingReject = reject