Files
freno-dev/src/server/fetch-utils.ts
2026-05-28 10:24:23 -04:00

191 lines
4.4 KiB
TypeScript

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<Response> {
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<Response> {
if (!response.ok) {
throw new APIError(
`API request failed: ${response.statusText}`,
response.status,
response.statusText
);
}
return response;
}
export async function safeJsonParse<T>(response: Response): Promise<T | null> {
try {
return await response.json();
} catch (error) {
console.error("Failed to parse JSON response:", error);
return null;
}
}
export async function fetchWithRetry<T>(
fn: () => Promise<T>,
options: {
maxRetries?: number;
retryDelay?: number;
retryableErrors?: (error: unknown) => boolean;
} = {}
): Promise<T> {
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<boolean> {
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;
}
}