import 'server-only'; import type { ConstellationDetail, ConstellationRow, CountryDetail, CountryRow, DebrisPayload, DensityPayload, Envelope, EventRow, Facets, HealthPayload, HomePayload, LaunchDetail, LaunchRow, LaunchSiteRow, LaunchTimeline, MethodologyPayload, OperatorDetail, OperatorRow, OrbitalElementRow, Paginated, Problem, RankingsPayload, ReentriesPayload, SatelliteDetail, SatelliteHistory, SatelliteRow, SearchPayload, SourceRow, SourcesStatus, StatsSnapshot, Track, } from './types'; /** * Typed fetch wrapper for the SatelliteIndex 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 + problem body); network failure → status 0. Pages must render an * "Unavailable" state rather than crash (graceful degradation). * - `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:8311').replace(/\/$/, ''); const BASE = `${API_URL}/api/v1`; export class ApiError extends Error { readonly status: number; readonly problem: Problem | null; readonly path: string; constructor(status: number, path: string, problem: Problem | null, message?: string) { super(message ?? problem?.detail ?? problem?.title ?? `API ${status} on ${path}`); this.name = 'ApiError'; this.status = status; this.problem = problem; 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[]; } 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 problem: Problem | null = null; try { const body = (await res.json()) as { error?: Problem }; problem = body.error ?? null; } catch { /* non-JSON error body */ } throw new ApiError(res.status, path, problem); } return (await res.json()) as T; } export async function safe(p: Promise): Promise { try { return await p; } catch { return null; } } // ---------------------------------------------------------------------------------------------------------- endpoints export const api = { health: () => request('/health', undefined, { revalidate: false }), home: () => request>('/stats/home', undefined, { revalidate: 120 }), stats: () => request>('/stats', undefined, { revalidate: 300 }), rankings: (metric: string, limit = 50) => request>('/rankings', { metric, limit }, { revalidate: 600 }), density: () => request>('/orbit/density', undefined, { revalidate: 900 }), satellites: (query: Query) => request>('/satellites', query, { revalidate: 120 }), satelliteFacets: (query: Query) => request>('/satellites/facets', query, { revalidate: 600 }), satellite: (ident: string) => request>(`/satellites/${encodeURIComponent(ident)}`, undefined, { revalidate: 60 }), satelliteOrbit: (ident: string, limit = 200) => request>(`/satellites/${encodeURIComponent(ident)}/orbit`, { limit }, { revalidate: 300 }), satelliteTrack: (ident: string) => request>(`/satellites/${encodeURIComponent(ident)}/track`, undefined, { revalidate: false }), satelliteHistory: (ident: string) => request>(`/satellites/${encodeURIComponent(ident)}/history`, undefined, { revalidate: 300 }), search: (q: string, limit = 20) => request>('/search', { q, limit }, { revalidate: false }), constellations: (query: Query = {}) => request>('/constellations', { page_size: 100, ...query }, { revalidate: 600 }), constellation: (slug: string) => request>(`/constellations/${encodeURIComponent(slug)}`, undefined, { revalidate: 300 }), operators: (query: Query = {}) => request>('/operators', { page_size: 100, ...query }, { revalidate: 600 }), operator: (slug: string) => request>(`/operators/${encodeURIComponent(slug)}`, undefined, { revalidate: 300 }), countries: (sort = 'active') => request>('/countries', { sort }, { revalidate: 900 }), country: (ident: string) => request>(`/countries/${encodeURIComponent(ident)}`, undefined, { revalidate: 600 }), launches: (query: Query) => request>('/launches', query, { revalidate: 300 }), launchTimeline: () => request>('/launches/timeline', undefined, { revalidate: 900 }), launch: (cospar: string) => request>(`/launches/${encodeURIComponent(cospar)}`, undefined, { revalidate: 300 }), launchSites: () => request>('/launch-sites', undefined, { revalidate: 900 }), launchSite: (slug: string) => request>(`/launch-sites/${encodeURIComponent(slug)}`, undefined, { revalidate: 600 }), debris: () => request>('/debris', undefined, { revalidate: 900 }), reentries: (query: Query) => request('/reentries', query, { revalidate: 300 }), events: (query: Query) => request & { types: { type: string; count: Num; latest: string }[] }>('/events', query, { revalidate: 60 }), event: (id: string) => request>(`/events/${encodeURIComponent(id)}`, undefined, { revalidate: 300 }), sources: () => request>('/sources', undefined, { revalidate: 60 }), sourcesStatus: () => request>('/sources/status', undefined, { revalidate: false }), methodology: () => request>('/methodology', undefined, { revalidate: 3600 }), sitemapSatellites: (page: number, page_size = 5000) => request>('/sitemap/satellites', { page, page_size }, { revalidate: 3600 }), sitemapEntities: () => request>('/sitemap/entities', undefined, { revalidate: 3600 }), }; type Num = number | string | null;