// @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 { // Error code constants (must match server-side Ajax::ERROR_* constants) static ERROR_VALIDATION = 'validation'; static ERROR_NOT_FOUND = 'not_found'; static ERROR_UNAUTHORIZED = 'unauthorized'; static ERROR_AUTH_REQUIRED = 'auth_required'; static ERROR_FATAL = 'fatal'; static ERROR_GENERIC = 'generic'; static ERROR_SERVER = 'server_error'; // Client-generated (HTTP 500) static ERROR_NETWORK = 'network_error'; // Client-generated (connection failed) static ERROR_PHP_EXCEPTION = 'php_exception'; // Client-generated (PHP exception with file/line/backtrace) /** * 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); // Suppress errors during navigation grace period // After page navigation, pending requests from the old page may error out // These errors are not relevant to the new page if (typeof Spa !== 'undefined' && Spa.is_within_navigation_grace_period()) { console.log('[Ajax] Suppressing error modal during navigation grace period'); return; } // Show error modal for uncaught Ajax errors // In debug mode, use fatal_error() for detailed file/line info if (typeof Modal !== 'undefined') { if (window.rsxapp?.debug && Modal.fatal_error) { await Modal.fatal_error(error, 'Uncaught Ajax Error'); } else if (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', contentType: 'application/json', data: JSON.stringify(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); }); } // Sync time with server (only on first AJAX or timezone change) if (response._server_time || response._user_timezone) { Rsx_Time.sync_from_ajax({ server_time: response._server_time, user_timezone: response._user_timezone }); } // 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 // Server may use error_code or error_type const error_code = response.error_code || response.error_type || Ajax.ERROR_GENERIC; const reason = response.reason || 'An error occurred'; const metadata = response.metadata || {}; // Create error object const error = new Error(reason); error.code = error_code; error.metadata = metadata; // Handle fatal errors specially - detect PHP exceptions if (error_code === Ajax.ERROR_FATAL) { const fatal_error_data = response.error || {}; // Check if this is a PHP exception (has file, line, error, backtrace) if (fatal_error_data.file && fatal_error_data.line && fatal_error_data.error) { error.code = Ajax.ERROR_PHP_EXCEPTION; error.message = fatal_error_data.error; error.metadata = { file: fatal_error_data.file, line: fatal_error_data.line, error: fatal_error_data.error, backtrace: fatal_error_data.backtrace || [] }; console.error('PHP Exception:', fatal_error_data.error, 'at', fatal_error_data.file + ':' + fatal_error_data.line); } else { error.message = fatal_error_data.error || 'Fatal error occurred'; error.metadata = response.error; console.error('Ajax error response from server:', response.error); } // Log to server Debugger.log_error({ message: `Ajax Fatal Error: ${error.message}`, type: 'ajax_fatal', endpoint: url, details: response.error, }); } // Log auth errors for debugging if (error_code === Ajax.ERROR_AUTH_REQUIRED) { console.error('User is no longer authenticated'); } if (error_code === Ajax.ERROR_UNAUTHORIZED) { console.error('User is unauthorized to perform this action'); } reject(error); } }, error: (xhr, status, error) => { const err = new Error(); // Determine error code based on status if (xhr.status >= 500) { // Server error (PHP crashed) err.code = Ajax.ERROR_SERVER; err.message = 'A server error occurred. Please try again.'; } else if (xhr.status === 0 || status === 'timeout' || status === 'error') { // Network error (connection failed) err.code = Ajax.ERROR_NETWORK; err.message = 'Could not connect to server. Please check your connection.'; } else { // Generic error err.code = Ajax.ERROR_GENERIC; err.message = Ajax._extract_error_message(xhr); } err.metadata = { status: xhr.status, statusText: status }; // Log server errors to server if (xhr.status >= 500) { Debugger.log_error({ message: `Ajax Server Error ${xhr.status}: ${err.message}`, type: 'ajax_server_error', endpoint: url, status: xhr.status, statusText: status, }); } reject(err); }, }); }); } /** * 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', contentType: 'application/json', data: JSON.stringify({ batch_calls: calls_to_send }), dataType: 'json', __local_integration: true, // Bypass $.ajax override }); // Sync time with server (only on first AJAX or timezone change) if (response._server_time || response._user_timezone) { Rsx_Time.sync_from_ajax({ server_time: response._server_time, user_timezone: response._user_timezone }); } // Process batch response // Response format: { C_0: {success, _ajax_return_value}, C_1: {...}, ..., _server_time, _user_timezone } 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 // Server may use error_code or error_type const error_code = call_response.error_code || call_response.error_type || Ajax.ERROR_GENERIC; const error_message = call_response.reason || 'Unknown error occurred'; const metadata = call_response.metadata || {}; const error = new Error(error_message); error.code = error_code; error.metadata = metadata; // Handle fatal errors specially - detect PHP exceptions if (error_code === Ajax.ERROR_FATAL) { const fatal_error_data = call_response.error || {}; // Check if this is a PHP exception (has file, line, error, backtrace) if (fatal_error_data.file && fatal_error_data.line && fatal_error_data.error) { error.code = Ajax.ERROR_PHP_EXCEPTION; error.message = fatal_error_data.error; error.metadata = { file: fatal_error_data.file, line: fatal_error_data.line, error: fatal_error_data.error, backtrace: fatal_error_data.backtrace || [] }; console.error('PHP Exception:', fatal_error_data.error, 'at', fatal_error_data.file + ':' + fatal_error_data.line); } else { error.message = fatal_error_data.error || 'Fatal error occurred'; error.metadata = call_response.error; console.error('Ajax error response from server:', call_response.error); } } 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.code = Ajax.ERROR_NETWORK; error.metadata = {}; 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(); } }