spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
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