shoc-frontend-new/src/api/upload-form-with-progress.ts
Arthur Bassi 74e5c58e5d fix(work-orders): restore completion media uploads over HTTP/1.1
Use XHR for media progress so Ky does not stream the body on HTTP/1.1
(Vite proxy), and keep completion vs extra tiles from mixing after upload.
2026-09-07 15:19:39 -03:00

103 lines
2.7 KiB
TypeScript

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<UploadFormResult> {
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);
});
}