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:
Scott Idem
2026-05-07 17:46:38 -04:00
parent 730eea4ce7
commit af74b52481

View File

@@ -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);
});
}