Created
November 3, 2025 21:51
-
-
Save nandordudas/a93bcffdb872fb45d6aa9ea4b5c2009b to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Result } from 'typescript-result' | |
| export interface AsyncState<E = Error> { | |
| loading: boolean | |
| error: E | null | |
| lastFetch: number | null | |
| retryCount: number | |
| signal?: AbortSignal | |
| controller?: AbortController | |
| } | |
| export function createAsyncState<E = Error>(): AsyncState<E> { | |
| return { | |
| loading: false, | |
| error: null, | |
| lastFetch: null, | |
| retryCount: 0, | |
| } | |
| } | |
| export interface WithAsyncStateOptions<T, E> { | |
| onSuccess?: (data: T) => void | Promise<void> | |
| onError?: (error: E) => void | Promise<void> | |
| onFinally?: () => void | Promise<void> | |
| resetRetryOnSuccess?: boolean | |
| optimistic?: { | |
| apply: () => void | |
| rollback: () => void | |
| } | |
| maxRetries?: number | |
| timeoutMs?: number | |
| signal?: AbortSignal | |
| } | |
| class TimeoutError extends Error { | |
| constructor(readonly timeoutMs: number) { | |
| super(`Operation timed out after ${timeoutMs}ms`) | |
| this.name = 'TimeoutError' | |
| } | |
| } | |
| function withTimeout<T>( | |
| promise: Promise<T>, | |
| timeoutMs: number, | |
| signal?: AbortSignal, | |
| ): Promise<T> { | |
| const controller = new AbortController() | |
| const timeoutId = setTimeout(() => controller.abort(), timeoutMs) | |
| let abortListener: (() => void) | null = null | |
| const timeoutPromise: Promise<T> = Promise.resolve().then(() => { | |
| return new Promise<T>((_, reject) => { | |
| const onAbort = () => reject(new TimeoutError(timeoutMs)) | |
| controller.signal.addEventListener('abort', onAbort, { once: true }) | |
| }) | |
| }) | |
| if (signal && !signal.aborted) { | |
| abortListener = () => controller.abort() | |
| signal.addEventListener('abort', abortListener, { once: true }) | |
| } | |
| return Promise.race([promise, timeoutPromise]).finally(() => { | |
| clearTimeout(timeoutId) | |
| }) | |
| } | |
| function setupExternalSignal( | |
| externalSignal: AbortSignal | undefined, | |
| controller: AbortController, | |
| ): (() => void) | null { | |
| if (externalSignal?.aborted) { | |
| controller.abort() | |
| return null | |
| } | |
| if (externalSignal && !externalSignal.aborted) { | |
| const listener = () => controller.abort() | |
| externalSignal.addEventListener('abort', listener, { once: true }) | |
| return listener | |
| } | |
| return null | |
| } | |
| function cleanupExternalSignal( | |
| externalSignal: AbortSignal | undefined, | |
| listener: (() => void) | null, | |
| ): void { | |
| if (listener && externalSignal && !externalSignal.aborted) { | |
| externalSignal.removeEventListener('abort', listener) | |
| } | |
| } | |
| async function executeCallbacks<E>( | |
| result: Result<unknown, E>, | |
| callbacks: { | |
| onSuccess?: (data: unknown) => void | Promise<void> | |
| onError?: (error: E) => void | Promise<void> | |
| onFinally?: () => void | Promise<void> | |
| }, | |
| ): Promise<void> { | |
| if (result.isErr()) { | |
| const onErrorResult = await Result.wrap( | |
| () => callbacks.onError?.(result.error) ?? Promise.resolve(), | |
| err => err as E, | |
| ) | |
| if (onErrorResult.isErr()) { | |
| console.error('onError callback failed:', onErrorResult.error) | |
| } | |
| } | |
| const finallyResult = await Result.wrap( | |
| () => callbacks.onFinally?.() ?? Promise.resolve(), | |
| error => error as E, | |
| ) | |
| if (finallyResult.isErr()) { | |
| console.error('onFinally callback failed:', finallyResult.error) | |
| } | |
| } | |
| export async function withAsyncState<T, E = Error>( | |
| state: AsyncState<E>, | |
| operation: (signal: AbortSignal) => Promise<T>, | |
| options?: WithAsyncStateOptions<T, E>, | |
| ): Promise<Result<T, E>> { | |
| const { | |
| onSuccess, | |
| onError, | |
| onFinally, | |
| resetRetryOnSuccess = true, | |
| optimistic, | |
| maxRetries = 3, | |
| timeoutMs, | |
| signal: externalSignal, | |
| } = options ?? {} | |
| const controller = new AbortController() | |
| const abortListener = setupExternalSignal(externalSignal, controller) | |
| state.loading = true | |
| state.error = null | |
| state.signal = controller.signal | |
| state.controller = controller | |
| optimistic?.apply() | |
| const result = await Result.gen( | |
| async function* () { | |
| const operationWithSignal = operation(controller.signal) | |
| const operationWithTimeout = timeoutMs | |
| ? withTimeout(operationWithSignal, timeoutMs, controller.signal) | |
| : operationWithSignal | |
| const wrapped = Result.wrap( | |
| () => operationWithTimeout, | |
| error => error as E, | |
| ) | |
| const data = yield* await wrapped() | |
| state.loading = false | |
| state.lastFetch = Date.now() | |
| if (resetRetryOnSuccess) { | |
| state.retryCount = 0 | |
| } | |
| return data | |
| }, | |
| ).mapError(error => { | |
| state.loading = false | |
| state.error = error as E | |
| state.retryCount++ | |
| if (state.retryCount > maxRetries) { | |
| optimistic?.rollback() | |
| } | |
| return error | |
| }).andThen(data => | |
| Result.wrap( | |
| () => onSuccess?.(data) ?? Promise.resolve(), | |
| error => error as E, | |
| ).map(() => data), | |
| ).mapError(error => { | |
| console.error('onSuccess callback failed:', error) | |
| return error | |
| }) | |
| await executeCallbacks(result, { onError, onFinally }) | |
| cleanupExternalSignal(externalSignal, abortListener) | |
| return result | |
| } | |
| export function isStale(state: AsyncState, staleTime = 5 * 60 * 1_000): boolean { | |
| if (!state) { | |
| throw new Error('State object is required') | |
| } | |
| if (staleTime < 0) { | |
| throw new Error('staleTime must be non-negative') | |
| } | |
| if (!state.lastFetch) { | |
| return true | |
| } | |
| return Date.now() - state.lastFetch > staleTime | |
| } | |
| export function resetAsyncState<E = Error>(state: AsyncState<E>): void { | |
| if (!state) { | |
| throw new Error('State object is required') | |
| } | |
| state.controller?.abort() | |
| Object.assign(state, createAsyncState<E>()) | |
| } | |
| export function canRetry(state: AsyncState, maxRetries = 3): boolean { | |
| if (!state) { | |
| throw new Error('State object is required') | |
| } | |
| return state.retryCount < maxRetries && state.error !== null | |
| } | |
| export function abortOperation(state: AsyncState): void { | |
| if (!state) { | |
| throw new Error('State object is required') | |
| } | |
| state.controller?.abort() | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment