import { browser } from '$app/environment'; import { ae_auth_error } from '$lib/stores/ae_stores'; import type { key_val } from '$lib/stores/ae_stores'; export const temp_post_blob_percent_completed = 0; export const post_blob_percent_completed = temp_post_blob_percent_completed; export const temp_post_object_percent_completed = 0; export const post_object_percent_completed = temp_post_object_percent_completed; // Updated 2026-01-26 (Abort Silence) export const post_object = async function post_object({ api_cfg = null, endpoint = '', headers = {}, params = {}, data = {}, form_data = null, timeout = 20000, return_meta = false, return_blob = false, filename = '', auto_download = false, // 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, // 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; headers?: any; params?: any; data?: any; form_data?: any; timeout?: number; return_meta?: boolean; return_blob?: boolean; filename?: string; auto_download?: boolean; task_id?: string; log_lvl?: number; retry_count?: number; track_progress?: boolean; }) { if (log_lvl) { console.log( `*** post_object() *** Endpoint: ${endpoint} Task ID: ${task_id}` ); console.log('Params:', params); if (log_lvl > 1) { console.log('Data:', data); console.log(typeof data); console.log(`Base URL: ${api_cfg['base_url']}`); console.log('API Config:', api_cfg); } if (log_lvl > 2) { console.log(`Return Meta: ${return_meta}`); console.log(`Return Blob: ${return_blob}`); console.log(`Filename: ${filename}`); console.log(`Auto Download: ${auto_download}`); } } if (!api_cfg) { console.error('No API Config was provided. Returning false.'); return false; } // Construct the URL with query parameters const url = new URL(endpoint, api_cfg['base_url']); if (params) { Object.keys(params).forEach((key) => url.searchParams.append(key, params[key]) ); } // Clean and merge headers const headers_cleaned: key_val = {}; const merged_headers = { ...api_cfg['headers'], ...headers }; // Auto-promote account_id from api_cfg to header if missing let account_id = merged_headers['x-account-id'] || api_cfg['account_id']; // IMMEDIATE ACCOUNT ID SCAVENGING: Read from localStorage to avoid race conditions if (!account_id && typeof localStorage !== 'undefined') { try { const ae_loc_raw = localStorage.getItem('ae_loc'); if (ae_loc_raw) { const ae_loc_json = JSON.parse(ae_loc_raw); if (ae_loc_json.account_id) { account_id = ae_loc_json.account_id; } } } catch (e) { // Silently fail on storage read } } if (account_id) { merged_headers['x-account-id'] = account_id; } // Handle "Bootstrap Paradox" for unauthenticated requests const bypass_val = merged_headers['x-no-account-id'] || merged_headers['x_no_account_id']; const is_valid_bypass = bypass_val === 'bypass' || bypass_val === 'Nothing to See Here' || bypass_val === 'direct-download'; if (is_valid_bypass) { if (log_lvl > 1) console.log( 'api_post_object: Valid bypass detected. Stripping account ID context.' ); delete merged_headers['x-account-id']; delete merged_headers['x_account_id']; } else { // If it's a placeholder (like "No_Account_ID_Here"), just remove the bypass header // but DO NOT strip the valid Account ID. delete merged_headers['x-no-account-id']; delete merged_headers['x_no_account_id']; } for (const prop in merged_headers) { const prop_cleaned = prop.replaceAll('_', '-'); let value = merged_headers[prop]; if (value === null || value === undefined) continue; if (typeof value !== 'string') { value = JSON.stringify(value); } headers_cleaned[prop_cleaned] = value; } // Auto-inject Authorization header if JWT is present but header is missing let jwt = headers_cleaned['jwt'] || headers_cleaned['JWT'] || api_cfg['jwt'] || api_cfg['headers']?.['jwt'] || api_cfg['headers']?.['JWT']; // Final Fallback: Direct check of primary ae_loc key if (!jwt && typeof localStorage !== 'undefined') { try { const raw = localStorage.getItem('ae_loc'); if (raw) { const json = JSON.parse(raw); if (json.jwt) jwt = json.jwt; } } catch (e) { // Silently fail on storage read } } if ( jwt && !headers_cleaned['Authorization'] && !headers_cleaned['authorization'] ) { headers_cleaned['Authorization'] = `Bearer ${jwt}`; } if (form_data) { delete headers_cleaned['content-type']; delete headers_cleaned['Content-Type']; if (log_lvl > 1) console.log('Form Data:', form_data); } else { headers_cleaned['Content-Type'] = 'application/json'; } if (log_lvl > 1) { console.log('Final cleaned headers:', headers_cleaned); } let fetch_method: any = fetch; if (api_cfg.fetch) { if (log_lvl > 1) { console.log('Using custom fetch function from api_cfg!!!'); } 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++) { // Declared at loop scope (not inside try) so the catch block can clearTimeout. // Fresh controller per attempt — same rationale as api_get_object.ts. const controller = new AbortController(); // AbortError is not specific enough by itself. Distinguish timeout-aborts // (retryable transient class) from intentional caller aborts (fail-fast). let did_timeout_abort = false; const timeoutId = setTimeout(() => { did_timeout_abort = true; console.warn(`API POST: Request timed out after ${timeout}ms (attempt ${attempt}/${retry_count}).`); controller.abort(); }, timeout); try { const fetchOptions: RequestInit = { method: 'POST', headers: headers_cleaned, body: form_data ? form_data : JSON.stringify(data), signal: controller.signal }; if (log_lvl > 1) { console.log('Fetch Options:', fetchOptions); } const response = await fetch_method( url.toString(), fetchOptions ).catch(function (error: any) { // SILENCE NOISE: Aborted requests shouldn't spam logs at log_lvl 0 if ( error.name === 'AbortError' || error.message?.includes('aborted') || error.name === 'TypeError' ) { if (log_lvl > 1) { console.log( 'API POST: Request was aborted or terminated by browser. Expected during navigation.', error ); } return error; } console.log( 'API POST Object *fetch* request failed in an unexpected way.', error ); return error; }); clearTimeout(timeoutId); // Check if we should stop due to abort or network failure. if ( response instanceof Error || (response && (response.name === 'TypeError' || response.name === 'AbortError')) ) { // Retry timeout-aborts from this helper; do not retry caller aborts // (route change/unmount/manual cancellation). if (response.name === 'AbortError') { if (did_timeout_abort) { throw new Error( `Timeout abort (attempt ${attempt}/${retry_count}) after ${timeout}ms` ); } return false; } // TypeError = transient network failure. Throw into the retry loop // so backoff-and-retry applies. Same fix as api_get_object.ts — see // comment there for the full history of why this was broken. throw new Error(`Network error (attempt ${attempt}): ${response.message}`); } if (!response) { throw new Error( `HTTP fetch request was aborted or failed in an unexpected way! URL = ${url.toString()}` ); } if (log_lvl) { console.log( `Response: status=${response.status} attempt=${attempt}` ); } if (!response.ok) { if (response.status === 404) { if (log_lvl) { console.log( 'The response was a 404 not found "error". Returning null.' ); } return null; } // FAIL FAST (Section 2D): Do not retry on Auth or Client errors (400, 401, 403, 422) if ( response.status === 400 || response.status === 401 || response.status === 403 || response.status === 422 ) { if (log_lvl) console.error( `API Client Failure (${response.status}). Failing fast.` ); if (response.status === 401 || response.status === 403) { console.warn( `AUTH DIAGNOSTICS (POST): Headers sent for ${endpoint}:`, { has_auth: !!headers_cleaned['Authorization'], has_api_key: !!headers_cleaned['x-aether-api-key'], has_account_id: !!headers_cleaned['x-account-id'], jwt_preview: jwt ? `${jwt.slice(0, 8)}...` : 'MISSING' } ); // Signal the root layout to show the session-expired banner. if (browser) ae_auth_error.set({ type: 'expired', ts: Date.now() }); } // Structured Error Handling (V3): Attempt to get rich error metadata let error_json: any = null; try { error_json = await response.json(); } catch (e) { // Not JSON } if (log_lvl) console.log( 'The response was not ok. Structured Error Check:', error_json ); if (error_json?.meta?.details) { return error_json; } // Fallback for standard FastAPI "detail" errors if (error_json?.detail) { return { meta: { success: false, status_code: response.status, details: { category: 'validation', message: typeof error_json.detail === 'string' ? error_json.detail : JSON.stringify(error_json.detail), raw: error_json.detail } } }; } return false; } throw new Error(`HTTP error! status: ${response.status}`); } if (!return_blob) { const json = await response.json(); if (log_lvl > 1) { console.log('Response JSON:', json); } // Post a message to the window indicating the upload is complete try { if (typeof window !== 'undefined') { window.postMessage( { type: 'api_post_json_form', status: 'complete', task_id: task_id, endpoint: endpoint, size_total: 0, size_loaded: 0, percent_completed: 100, progress: 100, rate: 0 }, '*' ); } } catch (error) { console.error('Error posting message to window:', error); } // Return the response data or metadata // Robustly handle V3 response envelopes return return_meta ? json : json.data !== undefined ? json.data : json; } else { const blob = await response.blob(); if (auto_download) { const downloadUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = downloadUrl; link.setAttribute('download', filename || 'download'); document.body.appendChild(link); link.click(); link.remove(); return true; } else { return blob; } } } catch (error) { // Ensure the per-attempt timeout timer is always cancelled on failure. clearTimeout(timeoutId); console.error(`API POST error on attempt ${attempt}:`, error); if (attempt === retry_count) { console.error('Max retry attempts reached. Returning false.'); return false; } // Backoff before retrying — same rationale as api_get_object.ts. const delay_ms = Math.min(2000 * attempt, 8000); console.log(`API POST: Retrying in ${delay_ms}ms... (attempt ${attempt}/${retry_count})`); await new Promise((resolve) => setTimeout(resolve, delay_ms)); } } }; 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); }); }