SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
9.2 KB · 206 lines typescript
Raw Blame History
1import 'server-only';2import type {3  ActivityIndex,4  ChangeDetail,5  CompanyCard,6  CompanyDetail,7  CompanyMetrics,8  ComparePayload,9  CountryDetailRaw,10  CountryRow,11  Event,12  EventDetail,13  EventSummary,14  EventTypes,15  GlobalDaily,16  HistoryPayload,17  IndustryDetailRaw,18  IndustryRow,19  JobsPage,20  Location,21  MapBucket,22  Methodology,23  NewsItem,24  Page,25  Person,26  Plan,27  Product,28  Pulse,29  Rankings,30  SearchPayload,31  Sensor,32  SensorDetail,33  Signal,34  SitemapPayload,35  Snapshot,36  SnapshotDetail,37  SnapshotDiff,38  Stats,39  Suggestion,40  SystemHealth,41  TimelinePayload,42  TrendRow,43  Change,44  AskPayload,45} from './types';4647/**48 * Typed fetch wrapper for the Company Atlas API (server components only — client code uses the same-origin `/api/v1/*`49 * rewrite through `src/lib/client-api.ts`).50 *51 * - default cache: ISR `next: { revalidate: 120 }`; live endpoints pass `revalidate: false` (no-store).52 * - non-2xx → `ApiError` (status + `{detail}` body); network failure → status 0. Pages render an "unavailable"53 *   state rather than crash; 404 → `notFound()` in the page/layout.54 * - `safe(promise)` turns any error into `null` for optional panels fetched in parallel.55 */56export const API_URL = (process.env.API_URL ?? 'http://127.0.0.1:8371').replace(/\/$/, '');57const BASE = `${API_URL}/api/v1`;5859export class ApiError extends Error {60  readonly status: number;61  readonly detail: string | null;62  readonly path: string;63  constructor(status: number, path: string, detail: string | null, message?: string) {64    super(message ?? detail ?? `API ${status} on ${path}`);65    this.name = 'ApiError';66    this.status = status;67    this.detail = detail;68    this.path = path;69  }70  get unavailable(): boolean {71    return this.status === 0 || this.status >= 500;72  }73  get notFound(): boolean {74    return this.status === 404;75  }76}7778export interface FetchOptions {79  /** Seconds; `false` → `cache: 'no-store'`. Default 120. */80  revalidate?: number | false;81  tags?: string[];82}83export type Query = Record<string, string | number | boolean | null | undefined>;8485function qs(query?: Query): string {86  if (!query) return '';87  const p = new URLSearchParams();88  for (const [k, v] of Object.entries(query)) {89    if (v === undefined || v === null || v === '') continue;90    p.set(k, typeof v === 'boolean' ? (v ? '1' : '0') : String(v));91  }92  const s = p.toString();93  return s ? `?${s}` : '';94}9596export async function request<T>(path: string, query?: Query, opts: FetchOptions = {}): Promise<T> {97  const url = `${BASE}${path}${qs(query)}`;98  const init: RequestInit & { next?: { revalidate?: number | false; tags?: string[] } } = { headers: { accept: 'application/json' } };99  if (opts.revalidate === false) init.cache = 'no-store';100  else init.next = { revalidate: opts.revalidate ?? 120, tags: opts.tags };101  let res: Response;102  try {103    res = await fetch(url, init);104  } catch (e) {105    throw new ApiError(0, path, null, `API unreachable: ${(e as Error).message}`);106  }107  if (!res.ok) {108    let detail: string | null = null;109    try {110      const body = (await res.json()) as { detail?: unknown };111      detail = typeof body.detail === 'string' ? body.detail : body.detail ? JSON.stringify(body.detail) : null;112    } catch {113      /* non-JSON error body */114    }115    throw new ApiError(res.status, path, detail);116  }117  return (await res.json()) as T;118}119120export async function safe<T>(p: Promise<T>): Promise<T | null> {121  try {122    return await p;123  } catch {124    return null;125  }126}127128/** Throw `notFound()`-worthy errors up, swallow the rest as null (for detail pages: 404 must reach the layout). */129export async function orNull<T>(p: Promise<T>): Promise<T | null> {130  try {131    return await p;132  } catch (e) {133    if (e instanceof ApiError && e.notFound) throw e;134    return null;135  }136}137138const enc = encodeURIComponent;139const LIVE = { revalidate: false } as const;140141export const api = {142  // platform143  stats: () => request<Stats>('/stats', undefined, { revalidate: 60 }),144  statsHistory: (days = 90) => request<{ items: GlobalDaily[] }>('/stats/history', { days }, { revalidate: 900 }),145  system: () => request<SystemHealth>('/system', undefined, { revalidate: 30 }),146  pulse: () => request<Pulse>('/pulse', undefined, { revalidate: 60 }),147  live: (query: Query = {}) => request<{ items: Event[] } | Event[]>('/live', { limit: 50, ...query }, LIVE),148149  // companies150  companies: (query: Query) => request<Page<CompanyCard>>('/companies', query, { revalidate: 120 }),151  company: (slug: string) => request<CompanyDetail>(`/companies/${enc(slug)}`, undefined, { revalidate: 120 }),152  companyEvents: (slug: string, query: Query = {}) => request<Page<Event>>(`/companies/${enc(slug)}/events`, query, { revalidate: 120 }),153  companyTimeline: (slug: string, filter = 'all', limit = 200) => request<TimelinePayload>(`/companies/${enc(slug)}/timeline`, { filter, limit }, { revalidate: 120 }),154  companyMetrics: (slug: string, days = 90, metric?: string) => request<CompanyMetrics>(`/companies/${enc(slug)}/metrics`, { days, metric }, { revalidate: 300 }),155  companyJobs: (slug: string, query: Query = {}) => request<JobsPage>(`/companies/${enc(slug)}/jobs`, query, { revalidate: 120 }),156  companyPeople: (slug: string) => request<{ listed: Person[]; no_longer_listed: Person[] }>(`/companies/${enc(slug)}/people`, undefined, { revalidate: 300 }),157  companyProducts: (slug: string) => request<{ listed: Product[]; removed: Product[] }>(`/companies/${enc(slug)}/products`, undefined, { revalidate: 300 }),158  companyPricing: (slug: string) => request<{ current: Plan[]; history: Plan[] }>(`/companies/${enc(slug)}/pricing`, undefined, { revalidate: 300 }),159  companyLocations: (slug: string) => request<{ items: Location[]; countries: string[] }>(`/companies/${enc(slug)}/locations`, undefined, { revalidate: 300 }),160  companyNews: (slug: string, limit = 50) => request<{ items: NewsItem[] }>(`/companies/${enc(slug)}/news`, { limit }, { revalidate: 300 }),161  companySensors: (slug: string) => request<{ items: Sensor[] }>(`/companies/${enc(slug)}/sensors`, undefined, { revalidate: 120 }),162  companyHistory: (slug: string) => request<HistoryPayload>(`/companies/${enc(slug)}/history`, undefined, { revalidate: 300 }),163  companySimilar: (slug: string, limit = 8) => request<{ items: CompanyCard[] }>(`/companies/${enc(slug)}/similar`, { limit }, { revalidate: 600 }),164  compare: (slugs: string[]) => request<ComparePayload>('/companies/compare', { companies: slugs.join(',') }, { revalidate: 120 }),165166  // provenance167  sensor: (id: string) => request<SensorDetail>(`/sensors/${enc(id)}`, undefined, { revalidate: 60 }),168  sensorSnapshots: (id: string, limit = 50) => request<{ items: Snapshot[] }>(`/sensors/${enc(id)}/snapshots`, { limit }, { revalidate: 60 }),169  sensorChanges: (id: string, limit = 50) => request<{ items: Change[] }>(`/sensors/${enc(id)}/changes`, { limit }, { revalidate: 60 }),170  snapshot: (id: string) => request<SnapshotDetail>(`/snapshots/${enc(id)}`, undefined, { revalidate: 3600 }),171  snapshotDiff: (id: string, other: string) => request<SnapshotDiff>(`/snapshots/${enc(id)}/diff/${enc(other)}`, undefined, { revalidate: 3600 }),172  change: (id: string) => request<ChangeDetail>(`/changes/${enc(id)}`, undefined, { revalidate: 600 }),173  event: (id: string) => request<EventDetail>(`/events/${enc(id)}`, undefined, { revalidate: 120 }),174175  // events176  events: (query: Query) => request<Page<Event>>('/events', query, { revalidate: 60 }),177  eventTypes: () => request<EventTypes>('/events/types', undefined, { revalidate: 600 }),178  eventSummary: (days = 7, group = 'type') => request<EventSummary>('/events/summary', { days, group }, { revalidate: 300 }),179180  // rankings & atlases181  rankings: (query: Query) => request<Rankings>('/rankings', query, { revalidate: 120 }),182  industries: () => request<{ items: IndustryRow[] }>('/industries', undefined, { revalidate: 300 }),183  industry: (slug: string) => request<IndustryDetailRaw>(`/industries/${enc(slug)}`, undefined, { revalidate: 300 }),184  countries: () => request<{ items: CountryRow[] }>('/countries', undefined, { revalidate: 300 }),185  country: (code: string) => request<CountryDetailRaw>(`/countries/${enc(code)}`, undefined, { revalidate: 300 }),186  signals: (query: Query = {}) => request<{ items: Signal[] }>('/signals', query, { revalidate: 120 }),187  trends: (window = '7d', limit = 30) => request<{ items: TrendRow[] }>('/trends', { window, limit }, { revalidate: 300 }),188  map: (metric = 'events_30d') => request<{ buckets: MapBucket[] }>('/map', { metric }, { revalidate: 300 }),189  index: () => request<ActivityIndex>('/index', undefined, { revalidate: 300 }),190191  // search192  search: (q: string, query: Query = {}) => request<SearchPayload>('/search', { q, ...query }, LIVE),193  suggest: (q: string) => request<{ items: Suggestion[] }>('/search/suggest', { q }, LIVE),194  ask: (q: string) => request<AskPayload>('/ask', { q }, LIVE),195196  // docs197  sitemap: (kind: string, page = 0) => request<SitemapPayload>('/sitemap', { kind, page }, { revalidate: 3600 }),198  methodology: () => request<Methodology>('/methodology', undefined, { revalidate: 3600 }),199};200201/** `/live` may answer `{items}` or a bare array — normalise. */202export function liveItems(v: { items: Event[] } | Event[] | null): Event[] {203  if (!v) return [];204  return Array.isArray(v) ? v : (v.items ?? []);205}206