import { useAuthStore } from "./auth-store" import type { ApiError } from "./types" import type { PlatformUser } from "@/lib/auth/jwt-claims" export class OfflineError extends Error { constructor() { super("Device is offline") this.name = "OfflineError" } } export class ApiRequestError extends Error { code: string details?: unknown status: number constructor(status: number, code: string, message: string, details?: unknown) { super(message) this.name = "ApiRequestError" this.status = status this.code = code this.details = details } } const DEFAULT_TIMEOUT = 10_000 const DEFAULT_RETRIES = 3 const BASE_DELAY = 1000 async function tryRefreshSession(): Promise { try { const res = await fetch("/api/auth/session", { credentials: "include" }) if (!res.ok) return false const data = (await res.json()) as { authenticated?: boolean accessToken?: string refreshToken?: string | null expiresAt?: number user?: unknown } if (data.authenticated && data.accessToken && data.expiresAt) { useAuthStore.getState().login( data.accessToken, data.refreshToken ?? "", data.expiresAt, (data.user as PlatformUser | null | undefined) ?? null ) return true } } catch { // ignore } return false } class ApiClient { constructor(private baseUrl: string) {} private resolveUrl(path: string): URL { const base = this.baseUrl.startsWith("http") ? this.baseUrl : `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${this.baseUrl}` // Absolute path (leading /) would replace /api/v1 — keep base path segment. const normalizedBase = base.endsWith("/") ? base : `${base}/` const normalizedPath = path.startsWith("/") ? path.slice(1) : path return new URL(normalizedPath, normalizedBase) } private getHeaders(): HeadersInit { const headers: Record = { "Content-Type": "application/json", } const token = useAuthStore.getState().accessToken if (token) { headers["Authorization"] = `Bearer ${token}` } return headers } private async request( method: string, path: string, opts?: { body?: unknown params?: Record timeout?: number retries?: number } ): Promise { if (typeof navigator !== "undefined" && !navigator.onLine) { throw new OfflineError() } const url = this.resolveUrl(path) if (opts?.params) { for (const [key, value] of Object.entries(opts.params)) { if (value !== undefined) { url.searchParams.set(key, value) } } } const timeout = opts?.timeout ?? DEFAULT_TIMEOUT const maxRetries = opts?.retries ?? DEFAULT_RETRIES let lastError: Error | null = null let authRetried = false for (let attempt = 0; attempt <= maxRetries; attempt++) { if (attempt > 0) { const delay = BASE_DELAY * Math.pow(2, attempt - 1) await new Promise((resolve) => setTimeout(resolve, delay)) } const controller = new AbortController() const timer = setTimeout(() => controller.abort(), timeout) try { const response = await fetch(url.toString(), { method, headers: this.getHeaders(), body: opts?.body ? JSON.stringify(opts.body) : undefined, signal: controller.signal, }) clearTimeout(timer) if (!response.ok) { let errorBody: ApiError | undefined try { errorBody = await response.json() } catch {} const err = new ApiRequestError( response.status, errorBody?.code ?? "UNKNOWN", errorBody?.message ?? response.statusText, errorBody?.details ) if (response.status === 401 && !authRetried) { authRetried = true if (await tryRefreshSession()) { continue } } if (response.status >= 400 && response.status < 500) { throw err } lastError = err continue } if (response.status === 204) { return undefined as T } const text = await response.text() if (!text.trim()) { return undefined as T } return JSON.parse(text) as T } catch (err) { clearTimeout(timer) if (err instanceof ApiRequestError && err.status >= 400 && err.status < 500) { throw err } lastError = err instanceof Error ? err : new Error(String(err)) if (err instanceof DOMException && err.name === "AbortError") { lastError = new Error("Request timed out") } } } throw lastError ?? new Error("Request failed") } async get(path: string, params?: Record): Promise { return this.request("GET", path, { params }) } async post(path: string, body?: unknown): Promise { return this.request("POST", path, { body }) } async put(path: string, body?: unknown): Promise { return this.request("PUT", path, { body }) } async patch(path: string, body?: unknown): Promise { return this.request("PATCH", path, { body }) } async delete(path: string): Promise { await this.request("DELETE", path) } } export const apiClient = new ApiClient(process.env.NEXT_PUBLIC_API_URL ?? "/api/v1")