569 lines
20 KiB
TypeScript
569 lines
20 KiB
TypeScript
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<void>((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<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);
|
|
});
|
|
}
|