Files
rspade_system/app/RSpade/Core/Js/Rsx.js
2025-11-20 20:51:17 +00:00

879 lines
31 KiB
JavaScript
Executable File

// @ROUTE-EXISTS-01-EXCEPTION - This file contains documentation examples with fictional route names
/**
* Rsx - Core JavaScript Runtime System
*
* The Rsx class is the central hub for the RSX JavaScript runtime, providing essential
* system-level utilities that all other framework components depend on. It serves as the
* foundation for the client-side framework, handling core operations that must be globally
* accessible and consistently available.
*
* Core Responsibilities:
* - Event System: Application-wide event bus for framework lifecycle and custom events
* - Environment Detection: Runtime environment identification (dev/production)
* - Route Management: Type-safe route generation and URL building
* - Unique ID Generation: Client-side unique identifier generation
* - Framework Bootstrap: Multi-phase initialization orchestration
* - Logging: Centralized logging interface (delegates to console_debug)
*
* The Rsx class deliberately keeps its scope limited to core utilities. Advanced features
* are delegated to specialized classes:
* - Manifest operations → Manifest class
* - Caching → Rsx_Cache class
* - AJAX/API calls → Ajax_* classes
* - Route proxies → Rsx_Route_Proxy class
* - Behaviors → Rsx_Behaviors class
*
* All methods are static - Rsx is never instantiated. It's available globally from the
* moment bundles load and remains constant throughout the application lifecycle.
*
* Usage Examples:
* ```javascript
* // Event system
* Rsx.on('app_ready', () => console.log('App initialized'));
* Rsx.trigger('custom_event', {data: 'value'});
*
* // Environment detection
* if (Rsx.is_dev()) { console.log('Development mode'); }
*
* // Route generation
* const url = Rsx.Route('Controller::action').url();
*
* // Unique IDs
* const uniqueId = Rsx.uid(); // e.g., "rsx_1234567890_1"
* ```
*
* @static
* @global
*/
class Rsx {
// Gets set to true to interupt startup sequence
static __stopped = false;
// Timestamp of last flash alert for unhandled exceptions (rate limiting)
static _last_exception_flash = 0;
// Initialize event handlers storage
static _init_events() {
if (typeof Rsx._event_handlers === 'undefined') {
Rsx._event_handlers = {};
}
if (typeof Rsx._triggered_events === 'undefined') {
Rsx._triggered_events = {};
}
}
// Register an event handler
static on(event, callback) {
Rsx._init_events();
if (typeof callback !== 'function') {
throw new Error('Callback must be a function');
}
if (!Rsx._event_handlers[event]) {
Rsx._event_handlers[event] = [];
}
Rsx._event_handlers[event].push(callback);
// If this event was already triggered, call the callback immediately
if (Rsx._triggered_events[event]) {
console_debug('RSX_INIT', 'Triggering ' + event + ' for late registered callback');
callback(Rsx._triggered_events[event]);
}
}
// Trigger an event with optional data
static trigger(event, data = {}) {
Rsx._init_events();
// Record that this event was triggered
Rsx._triggered_events[event] = data;
if (!Rsx._event_handlers[event]) {
return;
}
console_debug('RSX_INIT', 'Triggering ' + event + ' for ' + Rsx._event_handlers[event].length + ' callbacks');
// Call all registered handlers for this event in order
for (const callback of Rsx._event_handlers[event]) {
callback(data);
}
}
// Alias for trigger.refresh(''), should be called after major UI updates to apply such effects as
// underlining links to unimplemented # routes
static trigger_refresh() {
// Use Rsx.on('refresh', callback); to register a callback for refresh
this.trigger('refresh');
}
/**
* Setup global unhandled exception handlers
* Must be called before framework initialization begins
*/
static _setup_exception_handlers() {
// Handle uncaught JavaScript errors
window.addEventListener('error', function (event) {
Rsx._handle_unhandled_exception({
message: event.message,
filename: event.filename,
lineno: event.lineno,
colno: event.colno,
stack: event.error ? event.error.stack : null,
type: 'error',
error: event.error,
});
});
// Handle unhandled promise rejections
window.addEventListener('unhandledrejection', function (event) {
Rsx._handle_unhandled_exception({
message: event.reason ? event.reason.message || String(event.reason) : 'Unhandled promise rejection',
stack: event.reason && event.reason.stack ? event.reason.stack : null,
type: 'unhandledrejection',
error: event.reason,
});
});
}
/**
* Internal handler for unhandled exceptions
* Triggers event, shows flash alert (rate limited), disables SPA, and logs to console
*/
static _handle_unhandled_exception(error_data) {
// Always log to console
console.error('[Rsx] Unhandled exception:', error_data);
// Trigger event for listeners (e.g., Debugger for server logging)
Rsx.trigger('unhandled_exception', error_data);
// Disable SPA navigation if in SPA mode
// This allows user to navigate away from broken page using normal browser navigation
if (typeof Spa !== 'undefined' && window.rsxapp && window.rsxapp.is_spa) {
Spa.disable();
}
// Show flash alert (rate limited to 1 per second)
const now = Date.now();
if (now - Rsx._last_exception_flash >= 1000) {
Rsx._last_exception_flash = now;
// Determine message based on dev/prod mode
let message;
if (window.rsxapp && window.rsxapp.debug) {
// Dev mode: Show actual error (shortened to 300 chars)
const error_text = error_data.message || 'Unknown error';
message = error_text.length > 300 ? error_text.substring(0, 300) + '...' : error_text;
} else {
// Production mode: Generic message
message = 'An unhandled error has occurred, you may need to refresh your page';
}
// Show flash alert if Flash_Alert is available
if (typeof Flash_Alert !== 'undefined') {
Flash_Alert.error(message);
}
}
}
// Log to server that an event happened
static log(type, message = 'notice') {
Core_Log.log(type, message);
}
// Returns true if the app is being run in dev mode
// This should affect caching and some debug checks
static is_dev() {
return window.rsxapp.debug;
}
static is_prod() {
return !window.rsxapp.debug;
}
// Generates a unique number for the application instance
static uid() {
if (typeof Rsx._uid == undef) {
Rsx._uid = 0;
}
return Rsx._uid++;
}
// Storage for route definitions loaded from bundles
static _routes = {};
/**
* Calculate scope key from current environment.
*
* Scope key is a hash key which includes the current value of the session, user, site, and build keys.
* Data hashed with this key will be scoped to the current logged in user, and will be invalidated if
* the user logs out or the application source code is updated / redeployed / etc.
*
* @returns {string}
* @private
*/
static scope_key() {
const parts = [];
// Get session hash (hashed on server for non-reversible scoping)
if (window.rsxapp?.session_hash) {
parts.push(window.rsxapp.session_hash);
}
// Get user ID
if (window.rsxapp?.user?.id) {
parts.push(window.rsxapp.user.id);
}
// Get site ID
if (window.rsxapp?.site?.id) {
parts.push(window.rsxapp.site.id);
}
// Get build key
if (window.rsxapp?.build_key) {
parts.push(window.rsxapp.build_key);
}
return parts.join('_');
}
/**
* Generate URL for a controller route or SPA action
*
* This method generates URLs by looking up route patterns and replacing parameters.
* It handles controller routes, SPA action routes, and Ajax endpoints.
*
* If the route is not found in the route definitions, a default pattern is used:
* `/_/{controller}/{action}` with all parameters appended as query strings.
*
* Usage examples:
* ```javascript
* // Controller route (defaults to 'index' method)
* const url = Rsx.Route('Frontend_Index_Controller');
* // Returns: /dashboard
*
* // Controller route with explicit method
* const url = Rsx.Route('Frontend_Client_View_Controller::view', 123);
* // Returns: /clients/view/123
*
* // SPA action route
* const url = Rsx.Route('Contacts_Index_Action');
* // Returns: /contacts
*
* // Route with integer parameter (sets 'id')
* const url = Rsx.Route('Contacts_View_Action', 123);
* // Returns: /contacts/123
*
* // Route with named parameters (object)
* const url = Rsx.Route('Contacts_View_Action', {id: 'C001'});
* // Returns: /contacts/C001
*
* // Route with required and query parameters
* const url = Rsx.Route('Contacts_View_Action', {
* id: 'C001',
* tab: 'history'
* });
* // Returns: /contacts/C001?tab=history
*
* // Placeholder route
* const url = Rsx.Route('Future_Controller::#index');
* // Returns: #
* ```
*
* @param {string} action Controller class, SPA action, or "Class::method". Defaults to 'index' method if not specified.
* @param {number|Object} [params=null] Route parameters. Integer sets 'id', object provides named params.
* @returns {string} The generated URL
*/
static Route(action, params = null) {
// Parse action into class_name and action_name
// Format: "Controller_Name" or "Controller_Name::method_name" or "Spa_Action_Name"
let class_name, action_name;
if (action.includes('::')) {
[class_name, action_name] = action.split('::', 2);
} else {
class_name = action;
action_name = 'index';
}
// Normalize params to object
let params_obj = {};
if (typeof params === 'number') {
params_obj = { id: params };
} else if (params && typeof params === 'object') {
params_obj = params;
} else if (params !== null && params !== undefined) {
throw new Error('Params must be number, object, or null');
}
// Placeholder route: action starts with # means unimplemented/scaffolding
if (action_name.startsWith('#')) {
return '#';
}
// Check if route exists in PHP controller definitions
let pattern;
if (Rsx._routes[class_name] && Rsx._routes[class_name][action_name]) {
pattern = Rsx._routes[class_name][action_name];
} else {
// Not found in PHP routes - check if it's a SPA action
pattern = Rsx._try_spa_action_route(class_name, params_obj);
if (!pattern) {
// Route not found - use default pattern /_/{controller}/{action}
// For SPA actions, action_name defaults to 'index'
pattern = `/_/${class_name}/${action_name}`;
}
}
// Generate URL from pattern
return Rsx._generate_url_from_pattern(pattern, params_obj);
}
/**
* Select the best matching route pattern from available patterns based on provided parameters
*
* Selection algorithm:
* 1. Filter patterns where all required parameters can be satisfied by provided params
* 2. Among satisfiable patterns, prioritize those with MORE parameters (more specific)
* 3. If tie, any pattern works (deterministic by using first match)
*
* @param {Array<string>} patterns Array of route patterns
* @param {Object} params_obj Provided parameters
* @returns {string|null} Selected pattern or null if none match
*/
static _select_best_route_pattern(patterns, params_obj) {
const satisfiable = [];
for (const pattern of patterns) {
// Extract required parameters from pattern
const required_params = [];
const matches = pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
if (matches) {
// Remove the : prefix from each match
for (const match of matches) {
required_params.push(match.substring(1));
}
}
// Check if all required parameters are provided
let can_satisfy = true;
for (const required of required_params) {
if (!(required in params_obj)) {
can_satisfy = false;
break;
}
}
if (can_satisfy) {
satisfiable.push({
pattern: pattern,
param_count: required_params.length
});
}
}
if (satisfiable.length === 0) {
return null;
}
// Sort by parameter count descending (most parameters first)
satisfiable.sort((a, b) => b.param_count - a.param_count);
// Return the pattern with the most parameters
return satisfiable[0].pattern;
}
/**
* Generate URL from route pattern by replacing parameters
*
* @param {string} pattern The route pattern (e.g., '/users/:id/view')
* @param {Object} params Parameters to fill into the route
* @returns {string} The generated URL
*/
static _generate_url_from_pattern(pattern, params) {
// Extract required parameters from the pattern
const required_params = [];
const matches = pattern.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g);
if (matches) {
// Remove the : prefix from each match
for (const match of matches) {
required_params.push(match.substring(1));
}
}
// Check for required parameters
const missing = [];
for (const required of required_params) {
if (!(required in params)) {
missing.push(required);
}
}
if (missing.length > 0) {
throw new Error(`Required parameters [${missing.join(', ')}] are missing for route ${pattern}`);
}
// Build the URL by replacing parameters
let url = pattern;
const used_params = {};
for (const param_name of required_params) {
const value = params[param_name];
// URL encode the value
const encoded_value = encodeURIComponent(value);
url = url.replace(':' + param_name, encoded_value);
used_params[param_name] = true;
}
// Collect any extra parameters for query string
const query_params = {};
for (const key in params) {
if (!used_params[key]) {
query_params[key] = params[key];
}
}
// Append query string if there are extra parameters
if (Object.keys(query_params).length > 0) {
const query_string = Object.entries(query_params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join('&');
url += '?' + query_string;
}
return url;
}
/**
* Try to find a route pattern for a SPA action class
* Returns the route pattern or null if not found
*
* @param {string} class_name The action class name
* @param {Object} params_obj The parameters for route selection
* @returns {string|null} The route pattern or null
*/
static _try_spa_action_route(class_name, params_obj) {
// Get all classes from manifest
const all_classes = Manifest.get_all_classes();
// Find the class by name
for (const class_info of all_classes) {
if (class_info.class_name === class_name) {
const class_object = class_info.class_object;
// Check if it's a SPA action (has Spa_Action in prototype chain)
if (typeof Spa_Action !== 'undefined' &&
class_object.prototype instanceof Spa_Action) {
// Get route patterns from decorator metadata
const routes = class_object._spa_routes || [];
if (routes.length > 0) {
// Select best matching route based on parameters
const selected = Rsx._select_best_route_pattern(routes, params_obj);
if (!selected) {
// Routes exist but none are satisfiable
throw new Error(
`No suitable route found for SPA action ${class_name} with provided parameters. ` +
`Available routes: ${routes.join(', ')}`
);
}
return selected;
}
}
// Found the class but it's not a SPA action or has no routes
return null;
}
}
// Class not found
return null;
}
/**
* Define routes from bundled data
* Called by generated JavaScript in bundles
*/
static _define_routes(routes) {
// Merge routes into the global route storage
for (const class_name in routes) {
if (!Rsx._routes[class_name]) {
Rsx._routes[class_name] = {};
}
for (const method_name in routes[class_name]) {
Rsx._routes[class_name][method_name] = routes[class_name][method_name];
}
}
}
/**
* Internal: Call a specific method on all classes that have it
* Collects promises from return values and waits for all to resolve
* @param {string} method_name The method name to call on all classes
* @returns {Promise} Promise that resolves when all method calls complete
*/
static async _rsx_call_all_classes(method_name) {
const all_classes = Manifest.get_all_classes();
const classes_with_method = [];
const promise_pile = [];
for (const class_info of all_classes) {
const class_object = class_info.class_object;
const class_name = class_info.class_name;
// Check if this class has the method (static methods are on the class itself)
if (typeof class_object[method_name] === 'function') {
classes_with_method.push(class_name);
const return_value = await class_object[method_name]();
// Collect promises from return value
if (return_value instanceof Promise) {
promise_pile.push(return_value);
} else if (Array.isArray(return_value)) {
for (const item of return_value) {
if (item instanceof Promise) {
promise_pile.push(item);
}
}
}
if (Rsx.__stopped) {
return;
}
}
}
if (classes_with_method.length > 0) {
console_debug('RSX_INIT', `${method_name}: ${classes_with_method.length} classes`);
}
// Await all promises before returning
if (promise_pile.length > 0) {
console_debug('RSX_INIT', `${method_name}: Awaiting ${promise_pile.length} promises`);
await Promise.all(promise_pile);
}
}
/**
* Internal: Execute multi-phase initialization for all registered classes
* This runs various initialization phases in order to properly set up the application
* @returns {Promise} Promise that resolves when all initialization phases complete
*/
static async _rsx_core_boot() {
if (Rsx.__booted) {
console.error('Rsx._rsx_core_boot called more than once');
return;
}
Rsx.__booted = true;
// Setup exception handlers first, before any initialization phases
Rsx._setup_exception_handlers();
// Get all registered classes from the manifest
const all_classes = Manifest.get_all_classes();
console_debug('RSX_INIT', `Starting _rsx_core_boot with ${all_classes.length} classes`);
if (!all_classes || all_classes.length === 0) {
// No classes to initialize
shouldnt_happen('No classes registered in js - there should be at least the core framework classes');
return;
}
// Define initialization phases in order
const phases = [
{ event: 'framework_core_define', method: '_on_framework_core_define' },
{ event: 'framework_modules_define', method: '_on_framework_modules_define' },
{ event: 'framework_core_init', method: '_on_framework_core_init' },
{ event: 'app_modules_define', method: 'on_app_modules_define' },
{ event: 'app_define', method: 'on_app_define' },
{ event: 'framework_modules_init', method: '_on_framework_modules_init' },
{ event: 'app_modules_init', method: 'on_app_modules_init' },
{ event: 'app_init', method: 'on_app_init' },
{ event: 'app_ready', method: 'on_app_ready' },
];
// Execute each phase in order
for (const phase of phases) {
await Rsx._rsx_call_all_classes(phase.method);
if (Rsx.__stopped) {
return;
}
Rsx.trigger(phase.event);
}
// Ui refresh callbacks
Rsx.trigger_refresh();
// All phases complete
console_debug('RSX_INIT', 'Initialization complete');
// TODO: Find a good wait to wait for all jqhtml components to load, then trigger on_ready and on('ready') emulating the top level last syntax that jqhtml components operateas, but as a standard js class (such as a page class). The biggest question is, how do we efficiently choose only the top level jqhtml components. do we only consider components cretaed directly on blade templates? that seams reasonable...
// Trigger _debug_ready event - this is ONLY for tooling like rsx:debug
// DO NOT use this in application code - use on_app_ready() phase instead
// This event exists solely for debugging tools that need to run after full initialization
Rsx.trigger('_debug_ready');
}
/* Calling this stops the boot process. */
static async _rsx_core_boot_stop(reason) {
console.error(reason);
Rsx.__stopped = true;
}
/**
* Parse URL hash into key-value object
* Handles format: #key=value&key2=value2
*
* @returns {Object} Parsed hash parameters
*/
static _parse_hash() {
const hash = window.location.hash;
if (!hash || hash === '#') {
return {};
}
// Remove leading # and parse as query string
const hash_string = hash.substring(1);
const params = {};
const pairs = hash_string.split('&');
for (const pair of pairs) {
const [key, value] = pair.split('=');
if (key) {
params[decodeURIComponent(key)] = value ? decodeURIComponent(value) : '';
}
}
return params;
}
/**
* Serialize object into URL hash format
* Produces format: #key=value&key2=value2
*
* @param {Object} params Key-value pairs to encode
* @returns {string} Encoded hash string (with leading #, or empty string)
*/
static _serialize_hash(params) {
const pairs = [];
for (const key in params) {
const value = params[key];
if (value !== null && value !== undefined && value !== '') {
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(value)}`);
}
}
return pairs.length > 0 ? '#' + pairs.join('&') : '';
}
/**
* Get all hash state from URL hash
*
* Usage:
* ```javascript
* const state = Rsx.url_hash_get_all();
* // Returns: {dg_page: '2', dg_sort: 'name'}
* ```
*
* @returns {Object} All hash parameters as key-value pairs
*/
static url_hash_get_all() {
return Rsx._parse_hash();
}
/**
* Get single value from URL hash state
*
* Usage:
* ```javascript
* const page = Rsx.url_hash_get('dg_page');
* // Returns: '2' or null if not set
* ```
*
* @param {string} key The key to retrieve
* @returns {string|null} The value or null if not found
*/
static url_hash_get(key) {
const state = Rsx._parse_hash();
return state[key] ?? null;
}
/**
* Set single value in URL hash state (replaces history, doesn't add)
*
* Usage:
* ```javascript
* Rsx.url_hash_set_single('dg_page', 2);
* // URL becomes: http://example.com/page#dg_page=2
*
* Rsx.url_hash_set_single('dg_page', null); // Remove key
* ```
*
* @param {string} key The key to set
* @param {string|number|null} value The value (null/empty removes the key)
*/
static url_hash_set_single(key, value) {
const state = Rsx._parse_hash();
// Update or remove the key
if (value === null || value === undefined || value === '') {
delete state[key];
} else {
state[key] = String(value);
}
// Update URL without adding history
const new_hash = Rsx._serialize_hash(state);
const url = window.location.pathname + window.location.search + new_hash;
history.replaceState(null, '', url);
}
/**
* Set multiple values in URL hash state at once
*
* Usage:
* ```javascript
* Rsx.url_hash_set({dg_page: 2, dg_sort: 'name'});
* // URL becomes: http://example.com/page#dg_page=2&dg_sort=name
*
* Rsx.url_hash_set({dg_page: null}); // Remove key from hash
* ```
*
* @param {Object} new_state Object with key-value pairs to set (null removes key)
*/
static url_hash_set(new_state) {
const state = Rsx._parse_hash();
// Merge new state
for (const key in new_state) {
const value = new_state[key];
if (value === null || value === undefined || value === '') {
delete state[key];
} else {
state[key] = String(value);
}
}
// Update URL without adding history
const new_hash = Rsx._serialize_hash(state);
const url = window.location.pathname + window.location.search + new_hash;
history.replaceState(null, '', url);
}
/**
* Render an error in a DOM element
*
* Displays errors from Ajax calls in a standardized format. Handles different
* error types (fatal, validation, auth, generic) with appropriate formatting.
*
* Usage:
* ```javascript
* try {
* const result = await Controller.method();
* } catch (error) {
* Rsx.render_error(error, '#error_container');
* }
* ```
*
* @param {Error|Object} error - Error object from Ajax call
* @param {jQuery|string} container - jQuery element or selector for error display
*/
static render_error(error, container) {
const $container = $(container);
if (!$container.exists()) {
console.error('Rsx.render_error: Container not found', container);
return;
}
// Clear existing content
$container.empty();
let html = '';
// Handle different error types
if (error.type === 'fatal' && error.details) {
// Fatal PHP error with file/line/error
const details = error.details;
const file = details.file || 'Unknown file';
const line = details.line || '?';
const message = details.error || error.message || 'Fatal error occurred';
html = `
<div class="alert alert-danger" role="alert">
<h5>Uncaught Fatal Error in ${file}:${line}:</h5>
<p class="mb-0">${Rsx._escape_html(message)}</p>
</div>
`;
} else if (error.type === 'form_error' && error.details) {
// Validation errors - show unmatched errors only
// (matched errors should be handled by Form_Utils.apply_form_errors)
const errors = error.details;
const error_list = [];
for (const field in errors) {
error_list.push(errors[field]);
}
if (error_list.length > 0) {
html = `
<div class="alert alert-warning" role="alert">
<h5>Validation Errors:</h5>
<ul class="mb-0">
${error_list.map((err) => `<li>${Rsx._escape_html(err)}</li>`).join('')}
</ul>
</div>
`;
}
} else if (error.type === 'auth_required' || error.type === 'unauthorized') {
// Authentication/authorization errors
const message = error.message || 'Authentication required';
html = `
<div class="alert alert-warning" role="alert">
<p class="mb-0">${Rsx._escape_html(message)}</p>
</div>
`;
} else if (error.type === 'network') {
// Network errors
const message = error.message || 'Unable to reach server. Please check your connection.';
html = `
<div class="alert alert-danger" role="alert">
<p class="mb-0">${Rsx._escape_html(message)}</p>
</div>
`;
} else {
// Generic/unknown error
const message = error.message || error.toString() || 'An unknown error occurred';
html = `
<div class="alert alert-danger" role="alert">
<p class="mb-0">${Rsx._escape_html(message)}</p>
</div>
`;
}
$container.html(html);
}
/**
* Escape HTML to prevent XSS in error messages
* @private
*/
static _escape_html(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}