import "server-only"; /** * Server-side client for the Fetcha API service (Fastify). Used by server actions and route * handlers only; authenticated with the shared internal service token. */ const API_URL = (process.env.API_URL ?? "http://127.0.0.1:8221").replace(/\/$/, ""); const TOKEN = process.env.INTERNAL_SERVICE_TOKEN ?? ""; export interface ApiErrorShape { error: { code: string; message: string; request_id: string | null; details?: Record }; } export class InternalApiError extends Error { constructor( readonly status: number, readonly code: string, message: string, readonly requestId: string | null, readonly details?: Record, ) { super(message); this.name = "InternalApiError"; } } async function call(path: string, init: RequestInit & { timeoutMs?: number } = {}): Promise { const ac = new AbortController(); const t = setTimeout(() => ac.abort(), init.timeoutMs ?? 150_000); try { const res = await fetch(`${API_URL}${path}`, { ...init, headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json", ...(init.headers ?? {}) }, signal: ac.signal, cache: "no-store", }); const text = await res.text(); let data: unknown = null; try { data = text ? JSON.parse(text) : null; } catch { data = null; } if (!res.ok) { const err = (data as ApiErrorShape | null)?.error; throw new InternalApiError(res.status, err?.code ?? "INTERNAL_ERROR", err?.message ?? `API error ${res.status}`, err?.request_id ?? res.headers.get("x-fetcha-request-id"), err?.details); } return data as T; } catch (e) { if (e instanceof InternalApiError) throw e; if ((e as Error).name === "AbortError") throw new InternalApiError(504, "TARGET_TIMEOUT", "The request timed out.", null); throw new InternalApiError(503, "PROVIDER_UNAVAILABLE", "The Fetcha API service is unreachable.", null); } finally { clearTimeout(t); } } // --------------------------------------------------------------------------- // Crawl & map (section 3 of docs/API-v0.2.md). Provider details are never included. // --------------------------------------------------------------------------- export type CrawlJobStatus = "queued" | "running" | "completed" | "failed" | "cancelled"; export interface CrawlJobStats { discovered: number; fetched: number; ok: number; blocked: number; failed: number; bytes: number; } export interface CrawlJob { id: string; status: CrawlJobStatus; label: string | null; seed_url: string; domain: string; options: Record; stats: CrawlJobStats; error: { code: string; message: string } | null; created_at: string; started_at: string | null; completed_at: string | null; } export interface CrawlPage { id: string; url: string; final_url: string | null; depth: number; status: string; http_status: number | null; error_code: string | null; title: string | null; description: string | null; content_type: string | null; content: string | null; links_count: number | null; bytes: number | null; duration_ms: number | null; mode: "http" | "browser" | null; fetched_at: string | null; } export interface CrawlPagesPage { data: CrawlPage[]; next_cursor: string | null; } export interface MapResult { url: string; count: number; urls: string[]; sources: { sitemap: number; links: number }; truncated: boolean; } export interface BrowserStatus { enabled: boolean; running: number; capacity: number; queue: number; } function qs(params: Record): string { const sp = new URLSearchParams(); for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") sp.set(k, String(v)); const s = sp.toString(); return s ? `?${s}` : ""; } export const internalApi = { health: () => call<{ status: string; version: string }>("/health", { timeoutMs: 5000 }), ready: () => call<{ status: string; checks: Record; available_networks: string[] }>("/ready", { timeoutMs: 8000 }).catch((e) => (e instanceof InternalApiError && e.status === 503 ? { status: "degraded", checks: {}, available_networks: [] } : Promise.reject(e))), playgroundFetch: (projectId: string, userId: string, request: unknown) => call("/internal/fetch", { method: "POST", body: JSON.stringify({ project_id: projectId, user_id: userId, request }) }), createSession: (projectId: string, userId: string, options: unknown) => call("/internal/sessions", { method: "POST", body: JSON.stringify({ project_id: projectId, user_id: userId, options }) }), listSessions: (projectId: string, userId: string) => call<{ data: unknown[] }>(`/internal/sessions?project_id=${projectId}&user_id=${userId}`), closeSession: (projectId: string, userId: string, id: string) => call(`/internal/sessions/${id}?project_id=${projectId}&user_id=${userId}`, { method: "DELETE" }), usage: (projectId: string, userId: string) => call>(`/internal/usage?project_id=${projectId}&user_id=${userId}`), providers: () => call<{ providers: Array<{ id: string; label: string; configured: boolean; networks: string[]; prices: Record; health: Array<{ network: string; status: string; latency_ms: number | null; detail: string | null; checked_at: string }>; circuits: Array<{ key: string; state: string; failures: number; successes: number; openedAt: number | null }>; }>; available_networks: string[]; }>("/internal/providers", { timeoutMs: 10_000 }), probeProviders: () => call<{ ok: boolean }>("/internal/providers/probe", { method: "POST", timeoutMs: 60_000 }), reloadProviders: () => call<{ ok: boolean }>("/internal/providers/reload", { method: "POST" }), resetCircuit: (key?: string) => call<{ ok: boolean }>("/internal/circuits/reset", { method: "POST", body: JSON.stringify({ key }) }), invalidateKey: (keyHash: string) => call<{ ok: boolean }>("/internal/keys/invalidate", { method: "POST", body: JSON.stringify({ key_hash: keyHash }) }).catch(() => ({ ok: false })), // Crawl & map createCrawl: (projectId: string, userId: string, options: unknown) => call("/internal/crawls", { method: "POST", body: JSON.stringify({ project_id: projectId, user_id: userId, options }), timeoutMs: 30_000 }), listCrawls: (projectId: string, userId: string, limit = 50) => call<{ data: CrawlJob[] }>(`/internal/crawls${qs({ project_id: projectId, user_id: userId, limit })}`, { timeoutMs: 15_000 }), getCrawl: (projectId: string, userId: string, id: string) => call(`/internal/crawls/${encodeURIComponent(id)}${qs({ project_id: projectId, user_id: userId })}`, { timeoutMs: 15_000 }), crawlPages: (projectId: string, userId: string, id: string, opts: { cursor?: string | null; limit?: number; status?: string | null } = {}) => call(`/internal/crawls/${encodeURIComponent(id)}/pages${qs({ project_id: projectId, user_id: userId, cursor: opts.cursor, limit: opts.limit, status: opts.status })}`, { timeoutMs: 20_000 }), cancelCrawl: (projectId: string, userId: string, id: string) => call<{ id: string; status: "cancelled" }>(`/internal/crawls/${encodeURIComponent(id)}${qs({ project_id: projectId, user_id: userId })}`, { method: "DELETE", timeoutMs: 15_000 }), mapSite: (projectId: string, userId: string, options: unknown) => call("/internal/map", { method: "POST", body: JSON.stringify({ project_id: projectId, user_id: userId, options }), timeoutMs: 90_000 }), browserStatus: () => call("/internal/browser", { timeoutMs: 5000 }).catch(() => ({ enabled: false, running: 0, capacity: 0, queue: 0 }) as BrowserStatus), };