import { joinURL } from "ufo" import type { IApiErrorHandler } from "@/types/api" import { redactRequestOptionsForLog } from "./api-request-log" import { parseApiError } from "./error-handler" import { logger } from "./logger" type ApiRequestInit = RequestInit & { body?: BodyInit | null; aws?: Record } export interface ApiTransport { fetch(input: string | Request, init?: ApiRequestInit): Promise } interface ApiClientOptions { baseUrl?: string headers?: Record errorHandler?: IApiErrorHandler } interface RequestOptions { method?: string headers?: Record body?: unknown params?: Record dedupe?: boolean /** * If true, 403 errors will throw an error instead of triggering global error handler * This allows components to handle permission errors gracefully */ suppress403Redirect?: boolean signal?: AbortSignal } async function createHttpError(response: Response) { const error = new Error(await parseApiError(response)) as Error & { status: number } error.status = response.status return error } export class ApiClient { private $api: ApiTransport private config?: ApiClientOptions private errorHandler?: IApiErrorHandler private inflightGetRequests = new Map>() constructor(api: ApiTransport, options?: ApiClientOptions) { this.$api = api this.config = options this.errorHandler = options?.errorHandler } setErrorHandler(handler: IApiErrorHandler): void { this.errorHandler = handler } resolveUrl(url: string): string { return new URL(url, this.config?.baseUrl).toString() } async request(url: string, options: RequestOptions = {}, parseJson: boolean = true) { url = this.config?.baseUrl && !/^https?:\/\//i.test(url) ? joinURL(this.config.baseUrl, url) : url const { params, ...providedOptions } = options const requestOptions: RequestOptions = { ...providedOptions, headers: { ...this.config?.headers, ...providedOptions.headers }, } const method = (requestOptions.method ?? "GET").toUpperCase() requestOptions.method = method if ( requestOptions.body && !(requestOptions.body instanceof FormData) && !(requestOptions.body instanceof Blob) && !(requestOptions.body instanceof ArrayBuffer) && !(requestOptions.body instanceof Uint8Array) && !(requestOptions.body instanceof File) ) { requestOptions.body = JSON.stringify(requestOptions.body) } if (params) { const queryString = new URLSearchParams(params).toString() if (queryString) url += `${url.includes("?") ? "&" : "?"}${queryString}` } const shouldDedupe = method === "GET" && parseJson && requestOptions.dedupe !== false && !providedOptions.headers && !requestOptions.signal const dedupeKey = shouldDedupe ? `${method}:${url}` : null const executeRequest = async () => { logger.log("[request] url:", url) logger.log("[request] options:", redactRequestOptionsForLog(requestOptions)) const response = await this.$api.fetch(url, requestOptions as ApiRequestInit) logger.log("[request] response:", response) if (response.status === 401) { if (this.errorHandler) { await this.errorHandler.handle401(url) } throw await createHttpError(response) } if (response.status === 403) { // If suppress403Redirect is true, throw error instead of triggering global handler // This allows components to handle permission errors gracefully if (requestOptions.suppress403Redirect) { throw await createHttpError(response) } try { const cloned = response.clone() let codeText = "" try { const data = (await cloned.json()) as Record let code: string | undefined = (data?.code as string | undefined) || (data?.Code as string | undefined) if (data && typeof data === "object" && "error" in data) { const errObj = (data as { error?: unknown }).error if (errObj && typeof errObj === "object") { const e = errObj as { code?: unknown } if (typeof e.code === "string") code = code ?? e.code } } codeText = typeof code === "string" ? code : "" } catch { codeText = "" } const normalizedCode = codeText.toLowerCase() const isUnauthorizedAccess = normalizedCode === "unauthorizedaccess" const isInvalidAccessKey = normalizedCode === "invalidaccesskeyid" if (this.errorHandler) { if (isUnauthorizedAccess || isInvalidAccessKey) { await this.errorHandler.handle401(url) } else { await this.errorHandler.handle403(url) } } } catch { if (this.errorHandler) { await this.errorHandler.handle403(url) } } throw await createHttpError(response) } if (!response.ok) { throw await createHttpError(response) } if (response.status === 204 || response.headers.get("content-length") === "0" || !response.body) { return null } if (parseJson) { return await response.json() } else { return response } } if (!dedupeKey) { return executeRequest() } const inflight = this.inflightGetRequests.get(dedupeKey) if (inflight) { return inflight } const requestPromise = executeRequest() this.inflightGetRequests.set(dedupeKey, requestPromise) try { return await requestPromise } finally { this.inflightGetRequests.delete(dedupeKey) } } async get(url: string, options?: RequestOptions) { return this.request(url, { method: "GET", ...options }) } async *streamRequest(url: string, options: RequestOptions = {}) { const response = (await this.request(url, { method: "GET", ...options }, false)) as Response | null if (!response?.body) { throw new Error("No response body") } const reader = response.body.getReader() const decoder = new TextDecoder("utf-8") let buffer = "" while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) // Support NDJSON: each line is a JSON object const lines = buffer.split("\n") buffer = lines.pop() ?? "" for (const line of lines) { const trimmed = line.trim() if (!trimmed) continue try { const data = JSON.parse(trimmed) as Record yield data } catch { // skip invalid JSON } } } if (buffer.trim()) { try { const data = JSON.parse(buffer.trim()) as Record yield data } catch { // skip trailing incomplete JSON } } } async post(url: string, body: unknown, options?: RequestOptions) { return this.request(url, { method: "POST", body, ...options }) } async delete(url: string, options?: RequestOptions) { return this.request(url, { method: "DELETE", ...options }) } async put(url: string, body: unknown, options?: RequestOptions) { return this.request(url, { method: "PUT", body, ...options }) } async patch(url: string, body: unknown, options?: RequestOptions) { return this.request(url, { method: "PATCH", body, ...options }) } }