diff --git a/async/deno.json b/async/deno.json index ed918c31e50c..4f6b68d49f1e 100644 --- a/async/deno.json +++ b/async/deno.json @@ -17,6 +17,7 @@ "./unstable-wait-for": "./unstable_wait_for.ts", "./unstable-semaphore": "./unstable_semaphore.ts", "./unstable-circuit-breaker": "./unstable_circuit_breaker.ts", - "./unstable-all-keyed": "./unstable_all_keyed.ts" + "./unstable-all-keyed": "./unstable_all_keyed.ts", + "./unstable-poll": "./unstable_poll.ts" } } diff --git a/async/unstable_poll.ts b/async/unstable_poll.ts new file mode 100644 index 000000000000..e9a5e5452a3f --- /dev/null +++ b/async/unstable_poll.ts @@ -0,0 +1,89 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +// This module is browser compatible. + +import { delay } from "./delay.ts"; + +/** Options for {@linkcode poll}. */ +export interface PollOptions { + /** Signal used to abort the polling. */ + signal?: AbortSignal; + /** + * The interval in milliseconds between each poll. + * + * @default {1000} + */ + interval?: number; +} + +/** + * Repeatedly calls a function until a condition is met, then returns the result. + * + * This is useful for polling external APIs that don't provide push notifications. + * The function is called repeatedly with `interval` milliseconds between each call + * until `isDone` returns `true`, at which point the result is returned. + * + * @experimental **UNSTABLE**: New API, yet to be vetted. + * + * @example Polling a payment status with {@linkcode deadline} + * ```ts ignore + * import { poll } from "@std/async/unstable-poll"; + * import { deadline } from "@std/async/deadline"; + * + * async function getPaymentStatus(id: string): Promise<{ status: string }> { + * // Fetch payment status from API + * return { status: "pending" }; + * } + * + * const result = await deadline( + * poll( + * () => getPaymentStatus("payment-123"), + * (payment) => payment.status !== "pending", + * { interval: 1000 }, + * ), + * 30_000, // 30 second timeout + * ); + * ``` + * + * @example Using AbortSignal for timeout + * ```ts ignore + * import { poll } from "@std/async/unstable-poll"; + * + * const result = await poll( + * () => fetch("https://api.example.com/status").then((r) => r.json()), + * (data) => data.completed === true, + * { signal: AbortSignal.timeout(30_000) }, + * ); + * ``` + * + * @throws {DOMException & { name: "AbortError" }} If the optional signal is + * aborted with the default `reason`. + * @throws {AbortSignal["reason"]} If the optional signal is aborted with a + * custom `reason`. + * @throws If `fn` throws, that error propagates up immediately. + * @throws If `isDone` throws, that error propagates up immediately. + * @typeParam T The return type of the function being polled. + * @param fn The function to poll. Can be sync or async. + * @param isDone A predicate that returns `true` when polling should stop. + * @param options Additional options. + * @returns The result of `fn` when `isDone` returns `true`. + */ +export async function poll( + fn: () => T, + isDone: (result: Awaited) => boolean, + options: PollOptions = {}, +): Promise> { + const { signal, interval = 1000 } = options; + const delayOptions = signal ? { signal } : undefined; + + while (true) { + signal?.throwIfAborted(); + + const result = await fn(); + + if (isDone(result)) { + return result; + } + + await delay(interval, delayOptions); + } +} diff --git a/async/unstable_poll_test.ts b/async/unstable_poll_test.ts new file mode 100644 index 000000000000..f9ad9e0a5dea --- /dev/null +++ b/async/unstable_poll_test.ts @@ -0,0 +1,131 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +import { assertEquals, assertRejects } from "@std/assert"; +import { poll } from "./unstable_poll.ts"; +import { FakeTime } from "@std/testing/time"; + +Deno.test("poll() returns result when isDone returns true", async () => { + let callCount = 0; + const result = await poll( + () => { + callCount++; + return { status: callCount >= 3 ? "done" : "pending" }; + }, + (r) => r.status === "done", + { interval: 10 }, + ); + + assertEquals(result, { status: "done" }); + assertEquals(callCount, 3); +}); + +Deno.test("poll() returns immediately when isDone returns true on first call", async () => { + using time = new FakeTime(); + const startTime = time.now; + + const result = await poll( + () => "immediate", + () => true, + ); + + assertEquals(result, "immediate"); + assertEquals(time.now, startTime); +}); + +Deno.test("poll() works with async functions", async () => { + let callCount = 0; + const result = await poll( + async () => { + await Promise.resolve(); + return ++callCount; + }, + (n) => n >= 3, + { interval: 10 }, + ); + + assertEquals(result, 3); +}); + +Deno.test("poll() respects interval timing", async () => { + using time = new FakeTime(); + + let callCount = 0; + const promise = poll( + () => ++callCount, + (n) => n >= 4, + { interval: 200 }, + ); + + // First call is immediate + await time.runMicrotasks(); + assertEquals(callCount, 1); + + // Advance through remaining calls + await time.nextAsync(); + assertEquals(callCount, 2); + + await time.nextAsync(); + assertEquals(callCount, 3); + + await time.nextAsync(); + assertEquals(callCount, 4); + + await promise; +}); + +Deno.test("poll() handles abort signal", async () => { + using time = new FakeTime(); + const controller = new AbortController(); + let callCount = 0; + + const promise = poll( + () => ++callCount, + () => false, // Never done + { signal: controller.signal, interval: 10 }, + ); + + // First call is immediate + await time.runMicrotasks(); + assertEquals(callCount, 1); + + // Advance through two more intervals + await time.nextAsync(); + assertEquals(callCount, 2); + + await time.nextAsync(); + assertEquals(callCount, 3); + + // Abort during the delay after call 3 + controller.abort(); + + await assertRejects(() => promise, DOMException); + assertEquals(callCount, 3); +}); + +Deno.test("poll() handles already aborted signal", async () => { + const controller = new AbortController(); + controller.abort(); + + await assertRejects( + () => + poll( + () => "value", + () => false, + { signal: controller.signal }, + ), + DOMException, + ); +}); + +Deno.test("poll() propagates errors from fn", async () => { + await assertRejects( + () => + poll( + () => { + throw new Error("fetch failed"); + }, + () => true, + ), + Error, + "fetch failed", + ); +});