diff --git a/src/lib/ae_api/api_post_object.ts b/src/lib/ae_api/api_post_object.ts index 057eed67..104aeb47 100644 --- a/src/lib/ae_api/api_post_object.ts +++ b/src/lib/ae_api/api_post_object.ts @@ -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 { + 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); + }); +}