SPB Git
3.0 KB · 46 lines typescript
1"use client";23export class ApiError extends Error { constructor(public status: number, message: string, public issues?: unknown) { super(message); } }45export async function api<T = unknown>(url: string, init?: RequestInit & { json?: unknown }): Promise<T> {6  const headers = new Headers(init?.headers);7  let body = init?.body;8  if (init?.json !== undefined) { headers.set("content-type", "application/json"); body = JSON.stringify(init.json); }9  const r = await fetch(url, { ...init, headers, body, credentials: "same-origin" });10  if (r.status === 401) { if (typeof window !== "undefined" && !location.pathname.startsWith("/login")) location.href = `/login?next=${encodeURIComponent(location.pathname)}`; throw new ApiError(401, "Session expirée"); }11  const ct = r.headers.get("content-type") ?? "";12  const data = ct.includes("application/json") ? await r.json() : await r.text();13  if (!r.ok) throw new ApiError(r.status, (data as { error?: string })?.error ?? `HTTP ${r.status}`, (data as { issues?: unknown })?.issues);14  return data as T;15}1617export const fetcher = <T,>(url: string) => api<T>(url);1819/** Parses a text/event-stream response into {event, data} objects. */20export async function* readSSE(res: Response): AsyncGenerator<{ event: string; data: unknown }> {21  if (!res.ok) { const t = await res.text(); let msg = t; try { msg = JSON.parse(t).error ?? t; } catch { /* raw */ } throw new ApiError(res.status, msg); }22  const reader = res.body!.getReader(); const dec = new TextDecoder(); let buf = "";23  while (true) {24    const { value, done } = await reader.read();25    if (done) break;26    buf += dec.decode(value, { stream: true });27    let idx: number;28    while ((idx = buf.indexOf("\n\n")) >= 0) {29      const chunk = buf.slice(0, idx); buf = buf.slice(idx + 2);30      let event = "message"; let data = "";31      for (const line of chunk.split("\n")) { if (line.startsWith("event:")) event = line.slice(6).trim(); else if (line.startsWith("data:")) data += line.slice(5).trim(); }32      if (!data) continue;33      try { yield { event, data: JSON.parse(data) }; } catch { yield { event, data }; }34    }35  }36}3738export function fmtTokens(n?: number | null) { if (n == null) return "—"; return n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n); }39export function fmtUsd(v?: number | null, digits = 4) { if (v == null) return "—"; if (v === 0) return "0 $"; return v < 0.01 ? `${v.toFixed(digits)} $` : `${v.toFixed(2)} $`; }40export function fmtMs(ms?: number | null) { if (ms == null) return "—"; return ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${ms} ms`; }41export function relTime(d: string | Date) {42  const t = new Date(d).getTime(); const diff = Date.now() - t; const m = Math.round(diff / 60000);43  if (m < 1) return "à l'instant"; if (m < 60) return `${m} min`; const h = Math.round(m / 60); if (h < 24) return `${h} h`; const dd = Math.round(h / 24); if (dd < 7) return `${dd} j`;44  return new Date(d).toLocaleDateString("fr-CA", { day: "numeric", month: "short" });45}46
Files · main
Menu

Theme