Add XHR upload path with real-time progress tracking to post_object
New track_progress param (default false) switches to XMLHttpRequest for form_data uploads so xhr.upload.onprogress can fire percent_completed postMessages into api_upload_kv. fetch() has no upload progress events. No retry loop on XHR path — silently retrying a large video upload is bad UX; caller re-submits on failure. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -23,7 +23,10 @@ export const post_object = async function post_object({
|
||||
// The task_id value should be a random string that is unique to the task. This is used to identify the task in the message event.
|
||||
task_id = crypto.randomUUID(),
|
||||
log_lvl = 0,
|
||||
retry_count = 5
|
||||
retry_count = 5,
|
||||
// When true: use XHR instead of fetch so xhr.upload.onprogress can fire
|
||||
// progress postMessages into api_upload_kv. Only meaningful for form_data uploads.
|
||||
track_progress = false
|
||||
}: {
|
||||
api_cfg: any;
|
||||
endpoint: string;
|
||||
@@ -39,6 +42,7 @@ export const post_object = async function post_object({
|
||||
task_id?: string;
|
||||
log_lvl?: number;
|
||||
retry_count?: number;
|
||||
track_progress?: boolean;
|
||||
}) {
|
||||
if (log_lvl) {
|
||||
console.log(
|
||||
@@ -180,6 +184,21 @@ export const post_object = async function post_object({
|
||||
fetch_method = api_cfg.fetch;
|
||||
}
|
||||
|
||||
// XHR path — only for form_data uploads with progress tracking requested.
|
||||
// fetch() has no upload progress events; XHR.upload.onprogress does.
|
||||
if (track_progress && form_data) {
|
||||
return _post_with_xhr({
|
||||
url_str: url.toString(),
|
||||
headers_cleaned,
|
||||
form_data,
|
||||
task_id,
|
||||
endpoint,
|
||||
timeout,
|
||||
return_meta,
|
||||
log_lvl
|
||||
});
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= retry_count; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
@@ -405,3 +424,127 @@ export const post_object = async function post_object({
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function _post_with_xhr({
|
||||
url_str,
|
||||
headers_cleaned,
|
||||
form_data,
|
||||
task_id,
|
||||
endpoint,
|
||||
timeout,
|
||||
return_meta,
|
||||
log_lvl
|
||||
}: {
|
||||
url_str: string;
|
||||
headers_cleaned: key_val;
|
||||
form_data: FormData;
|
||||
task_id: string;
|
||||
endpoint: string;
|
||||
timeout: number;
|
||||
return_meta: boolean;
|
||||
log_lvl: number;
|
||||
}): Promise<any> {
|
||||
return new Promise((resolve) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url_str);
|
||||
xhr.timeout = timeout;
|
||||
|
||||
// Apply auth/custom headers. Content-Type is intentionally omitted —
|
||||
// the browser sets the multipart boundary automatically for FormData.
|
||||
for (const [key, value] of Object.entries(headers_cleaned)) {
|
||||
xhr.setRequestHeader(key, String(value));
|
||||
}
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable && typeof window !== 'undefined') {
|
||||
const pct = Math.round((event.loaded / event.total) * 100);
|
||||
try {
|
||||
window.postMessage(
|
||||
{
|
||||
type: 'api_post_json_form',
|
||||
status: 'uploading',
|
||||
task_id,
|
||||
endpoint,
|
||||
size_total: event.total,
|
||||
size_loaded: event.loaded,
|
||||
percent_completed: pct,
|
||||
progress: pct,
|
||||
rate: 0
|
||||
},
|
||||
'*'
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
};
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
console.error(`XHR upload timed out after ${timeout}ms. Endpoint: ${endpoint}`);
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
console.error(`XHR upload network error. Endpoint: ${endpoint}`);
|
||||
resolve(false);
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (log_lvl)
|
||||
console.log(`XHR response: status=${xhr.status} endpoint=${endpoint}`);
|
||||
|
||||
if (xhr.status === 401 || xhr.status === 403) {
|
||||
console.warn(`XHR AUTH FAILURE (${xhr.status}): ${endpoint}`);
|
||||
if (browser) ae_auth_error.set({ type: 'expired', ts: Date.now() });
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (xhr.status === 404) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
console.error(`XHR upload failed: HTTP ${xhr.status}. Endpoint: ${endpoint}`);
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fire completion postMessage (matches the fetch path shape)
|
||||
try {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.postMessage(
|
||||
{
|
||||
type: 'api_post_json_form',
|
||||
status: 'complete',
|
||||
task_id,
|
||||
endpoint,
|
||||
size_total: 0,
|
||||
size_loaded: 0,
|
||||
percent_completed: 100,
|
||||
progress: 100,
|
||||
rate: 0
|
||||
},
|
||||
'*'
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(xhr.responseText);
|
||||
if (log_lvl > 1) console.log('XHR Response JSON:', json);
|
||||
resolve(
|
||||
return_meta
|
||||
? json
|
||||
: json.data !== undefined
|
||||
? json.data
|
||||
: json
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('XHR: Failed to parse response JSON.', e);
|
||||
resolve(false);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.send(form_data);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user