Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 94 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
5 changes: 0 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

39 changes: 39 additions & 0 deletions src/with-promises/clear-recursive-timeout.spec.ts
Original file line number Diff line number Diff line change
@@ -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
})
})
12 changes: 10 additions & 2 deletions src/with-promises/clear-recursive-timeout.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import type { RecursiveTimeout, ArgsShape } from './recursive-timeout'
import type { RecursiveTimeout } from './recursive-timeout'

export function clearRecursiveTimeout(recursive: RecursiveTimeout<ArgsShape>): 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<T>(recursive: RecursiveTimeout<T>): void {
recursive.clear()
}
162 changes: 162 additions & 0 deletions src/with-promises/create-recursive-timeout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
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
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', () => {
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
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 () => {
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)
})
})
Loading
Loading