// @FILE-SUBCLASS-01-EXCEPTION /** * Client-side Ajax class for making API calls to RSX controllers * * Automatically batches multiple calls into single HTTP requests to reduce network overhead. * Batches up to 20 calls or flushes after setTimeout(0) debounce. */ class Ajax { /** * Initialize Ajax system * Called automatically when class is loaded */ static _on_framework_core_init() { // Queue of pending calls waiting to be batched Ajax._pending_calls = {}; // Timer for batching flush Ajax._flush_timeout = null; // Call counter for generating unique call IDs Ajax._call_counter = 0; // Maximum batch size before forcing immediate flush Ajax.MAX_BATCH_SIZE = 20; // Debounce time in milliseconds Ajax.DEBOUNCE_MS = 0; // Track promises from Ajax calls to detect uncaught rejections Ajax._tracked_promises = new WeakSet(); // Set up global unhandled rejection handler for Ajax errors window.addEventListener('unhandledrejection', async (event) => { // Only handle rejections from Ajax promises if (Ajax._tracked_promises.has(event.promise)) { event.preventDefault(); // Prevent browser's default "Uncaught (in promise)" error const error = event.reason; console.error('Uncaught Ajax error:', error); // Show Modal.error() for uncaught Ajax errors if (typeof Modal !== 'undefined' && Modal.error) { await Modal.error(error, 'Uncaught Ajax Error'); } } }); } /** * Make an AJAX call to an RSX controller action * * All calls are automatically batched unless window.rsxapp.ajax_disable_batching is true. * * @param {string|object|function} url - The Ajax URL (e.g., '/_ajax/Controller_Name/action_name') or an object/function with a .path property * @param {object} params - Parameters to send to the action * @returns {Promise} - Resolves with the return value, rejects with error */ static async call(url, params = {}) { // If url is an object or function with a .path property, use that as the URL if (url && typeof url === 'object' && url.path) { url = url.path; } else if (url && typeof url === 'function' && url.path) { url = url.path; } // Validate url is a non-empty string if (typeof url !== 'string' || url.length === 0) { throw new Error('Ajax.call() requires a non-empty string URL or an object/function with a .path property'); } // Extract controller and action from URL const { controller, action } = Ajax.ajax_url_to_controller_action(url); console.log('Ajax:', controller, action, params); // Check if batching is disabled for debugging let promise; if (window.rsxapp && window.rsxapp.ajax_disable_batching) { promise = Ajax._call_direct(controller, action, params); } else { promise = Ajax._call_batch(controller, action, params); } // Track this promise for unhandled rejection detection Ajax._tracked_promises.add(promise); return promise; } /** * Make a batched Ajax call * @private */ static _call_batch(controller, action, params = {}) { console.log('Ajax Batch:', controller, action, params); return new Promise((resolve, reject) => { // Generate call key for deduplication const call_key = Ajax._generate_call_key(controller, action, params); // Check if this exact call is already pending if (Ajax._pending_calls[call_key]) { const existing_call = Ajax._pending_calls[call_key]; // If call already completed (cached), return immediately if (existing_call.is_complete) { if (existing_call.is_error) { reject(existing_call.error); } else { resolve(existing_call.result); } return; } // Call is pending, add this promise to callbacks existing_call.callbacks.push({ resolve, reject }); return; } // Create new pending call const call_id = Ajax._call_counter++; const pending_call = { call_id: call_id, call_key: call_key, controller: controller, action: action, params: params, callbacks: [{ resolve, reject }], is_complete: false, is_error: false, result: null, error: null, }; // Add to pending queue Ajax._pending_calls[call_key] = pending_call; // Count pending calls const pending_count = Object.keys(Ajax._pending_calls).filter((key) => !Ajax._pending_calls[key].is_complete).length; // If we've hit the batch size limit, flush immediately if (pending_count >= Ajax.MAX_BATCH_SIZE) { clearTimeout(Ajax._flush_timeout); Ajax._flush_timeout = null; Ajax._flush_pending_calls(); } else { // Schedule batch flush with debounce clearTimeout(Ajax._flush_timeout); Ajax._flush_timeout = setTimeout(() => { Ajax._flush_pending_calls(); }, Ajax.DEBOUNCE_MS); } }); } /** * Make a direct (non-batched) Ajax call * @private */ static async _call_direct(controller, action, params = {}) { // Construct URL from controller and action const url = `/_ajax/${controller}/${action}`; // Log the AJAX call using console_debug if (typeof Debugger !== 'undefined' && Debugger.console_debug) { Debugger.console_debug('AJAX', `Calling ${controller}.${action} (unbatched)`, params); } return new Promise((resolve, reject) => { $.ajax({ url: url, method: 'POST', data: params, dataType: 'json', __local_integration: true, // Bypass $.ajax override success: (response) => { // Handle console_debug messages if (response.console_debug && Array.isArray(response.console_debug)) { response.console_debug.forEach((msg) => { if (!Array.isArray(msg) || msg.length !== 2) { throw new Error('Invalid console_debug message format - expected [channel, [arguments]]'); } const [channel, args] = msg; console.log(channel, ...args); }); } // Handle flash_alerts from server if (response.flash_alerts && Array.isArray(response.flash_alerts)) { Server_Side_Flash.process(response.flash_alerts); } // Check if the response was successful if (response._success === true) { // @JS-AJAX-02-EXCEPTION - Unwrap server responses with _ajax_return_value const processed_value = Rsx_Js_Model._instantiate_models_recursive(response._ajax_return_value); resolve(processed_value); } else { // Handle error responses const error_type = response.error_type || 'unknown_error'; const reason = response.reason || 'Unknown error occurred'; const details = response.details || {}; // Handle specific error types switch (error_type) { case 'fatal': // Fatal PHP error with full error details const fatal_error_data = response.error || {}; const error_message = fatal_error_data.error || 'Fatal error occurred'; console.error('Ajax error response from server:', response.error); const fatal_error = new Error(error_message); fatal_error.type = 'fatal'; fatal_error.details = response.error; // Log to server if browser error logging is enabled Debugger.log_error({ message: `Ajax Fatal Error: ${error_message}`, type: 'ajax_fatal', endpoint: url, details: response.error, }); reject(fatal_error); break; case 'response_auth_required': console.error( 'The user is no longer authenticated, this is a placeholder for future code which handles this scenario.' ); const auth_error = new Error(reason); auth_error.type = 'auth_required'; auth_error.details = details; reject(auth_error); break; case 'response_unauthorized': console.error( 'The user is unauthorized to perform this action, this is a placeholder for future code which handles this scenario.' ); const unauth_error = new Error(reason); unauth_error.type = 'unauthorized'; unauth_error.details = details; reject(unauth_error); break; case 'response_form_error': const form_error = new Error(reason); form_error.type = 'form_error'; form_error.details = details; reject(form_error); break; default: const generic_error = new Error(reason); generic_error.type = error_type; generic_error.details = details; reject(generic_error); break; } } }, error: (xhr, status, error) => { const error_message = Ajax._extract_error_message(xhr); const network_error = new Error(error_message); network_error.type = 'network_error'; network_error.status = xhr.status; network_error.statusText = status; // Log server errors (500+) to the server if browser error logging is enabled if (xhr.status >= 500) { Debugger.log_error({ message: `Ajax Server Error ${xhr.status}: ${error_message}`, type: 'ajax_server_error', endpoint: url, status: xhr.status, statusText: status, }); } reject(network_error); }, }); }); } /** * Flush all pending calls by sending batch request * @private */ static async _flush_pending_calls() { // Collect all pending calls const calls_to_send = []; const call_map = {}; // Map call_id to pending_call object for (const call_key in Ajax._pending_calls) { const pending_call = Ajax._pending_calls[call_key]; if (!pending_call.is_complete) { calls_to_send.push({ call_id: pending_call.call_id, controller: pending_call.controller, action: pending_call.action, params: pending_call.params, }); call_map[pending_call.call_id] = pending_call; } } // Nothing to send if (calls_to_send.length === 0) { return; } // Log batch for debugging if (typeof Debugger !== 'undefined' && Debugger.console_debug) { Debugger.console_debug( 'AJAX_BATCH', `Sending batch of ${calls_to_send.length} calls`, calls_to_send.map((c) => `${c.controller}.${c.action}`) ); } try { // Send batch request const response = await $.ajax({ url: '/_ajax/_batch', method: 'POST', data: { batch_calls: JSON.stringify(calls_to_send) }, dataType: 'json', __local_integration: true, // Bypass $.ajax override }); // Process batch response // Response format: { C_0: {success, _ajax_return_value}, C_1: {...}, ... } for (const response_key in response) { if (!response_key.startsWith('C_')) { continue; } const call_id = parseInt(response_key.substring(2), 10); const call_response = response[response_key]; const pending_call = call_map[call_id]; if (!pending_call) { console.error('Received response for unknown call_id:', call_id); continue; } // Handle console_debug messages if present if (call_response.console_debug && Array.isArray(call_response.console_debug)) { call_response.console_debug.forEach((msg) => { if (!Array.isArray(msg) || msg.length !== 2) { throw new Error('Invalid console_debug message format - expected [channel, [arguments]]'); } const [channel, args] = msg; console.log(channel, ...args); }); } // Mark call as complete pending_call.is_complete = true; // Check if successful if (call_response._success === true) { // @JS-AJAX-02-EXCEPTION - Batch system unwraps server responses with _ajax_return_value const processed_value = Rsx_Js_Model._instantiate_models_recursive(call_response._ajax_return_value); pending_call.result = processed_value; // Resolve all callbacks pending_call.callbacks.forEach(({ resolve }) => { resolve(processed_value); }); } else { // Handle error const error_type = call_response.error_type || 'unknown_error'; let error_message; let error_details; if (error_type === 'fatal' && call_response.error) { // Fatal PHP error with full error details const fatal_error_data = call_response.error; error_message = fatal_error_data.error || 'Fatal error occurred'; error_details = call_response.error; console.error('Ajax error response from server:', call_response.error); } else { // Other error types error_message = call_response.reason || 'Unknown error occurred'; error_details = call_response.details || {}; } const error = new Error(error_message); error.type = error_type; error.details = error_details; pending_call.is_error = true; pending_call.error = error; // Reject all callbacks pending_call.callbacks.forEach(({ reject }) => { reject(error); }); } } } catch (xhr_error) { // Network or server error - reject all pending calls const error_message = Ajax._extract_error_message(xhr_error); const error = new Error(error_message); error.type = 'network_error'; for (const call_id in call_map) { const pending_call = call_map[call_id]; pending_call.is_complete = true; pending_call.is_error = true; pending_call.error = error; pending_call.callbacks.forEach(({ reject }) => { reject(error); }); } console.error('Batch Ajax request failed:', error_message); } } /** * Generate a unique key for deduplicating calls * @private */ static _generate_call_key(controller, action, params) { // Create a stable string representation of the call // Sort params keys for consistent hashing const sorted_params = {}; Object.keys(params) .sort() .forEach((key) => { sorted_params[key] = params[key]; }); return `${controller}::${action}::${JSON.stringify(sorted_params)}`; } /** * Extract error message from jQuery XHR object * @private */ static _extract_error_message(xhr) { if (xhr.responseJSON && xhr.responseJSON.message) { return xhr.responseJSON.message; } else if (xhr.responseText) { try { const response = JSON.parse(xhr.responseText); if (response.message) { return response.message; } } catch (e) { // Not JSON } } return `${xhr.status}: ${xhr.statusText || 'Unknown error'}`; } /** * Parses an AJAX URL into controller and action * Supports both /_ajax/ and /_/ URL prefixes * @param {string|object|function} url - URL in format '/_ajax/Controller_Name/action_name' or '/_/Controller_Name/action_name', or an object/function with a .path property * @returns {Object} Object with {controller: string, action: string} * @throws {Error} If URL doesn't start with /_ajax or /_ or has invalid structure */ static ajax_url_to_controller_action(url) { // If url is an object or function with a .path property, use that as the URL if (url && typeof url === 'object' && url.path) { url = url.path; } else if (url && typeof url === 'function' && url.path) { url = url.path; } // Validate url is a string if (typeof url !== 'string') { throw new Error(`URL must be a string or have a .path property, got: ${typeof url}`); } if (!url.startsWith('/_ajax') && !url.startsWith('/_/')) { throw new Error(`URL must start with /_ajax or /_, got: ${url}`); } const parts = url.split('/').filter((part) => part !== ''); if (parts.length < 2) { throw new Error(`Invalid AJAX URL structure: ${url}`); } if (parts.length > 3) { throw new Error(`AJAX URL has too many segments: ${url}`); } const controller = parts[1]; const action = parts[2] || 'index'; return { controller, action }; } /** * Auto-initialize static properties when class is first loaded */ static on_core_define() { Ajax._on_framework_core_init(); } }