import { telemetry } from './app-telemetry.js'; const JSON_ACCEPT = 'application/json'; const DEFAULT_HEADERS = Object.freeze({ 'Accept': JSON_ACCEPT, 'X-Requested-With': 'XMLHttpRequest', }); const toObject = (value) => { if (!value || typeof value !== 'object' || Array.isArray(value)) { return {}; } return value; }; const normalizeUrl = (value) => { try { return new URL(String(value || ''), window.location.href).toString(); } catch { return String(value || ''); } }; const normalizeRoute = (url) => { try { return new URL(url, window.location.href).pathname || '/'; } catch { return '/'; } }; const mergeHeaders = (baseHeaders, inputHeaders) => { const headers = new Headers(); Object.entries(baseHeaders).forEach(([key, value]) => { headers.set(key, value); }); if (!inputHeaders) { return headers; } if (inputHeaders instanceof Headers) { inputHeaders.forEach((value, key) => headers.set(key, value)); return headers; } if (Array.isArray(inputHeaders)) { inputHeaders.forEach((entry) => { if (Array.isArray(entry) && entry.length >= 2) { headers.set(String(entry[0]), String(entry[1])); } }); return headers; } if (typeof inputHeaders === 'object') { Object.entries(inputHeaders).forEach(([key, value]) => { if (value === null || value === undefined) { return; } headers.set(String(key), String(value)); }); } return headers; }; const readJsonSafe = async (response) => { const raw = await response.text(); if (raw.trim() === '') { return null; } return JSON.parse(raw); }; const messageFromPayload = (payload, fallback) => { if (!payload || typeof payload !== 'object') { return fallback; } if (typeof payload.message === 'string' && payload.message.trim() !== '') { return payload.message.trim(); } if (typeof payload.error === 'string' && payload.error.trim() !== '') { return payload.error.trim(); } if (payload.errors && typeof payload.errors === 'object') { const general = payload.errors.general; if (typeof general === 'string' && general.trim() !== '') { return general.trim(); } } return fallback; }; const normalizeMethod = (value, fallback = 'GET') => { const method = String(value || fallback).trim().toUpperCase(); return method === '' ? fallback : method; }; const buildHttpErrorMessage = (method, status, message) => { if (status > 0) { return `[${method}] HTTP ${status}: ${message}`; } return `[${method}] ${message}`; }; export class HttpError extends Error { constructor(details = {}) { const method = normalizeMethod(details.method || 'GET'); const status = Number.isFinite(Number(details.status)) ? Number(details.status) : 0; const message = String(details.message || 'Request failed').trim() || 'Request failed'; super(buildHttpErrorMessage(method, status, message)); this.name = 'HttpError'; this.status = status; this.method = method; this.url = normalizeUrl(details.url || ''); this.payload = details.payload ?? null; this.cause = details.cause ?? null; } } export class SessionExpiredError extends Error { constructor(redirectUrl = '') { super('Session expired'); this.name = 'SessionExpiredError'; this.redirectUrl = redirectUrl; } } const emitHttpTelemetry = (error, source) => { if (!(error instanceof HttpError)) { return; } telemetry.capture('ajax_error', { severity: error.status >= 500 || error.status === 0 ? 'error' : 'warning', message: error.message, meta: { source, module: 'app-http', http_method: error.method, http_status: error.status, request_path: normalizeRoute(error.url), error_code: error.status === 0 ? 'http_error_status_0' : `http_${error.status}`, }, }); }; const toFormBody = (data) => { if (data instanceof URLSearchParams) { return data; } if (typeof FormData !== 'undefined' && data instanceof FormData) { return new URLSearchParams(data); } if (data && typeof data === 'object') { const body = new URLSearchParams(); Object.entries(data).forEach(([key, value]) => { if (value === null || value === undefined) { body.append(key, ''); } else { body.append(key, String(value)); } }); return body; } return new URLSearchParams(); }; const performRequest = async (url, options = {}) => { const normalizedUrl = normalizeUrl(url); const method = normalizeMethod(options.method || 'GET'); const headers = mergeHeaders(DEFAULT_HEADERS, options.headers || null); const requestInit = { ...options, method, credentials: options.credentials || 'same-origin', headers, }; let response; try { response = await fetch(normalizedUrl, requestInit); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw error; } const httpError = new HttpError({ status: 0, method, url: normalizedUrl, message: 'Network error', payload: null, cause: error, }); emitHttpTelemetry(httpError, 'http.network'); throw httpError; } let payload = null; try { payload = await readJsonSafe(response); } catch (error) { const httpError = new HttpError({ status: 0, method, url: normalizedUrl, message: 'Invalid JSON response', payload: null, cause: error, }); emitHttpTelemetry(httpError, 'http.parse'); throw httpError; } if (!response.ok) { const message = messageFromPayload(payload, response.statusText || 'Request failed'); const httpError = new HttpError({ status: response.status, method, url: normalizedUrl, message, payload, }); emitHttpTelemetry(httpError, 'http.response'); throw httpError; } return payload; }; export const getJson = (url, options = {}) => performRequest(url, { ...options, method: 'GET', }); export const postForm = (url, data, options = {}) => { const body = toFormBody(data); const headers = mergeHeaders({ ...DEFAULT_HEADERS, 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', }, options.headers || null); return performRequest(url, { ...options, method: 'POST', headers, body, }); }; export const getHtml = async (url, options = {}) => { const normalizedUrl = normalizeUrl(url); const headers = mergeHeaders({ 'Accept': 'text/html', 'X-Requested-With': 'XMLHttpRequest', }, options.headers || null); let response; try { response = await fetch(normalizedUrl, { ...options, method: 'GET', credentials: options.credentials || 'same-origin', headers, }); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw error; } const httpError = new HttpError({ status: 0, method: 'GET', url: normalizedUrl, message: 'Network error', cause: error, }); emitHttpTelemetry(httpError, 'http.network'); throw httpError; } if (!response.ok) { const httpError = new HttpError({ status: response.status, method: 'GET', url: normalizedUrl, message: response.statusText || 'Request failed', }); emitHttpTelemetry(httpError, 'http.response'); throw httpError; } // Session-expiry detection: fetch follows 302 redirects transparently, so we // land on the login page with a 200 response. The final URL path diverges // from the requested one — that's the signal. try { const requested = new URL(normalizedUrl).pathname; const landed = new URL(response.url || normalizedUrl).pathname; if (landed !== requested && /\/login(\/|$)/.test(landed)) { throw new SessionExpiredError(response.url || ''); } } catch (err) { if (err instanceof SessionExpiredError) { throw err; } // URL parsing error — ignore, let caller process the body } return response.text(); }; export const postJson = (url, payload, options = {}) => { const headers = mergeHeaders({ ...DEFAULT_HEADERS, 'Content-Type': 'application/json;charset=UTF-8', }, options.headers || null); return performRequest(url, { ...options, method: 'POST', headers, body: JSON.stringify(payload ?? {}), }); };