import 'server-only'; import type { AsOfPayload, BenchmarkResult, BenchmarkRow, ChangeCategories, ChangeEvent, Claim, CompaniesPage, ComparePayload, DailyDigest, DiffPayload, EntityDetail, EntitySummary, ExploreType, GraphPayload, HardwareFit, Health, Methodology, ModelsPage, Page, Price, PriceIndex, ProviderRow, SearchPayload, SitemapPayload, SourceRef, SourceRow, Stats, StatsHistory, Suggestion, TimelinePayload, TrendingRow, } from './types'; /** * Typed fetch wrapper for the AI Atlas API (server components only — client code calls the same-origin `/api/v1/*` * rewrite through `src/lib/client-api.ts`). * * - default cache: ISR `next: { revalidate: 300 }` (5 min); live endpoints pass `revalidate: false` (no-store). * - non-2xx → `ApiError` (status + `{detail}` body); network failure → status 0. Pages must render an * "Unavailable" state rather than crash (graceful degradation). 404 → `notFound()` in the page. * - `safe(promise)` turns any error into `null` for optional panels fetched in parallel. */ export const API_URL = (process.env.API_URL ?? 'http://127.0.0.1:8331').replace(/\/$/, ''); const BASE = `${API_URL}/api/v1`; export class ApiError extends Error { readonly status: number; readonly detail: string | null; readonly path: string; constructor(status: number, path: string, detail: string | null, message?: string) { super(message ?? detail ?? `API ${status} on ${path}`); this.name = 'ApiError'; this.status = status; this.detail = detail; this.path = path; } get unavailable(): boolean { return this.status === 503 || this.status === 0 || this.status >= 500; } get notFound(): boolean { return this.status === 404; } } export interface FetchOptions { /** Seconds; `false` → `cache: 'no-store'`. Default 300. */ revalidate?: number | false; tags?: string[]; } export type Query = Record; function qs(query?: Query): string { if (!query) return ''; const p = new URLSearchParams(); for (const [k, v] of Object.entries(query)) { if (v === undefined || v === null || v === '') continue; p.set(k, String(v)); } const s = p.toString(); return s ? `?${s}` : ''; } export async function request(path: string, query?: Query, opts: FetchOptions = {}): Promise { const url = `${BASE}${path}${qs(query)}`; const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = { headers: { accept: 'application/json' } }; if (opts.revalidate === false) init.cache = 'no-store'; else init.next = { revalidate: opts.revalidate ?? 300, tags: opts.tags }; let res: Response; try { res = await fetch(url, init); } catch (e) { throw new ApiError(0, path, null, `API unreachable: ${(e as Error).message}`); } if (!res.ok) { let detail: string | null = null; try { const body = (await res.json()) as { detail?: unknown }; detail = typeof body.detail === 'string' ? body.detail : body.detail ? JSON.stringify(body.detail) : null; } catch { /* non-JSON error body */ } throw new ApiError(res.status, path, detail); } return (await res.json()) as T; } export async function safe(p: Promise): Promise { try { return await p; } catch { return null; } } const enc = encodeURIComponent; // ---------------------------------------------------------------------------------------------------------- endpoints export const api = { health: () => request('/health', undefined, { revalidate: false }), stats: () => request('/stats', undefined, { revalidate: 60 }), statsHistory: (days = 90) => request('/stats/history', { days }, { revalidate: 900 }), search: (q: string, query: Query = {}) => request('/search', { q, ...query }, { revalidate: false }), suggest: (q: string) => request<{ items: Suggestion[] }>('/search/suggest', { q }, { revalidate: false }), entity: (slugOrId: string) => request(`/entities/${enc(slugOrId)}`, undefined, { revalidate: 120 }), /** Type-mounted detail: `/models/`, `/companies/`, … (404 if the type does not match). */ entityOfType: (typePath: string, slug: string) => request(`/${typePath}/${enc(slug)}`, undefined, { revalidate: 120 }), entityTimeline: (slug: string, limit = 50, before?: string) => request<{ items: ChangeEvent[] }>(`/entities/${enc(slug)}/timeline`, { limit, before }, { revalidate: 120 }), entityHistory: (slug: string, property?: string) => request<{ items: Claim[] }>(`/entities/${enc(slug)}/history`, { property }, { revalidate: 300 }), entityAsOf: (slug: string, date: string) => request(`/entities/${enc(slug)}/asof`, { date }, { revalidate: 3600 }), entityGraph: (slug: string, depth = 1, limit = 80) => request(`/entities/${enc(slug)}/graph`, { depth, limit }, { revalidate: 600 }), entitySources: (slug: string) => request<{ items: SourceRef[] }>(`/entities/${enc(slug)}/sources`, undefined, { revalidate: 300 }), entityRelated: (slug: string, limit = 12) => request<{ items: EntitySummary[] }>(`/entities/${enc(slug)}/related`, { limit }, { revalidate: 600 }), models: (query: Query) => request('/models', query, { revalidate: 120 }), companies: (query: Query) => request('/companies', query, { revalidate: 300 }), papers: (query: Query) => request>('/papers', query, { revalidate: 300 }), providers: () => request<{ items: ProviderRow[] }>('/providers', undefined, { revalidate: 300 }), prices: (query: Query) => request>('/prices', { current: 1, ...query }, { revalidate: 300 }), priceHistory: (query: Query) => request<{ items: Price[] }>('/prices/history', query, { revalidate: 600 }), priceIndex: (days = 180) => request('/prices/index', { days }, { revalidate: 900 }), benchmarks: () => request<{ items: BenchmarkRow[] }>('/benchmarks', undefined, { revalidate: 300 }), benchmarkResults: (slug: string, query: Query = {}) => request>(`/benchmarks/${enc(slug)}/results`, query, { revalidate: 300 }), benchmarkHistory: (slug: string, model?: string) => request<{ items: BenchmarkResult[] }>(`/benchmarks/${enc(slug)}/history`, { model }, { revalidate: 600 }), hardware: (query: Query) => request>('/hardware', query, { revalidate: 600 }), hardwareFit: (query: Query) => request('/hardware/fit', query, { revalidate: 600 }), exploreTypes: () => request<{ items: ExploreType[] }>('/explore/types', undefined, { revalidate: 300 }), explore: (type: string, query: Query) => request>(`/explore/${enc(type)}`, query, { revalidate: 300 }), changes: (query: Query) => request>('/changes', query, { revalidate: 60 }), changesDaily: (date?: string) => request('/changes/daily', { date }, { revalidate: 300 }), changesCategories: (days = 7) => request('/changes/categories', { days }, { revalidate: 600 }), timeline: (query: Query) => request('/timeline', query, { revalidate: 600 }), compare: (ids: string[]) => request('/compare', { ids: ids.join(',') }, { revalidate: 300 }), diff: (a: string, b: string, scope = 'all') => request('/diff', { a, b, scope }, { revalidate: 3600 }), sources: () => request<{ items: SourceRow[] }>('/sources', undefined, { revalidate: 300 }), methodology: () => request('/methodology', undefined, { revalidate: 3600 }), trending: (days = 7, limit = 12) => request<{ items: TrendingRow[] }>('/trending', { days, limit }, { revalidate: 300 }), sitemap: (type?: string, limit = 5000, offset = 0) => request('/sitemap', { type, limit, offset }, { revalidate: 3600 }), }; /** Total number of items of one type, from `/stats` (preferred) or a 1-item listing. Null when unavailable. */ export async function countOfType(type: string): Promise { const s = await safe(api.stats()); const v = s?.entities?.[type]; if (v === undefined || v === null) return null; const n = Number(v); return Number.isFinite(n) ? n : null; }