spb/social-runtime-crawler
Public
TypeScript 91.8%
HTML 3.2%
JavaScript 3%
SQL 1.4%
CSS 0.7%
1"use client";23import { useCallback, useEffect, useRef, useState } from "react";45/** Client-side data layer: every call goes through the Next proxy (/api/*) which adds the API token. */6export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {7 const res = await fetch(`/api${path}`, { ...init, headers: { "content-type": "application/json", ...(init?.headers ?? {}) }, cache: "no-store" });8 if (!res.ok) {9 let msg = `${res.status}`;10 try {11 msg = ((await res.json()) as { error?: string }).error ?? msg;12 } catch {13 /* ignore */14 }15 throw new Error(msg);16 }17 return (await res.json()) as T;18}1920export function useApi<T>(path: string | null, opts: { refreshMs?: number; deps?: unknown[] } = {}) {21 const [data, setData] = useState<T | null>(null);22 const [error, setError] = useState<string | null>(null);23 const [loading, setLoading] = useState(!!path);24 const [tick, setTick] = useState(0);25 const refresh = useCallback(() => setTick((t) => t + 1), []);26 useEffect(() => {27 if (!path) return;28 let alive = true;29 api<T>(path)30 .then((d) => {31 if (!alive) return;32 setData(d);33 setError(null);34 })35 .catch((e: Error) => alive && setError(e.message))36 .finally(() => alive && setLoading(false));37 const id = opts.refreshMs ? setInterval(() => setTick((t) => t + 1), opts.refreshMs) : undefined;38 return () => {39 alive = false;40 if (id) clearInterval(id);41 };42 // eslint-disable-next-line react-hooks/exhaustive-deps43 }, [path, tick, ...(opts.deps ?? [])]);44 return { data, error, loading, refresh };45}4647export interface LiveEvent {48 event_id: string;49 session_id: string;50 platform: string;51 event_type: string;52 step: number | null;53 ts: string;54 payload: Record<string, unknown>;55 provenance?: { surface: string; confidence: number }[] | null;56}5758/** Server-Sent Events subscription (live feed). Keeps the last `max` events. */59export function useLive(opts: { session?: string; types?: string[]; max?: number; enabled?: boolean } = {}) {60 const [events, setEvents] = useState<LiveEvent[]>([]);61 const [connected, setConnected] = useState(false);62 const [rate, setRate] = useState(0); // events per minute (rolling)63 const stamps = useRef<number[]>([]);64 const max = opts.max ?? 250;65 const enabled = opts.enabled !== false;66 useEffect(() => {67 if (!enabled) return;68 const params = new URLSearchParams();69 if (opts.session) params.set("session", opts.session);70 if (opts.types?.length) params.set("types", opts.types.join(","));71 const es = new EventSource(`/api/stream?${params}`);72 es.onopen = () => setConnected(true);73 es.onerror = () => setConnected(false);74 es.addEventListener("observation", (m) => {75 const ev = JSON.parse((m as MessageEvent).data) as LiveEvent;76 const now = Date.now();77 stamps.current.push(now);78 stamps.current = stamps.current.filter((t) => now - t < 60_000);79 setRate(stamps.current.length);80 setEvents((prev) => [ev, ...prev].slice(0, max));81 });82 const id = setInterval(() => {83 const now = Date.now();84 stamps.current = stamps.current.filter((t) => now - t < 60_000);85 setRate(stamps.current.length);86 }, 5000);87 return () => {88 es.close();89 clearInterval(id);90 };91 // eslint-disable-next-line react-hooks/exhaustive-deps92 }, [opts.session, opts.types?.join(","), enabled, max]);93 return { events, connected, rate };94}95