export class NetworkError extends Error { constructor( message: string, public readonly cause?: unknown ) { super(message); this.name = "NetworkError"; } } export class TimeoutError extends Error { constructor( message: string, public readonly timeoutMs: number ) { super(message); this.name = "TimeoutError"; } } export class APIError extends Error { constructor( message: string, public readonly status: number, public readonly statusText: string ) { super(message); this.name = "APIError"; } } interface FetchWithTimeoutOptions extends RequestInit { timeout?: number; } export async function fetchWithTimeout( url: string, options: FetchWithTimeoutOptions = {} ): Promise { const { timeout = 10000, ...fetchOptions } = options; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeout); try { const response = await fetch(url, { ...fetchOptions, signal: controller.signal }); clearTimeout(timeoutId); return response; } catch (error: unknown) { clearTimeout(timeoutId); if (error instanceof Error) { if (error.name === "AbortError") { throw new TimeoutError( `Request to ${url} timed out after ${timeout}ms`, timeout ); } if ( error.message.includes("fetch failed") || error.message.includes("ECONNREFUSED") || error.message.includes("ENOTFOUND") || error.message.includes("ETIMEDOUT") || error.message.includes("UND_ERR_CONNECT_TIMEOUT") || error.name === "FailedToOpenSocket" || error.message.includes("Was there a typo") ) { throw new NetworkError( `Failed to connect to ${url}: ${error.message}`, error ); } } throw error; } } export async function checkResponse(response: Response): Promise { if (!response.ok) { throw new APIError( `API request failed: ${response.statusText}`, response.status, response.statusText ); } return response; } export async function safeJsonParse(response: Response): Promise { try { return await response.json(); } catch (error) { console.error("Failed to parse JSON response:", error); return null; } } export async function fetchWithRetry( fn: () => Promise, options: { maxRetries?: number; retryDelay?: number; retryableErrors?: (error: unknown) => boolean; } = {} ): Promise { const { maxRetries = 2, retryDelay = 1000, retryableErrors = (error) => error instanceof TimeoutError || error instanceof NetworkError } = options; let lastError: unknown; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error; if (attempt === maxRetries || !retryableErrors(error)) { throw error; } const delay = retryDelay * Math.pow(2, attempt); await new Promise((resolve) => setTimeout(resolve, delay)); } } throw lastError; } // ============================================================ // CLOUDFLARE TURNSTILE VERIFICATION // ============================================================ interface TurnstileResponse { success: boolean; "challenge-ts"?: string; action?: string; cdata?: string; "error-codes"?: string[]; } export async function verifyTurnstileToken( token: string, secretKey: string, verifyUrl: string = "https://challenges.cloudflare.com/turnstile/v0/siteverify", timeoutMs: number = 10000 ): Promise { if (!token || token.trim() === "") { return false; } try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const response = await fetch(verifyUrl, { method: "POST", body: new URLSearchParams({ secret: secretKey, response: token, }), headers: { "content-type": "application/x-www-form-urlencoded", }, signal: controller.signal, }); clearTimeout(timeoutId); if (!response.ok) { console.error(`Turnstile verification failed with status ${response.status}`); return false; } const data = (await response.json()) as TurnstileResponse; return data.success === true; } catch (error) { console.error("Turnstile verification error:", error); return false; } }