import 'server-only'; import type { ActivityIndex, ChangeDetail, CompanyCard, CompanyDetail, CompanyMetrics, ComparePayload, CountryDetailRaw, CountryRow, Event, EventDetail, EventSummary, EventTypes, GlobalDaily, HistoryPayload, IndustryDetailRaw, IndustryRow, JobsPage, Location, MapBucket, Methodology, NewsItem, Page, Person, Plan, Product, Pulse, Rankings, SearchPayload, Sensor, SensorDetail, Signal, SitemapPayload, Snapshot, SnapshotDetail, SnapshotDiff, Stats, Suggestion, SystemHealth, TimelinePayload, TrendRow, Change, AskPayload, } from './types'; /** * Typed fetch wrapper for the Company Atlas API (server components only — client code uses the same-origin `/api/v1/*` * rewrite through `src/lib/client-api.ts`). * * - default cache: ISR `next: { revalidate: 120 }`; live endpoints pass `revalidate: false` (no-store). * - non-2xx → `ApiError` (status + `{detail}` body); network failure → status 0. Pages render an "unavailable" * state rather than crash; 404 → `notFound()` in the page/layout. * - `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:8371').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 === 0 || this.status >= 500; } get notFound(): boolean { return this.status === 404; } } export interface FetchOptions { /** Seconds; `false` → `cache: 'no-store'`. Default 120. */ 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, typeof v === 'boolean' ? (v ? '1' : '0') : 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 ?? 120, 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; } } /** Throw `notFound()`-worthy errors up, swallow the rest as null (for detail pages: 404 must reach the layout). */ export async function orNull(p: Promise): Promise { try { return await p; } catch (e) { if (e instanceof ApiError && e.notFound) throw e; return null; } } const enc = encodeURIComponent; const LIVE = { revalidate: false } as const; export const api = { // platform stats: () => request('/stats', undefined, { revalidate: 60 }), statsHistory: (days = 90) => request<{ items: GlobalDaily[] }>('/stats/history', { days }, { revalidate: 900 }), system: () => request('/system', undefined, { revalidate: 30 }), pulse: () => request('/pulse', undefined, { revalidate: 60 }), live: (query: Query = {}) => request<{ items: Event[] } | Event[]>('/live', { limit: 50, ...query }, LIVE), // companies companies: (query: Query) => request>('/companies', query, { revalidate: 120 }), company: (slug: string) => request(`/companies/${enc(slug)}`, undefined, { revalidate: 120 }), companyEvents: (slug: string, query: Query = {}) => request>(`/companies/${enc(slug)}/events`, query, { revalidate: 120 }), companyTimeline: (slug: string, filter = 'all', limit = 200) => request(`/companies/${enc(slug)}/timeline`, { filter, limit }, { revalidate: 120 }), companyMetrics: (slug: string, days = 90, metric?: string) => request(`/companies/${enc(slug)}/metrics`, { days, metric }, { revalidate: 300 }), companyJobs: (slug: string, query: Query = {}) => request(`/companies/${enc(slug)}/jobs`, query, { revalidate: 120 }), companyPeople: (slug: string) => request<{ listed: Person[]; no_longer_listed: Person[] }>(`/companies/${enc(slug)}/people`, undefined, { revalidate: 300 }), companyProducts: (slug: string) => request<{ listed: Product[]; removed: Product[] }>(`/companies/${enc(slug)}/products`, undefined, { revalidate: 300 }), companyPricing: (slug: string) => request<{ current: Plan[]; history: Plan[] }>(`/companies/${enc(slug)}/pricing`, undefined, { revalidate: 300 }), companyLocations: (slug: string) => request<{ items: Location[]; countries: string[] }>(`/companies/${enc(slug)}/locations`, undefined, { revalidate: 300 }), companyNews: (slug: string, limit = 50) => request<{ items: NewsItem[] }>(`/companies/${enc(slug)}/news`, { limit }, { revalidate: 300 }), companySensors: (slug: string) => request<{ items: Sensor[] }>(`/companies/${enc(slug)}/sensors`, undefined, { revalidate: 120 }), companyHistory: (slug: string) => request(`/companies/${enc(slug)}/history`, undefined, { revalidate: 300 }), companySimilar: (slug: string, limit = 8) => request<{ items: CompanyCard[] }>(`/companies/${enc(slug)}/similar`, { limit }, { revalidate: 600 }), compare: (slugs: string[]) => request('/companies/compare', { companies: slugs.join(',') }, { revalidate: 120 }), // provenance sensor: (id: string) => request(`/sensors/${enc(id)}`, undefined, { revalidate: 60 }), sensorSnapshots: (id: string, limit = 50) => request<{ items: Snapshot[] }>(`/sensors/${enc(id)}/snapshots`, { limit }, { revalidate: 60 }), sensorChanges: (id: string, limit = 50) => request<{ items: Change[] }>(`/sensors/${enc(id)}/changes`, { limit }, { revalidate: 60 }), snapshot: (id: string) => request(`/snapshots/${enc(id)}`, undefined, { revalidate: 3600 }), snapshotDiff: (id: string, other: string) => request(`/snapshots/${enc(id)}/diff/${enc(other)}`, undefined, { revalidate: 3600 }), change: (id: string) => request(`/changes/${enc(id)}`, undefined, { revalidate: 600 }), event: (id: string) => request(`/events/${enc(id)}`, undefined, { revalidate: 120 }), // events events: (query: Query) => request>('/events', query, { revalidate: 60 }), eventTypes: () => request('/events/types', undefined, { revalidate: 600 }), eventSummary: (days = 7, group = 'type') => request('/events/summary', { days, group }, { revalidate: 300 }), // rankings & atlases rankings: (query: Query) => request('/rankings', query, { revalidate: 120 }), industries: () => request<{ items: IndustryRow[] }>('/industries', undefined, { revalidate: 300 }), industry: (slug: string) => request(`/industries/${enc(slug)}`, undefined, { revalidate: 300 }), countries: () => request<{ items: CountryRow[] }>('/countries', undefined, { revalidate: 300 }), country: (code: string) => request(`/countries/${enc(code)}`, undefined, { revalidate: 300 }), signals: (query: Query = {}) => request<{ items: Signal[] }>('/signals', query, { revalidate: 120 }), trends: (window = '7d', limit = 30) => request<{ items: TrendRow[] }>('/trends', { window, limit }, { revalidate: 300 }), map: (metric = 'events_30d') => request<{ buckets: MapBucket[] }>('/map', { metric }, { revalidate: 300 }), index: () => request('/index', undefined, { revalidate: 300 }), // search search: (q: string, query: Query = {}) => request('/search', { q, ...query }, LIVE), suggest: (q: string) => request<{ items: Suggestion[] }>('/search/suggest', { q }, LIVE), ask: (q: string) => request('/ask', { q }, LIVE), // docs sitemap: (kind: string, page = 0) => request('/sitemap', { kind, page }, { revalidate: 3600 }), methodology: () => request('/methodology', undefined, { revalidate: 3600 }), }; /** `/live` may answer `{items}` or a bare array — normalise. */ export function liveItems(v: { items: Event[] } | Event[] | null): Event[] { if (!v) return []; return Array.isArray(v) ? v : (v.items ?? []); }