import { request, FormData as UndiciFormData } from "undici"; export interface ApiClientOptions { baseUrl: string; token?: string; } interface NativeApiResponse { code: number; msg: string; data: T; timestamp: string; } export class ApiClient { constructor(private options: ApiClientOptions) {} /** * Unwrap Native API response format: * { code: 0, msg: "success", data: T } -> returns T * { code: non-zero, msg: "error", data: null } -> throws ApiError * * Pass-through Compat API format (no code field): * { user: {...} } -> returns as-is * { results: [...] } -> returns as-is */ private unwrapResponse(data: unknown): T { // Check if it's a Native API response (has code field) if (typeof data === "object" && data !== null && "code" in data) { const native = data as NativeApiResponse; if (native.code !== 0) { throw new ApiError(native.code, native); } return native.data as T; } // Otherwise it's a Compat API response, return as-is return data as T; } async get(path: string): Promise { const url = new URL(path, this.options.baseUrl); const { statusCode, body } = await request(url.toString(), { method: "GET", headers: this.headers(), }); const data = await body.json(); if (statusCode >= 400) { throw new ApiError(statusCode, data); } return this.unwrapResponse(data); } async postForm(path: string, form: UndiciFormData, queryParams?: Record): Promise { const url = new URL(path, this.options.baseUrl); if (queryParams) { for (const [k, v] of Object.entries(queryParams)) { url.searchParams.set(k, v); } } const { statusCode, body } = await request(url.toString(), { method: "POST", headers: { ...this.headers(), }, body: form, }); const data = await body.json(); if (statusCode >= 400) { throw new ApiError(statusCode, data); } return this.unwrapResponse(data); } async post(path: string, opts?: { body?: string; headers?: Record }): Promise { const url = new URL(path, this.options.baseUrl); const { statusCode, body } = await request(url.toString(), { method: "POST", headers: { ...this.headers(), ...opts?.headers }, body: opts?.body, }); const data = await body.json(); if (statusCode >= 400) { throw new ApiError(statusCode, data); } return this.unwrapResponse(data); } async put(path: string, opts?: { body?: string; headers?: Record }): Promise { const url = new URL(path, this.options.baseUrl); const { statusCode, body } = await request(url.toString(), { method: "PUT", headers: { ...this.headers(), ...opts?.headers }, body: opts?.body, }); const data = await body.json(); if (statusCode >= 400) { throw new ApiError(statusCode, data); } return this.unwrapResponse(data); } async delete(path: string): Promise { const url = new URL(path, this.options.baseUrl); const { statusCode, body } = await request(url.toString(), { method: "DELETE", headers: this.headers(), }); const data = await body.json(); if (statusCode >= 400) { throw new ApiError(statusCode, data); } return this.unwrapResponse(data); } private headers(): Record { const h: Record = {}; if (this.options.token) { h["Authorization"] = `Bearer ${this.options.token}`; } return h; } } export class ApiError extends Error { constructor( public statusCode: number, public body: unknown, ) { super(`API error ${statusCode}: ${JSON.stringify(body)}`); } }