import { transferableAbortController } from 'node:util'; import { AiApiError, AiErrorCodes } from './AiError'; export interface AiTimeoutBudget { readonly signal: AbortSignal; remainingMs(): number; throwIfExpired(): void; dispose(): void; } export function createAiTimeoutBudget( timeoutMs: number, parentSignal?: AbortSignal, ): AiTimeoutBudget { const startedAt = performance.now(); // Node fetch validates AbortSignal's realm. Server code can temporarily run // beside a DOM realm during smoke rendering, so keep this timeout signal native. const controller = transferableAbortController(); const timeout = setTimeout(() => { controller.abort(new DOMException('AI request timed out', 'TimeoutError')); }, timeoutMs); const onParentAbort = () => controller.abort( parentSignal?.reason ?? new DOMException('AI request aborted', 'AbortError'), ); if (parentSignal?.aborted) { onParentAbort(); } else { parentSignal?.addEventListener('abort', onParentAbort, { once: true }); } const remainingMs = () => Math.max(0, Math.ceil(timeoutMs - (performance.now() - startedAt))); return { signal: controller.signal, remainingMs, throwIfExpired() { if (remainingMs() <= 0 || controller.signal.aborted) { throw new AiApiError(AiErrorCodes.AI_UPSTREAM_TIMEOUT, 'AI 服务调用超时'); } }, dispose() { clearTimeout(timeout); parentSignal?.removeEventListener('abort', onParentAbort); }, }; }