import type { ApiError } from "./types" import { ensureAccessToken } from "@/lib/auth/ensure-access-token" import { handleUnauthorized } from "@/lib/auth/handle-unauthorized" import { isSessionExpired } from "@/lib/auth/session-guard-store" import { getApiBaseUrl } from "@/lib/runtime-config" 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 class ApiClient { /** * Resolver so the base URL is read at call time. On native the backend is * only known after the server picker runs (runtime config); on web it stays * the proxied `/api/v1`. */ constructor(private resolveBaseUrl: () => string = getApiBaseUrl) {} private resolveUrl(path: string): URL { const baseUrl = this.resolveBaseUrl() const base = baseUrl.startsWith("http") ? baseUrl : `${typeof window !== "undefined" ? window.location.origin : "http://localhost"}${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 async getHeaders(): Promise { const headers: Record = { "Content-Type": "application/json", } const token = await ensureAccessToken() if (token) { headers["Authorization"] = `Bearer ${token}` } return headers } private async request( method: string, path: string, opts?: { body?: unknown params?: Record headers?: 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: { ...(await this.getHeaders()), ...opts?.headers }, 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) { if (isSessionExpired()) { throw err } if (!authRetried) { authRetried = true const resolution = await handleUnauthorized() if (resolution === "refreshed") { continue } if (resolution === "offline") { throw new OfflineError() } } else { await handleUnauthorized({ forceExpired: true }) } } 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 }) } /** GET binary body (inline attachments, exports). */ async getBlob(path: string, authRetried = false): Promise { if (typeof navigator !== "undefined" && !navigator.onLine) { throw new OfflineError() } const url = this.resolveUrl(path) const headers: Record = {} const token = await ensureAccessToken() if (token) { headers["Authorization"] = `Bearer ${token}` } const response = await fetch(url.toString(), { method: "GET", headers }) if (!response.ok) { if (response.status === 401) { if (isSessionExpired()) { throw new ApiRequestError(response.status, "UNKNOWN", response.statusText) } if (!authRetried) { const resolution = await handleUnauthorized() if (resolution === "refreshed") { return this.getBlob(path, true) } if (resolution === "offline") { throw new OfflineError() } } else { await handleUnauthorized({ forceExpired: true }) } } throw new ApiRequestError( response.status, "UNKNOWN", response.statusText ) } return response.blob() } async post(path: string, body?: unknown): Promise { return this.request("POST", path, { body }) } async put( path: string, body?: unknown, headers?: Record, ): Promise { return this.request("PUT", path, { body, headers }) } 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()