spb/satelliteindex
Public
TypeScript 66.5%
Python 30.9%
JavaScript 1.4%
CSS 0.7%
1import 'server-only';2import type {3 ConstellationDetail,4 ConstellationRow,5 CountryDetail,6 CountryRow,7 DebrisPayload,8 DensityPayload,9 Envelope,10 EventRow,11 Facets,12 HealthPayload,13 HomePayload,14 LaunchDetail,15 LaunchRow,16 LaunchSiteRow,17 LaunchTimeline,18 MethodologyPayload,19 OperatorDetail,20 OperatorRow,21 OrbitalElementRow,22 Paginated,23 Problem,24 RankingsPayload,25 ReentriesPayload,26 SatelliteDetail,27 SatelliteHistory,28 SatelliteRow,29 SearchPayload,30 SourceRow,31 SourcesStatus,32 StatsSnapshot,33 Track,34} from './types';3536/**37 * Typed fetch wrapper for the SatelliteIndex API (server components only — client code calls the same-origin38 * `/api/v1/*` rewrite through `src/lib/client-api.ts`).39 *40 * - default cache: ISR `next: { revalidate: 300 }` (5 min); live endpoints pass `revalidate: false` (no-store).41 * - non-2xx → `ApiError` (status + problem body); network failure → status 0. Pages must render an42 * "Unavailable" state rather than crash (graceful degradation).43 * - `safe(promise)` turns any error into `null` for optional panels fetched in parallel.44 */45export const API_URL = (process.env.API_URL ?? 'http://127.0.0.1:8311').replace(/\/$/, '');46const BASE = `${API_URL}/api/v1`;4748export class ApiError extends Error {49 readonly status: number;50 readonly problem: Problem | null;51 readonly path: string;52 constructor(status: number, path: string, problem: Problem | null, message?: string) {53 super(message ?? problem?.detail ?? problem?.title ?? `API ${status} on ${path}`);54 this.name = 'ApiError';55 this.status = status;56 this.problem = problem;57 this.path = path;58 }59 get unavailable(): boolean {60 return this.status === 503 || this.status === 0 || this.status >= 500;61 }62 get notFound(): boolean {63 return this.status === 404;64 }65}6667export interface FetchOptions {68 /** Seconds; `false` → `cache: 'no-store'`. Default 300. */69 revalidate?: number | false;70 tags?: string[];71}72type Query = Record<string, string | number | boolean | null | undefined>;7374function qs(query?: Query): string {75 if (!query) return '';76 const p = new URLSearchParams();77 for (const [k, v] of Object.entries(query)) {78 if (v === undefined || v === null || v === '') continue;79 p.set(k, String(v));80 }81 const s = p.toString();82 return s ? `?${s}` : '';83}8485export async function request<T>(path: string, query?: Query, opts: FetchOptions = {}): Promise<T> {86 const url = `${BASE}${path}${qs(query)}`;87 const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = { headers: { accept: 'application/json' } };88 if (opts.revalidate === false) init.cache = 'no-store';89 else init.next = { revalidate: opts.revalidate ?? 300, tags: opts.tags };90 let res: Response;91 try {92 res = await fetch(url, init);93 } catch (e) {94 throw new ApiError(0, path, null, `API unreachable: ${(e as Error).message}`);95 }96 if (!res.ok) {97 let problem: Problem | null = null;98 try {99 const body = (await res.json()) as { error?: Problem };100 problem = body.error ?? null;101 } catch {102 /* non-JSON error body */103 }104 throw new ApiError(res.status, path, problem);105 }106 return (await res.json()) as T;107}108109export async function safe<T>(p: Promise<T>): Promise<T | null> {110 try {111 return await p;112 } catch {113 return null;114 }115}116117// ---------------------------------------------------------------------------------------------------------- endpoints118export const api = {119 health: () => request<HealthPayload>('/health', undefined, { revalidate: false }),120 home: () => request<Envelope<HomePayload>>('/stats/home', undefined, { revalidate: 120 }),121 stats: () => request<Envelope<StatsSnapshot>>('/stats', undefined, { revalidate: 300 }),122 rankings: (metric: string, limit = 50) => request<Envelope<RankingsPayload>>('/rankings', { metric, limit }, { revalidate: 600 }),123 density: () => request<Envelope<DensityPayload>>('/orbit/density', undefined, { revalidate: 900 }),124125 satellites: (query: Query) => request<Paginated<SatelliteRow>>('/satellites', query, { revalidate: 120 }),126 satelliteFacets: (query: Query) => request<Envelope<Facets>>('/satellites/facets', query, { revalidate: 600 }),127 satellite: (ident: string) => request<Envelope<SatelliteDetail>>(`/satellites/${encodeURIComponent(ident)}`, undefined, { revalidate: 60 }),128 satelliteOrbit: (ident: string, limit = 200) => request<Envelope<{ elements: OrbitalElementRow[]; count: number }>>(`/satellites/${encodeURIComponent(ident)}/orbit`, { limit }, { revalidate: 300 }),129 satelliteTrack: (ident: string) => request<Envelope<Track>>(`/satellites/${encodeURIComponent(ident)}/track`, undefined, { revalidate: false }),130 satelliteHistory: (ident: string) => request<Envelope<SatelliteHistory>>(`/satellites/${encodeURIComponent(ident)}/history`, undefined, { revalidate: 300 }),131132 search: (q: string, limit = 20) => request<Envelope<SearchPayload>>('/search', { q, limit }, { revalidate: false }),133134 constellations: (query: Query = {}) => request<Paginated<ConstellationRow>>('/constellations', { page_size: 100, ...query }, { revalidate: 600 }),135 constellation: (slug: string) => request<Envelope<ConstellationDetail>>(`/constellations/${encodeURIComponent(slug)}`, undefined, { revalidate: 300 }),136137 operators: (query: Query = {}) => request<Paginated<OperatorRow>>('/operators', { page_size: 100, ...query }, { revalidate: 600 }),138 operator: (slug: string) => request<Envelope<OperatorDetail>>(`/operators/${encodeURIComponent(slug)}`, undefined, { revalidate: 300 }),139140 countries: (sort = 'active') => request<Envelope<CountryRow[]>>('/countries', { sort }, { revalidate: 900 }),141 country: (ident: string) => request<Envelope<CountryDetail>>(`/countries/${encodeURIComponent(ident)}`, undefined, { revalidate: 600 }),142143 launches: (query: Query) => request<Paginated<LaunchRow>>('/launches', query, { revalidate: 300 }),144 launchTimeline: () => request<Envelope<LaunchTimeline>>('/launches/timeline', undefined, { revalidate: 900 }),145 launch: (cospar: string) => request<Envelope<LaunchDetail>>(`/launches/${encodeURIComponent(cospar)}`, undefined, { revalidate: 300 }),146 launchSites: () => request<Envelope<LaunchSiteRow[]>>('/launch-sites', undefined, { revalidate: 900 }),147 launchSite: (slug: string) => request<Envelope<LaunchSiteRow & { country_name: string | null; country_slug: string | null; years: { year: number; launches: Num; payloads: Num }[]; recent_launches: LaunchRow[]; owners: { code: string; name: string; launches: Num }[] }>>(`/launch-sites/${encodeURIComponent(slug)}`, undefined, { revalidate: 600 }),148149 debris: () => request<Envelope<DebrisPayload>>('/debris', undefined, { revalidate: 900 }),150 reentries: (query: Query) => request<ReentriesPayload>('/reentries', query, { revalidate: 300 }),151152 events: (query: Query) => request<Paginated<EventRow> & { types: { type: string; count: Num; latest: string }[] }>('/events', query, { revalidate: 60 }),153 event: (id: string) => request<Envelope<EventRow>>(`/events/${encodeURIComponent(id)}`, undefined, { revalidate: 300 }),154155 sources: () => request<Envelope<SourceRow[]>>('/sources', undefined, { revalidate: 60 }),156 sourcesStatus: () => request<Envelope<SourcesStatus>>('/sources/status', undefined, { revalidate: false }),157 methodology: () => request<Envelope<MethodologyPayload>>('/methodology', undefined, { revalidate: 3600 }),158 sitemapSatellites: (page: number, page_size = 5000) => request<Envelope<{ items: { slug: string; updated_at: string; status: string }[]; total: number }>>('/sitemap/satellites', { page, page_size }, { revalidate: 3600 }),159 sitemapEntities: () => request<Envelope<{ constellations: { slug: string; updated_at: string }[]; operators: { slug: string; updated_at: string }[]; countries: { slug: string }[]; launches: { slug: string; updated_at: string }[]; launch_sites: { slug: string }[] }>>('/sitemap/entities', undefined, { revalidate: 3600 }),160};161162type Num = number | string | null;163