import type { AiIndex, ApiEndpointDoc, ApiEnvelope, ClaimDTO, CloudRegionSummary, ConnectivityOverview, CoverageReport, DownloadDataset, EntityHistory, ExploreQuery, ExploreResponse, NearbyInfrastructure, OperatorComparison, PowerOverview, Pulse, SourceCoverage, TimeMachine, WatchlistItem, CountryDetail, CountrySummary, Dashboard, EventDTO, FacilityDetail, FacilityFilters, FacilitySummary, IxpSummary, MapResponse, MetroDetail, MetroSummary, OperatorDetail, OperatorSummary, ProjectDetail, ProjectSummary, Ranking, SearchResponse, } from "@dci/core"; import type { CloudRegionDetail, IxpDetail, PipelineAggregates, SitemapEntry, SitemapKind, SourceDetail, SourceWithCounts } from "@/lib/dto"; /** * Typed API client over the v1 contract (packages/core/src/api-types.ts). * Server: `API_URL_INTERNAL ?? NEXT_PUBLIC_API_URL` + `/api/v1`. Client: `NEXT_PUBLIC_API_URL`. * `apiGet` NEVER throws — pages render skeletons/empty states from `{ ok:false }`. */ export const REVALIDATE = { dashboard: 60, list: 120, detail: 300, live: 30, map: 0 } as const; export const TIMEOUT_MS = 8000; export function serverApiBase(): string { const base = process.env.API_URL_INTERNAL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://127.0.0.1:8311"; return `${base.replace(/\/$/, "")}/api/v1`; } export function clientApiBase(): string { if (typeof window === "undefined") return serverApiBase(); const base = process.env.NEXT_PUBLIC_API_URL ?? ""; return `${base.replace(/\/$/, "")}/api/v1`; } export type ApiResult = { ok: true; data: T; meta: ApiEnvelope["meta"]; sources: ApiEnvelope["sources"]; status: number } | { ok: false; data: null; meta?: undefined; sources?: undefined; error: string; status: number }; export type Query = Record; export function buildQuery(q?: Query): string { if (!q) return ""; const sp = new URLSearchParams(); for (const [k, v] of Object.entries(q)) { if (v === undefined || v === null || v === "" || v === false) continue; sp.set(k, v === true ? "1" : String(v)); } const s = sp.toString(); return s ? `?${s}` : ""; } interface GetOptions { revalidate?: number; timeoutMs?: number; /** override the base (client hooks pass clientApiBase()) */ base?: string; signal?: AbortSignal; tags?: string[]; } export async function apiGet(path: string, query?: Query, opts: GetOptions = {}): Promise> { const base = opts.base ?? serverApiBase(); const url = `${base}${path.startsWith("/") ? path : `/${path}`}${buildQuery(query)}`; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(new Error("timeout")), opts.timeoutMs ?? TIMEOUT_MS); if (opts.signal) opts.signal.addEventListener("abort", () => ctrl.abort(opts.signal?.reason), { once: true }); try { const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = { headers: { accept: "application/json" }, signal: ctrl.signal, }; if (typeof window === "undefined") { const rv = opts.revalidate ?? REVALIDATE.list; init.next = rv > 0 ? { revalidate: rv, tags: opts.tags } : { revalidate: 0 }; if (rv === 0) init.cache = "no-store"; } const res = await fetch(url, init); if (!res.ok) return { ok: false, data: null, error: `HTTP ${res.status}`, status: res.status }; const json = (await res.json()) as ApiEnvelope | T; if (json && typeof json === "object" && "data" in (json as object)) { const env = json as ApiEnvelope; return { ok: true, data: env.data, meta: env.meta, sources: env.sources, status: res.status }; } return { ok: true, data: json as T, meta: undefined, sources: undefined, status: res.status }; } catch (e) { const msg = e instanceof Error ? (e.name === "AbortError" ? "timeout" : e.message) : "network error"; return { ok: false, data: null, error: msg, status: 0 }; } finally { clearTimeout(timer); } } /* ------------------------------------------------------------------ server helpers (typed) */ export interface Paged { items: T[]; total: number; page: number; perPage: number; } function paged(r: ApiResult, fallbackPage = 1, fallbackPer = 50): Paged { if (!r.ok || !r.data) return { items: [], total: 0, page: fallbackPage, perPage: fallbackPer }; const items = Array.isArray(r.data) ? r.data : (r.data.items ?? []); const total = (r.meta?.total as number | undefined) ?? (Array.isArray(r.data) ? items.length : (r.data.total ?? items.length)); return { items, total, page: (r.meta?.page as number | undefined) ?? fallbackPage, perPage: (r.meta?.perPage as number | undefined) ?? fallbackPer }; } export const api = { health: () => apiGet<{ status: string }>("/health", undefined, { revalidate: 0, timeoutMs: 2500 }), dashboard: () => apiGet("/dashboard", undefined, { revalidate: REVALIDATE.dashboard, tags: ["dashboard"] }), facilities: async (f: FacilityFilters) => { const r = await apiGet("/datacenters", f as Query, { revalidate: REVALIDATE.list }); return { ...paged(r, f.page ?? 1, f.per_page ?? 50), ok: r.ok, error: r.ok ? undefined : r.error }; }, facility: (slug: string) => apiGet(`/datacenters/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }), operators: async (q?: Query) => { const r = await apiGet("/operators", q, { revalidate: REVALIDATE.list }); return paged(r); }, countries: async (q?: Query) => { const r = await apiGet("/countries", q, { revalidate: REVALIDATE.list }); return paged(r); }, metros: async (q?: Query) => { const r = await apiGet("/metros", q, { revalidate: REVALIDATE.list }); return paged(r); }, projects: async (q?: Query) => { const r = await apiGet("/projects", q, { revalidate: REVALIDATE.list }); return paged(r); }, cloudRegions: async (q?: Query) => { const r = await apiGet("/cloud-regions", q, { revalidate: REVALIDATE.list }); return paged(r); }, ixps: async (q?: Query) => { const r = await apiGet("/ixps", q, { revalidate: REVALIDATE.list }); return paged(r); }, rankings: async () => { const r = await apiGet("/rankings", undefined, { revalidate: REVALIDATE.list }); return r.ok && Array.isArray(r.data) ? r.data : []; }, ranking: (key: string, q?: Query) => apiGet(`/rankings/${encodeURIComponent(key)}`, q, { revalidate: REVALIDATE.list }), events: async (q?: Query) => { const r = await apiGet("/events", q, { revalidate: REVALIDATE.live }); return { ...paged(r, Number(q?.page ?? 1), Number(q?.per_page ?? 50)), ok: r.ok, error: r.ok ? undefined : r.error }; }, search: (q: string, extra?: Query) => apiGet("/search", { q, ...extra }, { revalidate: REVALIDATE.list }), map: (q: Query) => apiGet("/map", q, { revalidate: REVALIDATE.map }), /* ---- entity details (facilities paginated with ?fPage on operator / country / metro) */ operator: (slug: string, q?: Query) => apiGet(`/operators/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }), country: (slug: string, q?: Query) => apiGet(`/countries/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }), metro: (slug: string, q?: Query) => apiGet(`/metros/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }), project: (slug: string) => apiGet(`/projects/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }), projectPipeline: () => apiGet("/projects/pipeline", undefined, { revalidate: REVALIDATE.list }), cloudRegion: (slug: string) => apiGet(`/cloud-regions/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }), ixp: (slug: string) => apiGet(`/ixps/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }), sources: async (q?: Query) => { const r = await apiGet("/sources", q, { revalidate: REVALIDATE.list }); return paged(r); }, source: (id: string) => apiGet(`/sources/${encodeURIComponent(id)}`, undefined, { revalidate: REVALIDATE.detail }), sitemap: (kind: SitemapKind, page = 1) => apiGet(`/sitemap/${kind}`, { page }, { revalidate: 600 }), /* ---- API 2.0 (2026-09-12): evidence, history, graph, intelligence */ facilityHistory: (slug: string) => apiGet(`/datacenters/${encodeURIComponent(slug)}/history`, undefined, { revalidate: REVALIDATE.detail }), facilityClaims: (slug: string) => apiGet(`/datacenters/${encodeURIComponent(slug)}/claims`, undefined, { revalidate: REVALIDATE.detail }), projectHistory: (slug: string) => apiGet(`/projects/${encodeURIComponent(slug)}/history`, undefined, { revalidate: REVALIDATE.detail }), projectClaims: (slug: string) => apiGet(`/projects/${encodeURIComponent(slug)}/claims`, undefined, { revalidate: REVALIDATE.detail }), nearby: (q: { lat: number; lng: number; radius_km?: number; types?: string }) => apiGet("/nearby", q, { revalidate: REVALIDATE.detail }), pulse: (window: "24h" | "7d" | "30d" = "7d") => apiGet("/pulse", { window }, { revalidate: REVALIDATE.dashboard, tags: ["pulse"] }), compareOperators: (slugs: string[]) => apiGet("/compare/operators", { slugs: slugs.join(",") }, { revalidate: REVALIDATE.list }), explore: (q: ExploreQuery) => apiGet("/explore", q as Query, { revalidate: REVALIDATE.list }), coverage: () => apiGet("/coverage", undefined, { revalidate: REVALIDATE.list }), coverageSources: () => apiGet("/coverage/sources", undefined, { revalidate: REVALIDATE.list }), aiIndex: () => apiGet("/ai-infrastructure", undefined, { revalidate: REVALIDATE.list }), power: () => apiGet("/power", undefined, { revalidate: REVALIDATE.list }), connectivity: () => apiGet("/connectivity", undefined, { revalidate: REVALIDATE.list }), timeMachine: () => apiGet("/time-machine", undefined, { revalidate: REVALIDATE.list }), downloadDatasets: () => apiGet("/download/datasets", undefined, { revalidate: REVALIDATE.list }), docsMeta: () => apiGet("/docs-meta", undefined, { revalidate: 600 }), watchlist: () => apiGet("/watchlist", undefined, { revalidate: 0 }), watchlistFeed: (q?: Query) => apiGet("/watchlist/feed", q, { revalidate: 0 }), }; /** Event-list query for /live and the home module. */ export interface EventQuery { type?: string; country?: string; operator?: string; min_significance?: number; since?: string; page?: number; per_page?: number; }