475 lines
11 KiB
TypeScript
475 lines
11 KiB
TypeScript
const DEFAULT_MAX_RETRIES = 3;
|
|
const MAX_BACKOFF_MS = 8000;
|
|
const MAX_CONCURRENT_UPLOADS = 3;
|
|
const DIRECT_UPLOAD_MIN_BYTES = 5 * 1024 * 1024;
|
|
const DIRECT_UPLOAD_MAX_BYTES = 500 * 1024 * 1024;
|
|
const DIRECT_UPLOAD_PART_CONCURRENCY = 4;
|
|
const DIRECT_UPLOAD_SUPPRESS_MS = 2 * 60 * 1000;
|
|
|
|
const RETRYABLE_UPLOAD_STATUSES = new Set([
|
|
408,
|
|
425,
|
|
429,
|
|
500,
|
|
502,
|
|
503,
|
|
504,
|
|
]);
|
|
|
|
const DIRECT_UPLOAD_IMAGE_TYPES = new Set([
|
|
"image/jpeg",
|
|
"image/jpg",
|
|
"image/png",
|
|
"image/webp",
|
|
]);
|
|
|
|
let activeUploadCount = 0;
|
|
const uploadQueue: Array<() => void> = [];
|
|
let directUploadSuppressedUntil = 0;
|
|
|
|
type UploadFormFile = {
|
|
blob: Blob;
|
|
filename: string;
|
|
contentType: string;
|
|
purpose: string;
|
|
};
|
|
|
|
type DirectUploadPart = {
|
|
partNumber: number;
|
|
url: string;
|
|
};
|
|
|
|
type DirectUploadInitiateResponse =
|
|
| {
|
|
mode: "single";
|
|
key: string;
|
|
url: string;
|
|
headers?: Record<string, string>;
|
|
}
|
|
| {
|
|
mode: "multipart";
|
|
key: string;
|
|
uploadId: string;
|
|
partSize: number;
|
|
partCount: number;
|
|
parts: DirectUploadPart[];
|
|
};
|
|
|
|
function createAbortError() {
|
|
const error = new Error("Upload aborted");
|
|
error.name = "AbortError";
|
|
return error;
|
|
}
|
|
|
|
function sleep(ms: number, signal?: AbortSignal) {
|
|
if (signal?.aborted) {
|
|
return Promise.reject(createAbortError());
|
|
}
|
|
|
|
return new Promise<void>((resolve, reject) => {
|
|
const timeout = globalThis.setTimeout(() => {
|
|
signal?.removeEventListener("abort", onAbort);
|
|
resolve();
|
|
}, ms);
|
|
|
|
const onAbort = () => {
|
|
globalThis.clearTimeout(timeout);
|
|
reject(createAbortError());
|
|
};
|
|
|
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
});
|
|
}
|
|
|
|
function acquireUploadSlot() {
|
|
return new Promise<void>((resolve) => {
|
|
const start = () => {
|
|
activeUploadCount += 1;
|
|
resolve();
|
|
};
|
|
|
|
if (activeUploadCount < MAX_CONCURRENT_UPLOADS) {
|
|
start();
|
|
} else {
|
|
uploadQueue.push(start);
|
|
}
|
|
});
|
|
}
|
|
|
|
async function withUploadSlot<T>(task: () => Promise<T>): Promise<T> {
|
|
await acquireUploadSlot();
|
|
|
|
try {
|
|
return await task();
|
|
} finally {
|
|
activeUploadCount = Math.max(0, activeUploadCount - 1);
|
|
uploadQueue.shift()?.();
|
|
}
|
|
}
|
|
|
|
function parseRetryAfterMs(value: string | null): number | null {
|
|
if (!value) return null;
|
|
const seconds = Number(value);
|
|
if (Number.isFinite(seconds) && seconds > 0) {
|
|
return Math.round(seconds * 1000);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function computeBackoffMs(attempt: number, retryAfterMs: number | null) {
|
|
if (retryAfterMs && retryAfterMs > 0) {
|
|
return Math.min(retryAfterMs, MAX_BACKOFF_MS);
|
|
}
|
|
const exp = Math.min(MAX_BACKOFF_MS, 500 * 2 ** attempt);
|
|
const jitter = Math.floor(Math.random() * 300);
|
|
return exp + jitter;
|
|
}
|
|
|
|
function isRetryableUploadStatus(status: number) {
|
|
return RETRYABLE_UPLOAD_STATUSES.has(status);
|
|
}
|
|
|
|
function isAbortError(error: unknown, signal?: AbortSignal) {
|
|
return (
|
|
signal?.aborted ||
|
|
(error instanceof Error && error.name === "AbortError")
|
|
);
|
|
}
|
|
|
|
function cloneFormData(formData: FormData) {
|
|
const copy = new FormData();
|
|
|
|
formData.forEach((value, key) => {
|
|
if (typeof value === "string") {
|
|
copy.append(key, value);
|
|
return;
|
|
}
|
|
|
|
const filename =
|
|
typeof File !== "undefined" && value instanceof File
|
|
? value.name
|
|
: undefined;
|
|
|
|
if (filename) {
|
|
copy.append(key, value, filename);
|
|
} else {
|
|
copy.append(key, value);
|
|
}
|
|
});
|
|
|
|
return copy;
|
|
}
|
|
|
|
function getUploadFormFile(formData: FormData): UploadFormFile | null {
|
|
const value = formData.get("file");
|
|
if (!(value instanceof Blob)) {
|
|
return null;
|
|
}
|
|
|
|
const filename =
|
|
typeof File !== "undefined" && value instanceof File && value.name
|
|
? value.name
|
|
: `upload-${Date.now()}`;
|
|
const contentType = value.type || "application/octet-stream";
|
|
const purposeValue = formData.get("purpose");
|
|
|
|
return {
|
|
blob: value,
|
|
filename,
|
|
contentType,
|
|
purpose: typeof purposeValue === "string" ? purposeValue : "",
|
|
};
|
|
}
|
|
|
|
function shouldTryDirectUpload(file: UploadFormFile) {
|
|
if (Date.now() < directUploadSuppressedUntil) return false;
|
|
if (file.blob.size <= 0 || file.blob.size > DIRECT_UPLOAD_MAX_BYTES) {
|
|
return false;
|
|
}
|
|
if (!DIRECT_UPLOAD_IMAGE_TYPES.has(file.contentType.toLowerCase())) {
|
|
return false;
|
|
}
|
|
if (file.purpose === "agent-workflow-preview") {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
file.purpose === "canvas-upload" ||
|
|
file.blob.size >= DIRECT_UPLOAD_MIN_BYTES
|
|
);
|
|
}
|
|
|
|
function suppressDirectUploadTemporarily() {
|
|
directUploadSuppressedUntil = Date.now() + DIRECT_UPLOAD_SUPPRESS_MS;
|
|
}
|
|
|
|
async function readErrorPayload(response: Response) {
|
|
try {
|
|
const payload = await response.clone().json();
|
|
return payload?.error || payload?.details || response.statusText;
|
|
} catch {
|
|
return response.statusText;
|
|
}
|
|
}
|
|
|
|
async function postDirectUploadAction<T>(
|
|
payload: Record<string, unknown>,
|
|
signal?: AbortSignal
|
|
): Promise<T> {
|
|
const response = await fetch("/api/files/direct-upload", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(payload),
|
|
signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(String(await readErrorPayload(response)));
|
|
}
|
|
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
async function fetchDirectPartWithRetry(
|
|
url: string,
|
|
init: RequestInit,
|
|
maxRetries: number,
|
|
signal?: AbortSignal
|
|
) {
|
|
for (let attempt = 0; ; attempt += 1) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
...init,
|
|
signal,
|
|
});
|
|
|
|
if (response.ok || !isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
|
|
return response;
|
|
}
|
|
|
|
await sleep(computeBackoffMs(attempt, null), signal);
|
|
} catch (error) {
|
|
if (isAbortError(error, signal) || attempt >= maxRetries) {
|
|
throw error;
|
|
}
|
|
|
|
await sleep(computeBackoffMs(attempt, null), signal);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function uploadMultipartParts(params: {
|
|
file: UploadFormFile;
|
|
upload: Extract<DirectUploadInitiateResponse, { mode: "multipart" }>;
|
|
maxRetries: number;
|
|
signal?: AbortSignal;
|
|
}) {
|
|
const uploadedParts: Array<{ ETag: string; PartNumber: number }> = [];
|
|
let nextIndex = 0;
|
|
|
|
async function worker() {
|
|
for (;;) {
|
|
const part = params.upload.parts[nextIndex];
|
|
nextIndex += 1;
|
|
if (!part) return;
|
|
|
|
const start = (part.partNumber - 1) * params.upload.partSize;
|
|
const end = Math.min(start + params.upload.partSize, params.file.blob.size);
|
|
const body = params.file.blob.slice(start, end);
|
|
const response = await fetchDirectPartWithRetry(
|
|
part.url,
|
|
{
|
|
method: "PUT",
|
|
body,
|
|
},
|
|
params.maxRetries,
|
|
params.signal
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Direct multipart upload failed: ${response.status}`);
|
|
}
|
|
|
|
const etag = response.headers.get("ETag") || response.headers.get("etag");
|
|
if (!etag) {
|
|
throw new Error("Storage did not expose ETag; please allow ETag in bucket CORS");
|
|
}
|
|
|
|
uploadedParts.push({
|
|
ETag: etag,
|
|
PartNumber: part.partNumber,
|
|
});
|
|
}
|
|
}
|
|
|
|
await Promise.all(
|
|
Array.from(
|
|
{ length: Math.min(DIRECT_UPLOAD_PART_CONCURRENCY, params.upload.parts.length) },
|
|
() => worker()
|
|
)
|
|
);
|
|
|
|
return uploadedParts.sort((a, b) => a.PartNumber - b.PartNumber);
|
|
}
|
|
|
|
async function fetchDirectUpload(
|
|
formData: FormData,
|
|
maxRetries: number,
|
|
signal?: AbortSignal
|
|
): Promise<Response | null> {
|
|
const file = getUploadFormFile(formData);
|
|
if (!file || !shouldTryDirectUpload(file)) {
|
|
return null;
|
|
}
|
|
|
|
let multipartUpload:
|
|
| Extract<DirectUploadInitiateResponse, { mode: "multipart" }>
|
|
| null = null;
|
|
|
|
try {
|
|
const upload = await postDirectUploadAction<DirectUploadInitiateResponse>(
|
|
{
|
|
action: "initiate",
|
|
filename: file.filename,
|
|
contentType: file.contentType,
|
|
size: file.blob.size,
|
|
purpose: file.purpose,
|
|
},
|
|
signal
|
|
);
|
|
|
|
if (upload.mode === "single") {
|
|
const response = await fetchDirectPartWithRetry(
|
|
upload.url,
|
|
{
|
|
method: "PUT",
|
|
headers: upload.headers || {
|
|
"Content-Type": file.contentType,
|
|
},
|
|
body: file.blob,
|
|
},
|
|
maxRetries,
|
|
signal
|
|
);
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Direct upload failed: ${response.status}`);
|
|
}
|
|
|
|
const payload = await postDirectUploadAction<Record<string, unknown>>(
|
|
{
|
|
action: "complete",
|
|
key: upload.key,
|
|
filename: file.filename,
|
|
contentType: file.contentType,
|
|
size: file.blob.size,
|
|
purpose: file.purpose,
|
|
},
|
|
signal
|
|
);
|
|
|
|
return Response.json(payload);
|
|
}
|
|
|
|
multipartUpload = upload;
|
|
const parts = await uploadMultipartParts({
|
|
file,
|
|
upload,
|
|
maxRetries,
|
|
signal,
|
|
});
|
|
const payload = await postDirectUploadAction<Record<string, unknown>>(
|
|
{
|
|
action: "complete",
|
|
key: upload.key,
|
|
uploadId: upload.uploadId,
|
|
filename: file.filename,
|
|
contentType: file.contentType,
|
|
size: file.blob.size,
|
|
parts,
|
|
purpose: file.purpose,
|
|
},
|
|
signal
|
|
);
|
|
|
|
return Response.json(payload);
|
|
} catch (error) {
|
|
if (multipartUpload && !isAbortError(error, signal)) {
|
|
postDirectUploadAction(
|
|
{
|
|
action: "abort",
|
|
key: multipartUpload.key,
|
|
uploadId: multipartUpload.uploadId,
|
|
},
|
|
signal
|
|
).catch(() => {});
|
|
}
|
|
|
|
if (!isAbortError(error, signal)) {
|
|
console.warn("[Upload] Direct upload failed; falling back to server upload", error);
|
|
suppressDirectUploadTemporarily();
|
|
return null;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function fetchUploadWithRetry(
|
|
formData: FormData,
|
|
options?: {
|
|
maxRetries?: number;
|
|
signal?: AbortSignal;
|
|
}
|
|
) {
|
|
const maxRetries = options?.maxRetries ?? DEFAULT_MAX_RETRIES;
|
|
|
|
return withUploadSlot(async () => {
|
|
const directResponse = await fetchDirectUpload(
|
|
formData,
|
|
maxRetries,
|
|
options?.signal
|
|
);
|
|
if (directResponse) {
|
|
return directResponse;
|
|
}
|
|
|
|
for (let attempt = 0; ; attempt++) {
|
|
try {
|
|
const response = await fetch("/api/files/upload", {
|
|
method: "POST",
|
|
body: cloneFormData(formData),
|
|
signal: options?.signal,
|
|
});
|
|
|
|
if (!isRetryableUploadStatus(response.status) || attempt >= maxRetries) {
|
|
return response;
|
|
}
|
|
|
|
let retryAfterMs = parseRetryAfterMs(response.headers.get("Retry-After"));
|
|
if (!retryAfterMs) {
|
|
try {
|
|
const payload = await response.clone().json();
|
|
const retryAfterSec = Number(payload?.retryAfter || 0);
|
|
if (Number.isFinite(retryAfterSec) && retryAfterSec > 0) {
|
|
retryAfterMs = Math.round(retryAfterSec * 1000);
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
const delayMs = computeBackoffMs(attempt, retryAfterMs);
|
|
await sleep(delayMs, options?.signal);
|
|
} catch (error) {
|
|
if (isAbortError(error, options?.signal) || attempt >= maxRetries) {
|
|
throw error;
|
|
}
|
|
|
|
const delayMs = computeBackoffMs(attempt, null);
|
|
await sleep(delayMs, options?.signal);
|
|
}
|
|
}
|
|
});
|
|
}
|