mirror of
https://github.com/Sea-Haven-Industries/shoc-frontend-new.git
synced 2026-09-30 15:03:12 +00:00
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.
103 lines
2.7 KiB
TypeScript
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);
|
|
});
|
|
}
|