SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
4 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
11.4 KB · 228 lines typescript
Raw Blame History
1import type {2  AiIndex,3  ApiEndpointDoc,4  ApiEnvelope,5  ClaimDTO,6  CloudRegionSummary,7  ConnectivityOverview,8  CoverageReport,9  DownloadDataset,10  EntityHistory,11  ExploreQuery,12  ExploreResponse,13  NearbyInfrastructure,14  OperatorComparison,15  PowerOverview,16  Pulse,17  SourceCoverage,18  TimeMachine,19  WatchlistItem,20  CountryDetail,21  CountrySummary,22  Dashboard,23  EventDTO,24  FacilityDetail,25  FacilityFilters,26  FacilitySummary,27  IxpSummary,28  MapResponse,29  MetroDetail,30  MetroSummary,31  OperatorDetail,32  OperatorSummary,33  ProjectDetail,34  ProjectSummary,35  Ranking,36  SearchResponse,37} from "@dci/core";38import type { CloudRegionDetail, IxpDetail, PipelineAggregates, SitemapEntry, SitemapKind, SourceDetail, SourceWithCounts } from "@/lib/dto";3940/**41 * Typed API client over the v1 contract (packages/core/src/api-types.ts).42 * Server: `API_URL_INTERNAL ?? NEXT_PUBLIC_API_URL` + `/api/v1`. Client: `NEXT_PUBLIC_API_URL`.43 * `apiGet` NEVER throws — pages render skeletons/empty states from `{ ok:false }`.44 */4546export const REVALIDATE = { dashboard: 60, list: 120, detail: 300, live: 30, map: 0 } as const;47export const TIMEOUT_MS = 8000;4849export function serverApiBase(): string {50  const base = process.env.API_URL_INTERNAL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://127.0.0.1:8311";51  return `${base.replace(/\/$/, "")}/api/v1`;52}5354export function clientApiBase(): string {55  if (typeof window === "undefined") return serverApiBase();56  const base = process.env.NEXT_PUBLIC_API_URL ?? "";57  return `${base.replace(/\/$/, "")}/api/v1`;58}5960export type ApiResult<T> = { ok: true; data: T; meta: ApiEnvelope<T>["meta"]; sources: ApiEnvelope<T>["sources"]; status: number } | { ok: false; data: null; meta?: undefined; sources?: undefined; error: string; status: number };6162export type Query = Record<string, string | number | boolean | undefined | null>;6364export function buildQuery(q?: Query): string {65  if (!q) return "";66  const sp = new URLSearchParams();67  for (const [k, v] of Object.entries(q)) {68    if (v === undefined || v === null || v === "" || v === false) continue;69    sp.set(k, v === true ? "1" : String(v));70  }71  const s = sp.toString();72  return s ? `?${s}` : "";73}7475interface GetOptions {76  revalidate?: number;77  timeoutMs?: number;78  /** override the base (client hooks pass clientApiBase()) */79  base?: string;80  signal?: AbortSignal;81  tags?: string[];82}8384export async function apiGet<T>(path: string, query?: Query, opts: GetOptions = {}): Promise<ApiResult<T>> {85  const base = opts.base ?? serverApiBase();86  const url = `${base}${path.startsWith("/") ? path : `/${path}`}${buildQuery(query)}`;87  const ctrl = new AbortController();88  const timer = setTimeout(() => ctrl.abort(new Error("timeout")), opts.timeoutMs ?? TIMEOUT_MS);89  if (opts.signal) opts.signal.addEventListener("abort", () => ctrl.abort(opts.signal?.reason), { once: true });90  try {91    const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = {92      headers: { accept: "application/json" },93      signal: ctrl.signal,94    };95    if (typeof window === "undefined") {96      const rv = opts.revalidate ?? REVALIDATE.list;97      init.next = rv > 0 ? { revalidate: rv, tags: opts.tags } : { revalidate: 0 };98      if (rv === 0) init.cache = "no-store";99    }100    const res = await fetch(url, init);101    if (!res.ok) return { ok: false, data: null, error: `HTTP ${res.status}`, status: res.status };102    const json = (await res.json()) as ApiEnvelope<T> | T;103    if (json && typeof json === "object" && "data" in (json as object)) {104      const env = json as ApiEnvelope<T>;105      return { ok: true, data: env.data, meta: env.meta, sources: env.sources, status: res.status };106    }107    return { ok: true, data: json as T, meta: undefined, sources: undefined, status: res.status };108  } catch (e) {109    const msg = e instanceof Error ? (e.name === "AbortError" ? "timeout" : e.message) : "network error";110    return { ok: false, data: null, error: msg, status: 0 };111  } finally {112    clearTimeout(timer);113  }114}115116/* ------------------------------------------------------------------ server helpers (typed) */117118export interface Paged<T> {119  items: T[];120  total: number;121  page: number;122  perPage: number;123}124125function paged<T>(r: ApiResult<T[] | { items: T[]; total?: number }>, fallbackPage = 1, fallbackPer = 50): Paged<T> {126  if (!r.ok || !r.data) return { items: [], total: 0, page: fallbackPage, perPage: fallbackPer };127  const items = Array.isArray(r.data) ? r.data : (r.data.items ?? []);128  const total = (r.meta?.total as number | undefined) ?? (Array.isArray(r.data) ? items.length : (r.data.total ?? items.length));129  return { items, total, page: (r.meta?.page as number | undefined) ?? fallbackPage, perPage: (r.meta?.perPage as number | undefined) ?? fallbackPer };130}131132export const api = {133  health: () => apiGet<{ status: string }>("/health", undefined, { revalidate: 0, timeoutMs: 2500 }),134  dashboard: () => apiGet<Dashboard>("/dashboard", undefined, { revalidate: REVALIDATE.dashboard, tags: ["dashboard"] }),135136  facilities: async (f: FacilityFilters) => {137    const r = await apiGet<FacilitySummary[] | { items: FacilitySummary[]; total?: number }>("/datacenters", f as Query, { revalidate: REVALIDATE.list });138    return { ...paged<FacilitySummary>(r, f.page ?? 1, f.per_page ?? 50), ok: r.ok, error: r.ok ? undefined : r.error };139  },140  facility: (slug: string) => apiGet<FacilityDetail>(`/datacenters/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }),141142  operators: async (q?: Query) => {143    const r = await apiGet<OperatorSummary[] | { items: OperatorSummary[]; total?: number }>("/operators", q, { revalidate: REVALIDATE.list });144    return paged<OperatorSummary>(r);145  },146  countries: async (q?: Query) => {147    const r = await apiGet<CountrySummary[] | { items: CountrySummary[]; total?: number }>("/countries", q, { revalidate: REVALIDATE.list });148    return paged<CountrySummary>(r);149  },150  metros: async (q?: Query) => {151    const r = await apiGet<MetroSummary[] | { items: MetroSummary[]; total?: number }>("/metros", q, { revalidate: REVALIDATE.list });152    return paged<MetroSummary>(r);153  },154  projects: async (q?: Query) => {155    const r = await apiGet<ProjectSummary[] | { items: ProjectSummary[]; total?: number }>("/projects", q, { revalidate: REVALIDATE.list });156    return paged<ProjectSummary>(r);157  },158  cloudRegions: async (q?: Query) => {159    const r = await apiGet<CloudRegionSummary[] | { items: CloudRegionSummary[]; total?: number }>("/cloud-regions", q, { revalidate: REVALIDATE.list });160    return paged<CloudRegionSummary>(r);161  },162  ixps: async (q?: Query) => {163    const r = await apiGet<IxpSummary[] | { items: IxpSummary[]; total?: number }>("/ixps", q, { revalidate: REVALIDATE.list });164    return paged<IxpSummary>(r);165  },166167  rankings: async () => {168    const r = await apiGet<Ranking[]>("/rankings", undefined, { revalidate: REVALIDATE.list });169    return r.ok && Array.isArray(r.data) ? r.data : [];170  },171  ranking: (key: string, q?: Query) => apiGet<Ranking>(`/rankings/${encodeURIComponent(key)}`, q, { revalidate: REVALIDATE.list }),172173  events: async (q?: Query) => {174    const r = await apiGet<EventDTO[] | { items: EventDTO[]; total?: number }>("/events", q, { revalidate: REVALIDATE.live });175    return { ...paged<EventDTO>(r, Number(q?.page ?? 1), Number(q?.per_page ?? 50)), ok: r.ok, error: r.ok ? undefined : r.error };176  },177178  search: (q: string, extra?: Query) => apiGet<SearchResponse>("/search", { q, ...extra }, { revalidate: REVALIDATE.list }),179180  map: (q: Query) => apiGet<MapResponse>("/map", q, { revalidate: REVALIDATE.map }),181182  /* ---- entity details (facilities paginated with ?fPage on operator / country / metro) */183  operator: (slug: string, q?: Query) => apiGet<OperatorDetail>(`/operators/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }),184  country: (slug: string, q?: Query) => apiGet<CountryDetail>(`/countries/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }),185  metro: (slug: string, q?: Query) => apiGet<MetroDetail>(`/metros/${encodeURIComponent(slug)}`, q, { revalidate: REVALIDATE.detail }),186  project: (slug: string) => apiGet<ProjectDetail>(`/projects/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }),187  projectPipeline: () => apiGet<PipelineAggregates>("/projects/pipeline", undefined, { revalidate: REVALIDATE.list }),188  cloudRegion: (slug: string) => apiGet<CloudRegionDetail>(`/cloud-regions/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }),189  ixp: (slug: string) => apiGet<IxpDetail>(`/ixps/${encodeURIComponent(slug)}`, undefined, { revalidate: REVALIDATE.detail }),190  sources: async (q?: Query) => {191    const r = await apiGet<SourceWithCounts[] | { items: SourceWithCounts[]; total?: number }>("/sources", q, { revalidate: REVALIDATE.list });192    return paged<SourceWithCounts>(r);193  },194  source: (id: string) => apiGet<SourceDetail>(`/sources/${encodeURIComponent(id)}`, undefined, { revalidate: REVALIDATE.detail }),195  sitemap: (kind: SitemapKind, page = 1) => apiGet<SitemapEntry[]>(`/sitemap/${kind}`, { page }, { revalidate: 600 }),196197  /* ---- API 2.0 (2026-09-12): evidence, history, graph, intelligence */198  facilityHistory: (slug: string) => apiGet<EntityHistory>(`/datacenters/${encodeURIComponent(slug)}/history`, undefined, { revalidate: REVALIDATE.detail }),199  facilityClaims: (slug: string) => apiGet<ClaimDTO[]>(`/datacenters/${encodeURIComponent(slug)}/claims`, undefined, { revalidate: REVALIDATE.detail }),200  projectHistory: (slug: string) => apiGet<EntityHistory>(`/projects/${encodeURIComponent(slug)}/history`, undefined, { revalidate: REVALIDATE.detail }),201  projectClaims: (slug: string) => apiGet<ClaimDTO[]>(`/projects/${encodeURIComponent(slug)}/claims`, undefined, { revalidate: REVALIDATE.detail }),202  nearby: (q: { lat: number; lng: number; radius_km?: number; types?: string }) => apiGet<NearbyInfrastructure>("/nearby", q, { revalidate: REVALIDATE.detail }),203  pulse: (window: "24h" | "7d" | "30d" = "7d") => apiGet<Pulse>("/pulse", { window }, { revalidate: REVALIDATE.dashboard, tags: ["pulse"] }),204  compareOperators: (slugs: string[]) => apiGet<OperatorComparison>("/compare/operators", { slugs: slugs.join(",") }, { revalidate: REVALIDATE.list }),205  explore: (q: ExploreQuery) => apiGet<ExploreResponse>("/explore", q as Query, { revalidate: REVALIDATE.list }),206  coverage: () => apiGet<CoverageReport>("/coverage", undefined, { revalidate: REVALIDATE.list }),207  coverageSources: () => apiGet<SourceCoverage[]>("/coverage/sources", undefined, { revalidate: REVALIDATE.list }),208  aiIndex: () => apiGet<AiIndex>("/ai-infrastructure", undefined, { revalidate: REVALIDATE.list }),209  power: () => apiGet<PowerOverview>("/power", undefined, { revalidate: REVALIDATE.list }),210  connectivity: () => apiGet<ConnectivityOverview>("/connectivity", undefined, { revalidate: REVALIDATE.list }),211  timeMachine: () => apiGet<TimeMachine>("/time-machine", undefined, { revalidate: REVALIDATE.list }),212  downloadDatasets: () => apiGet<DownloadDataset[]>("/download/datasets", undefined, { revalidate: REVALIDATE.list }),213  docsMeta: () => apiGet<ApiEndpointDoc[]>("/docs-meta", undefined, { revalidate: 600 }),214  watchlist: () => apiGet<WatchlistItem[]>("/watchlist", undefined, { revalidate: 0 }),215  watchlistFeed: (q?: Query) => apiGet<EventDTO[]>("/watchlist/feed", q, { revalidate: 0 }),216};217218/** Event-list query for /live and the home module. */219export interface EventQuery {220  type?: string;221  country?: string;222  operator?: string;223  min_significance?: number;224  since?: string;225  page?: number;226  per_page?: number;227}228