import { type ApiErrorResponse, ErrorCode, type LoginInput, type SafeUserProfile, type SignupInput, } from "@batch-cooking/shared"; /** Base URL of the API, configurable via `VITE_API_URL` (see `.env.example`). */ const API_BASE_URL: string = import.meta.env.VITE_API_URL ?? "http://localhost:3000"; /** * Thrown by {@link ApiClient} whenever the API responds with a non-2xx * status. Carries the same {@link ErrorCode} the API returned, so callers * can branch on `error.code` (and UI code can look up its label via * `ErrorMessageService.getLabel(error.code)`) instead of parsing text. */ export class ApiError extends Error { /** HTTP status code of the failed response. */ public readonly status: number; /** Machine-readable error code — see {@link ErrorCode}. */ public readonly code: ErrorCode; /** Per-field validation messages, present only when `code` is `VALIDATION_ERROR`. */ public readonly fieldErrors?: Record; public constructor(status: number, body: ApiErrorResponse) { super(body.message); this.name = "ApiError"; this.status = status; this.code = body.code; this.fieldErrors = body.details; } } /** * Thin fetch wrapper around the auth endpoints. A class (rather than plain * functions) so it reads as a cohesive service and stays easy to extend * (e.g. swapping the transport, adding request interceptors) without * touching every call site. Used as a single shared instance (`apiClient`, * exported below) — it's stateless, so there's no reason for more than one. */ export class ApiClient { /** * Performs a JSON request against the API and returns the parsed body. * * @throws {ApiError} if the response status is not in the 2xx range. */ private async request( path: string, options: RequestInit = {}, ): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { ...options, // Required for the httpOnly session cookie to be sent/received — the // API and the web app run on different origins. credentials: "include", headers: { "Content-Type": "application/json", ...options.headers }, }); if (!response.ok) { const body = (await response.json().catch(() => null)) as ApiErrorResponse | null; // Fallback for a response that couldn't even be parsed as JSON — no // hardcoded string, always the real enum member. throw new ApiError( response.status, body ?? { code: ErrorCode.INTERNAL_ERROR, message: "Something went wrong" }, ); } // 204 No Content (e.g. logout) has no body to parse. if (response.status === 204) { return undefined as TResponseBody; } return response.json() as Promise; } /** Creates a profile (+ household) and starts a session. */ public signup(input: SignupInput): Promise { return this.request("/auth/signup", { method: "POST", body: JSON.stringify(input) }); } /** Verifies credentials and starts a session. */ public login(input: LoginInput): Promise { return this.request("/auth/login", { method: "POST", body: JSON.stringify(input) }); } /** Ends the current session. */ public logout(): Promise { return this.request("/auth/logout", { method: "POST" }); } /** Fetches the currently authenticated profile — rejects with `NOT_AUTHENTICATED` if there's no session. */ public me(): Promise { return this.request("/auth/me"); } } /** Single shared instance — this client is stateless, no need for one per caller. */ export const apiClient = new ApiClient();