SPB Git forge
3commits 1branches 0releases
417.0 KBsize
maindefault branch
10 days agolast push
TypeScript 66.5% Python 30.9% JavaScript 1.4% CSS 0.7%
7.8 KB · 163 lines typescript
Raw Blame History
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