'use client'; import { useUIStore } from '@/stores/ui-store'; export interface ApiFetchOptions extends Omit { body?: unknown; } /** In-memory cache: slug -> id, populated lazily by `resolvePortIdFromSlug`. * Avoids re-fetching `/api/v1/admin/ports` on every request when the Zustand * store hasn't hydrated yet (fresh browser context, e2e tests, hard reload). */ const slugToIdCache = new Map(); /** Dedupe in-flight admin/ports lookups so a stampede of parallel apiFetch * calls (typical on dashboard mount) collapses into a single network round- * trip instead of N. */ let inFlightPortsLookup: Promise | null> | null = null; async function resolvePortIdFromSlug(slug: string): Promise { const cached = slugToIdCache.get(slug); if (cached) return cached; if (!inFlightPortsLookup) { inFlightPortsLookup = (async () => { try { const res = await fetch('/api/v1/admin/ports', { credentials: 'include' }); if (!res.ok) return null; const body = (await res.json()) as { data?: Array<{ id: string; slug: string }> }; return body.data ?? null; } catch { return null; } })().finally(() => { inFlightPortsLookup = null; }); } const ports = await inFlightPortsLookup; const port = ports?.find((p) => p.slug === slug); if (!port) return null; slugToIdCache.set(slug, port.id); return port.id; } /** * Client-side fetch wrapper that attaches the `X-Port-Id` header from the * UI store to every request. Used by all queryFn/mutationFn callbacks. * * Falls back to extracting the port slug from `window.location.pathname` and * resolving it via `/api/v1/admin/ports` when the Zustand store hasn't been * populated yet (fresh page load before `PortProvider`'s effect has fired). */ export async function apiFetch(url: string, opts: ApiFetchOptions = {}): Promise { let portId = useUIStore.getState().currentPortId; if (!portId && typeof window !== 'undefined') { const slug = window.location.pathname.split('/').filter(Boolean)[0]; if (slug && slug !== 'login' && slug !== 'portal' && slug !== 'api') { portId = await resolvePortIdFromSlug(slug); } } const headers = new Headers(opts.headers); if (portId) { headers.set('X-Port-Id', portId); } if (opts.body !== undefined && !headers.has('Content-Type')) { headers.set('Content-Type', 'application/json'); } const res = await fetch(url, { ...opts, headers, credentials: 'include', body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, }); if (!res.ok) { const error = (await res.json().catch(() => ({ error: res.statusText }))) as { error?: string; message?: string; code?: string; details?: unknown; requestId?: string; retryAfter?: number; }; // Surface the request id so toasts can display "Error ID: …" and // the user can copy it to a support ticket. Server-side wrappers // always set X-Request-Id, even on early-return 401/403 paths. const requestId = error.requestId ?? res.headers.get('x-request-id') ?? null; throw new ApiError({ message: error.error ?? error.message ?? 'Request failed', status: res.status, code: error.code ?? null, details: error.details ?? null, requestId, retryAfter: typeof error.retryAfter === 'number' ? error.retryAfter : null, }); } if (res.status === 204) return undefined as T; return res.json() as Promise; } /** * Structured client-side error thrown by `apiFetch`. Carries the stable * fields a toast / error boundary needs to render a useful message: * * - `message`: plain-text, ready to show to the user * - `code`: stable error code from `src/lib/error-codes.ts` * - `requestId`: paste this to support to find the row in * `/admin/errors/` * * Mutations should use the `toastError(err)` helper rather than reading * these fields directly — that keeps the toast format consistent. */ export class ApiError extends Error { status: number; code: string | null; details: unknown; requestId: string | null; retryAfter: number | null; constructor(args: { message: string; status: number; code: string | null; details: unknown; requestId: string | null; retryAfter: number | null; }) { super(args.message); this.name = 'ApiError'; this.status = args.status; this.code = args.code; this.details = args.details; this.requestId = args.requestId; this.retryAfter = args.retryAfter; } }