import { getAccessToken } from "@/lib/auth/auth-storage"; import { expireSessionAndRedirectToLogin } from "@/lib/auth/expire-session"; import { env } from "@/lib/env"; import { resolveApiBaseUrl } from "@/lib/resolve-api-prefix-url"; export interface UploadFormResult { ok: boolean; status: number; data: unknown; } export interface UploadFormOptions { signal?: AbortSignal; onProgress?: (percent: number) => void; } function parseJsonBody(text: string): unknown { if (!text.trim()) { return undefined; } try { return JSON.parse(text) as unknown; } catch { return undefined; } } function createAbortError(): DOMException { return new DOMException("The upload was aborted.", "AbortError"); } /** * hazard: Ky's `onUploadProgress` turns the body into a ReadableStream, and browsers only accept * streamed request bodies over HTTP/2 — on HTTP/1.1 (Vite proxy, plain-HTTP hosts) the request dies * as a network error before reaching the API. XHR reports progress on every transport. */ export function uploadFormWithProgress( path: string, body: FormData, options?: UploadFormOptions, ): Promise { return new Promise((resolve, reject) => { if (options?.signal?.aborted) { reject(createAbortError()); return; } const request = new XMLHttpRequest(); request.open("POST", `${resolveApiBaseUrl(env.apiUrl)}/${path.replace(/^\/+/, "")}`); request.setRequestHeader("Accept", "application/json"); const token = getAccessToken(); if (token) { request.setRequestHeader("Authorization", token); } const abortRequest = () => { request.abort(); }; options?.signal?.addEventListener("abort", abortRequest); const detachSignal = () => { options?.signal?.removeEventListener("abort", abortRequest); }; const { onProgress } = options ?? {}; if (onProgress) { request.upload.addEventListener("progress", (event) => { if (event.lengthComputable && event.total > 0) { onProgress(Math.round((event.loaded / event.total) * 100)); } }); } request.addEventListener("load", () => { detachSignal(); const { status } = request; if (status === 401) { expireSessionAndRedirectToLogin(); } resolve({ ok: status >= 200 && status < 300, status, data: parseJsonBody(request.responseText), }); }); request.addEventListener("error", () => { detachSignal(); reject(new Error("Upload failed. Check your connection and try again.")); }); request.addEventListener("abort", () => { detachSignal(); reject(createAbortError()); }); request.send(body); }); }