UNPKG

@tanstack/ai

Version:

Type-safe TypeScript AI SDK for streaming chat, tool calling, agents, structured outputs, and multimodal generation.

198 lines (182 loc) 5.84 kB
/** * Shared abort/timeout composition for media (and summarize) activities. * * Callers pass optional `timeout` and/or `abortSignal` on activity options. * Core composes them into one effective signal, races the adapter call so a * hung provider still rejects, clears timeout resources on settle, and * classifies aborts so lifecycle middleware gets `onAbort` rather than * `onError`. */ const ABORT_ERROR_NAMES = new Set([ 'AbortError', 'TimeoutError', 'APIUserAbortError', 'RequestAbortedError', ]) /** * Combine two optional AbortSignals into one that aborts when either does. * Returns the other signal directly when one is absent or already aborted. * First abort wins and preserves its reason. * * Manual implementation — `AbortSignal.any` requires Node >= 20.3. */ export function combineAbortSignals( a: AbortSignal | undefined, b: AbortSignal | undefined, ): AbortSignal | undefined { if (!a) return b if (!b) return a if (a.aborted) return a if (b.aborted) return b const controller = new AbortController() const onAbort = (source: AbortSignal) => () => { controller.abort(source.reason) } a.addEventListener('abort', onAbort(a), { once: true }) b.addEventListener('abort', onAbort(b), { once: true }) return controller.signal } function createTimeoutReason(ms: number): Error { if (typeof DOMException !== 'undefined') { return new DOMException(`Activity timed out after ${ms}ms`, 'TimeoutError') } const err = new Error(`Activity timed out after ${ms}ms`) err.name = 'TimeoutError' return err } /** Normalize an abort reason into an Error the activity can reject with. */ export function toAbortError(reason: unknown): Error { if (reason instanceof Error) return reason if (typeof reason === 'string' && reason.length > 0) { const err = new Error(reason) err.name = 'AbortError' return err } const err = new Error('The operation was aborted') err.name = 'AbortError' return err } export interface ActivityAbortControls { /** Effective signal, or `undefined` when neither timeout nor caller signal. */ signal: AbortSignal | undefined /** Clear the timeout timer if one was set. Idempotent. */ clear: () => void } /** * Compose an activity-level timeout with a caller AbortSignal. * * - No SDK-wide default timeout; omit both for unlimited wait. * - First of caller cancellation or timeout wins and keeps its reason. * - Call `clear()` when the activity settles (success or failure) so timers * do not leak. */ export function createActivityAbortControls(options: { abortSignal?: AbortSignal timeout?: number }): ActivityAbortControls { let timeoutId: ReturnType<typeof setTimeout> | undefined let timeoutSignal: AbortSignal | undefined if (options.timeout !== undefined) { if (!Number.isFinite(options.timeout) || options.timeout < 0) { throw new Error( `Invalid activity timeout: expected a non-negative finite number, got ${String(options.timeout)}`, ) } const controller = new AbortController() timeoutSignal = controller.signal const ms = options.timeout timeoutId = setTimeout(() => { controller.abort(createTimeoutReason(ms)) }, ms) } const signal = combineAbortSignals(options.abortSignal, timeoutSignal) return { signal, clear: () => { if (timeoutId !== undefined) { clearTimeout(timeoutId) timeoutId = undefined } }, } } /** * Reject when `signal` aborts, even if the underlying promise ignores it. * Ensures activity-level timeouts work for adapters that do not yet forward * the signal to the provider SDK. * * When the signal wins, the adapter promise is observed with an empty handler * so a later settle cannot surface as an unhandled rejection. */ export function raceWithAbort<T>( promise: Promise<T>, signal: AbortSignal | undefined, ): Promise<T> { if (!signal) return promise const swallow = () => { // Observe the adapter promise without acting on its outcome so a late // reject after we already aborted cannot become an unhandled rejection. promise.then( () => undefined, () => undefined, ) } if (signal.aborted) { swallow() return Promise.reject(toAbortError(signal.reason)) } return new Promise<T>((resolve, reject) => { let settled = false const onAbort = () => { if (settled) return settled = true cleanup() swallow() reject(toAbortError(signal.reason)) } const cleanup = () => { signal.removeEventListener('abort', onAbort) } signal.addEventListener('abort', onAbort, { once: true }) promise.then( (value) => { if (settled) return settled = true cleanup() resolve(value) }, (error: unknown) => { if (settled) return settled = true cleanup() reject(error) }, ) }) } /** * Whether a thrown value (and optional effective signal) should route to * middleware `onAbort` instead of `onError`. */ export function isActivityAbortError( error: unknown, signal?: AbortSignal, ): boolean { if (signal?.aborted) return true if (!error || typeof error !== 'object') return false const name = (error as { name?: unknown }).name return typeof name === 'string' && ABORT_ERROR_NAMES.has(name) } /** Best-effort string reason for {@link GenerationAbortInfo}. */ export function abortReasonMessage( error: unknown, signal?: AbortSignal, ): string | undefined { if (signal?.reason !== undefined) { if (typeof signal.reason === 'string') return signal.reason if (signal.reason instanceof Error) return signal.reason.message } if (error instanceof Error) return error.message if (typeof error === 'string') return error return undefined }